From 08074523e28aa67b3b45a94454a57eafdfa21a46 Mon Sep 17 00:00:00 2001 From: Salastil Date: Tue, 14 Oct 2025 23:15:15 -0400 Subject: [PATCH] Initial Commit --- .env.example | 14 + .gitignore | 2 + README.md | 2 + Sneedchat-Discord-Bridge.go | 1846 +++++++++++++++++++++++++++++++++++ go.mod | 15 + 5 files changed, 1879 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Sneedchat-Discord-Bridge.go create mode 100644 go.mod diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ecc62f0 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +#Copy or rename this example file to .env +DISCORD_BOT_TOKEN=your_discord_bot_token_here +DISCORD_CHANNEL_ID=your_discord_channel_id_here +DISCORD_GUILD_ID=your_discord_guild_id_here +DISCORD_PING_USER_ID=your_discord_user_id_here # Get this by right-clicking your Discord profile +DISCORD_WEBHOOK_URL=your_discord_webhook_url_here +RECONNECT_INTERVAL=5 # Interval between reconnect attempts if connection is lost +SNEEDCHAT_ROOM_ID=1 # Which room will be bridged, append integer at the end of room name. Current options: general.1, beauty-parlor.18, gunt.8, keno-kasino.15, sports.19, fishtank.16 +ENABLE_FILE_LOGGING=false # Enable logging to bridge.log file (true/false, default: false) + +# Optional: Prevent echo loops by filtering messages from the bridge bot +# BRIDGE_USER_ID=123456 # Numeric user ID of your bridge bot account on Sneedchat +# BRIDGE_USERNAME=YourBridgeBot # Username of your bridge bot account on Sneedchat +# BRIDGE_PASSWORD=Password # Password of your account \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5df184 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +go.sum +Sneedchat-Discord-Bridge diff --git a/README.md b/README.md new file mode 100644 index 0000000..be722a4 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# Sneedchat-Discord-Puppet-Bridge +A Go program to bridge a Kiwifarms Sneedchat channel to Discord via webhooks by puppeting a user account. diff --git a/Sneedchat-Discord-Bridge.go b/Sneedchat-Discord-Bridge.go new file mode 100644 index 0000000..73234d9 --- /dev/null +++ b/Sneedchat-Discord-Bridge.go @@ -0,0 +1,1846 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "html" + "io" + "log" + "math/rand" + "mime/multipart" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "os/signal" + "regexp" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/gorilla/websocket" + "github.com/joho/godotenv" +) + +// ----------------------------- +// Constants +// ----------------------------- +const ( + ProcessedCacheSize = 250 + OutboundMatchWindow = 60 * time.Second + CookieRefreshInterval = 4 * time.Hour + OutageUpdateInterval = 10 * time.Second + QueuedMessageTTL = 90 * time.Second + MaxAttachments = 4 + LitterboxTTL = "72h" + MappingCacheSize = 1000 + MappingCleanupInterval = 5 * time.Minute + MappingMaxAge = 1 * time.Hour + ReconnectInterval = 7 * time.Second + CookieRetryDelay = 5 * time.Second + MaxCookieRetryDelay = 60 * time.Second +) + +// ----------------------------- +// Configuration +// ----------------------------- +type Config struct { + DiscordBotToken string + DiscordChannelID string + DiscordGuildID string + DiscordWebhookURL string + SneedchatRoomID int + BridgeUsername string + BridgePassword string + BridgeUserID int + DiscordPingUserID string + Debug bool +} + +func loadConfig(envFile string) (*Config, error) { + if err := godotenv.Load(envFile); err != nil { + log.Printf("Warning: Error loading %s file: %v", envFile, err) + } + + config := &Config{ + DiscordBotToken: os.Getenv("DISCORD_BOT_TOKEN"), + DiscordChannelID: os.Getenv("DISCORD_CHANNEL_ID"), + DiscordGuildID: os.Getenv("DISCORD_GUILD_ID"), + DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"), + BridgeUsername: os.Getenv("BRIDGE_USERNAME"), + BridgePassword: os.Getenv("BRIDGE_PASSWORD"), + } + + roomID, err := strconv.Atoi(os.Getenv("SNEEDCHAT_ROOM_ID")) + if err != nil { + return nil, fmt.Errorf("invalid SNEEDCHAT_ROOM_ID: %w", err) + } + config.SneedchatRoomID = roomID + + if bridgeUserID := os.Getenv("BRIDGE_USER_ID"); bridgeUserID != "" { + config.BridgeUserID, _ = strconv.Atoi(bridgeUserID) + } + + config.DiscordPingUserID = os.Getenv("DISCORD_PING_USER_ID") + + return config, nil +} + +// ----------------------------- +// BBCode to Markdown Parser +// ----------------------------- +func bbcodeToMarkdown(text string) string { + if text == "" { + return "" + } + + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + + // Images & videos + text = regexp.MustCompile(`(?i)\[img\](.*?)\[/img\]`).ReplaceAllString(text, "$1") + text = regexp.MustCompile(`(?i)\[video\](.*?)\[/video\]`).ReplaceAllString(text, "$1") + + // URL with text + urlPattern := regexp.MustCompile(`(?i)\[url=(.*?)\](.*?)\[/url\]`) + text = urlPattern.ReplaceAllStringFunc(text, func(match string) string { + parts := urlPattern.FindStringSubmatch(match) + if len(parts) < 3 { + return match + } + link := strings.TrimSpace(parts[1]) + txt := strings.TrimSpace(parts[2]) + if regexp.MustCompile(`(?i)^https?://`).MatchString(txt) { + return txt + } + return fmt.Sprintf("[%s](%s)", txt, link) + }) + + // URL without text + text = regexp.MustCompile(`(?i)\[url\](.*?)\[/url\]`).ReplaceAllString(text, "$1") + + // Bold, italic, underline, strike + text = regexp.MustCompile(`(?i)\[(?:b|strong)\](.*?)\[/\s*(?:b|strong)\]`).ReplaceAllString(text, "**$1**") + text = regexp.MustCompile(`(?i)\[(?:i|em)\](.*?)\[/\s*(?:i|em)\]`).ReplaceAllString(text, "*$1*") + text = regexp.MustCompile(`(?i)\[u\](.*?)\[/\s*u\]`).ReplaceAllString(text, "__$1__") + text = regexp.MustCompile(`(?i)\[(?:s|strike)\](.*?)\[/\s*(?:s|strike)\]`).ReplaceAllString(text, "~~$1~~") + + // Code + text = regexp.MustCompile(`(?i)\[code\](.*?)\[/code\]`).ReplaceAllString(text, "`$1`") + text = regexp.MustCompile(`(?i)\[(?:php|plain|code=\w+)\](.*?)\[/(?:php|plain|code)\]`).ReplaceAllString(text, "```$1```") + + // Quotes (basic implementation) + quotePattern := regexp.MustCompile(`(?i)\[quote\](.*?)\[/quote\]`) + text = quotePattern.ReplaceAllStringFunc(text, func(match string) string { + parts := quotePattern.FindStringSubmatch(match) + if len(parts) < 2 { + return match + } + inner := strings.TrimSpace(parts[1]) + lines := strings.Split(inner, "\n") + for i, line := range lines { + lines[i] = "> " + line + } + return strings.Join(lines, "\n") + }) + + // Spoilers + text = regexp.MustCompile(`(?i)\[spoiler\](.*?)\[/spoiler\]`).ReplaceAllString(text, "||$1||") + + // Color/size - strip but keep content + text = regexp.MustCompile(`(?i)\[(?:color|size)=.*?\](.*?)\[/\s*(?:color|size)\]`).ReplaceAllString(text, "$1") + + // Lists + text = regexp.MustCompile(`(?m)^\[\*\]\s*`).ReplaceAllString(text, "โ€ข ") + text = regexp.MustCompile(`(?i)\[/?list\]`).ReplaceAllString(text, "") + + // Remove unknown tags + text = regexp.MustCompile(`\[/?[A-Za-z0-9\-=_]+\]`).ReplaceAllString(text, "") + + return strings.TrimSpace(text) +} + +// ----------------------------- +// Bounded Map (Memory Management) +// ----------------------------- +type BoundedMap struct { + mu sync.RWMutex + data map[int]interface{} + timestamps map[int]time.Time + maxSize int + maxAge time.Duration + keys []int // Track insertion order for LRU +} + +func NewBoundedMap(maxSize int, maxAge time.Duration) *BoundedMap { + return &BoundedMap{ + data: make(map[int]interface{}), + timestamps: make(map[int]time.Time), + maxSize: maxSize, + maxAge: maxAge, + keys: make([]int, 0, maxSize), + } +} + +func (bm *BoundedMap) Set(key int, value interface{}) { + bm.mu.Lock() + defer bm.mu.Unlock() + + // If key exists, update and move to end + if _, exists := bm.data[key]; exists { + bm.data[key] = value + bm.timestamps[key] = time.Now() + // Move to end + for i, k := range bm.keys { + if k == key { + bm.keys = append(bm.keys[:i], bm.keys[i+1:]...) + break + } + } + bm.keys = append(bm.keys, key) + return + } + + // New entry + bm.data[key] = value + bm.timestamps[key] = time.Now() + bm.keys = append(bm.keys, key) + + // Evict oldest if over capacity + if len(bm.data) > bm.maxSize { + oldest := bm.keys[0] + delete(bm.data, oldest) + delete(bm.timestamps, oldest) + bm.keys = bm.keys[1:] + } +} + +func (bm *BoundedMap) Get(key int) (interface{}, bool) { + bm.mu.RLock() + defer bm.mu.RUnlock() + val, exists := bm.data[key] + return val, exists +} + +func (bm *BoundedMap) Delete(key int) { + bm.mu.Lock() + defer bm.mu.Unlock() + delete(bm.data, key) + delete(bm.timestamps, key) + for i, k := range bm.keys { + if k == key { + bm.keys = append(bm.keys[:i], bm.keys[i+1:]...) + break + } + } +} + +func (bm *BoundedMap) CleanupOldEntries() int { + bm.mu.Lock() + defer bm.mu.Unlock() + + now := time.Now() + removed := 0 + + for key, ts := range bm.timestamps { + if now.Sub(ts) > bm.maxAge { + delete(bm.data, key) + delete(bm.timestamps, key) + for i, k := range bm.keys { + if k == key { + bm.keys = append(bm.keys[:i], bm.keys[i+1:]...) + break + } + } + removed++ + } + } + + return removed +} + +func (bm *BoundedMap) Len() int { + bm.mu.RLock() + defer bm.mu.RUnlock() + return len(bm.data) +} + +// ----------------------------- +// Cookie Refresh Service +// ----------------------------- +type CookieRefreshService struct { + username string + password string + domain string + client *http.Client + currentCookie string + cookieMu sync.RWMutex + cookieReady chan struct{} + stopChan chan struct{} + wg sync.WaitGroup +} + +func NewCookieRefreshService(username, password, domain string) (*CookieRefreshService, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + + return &CookieRefreshService{ + username: username, + password: password, + domain: domain, + client: &http.Client{Jar: jar, Timeout: 30 * time.Second}, + cookieReady: make(chan struct{}), + stopChan: make(chan struct{}), + }, nil +} + +func (crs *CookieRefreshService) getClearanceToken() (string, error) { + baseURL := fmt.Sprintf("https://%s/", crs.domain) + resp, err := crs.client.Get(baseURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + // Try multiple patterns for KiwiFlare challenge detection + patterns := []*regexp.Regexp{ + regexp.MustCompile(`]*id=["']sssg["'][^>]*data-sssg-challenge=["']([^"']+)["'][^>]*data-sssg-difficulty=["'](\d+)["']`), + regexp.MustCompile(`]*id=["']sssg["'][^>]*data-sssg-difficulty=["'](\d+)["'][^>]*data-sssg-challenge=["']([^"']+)["']`), + regexp.MustCompile(`data-sssg-challenge=["']([^"']+)["'][^>]*data-sssg-difficulty=["'](\d+)["']`), + } + + var salt string + var difficulty int + found := false + + for i, pattern := range patterns { + matches := pattern.FindStringSubmatch(string(body)) + if len(matches) >= 3 { + if i == 1 { + // Pattern has difficulty first, then challenge + difficulty, _ = strconv.Atoi(matches[1]) + salt = matches[2] + } else { + salt = matches[1] + difficulty, _ = strconv.Atoi(matches[2]) + } + found = true + break + } + } + + if !found { + log.Println("No KiwiFlare challenge required") + return "", nil + } + + if difficulty == 0 { + return "", nil + } + + log.Printf("Solving KiwiFlare challenge (difficulty=%d)", difficulty) + + nonce, err := crs.solvePoW(salt, difficulty) + if err != nil { + return "", err + } + + submitURL := fmt.Sprintf("https://%s/.sssg/api/answer", crs.domain) + formData := url.Values{"a": {salt}, "b": {nonce}} + + submitResp, err := crs.client.PostForm(submitURL, formData) + if err != nil { + return "", err + } + defer submitResp.Body.Close() + + var result map[string]interface{} + if err := json.NewDecoder(submitResp.Body).Decode(&result); err != nil { + return "", err + } + + if auth, ok := result["auth"].(string); ok { + // Manually add the clearance cookie to the jar + cookieURL, _ := url.Parse(baseURL) + clearanceCookie := &http.Cookie{ + Name: "sssg_clearance", + Value: auth, + Path: "/", + Domain: crs.domain, + } + crs.client.Jar.SetCookies(cookieURL, []*http.Cookie{clearanceCookie}) + log.Println("โœ… KiwiFlare clearance cookie set") + return auth, nil + } + + return "", fmt.Errorf("no auth token in response") +} + +func (crs *CookieRefreshService) solvePoW(salt string, difficulty int) (string, error) { + nonce := rand.Int63() + requiredBytes := difficulty / 8 + requiredBits := difficulty % 8 + maxAttempts := 10_000_000 + + for attempts := 0; attempts < maxAttempts; attempts++ { + nonce++ + + input := fmt.Sprintf("%s%d", salt, nonce) + hash := sha256.Sum256([]byte(input)) + + valid := true + for i := 0; i < requiredBytes; i++ { + if hash[i] != 0 { + valid = false + break + } + } + + if valid && requiredBits > 0 && requiredBytes < len(hash) { + mask := byte(0xFF << (8 - requiredBits)) + if hash[requiredBytes]&mask != 0 { + valid = false + } + } + + if valid { + return fmt.Sprintf("%d", nonce), nil + } + } + + return "", fmt.Errorf("failed to solve PoW within %d attempts", maxAttempts) +} + +func (crs *CookieRefreshService) FetchFreshCookie() (string, error) { + attempt := 0 + retryDelay := CookieRetryDelay + + for { + attempt++ + + if attempt > 1 { + log.Printf("๐Ÿ”„ Cookie fetch retry attempt %d (waiting %v)...", attempt, retryDelay) + time.Sleep(retryDelay) + + // Exponential backoff with cap + retryDelay *= 2 + if retryDelay > MaxCookieRetryDelay { + retryDelay = MaxCookieRetryDelay + } + } + + cookie, err := crs.attemptFetchCookie() + if err != nil { + log.Printf("โš ๏ธ Cookie fetch attempt %d failed: %v", attempt, err) + continue + } + + // Verify xf_user cookie is present + if strings.Contains(cookie, "xf_user=") { + log.Printf("โœ… Successfully fetched fresh cookie with xf_user (attempt %d)", attempt) + return cookie, nil + } + + log.Printf("โŒ Cookie fetch attempt %d missing xf_user - login failed, retrying...", attempt) + } +} + +func (crs *CookieRefreshService) attemptFetchCookie() (string, error) { + // Always start with a fresh cookie jar + jar, err := cookiejar.New(nil) + if err != nil { + return "", err + } + crs.client.Jar = jar + + log.Println("Step 1: Checking for KiwiFlare challenge...") + + clearanceToken, err := crs.getClearanceToken() + if err != nil { + return "", fmt.Errorf("clearance token error: %w", err) + } + if clearanceToken != "" { + log.Println("โœ… KiwiFlare challenge solved") + time.Sleep(1 * time.Second) // allow propagation + } + + // Force a new TLS session to avoid stale keep-alive + crs.client.Transport = &http.Transport{} + + // Step 2: Fetch login page + log.Println("Step 2: Fetching login page...") + loginURL := fmt.Sprintf("https://%s/login", crs.domain) + req, _ := http.NewRequest("GET", loginURL, nil) + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Pragma", "no-cache") + req.URL.RawQuery = fmt.Sprintf("r=%d", rand.Intn(999999)) + + resp, err := crs.client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to get login page: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("login page returned HTTP %d", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + // Step 3: Extract CSRF token + log.Println("Step 3: Extracting CSRF token...") + var csrfToken string + for _, pattern := range []*regexp.Regexp{ + regexp.MustCompile(`]*data-csrf=["']([^"']+)["']`), + regexp.MustCompile(`data-csrf=["']([^"']+)["']`), + regexp.MustCompile(`"csrf":"([^"]+)"`), + regexp.MustCompile(`XF\.config\.csrf\s*=\s*"([^"]+)"`), + } { + if m := pattern.FindStringSubmatch(bodyStr); len(m) >= 2 { + csrfToken = m[1] + break + } + } + + if csrfToken == "" { + log.Printf("โš ๏ธ CSRF token not found. Partial HTML:\n%s", bodyStr[:min(800, len(bodyStr))]) + return "", fmt.Errorf("CSRF token not found in login page") + } + log.Printf("โœ… Found CSRF token: %s...", csrfToken[:min(10, len(csrfToken))]) + + // Step 4: Submit login credentials + log.Println("Step 4: Submitting login credentials...") + postURL := fmt.Sprintf("https://%s/login/login", crs.domain) + formData := url.Values{ + "_xfToken": {csrfToken}, + "login": {crs.username}, + "password": {crs.password}, + "_xfRedirect": {fmt.Sprintf("https://%s/", crs.domain)}, + "remember": {"1"}, + } + + crs.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + loginResp, err := crs.client.PostForm(postURL, formData) + if err != nil { + return "", fmt.Errorf("login POST failed: %w", err) + } + defer loginResp.Body.Close() + + log.Printf("Login response status: %d", loginResp.StatusCode) + + // Step 5: Extract cookies + log.Println("Step 5: Extracting authentication cookies...") + cookieURL, _ := url.Parse(fmt.Sprintf("https://%s/", crs.domain)) + cookies := crs.client.Jar.Cookies(cookieURL) + + wanted := map[string]bool{ + "xf_user": true, + "xf_toggle": true, + "xf_csrf": true, + "xf_session": true, + "sssg_clearance": true, + } + + var cookieStrs []string + hasXfUser := false + for _, c := range cookies { + if wanted[c.Name] { + cookieStrs = append(cookieStrs, fmt.Sprintf("%s=%s", c.Name, c.Value)) + if c.Name == "xf_user" { + hasXfUser = true + } + } + } + + // Try manual redirect follow if still missing xf_user + if !hasXfUser && loginResp.StatusCode >= 300 && loginResp.StatusCode < 400 { + if loc := loginResp.Header.Get("Location"); loc != "" { + log.Printf("Following redirect to %s to check for xf_user...", loc) + followResp, err := crs.client.Get(fmt.Sprintf("https://%s%s", crs.domain, loc)) + if err == nil { + followResp.Body.Close() + cookies = crs.client.Jar.Cookies(cookieURL) + cookieStrs = []string{} // Reset + for _, c := range cookies { + if wanted[c.Name] { + cookieStrs = append(cookieStrs, fmt.Sprintf("%s=%s", c.Name, c.Value)) + if c.Name == "xf_user" { + hasXfUser = true + } + } + } + } + } + } + + if !hasXfUser { + // โŒ Return error (so FetchFreshCookie retries indefinitely) + return "", fmt.Errorf("xf_user cookie missing โ€” authentication failed, will retry") + } + + cookieString := strings.Join(cookieStrs, "; ") + log.Printf("โœ… Successfully fetched fresh cookie with xf_user") + return cookieString, nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func (crs *CookieRefreshService) Start() { + crs.wg.Add(1) + go crs.refreshLoop() +} + +func (crs *CookieRefreshService) refreshLoop() { + defer crs.wg.Done() + + log.Println("๐Ÿ”‘ Fetching initial cookie...") + freshCookie, err := crs.FetchFreshCookie() + if err != nil { + log.Printf("โŒ Failed to acquire initial cookie: %v", err) + return + } + + crs.cookieMu.Lock() + crs.currentCookie = freshCookie + crs.cookieMu.Unlock() + close(crs.cookieReady) + log.Println("โœ… Initial cookie acquired") + + ticker := time.NewTicker(CookieRefreshInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + log.Println("๐Ÿ”„ Starting automatic cookie refresh") + freshCookie, err := crs.FetchFreshCookie() + if err != nil { + log.Printf("โš ๏ธ Cookie refresh failed: %v", err) + } else { + crs.cookieMu.Lock() + crs.currentCookie = freshCookie + crs.cookieMu.Unlock() + log.Println("โœ… Cookie refresh completed") + } + case <-crs.stopChan: + return + } + } +} + +func (crs *CookieRefreshService) WaitForCookie() { + <-crs.cookieReady +} + +func (crs *CookieRefreshService) GetCurrentCookie() string { + crs.cookieMu.RLock() + defer crs.cookieMu.RUnlock() + return crs.currentCookie +} + +func (crs *CookieRefreshService) Stop() { + close(crs.stopChan) + crs.wg.Wait() +} + +// ----------------------------- +// Sneedchat Message Types +// ----------------------------- +type SneedMessage struct { + MessageID int `json:"message_id"` + Message string `json:"message"` + MessageRaw string `json:"message_raw"` + MessageEditDate int `json:"message_edit_date"` + Author map[string]interface{} `json:"author"` + Deleted bool `json:"deleted"` + IsDeleted bool `json:"is_deleted"` +} + +type SneedPayload struct { + Messages []SneedMessage `json:"messages"` + Message *SneedMessage `json:"message"` + Delete interface{} `json:"delete"` +} + +// ----------------------------- +// Sneedchat Client +// ----------------------------- +type SneedChatClient struct { + wsURL string + cookie string + cookieService *CookieRefreshService + roomID int + conn *websocket.Conn + connected bool + connMu sync.RWMutex + writeQueue chan string + stopChan chan struct{} + wg sync.WaitGroup + reconnectAttempts int + reconnectInterval time.Duration + lastMessageTime time.Time + + processedMessageIDs []int + messageEditDates *BoundedMap + processedMu sync.Mutex + + onMessage func(map[string]interface{}) + onEdit func(int, string) + onDelete func(int) + onConnect func() + onDisconnect func() + + recentOutboundIter func() []map[string]interface{} + mapDiscordSneed func(int, int, string) +} + +func NewSneedChatClient(cookie string, roomID int, cookieService *CookieRefreshService) *SneedChatClient { + return &SneedChatClient{ + wsURL: "wss://kiwifarms.st:9443/chat.ws", + cookie: cookie, + cookieService: cookieService, + roomID: roomID, + writeQueue: make(chan string, 100), + stopChan: make(chan struct{}), + reconnectInterval: ReconnectInterval, + processedMessageIDs: make([]int, 0, ProcessedCacheSize), + messageEditDates: NewBoundedMap(MappingCacheSize, MappingMaxAge), + lastMessageTime: time.Now(), + } +} + +func (sc *SneedChatClient) Connect() error { + sc.connMu.Lock() + if sc.connected { + sc.connMu.Unlock() + return nil + } + sc.connMu.Unlock() + + // Refresh cookie if available + if sc.cookieService != nil { + freshCookie := sc.cookieService.GetCurrentCookie() + if freshCookie != "" { + sc.cookie = freshCookie + } + } + + headers := http.Header{} + headers.Add("Cookie", sc.cookie) + + log.Printf("Connecting to Sneedchat room %d", sc.roomID) + conn, _, err := websocket.DefaultDialer.Dial(sc.wsURL, headers) + if err != nil { + return fmt.Errorf("websocket connection failed: %w", err) + } + + sc.connMu.Lock() + sc.conn = conn + sc.connected = true + sc.reconnectAttempts = 0 + sc.connMu.Unlock() + + // Start goroutines + sc.wg.Add(4) + go sc.readLoop() + go sc.writeLoop() + go sc.heartbeatLoop() + go sc.cleanupLoop() + + // Join room + sc.SendCommand(fmt.Sprintf("/join %d", sc.roomID)) + + log.Printf("โœ… Successfully connected to Sneedchat room %d", sc.roomID) + if sc.onConnect != nil { + sc.onConnect() + } + + return nil +} + +func (sc *SneedChatClient) Disconnect() { + log.Println("Disconnecting from Sneedchat") + + sc.connMu.Lock() + sc.connected = false + if sc.conn != nil { + sc.conn.Close() + } + sc.connMu.Unlock() + + close(sc.stopChan) + sc.wg.Wait() +} + +func (sc *SneedChatClient) readLoop() { + defer sc.wg.Done() + defer sc.handleDisconnect() + + for { + select { + case <-sc.stopChan: + return + default: + } + + sc.connMu.RLock() + conn := sc.conn + sc.connMu.RUnlock() + + if conn == nil { + return + } + + _, message, err := conn.ReadMessage() + if err != nil { + log.Printf("Sneedchat read error: %v", err) + return + } + + sc.lastMessageTime = time.Now() + sc.handleMessage(string(message)) + } +} + +func (sc *SneedChatClient) writeLoop() { + defer sc.wg.Done() + + for { + select { + case msg := <-sc.writeQueue: + sc.connMu.RLock() + conn := sc.conn + connected := sc.connected + sc.connMu.RUnlock() + + if !connected || conn == nil { + continue + } + + if err := conn.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil { + log.Printf("Sneedchat write error: %v", err) + return + } + + case <-sc.stopChan: + return + } + } +} + +func (sc *SneedChatClient) heartbeatLoop() { + defer sc.wg.Done() + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + sc.connMu.RLock() + connected := sc.connected + sc.connMu.RUnlock() + + if connected && time.Since(sc.lastMessageTime) > 60*time.Second { + sc.SendCommand("/ping") + } + + case <-sc.stopChan: + return + } + } +} + +func (sc *SneedChatClient) cleanupLoop() { + defer sc.wg.Done() + + ticker := time.NewTicker(MappingCleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + removed := sc.messageEditDates.CleanupOldEntries() + if removed > 0 { + log.Printf("๐Ÿงน Cleaned up %d old message edit tracking entries", removed) + } + + case <-sc.stopChan: + return + } + } +} + +func (sc *SneedChatClient) handleMessage(raw string) { + var payload SneedPayload + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return + } + + // Handle top-level deletes + if payload.Delete != nil { + var deleteIDs []int + switch v := payload.Delete.(type) { + case float64: + deleteIDs = []int{int(v)} + case []interface{}: + for _, id := range v { + if fid, ok := id.(float64); ok { + deleteIDs = append(deleteIDs, int(fid)) + } + } + } + + for _, did := range deleteIDs { + log.Printf("๐Ÿ—‘๏ธ Received top-level Sneed delete for id=%d", did) + sc.messageEditDates.Delete(did) + sc.removeFromProcessed(did) + if sc.onDelete != nil { + sc.onDelete(did) + } + } + } + + // Collect messages + var messages []SneedMessage + if len(payload.Messages) > 0 { + messages = payload.Messages + } else if payload.Message != nil { + messages = []SneedMessage{*payload.Message} + } + + for _, msg := range messages { + sc.processMessage(msg) + } +} + +func (sc *SneedChatClient) processMessage(msg SneedMessage) { + username := "Unknown" + var userID int + if author, ok := msg.Author["username"].(string); ok { + username = author + } + if id, ok := msg.Author["id"].(float64); ok { + userID = int(id) + } + + messageText := msg.MessageRaw + if messageText == "" { + messageText = msg.Message + } + messageText = html.UnescapeString(messageText) + + editDate := msg.MessageEditDate + deleted := msg.Deleted || msg.IsDeleted + + // Message-scoped deletion + if deleted { + log.Printf("๐Ÿ—‘๏ธ Sneed message-scoped deletion id=%d", msg.MessageID) + sc.messageEditDates.Delete(msg.MessageID) + sc.removeFromProcessed(msg.MessageID) + if sc.onDelete != nil { + sc.onDelete(msg.MessageID) + } + return + } + + // Skip bridge user echoes + bridgeUserID, _ := strconv.Atoi(os.Getenv("BRIDGE_USER_ID")) + bridgeUsername := os.Getenv("BRIDGE_USERNAME") + + if (bridgeUserID > 0 && userID == bridgeUserID) || (bridgeUsername != "" && username == bridgeUsername) { + log.Printf("๐Ÿšซ Received bridge-user echo from Sneed id=%d", msg.MessageID) + + // Attempt mapping + if msg.MessageID > 0 && sc.recentOutboundIter != nil && sc.mapDiscordSneed != nil { + now := time.Now() + for _, entry := range sc.recentOutboundIter() { + if mapped, ok := entry["mapped"].(bool); ok && mapped { + continue + } + if content, ok := entry["content"].(string); ok { + if ts, ok := entry["ts"].(time.Time); ok { + if content == messageText && now.Sub(ts) <= OutboundMatchWindow { + if discordID, ok := entry["discord_id"].(int); ok { + sc.mapDiscordSneed(discordID, msg.MessageID, username) + entry["mapped"] = true + break + } + } + } + } + } + } + + sc.addToProcessed(msg.MessageID) + sc.messageEditDates.Set(msg.MessageID, editDate) + return + } + + // Dedup / edit detection + if sc.isProcessed(msg.MessageID) { + if prevEdit, exists := sc.messageEditDates.Get(msg.MessageID); exists { + prevEditInt := prevEdit.(int) + if editDate > prevEditInt { + log.Printf("โœ๏ธ Edit detected for sneed_id=%d", msg.MessageID) + sc.messageEditDates.Set(msg.MessageID, editDate) + if sc.onEdit != nil { + sc.onEdit(msg.MessageID, messageText) + } + } + } + return + } + + // New message + log.Printf("๐Ÿ“„ New Sneed message from %s", username) + sc.addToProcessed(msg.MessageID) + sc.messageEditDates.Set(msg.MessageID, editDate) + + if sc.onMessage != nil { + sc.onMessage(map[string]interface{}{ + "username": username, + "content": messageText, + "raw": msg, + "message_id": msg.MessageID, + "author_id": userID, + }) + } +} + +func (sc *SneedChatClient) isProcessed(id int) bool { + sc.processedMu.Lock() + defer sc.processedMu.Unlock() + for _, pid := range sc.processedMessageIDs { + if pid == id { + return true + } + } + return false +} + +func (sc *SneedChatClient) addToProcessed(id int) { + sc.processedMu.Lock() + defer sc.processedMu.Unlock() + sc.processedMessageIDs = append(sc.processedMessageIDs, id) + if len(sc.processedMessageIDs) > ProcessedCacheSize { + sc.processedMessageIDs = sc.processedMessageIDs[1:] + } +} + +func (sc *SneedChatClient) removeFromProcessed(id int) { + sc.processedMu.Lock() + defer sc.processedMu.Unlock() + for i, pid := range sc.processedMessageIDs { + if pid == id { + sc.processedMessageIDs = append(sc.processedMessageIDs[:i], sc.processedMessageIDs[i+1:]...) + return + } + } +} + +func (sc *SneedChatClient) SendMessage(content string) bool { + sc.connMu.RLock() + connected := sc.connected + sc.connMu.RUnlock() + + if !connected { + log.Println("Cannot send to Sneedchat: not connected") + return false + } + + select { + case sc.writeQueue <- content: + return true + default: + log.Println("Write queue full") + return false + } +} + +func (sc *SneedChatClient) SendCommand(command string) { + sc.connMu.RLock() + connected := sc.connected + sc.connMu.RUnlock() + + if !connected { + return + } + + select { + case sc.writeQueue <- command: + default: + } +} + +func (sc *SneedChatClient) handleDisconnect() { + select { + case <-sc.stopChan: + return + default: + } + + sc.reconnectAttempts++ + sc.connMu.Lock() + sc.connected = false + sc.connMu.Unlock() + + log.Println("๐Ÿ”ด Sneedchat disconnected") + if sc.onDisconnect != nil { + sc.onDisconnect() + } + + time.Sleep(sc.reconnectInterval) + sc.Connect() +} + +// ----------------------------- +// Discord Bridge +// ----------------------------- +type OutboundEntry struct { + DiscordID int + Content string + Timestamp time.Time + Mapped bool +} + +type QueuedMessage struct { + Content string + ChannelID string + Timestamp time.Time + DiscordID int +} + +type DiscordBridge struct { + config *Config + session *discordgo.Session + sneedClient *SneedChatClient + httpClient *http.Client + + sneedToDiscord *BoundedMap + discordToSneed *BoundedMap + sneedUsernames *BoundedMap + + recentOutbound []OutboundEntry + recentOutboundMu sync.Mutex + + queuedOutbound []QueuedMessage + queuedOutboundMu sync.Mutex + + outageMessages []*discordgo.Message // Track all outage messages for cleanup + outageMessagesMu sync.Mutex + outageStart time.Time + outageMu sync.Mutex + + stopChan chan struct{} + wg sync.WaitGroup +} + +func NewDiscordBridge(config *Config, sneedClient *SneedChatClient) (*DiscordBridge, error) { + session, err := discordgo.New("Bot " + config.DiscordBotToken) + if err != nil { + return nil, err + } + + bridge := &DiscordBridge{ + config: config, + session: session, + sneedClient: sneedClient, + httpClient: &http.Client{Timeout: 60 * time.Second}, + sneedToDiscord: NewBoundedMap(MappingCacheSize, MappingMaxAge), + discordToSneed: NewBoundedMap(MappingCacheSize, MappingMaxAge), + sneedUsernames: NewBoundedMap(MappingCacheSize, MappingMaxAge), + recentOutbound: make([]OutboundEntry, 0, ProcessedCacheSize), + queuedOutbound: make([]QueuedMessage, 0), + outageMessages: make([]*discordgo.Message, 0), + stopChan: make(chan struct{}), + } + + // Set up callbacks + sneedClient.onMessage = bridge.onSneedMessage + sneedClient.onEdit = bridge.handleSneedEdit + sneedClient.onDelete = bridge.handleSneedDelete + sneedClient.onConnect = bridge.onSneedConnect + sneedClient.onDisconnect = bridge.onSneedDisconnect + + sneedClient.recentOutboundIter = bridge.recentOutboundIter + sneedClient.mapDiscordSneed = bridge.mapDiscordSneed + + // Set up Discord handlers + session.AddHandler(bridge.onDiscordReady) + session.AddHandler(bridge.onDiscordMessage) + session.AddHandler(bridge.onDiscordMessageEdit) + session.AddHandler(bridge.onDiscordMessageDelete) + + return bridge, nil +} + +func (db *DiscordBridge) Start() error { + db.session.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent + + if err := db.session.Open(); err != nil { + return err + } + + db.wg.Add(1) + go db.cleanupLoop() + + return nil +} + +func (db *DiscordBridge) Stop() { + close(db.stopChan) + db.session.Close() + db.wg.Wait() +} + +func (db *DiscordBridge) cleanupLoop() { + defer db.wg.Done() + + ticker := time.NewTicker(MappingCleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + removed := 0 + removed += db.sneedToDiscord.CleanupOldEntries() + removed += db.discordToSneed.CleanupOldEntries() + removed += db.sneedUsernames.CleanupOldEntries() + + if removed > 0 { + log.Printf("๐Ÿงน Cleaned up %d old message mappings", removed) + } + + // Cleanup expired queued messages + db.queuedOutboundMu.Lock() + now := time.Now() + before := len(db.queuedOutbound) + filtered := make([]QueuedMessage, 0) + for _, msg := range db.queuedOutbound { + if now.Sub(msg.Timestamp) <= QueuedMessageTTL { + filtered = append(filtered, msg) + } + } + db.queuedOutbound = filtered + after := len(db.queuedOutbound) + db.queuedOutboundMu.Unlock() + + if before > after { + log.Printf("๐Ÿงน Removed %d expired queued messages", before-after) + } + + case <-db.stopChan: + return + } + } +} + +func (db *DiscordBridge) onDiscordReady(s *discordgo.Session, event *discordgo.Ready) { + log.Printf("๐Ÿค– Discord bot ready: %s (id=%s)", event.User.Username, event.User.ID) + + if !db.sneedClient.connected { + go db.sneedClient.Connect() + } +} + +func (db *DiscordBridge) onDiscordMessage(s *discordgo.Session, m *discordgo.MessageCreate) { + if m.Author.Bot { + return + } + + if m.ChannelID != db.config.DiscordChannelID { + return + } + + // Handle commands + if strings.HasPrefix(m.Content, "!status") { + db.handleStatusCommand(s, m) + return + } + + if strings.HasPrefix(m.Content, "!test") { + db.handleTestCommand(s, m) + return + } + + log.Printf("๐Ÿ“ค Discord โ†’ Sneedchat: %s: %s", m.Author.Username, m.Content) + db.handleDiscordMessage(m) +} + +func (db *DiscordBridge) handleStatusCommand(s *discordgo.Session, m *discordgo.MessageCreate) { + status := "๐ŸŸข Connected" + color := 0x00FF00 + if !db.sneedClient.connected { + status = "๐Ÿ”ด Disconnected" + color = 0xFF0000 + } + + embed := &discordgo.MessageEmbed{ + Title: "๐ŸŒ‰ Bridge Status", + Description: fmt.Sprintf("**Sneedchat:** %s\n**Room ID:** %d", status, db.sneedClient.roomID), + Color: color, + } + + s.ChannelMessageSendEmbed(m.ChannelID, embed) +} + +func (db *DiscordBridge) handleTestCommand(s *discordgo.Session, m *discordgo.MessageCreate) { + text := "This is a test from !test" + if len(m.Content) > 6 { + text = strings.TrimSpace(m.Content[6:]) + } + + webhookID, webhookToken := parseWebhookURL(db.config.DiscordWebhookURL) + params := &discordgo.WebhookParams{ + Content: text, + Username: "SneedTestUser", + } + + _, err := s.WebhookExecute(webhookID, webhookToken, true, params) + if err != nil { + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("โŒ Failed: %v", err)) + return + } + + s.ChannelMessageSend(m.ChannelID, "โœ… Test message sent via webhook.") +} + +func (db *DiscordBridge) handleDiscordMessage(m *discordgo.MessageCreate) { + contentText := strings.TrimSpace(m.Content) + + // Handle reply mapping + if m.ReferencedMessage != nil { + refDiscordID := m.ReferencedMessage.ID + if sneedIDInt, exists := db.discordToSneed.Get(parseMessageID(refDiscordID)); exists { + if username, exists := db.sneedUsernames.Get(sneedIDInt.(int)); exists { + contentText = fmt.Sprintf("@%s, %s", username.(string), contentText) + } + } + } + + // Handle attachments + var attachmentsBB []string + if len(m.Attachments) > MaxAttachments { + db.session.ChannelMessageSend(m.ChannelID, fmt.Sprintf("โŒ Refusing to upload attachments: limit is %d.", MaxAttachments)) + return + } + + for _, att := range m.Attachments { + catboxURL, err := db.uploadToLitterbox(att.URL, att.Filename) + if err != nil { + db.session.ChannelMessageSend(m.ChannelID, fmt.Sprintf("โŒ Failed to upload attachment `%s` to Litterbox; aborting send.", att.Filename)) + log.Printf("Attachment upload failed for %s: %v", att.Filename, err) + return + } + + contentType := strings.ToLower(att.ContentType) + if strings.HasPrefix(contentType, "video") || strings.HasSuffix(strings.ToLower(att.Filename), ".mp4") || + strings.HasSuffix(strings.ToLower(att.Filename), ".webm") { + attachmentsBB = append(attachmentsBB, fmt.Sprintf("[url=%s][video]%s[/video][/url]", catboxURL, catboxURL)) + } else { + attachmentsBB = append(attachmentsBB, fmt.Sprintf("[url=%s][img]%s[/img][/url]", catboxURL, catboxURL)) + } + } + + combined := contentText + if len(attachmentsBB) > 0 { + if combined != "" { + combined += "\n" + } + combined += strings.Join(attachmentsBB, "\n") + } + + // Try to send + sent := db.sneedClient.SendMessage(combined) + if sent { + db.recentOutboundMu.Lock() + entry := OutboundEntry{ + DiscordID: parseMessageID(m.ID), + Content: combined, + Timestamp: time.Now(), + Mapped: false, + } + db.recentOutbound = append(db.recentOutbound, entry) + if len(db.recentOutbound) > ProcessedCacheSize { + db.recentOutbound = db.recentOutbound[1:] + } + db.recentOutboundMu.Unlock() + } else { + // Queue message + db.queuedOutboundMu.Lock() + db.queuedOutbound = append(db.queuedOutbound, QueuedMessage{ + Content: combined, + ChannelID: m.ChannelID, + Timestamp: time.Now(), + DiscordID: parseMessageID(m.ID), + }) + db.queuedOutboundMu.Unlock() + + db.session.ChannelMessageSend(m.ChannelID, fmt.Sprintf("โš ๏ธ Sneedchat appears offline. Your message has been queued for delivery (will expire after %ds).", int(QueuedMessageTTL.Seconds()))) + } +} + +func (db *DiscordBridge) uploadToLitterbox(fileURL, filename string) (string, error) { + // Download from Discord CDN + resp, err := db.httpClient.Get(fileURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + // Upload to Litterbox + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + writer.WriteField("reqtype", "fileupload") + writer.WriteField("time", LitterboxTTL) + + part, err := writer.CreateFormFile("fileToUpload", filename) + if err != nil { + return "", err + } + part.Write(data) + writer.Close() + + req, err := http.NewRequest("POST", "https://litterbox.catbox.moe/resources/internals/api.php", body) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + uploadResp, err := db.httpClient.Do(req) + if err != nil { + return "", err + } + defer uploadResp.Body.Close() + + if uploadResp.StatusCode != 200 { + return "", fmt.Errorf("Litterbox returned HTTP %d", uploadResp.StatusCode) + } + + urlBytes, err := io.ReadAll(uploadResp.Body) + if err != nil { + return "", err + } + + url := strings.TrimSpace(string(urlBytes)) + log.Printf("SUCCESS: Uploaded '%s' to Litterbox: %s", filename, url) + return url, nil +} + +func (db *DiscordBridge) onDiscordMessageEdit(s *discordgo.Session, m *discordgo.MessageUpdate) { + if m.Author == nil || m.Author.Bot { + return + } + + discordID := parseMessageID(m.ID) + sneedIDInt, exists := db.discordToSneed.Get(discordID) + if !exists { + return + } + + sneedID := sneedIDInt.(int) + payload := map[string]interface{}{ + "id": sneedID, + "message": strings.TrimSpace(m.Content), + } + + payloadJSON, _ := json.Marshal(payload) + log.Printf("โ†ฉ๏ธ Discord edit -> Sneedchat (sneed_id=%d)", sneedID) + db.sneedClient.SendCommand(fmt.Sprintf("/edit %s", string(payloadJSON))) +} + +func (db *DiscordBridge) onDiscordMessageDelete(s *discordgo.Session, m *discordgo.MessageDelete) { + discordID := parseMessageID(m.ID) + sneedIDInt, exists := db.discordToSneed.Get(discordID) + if !exists { + return + } + + sneedID := sneedIDInt.(int) + log.Printf("โ†ฉ๏ธ Discord delete -> Sneedchat (sneed_id=%d)", sneedID) + db.sneedClient.SendCommand(fmt.Sprintf("/delete %d", sneedID)) +} + +func (db *DiscordBridge) onSneedMessage(msg map[string]interface{}) { + username, _ := msg["username"].(string) + rawContent, _ := msg["content"].(string) + content := bbcodeToMarkdown(rawContent) + messageID, _ := msg["message_id"].(int) + + // Replace bridge username mentions with Discord ping + if db.config.BridgeUsername != "" && db.config.DiscordPingUserID != "" { + pattern := regexp.MustCompile(fmt.Sprintf(`(?i)@%s(?:\W|$)`, regexp.QuoteMeta(db.config.BridgeUsername))) + content = pattern.ReplaceAllString(content, fmt.Sprintf("<@%s>", db.config.DiscordPingUserID)) + } + + // Get avatar URL + var avatarURL string + if raw, ok := msg["raw"].(SneedMessage); ok { + if author, ok := raw.Author["avatar_url"].(string); ok { + if strings.HasPrefix(author, "/") { + avatarURL = "https://kiwifarms.st" + author + } else { + avatarURL = author + } + } + } + + // Send via webhook + webhookID, webhookToken := parseWebhookURL(db.config.DiscordWebhookURL) + params := &discordgo.WebhookParams{ + Content: content, + Username: username, + AvatarURL: avatarURL, + } + + sent, err := db.session.WebhookExecute(webhookID, webhookToken, true, params) + if err != nil { + log.Printf("โŒ Failed to send Sneed โ†’ Discord webhook message: %v", err) + return + } + + log.Printf("โœ… Sent Sneedchat โ†’ Discord: %s", username) + + // Map IDs + if messageID > 0 && sent != nil { + discordMsgID := parseMessageID(sent.ID) + db.sneedToDiscord.Set(messageID, discordMsgID) + db.discordToSneed.Set(discordMsgID, messageID) + db.sneedUsernames.Set(messageID, username) + } +} + +func (db *DiscordBridge) handleSneedEdit(sneedID int, newContent string) { + discordMsgIDInt, exists := db.sneedToDiscord.Get(sneedID) + if !exists { + return + } + + discordMsgID := discordMsgIDInt.(int) + parsed := bbcodeToMarkdown(newContent) + + webhookID, webhookToken := parseWebhookURL(db.config.DiscordWebhookURL) + edit := &discordgo.WebhookEdit{ + Content: &parsed, + } + + _, err := db.session.WebhookMessageEdit(webhookID, webhookToken, fmt.Sprintf("%d", discordMsgID), edit) + if err != nil { + log.Printf("โŒ Failed to edit Discord message id=%d: %v", discordMsgID, err) + return + } + + log.Printf("โœ๏ธ Edited Discord (webhook) message id=%d (sneed_id=%d)", discordMsgID, sneedID) +} + +func (db *DiscordBridge) handleSneedDelete(sneedID int) { + discordMsgIDInt, exists := db.sneedToDiscord.Get(sneedID) + if !exists { + return + } + + discordMsgID := discordMsgIDInt.(int) + + webhookID, webhookToken := parseWebhookURL(db.config.DiscordWebhookURL) + err := db.session.WebhookMessageDelete(webhookID, webhookToken, fmt.Sprintf("%d", discordMsgID)) + if err != nil { + log.Printf("โŒ Failed to delete Discord message id=%d: %v", discordMsgID, err) + return + } + + log.Printf("๐Ÿ—‘๏ธ Deleted Discord (webhook) message id=%d (sneed_id=%d)", discordMsgID, sneedID) + db.sneedToDiscord.Delete(sneedID) + db.discordToSneed.Delete(discordMsgID) + db.sneedUsernames.Delete(sneedID) +} + +func (db *DiscordBridge) onSneedConnect() { + log.Println("๐ŸŸข Sneedchat connected") + db.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "online"}) + + // Update the most recent outage message + db.outageMessagesMu.Lock() + if len(db.outageMessages) > 0 { + lastMessage := db.outageMessages[len(db.outageMessages)-1] + elapsed := int(time.Since(db.outageStart).Seconds()) + embed := &discordgo.MessageEmbed{ + Title: "๐ŸŒ‰ Bridge Status", + Description: "โœ… **Sneedchat reconnected**", + Color: 0x00FF00, + Fields: []*discordgo.MessageEmbedField{ + {Name: "Downtime", Value: fmt.Sprintf("%ds", elapsed), Inline: true}, + {Name: "Reconnect Attempts", Value: fmt.Sprintf("%d", db.sneedClient.reconnectAttempts), Inline: true}, + {Name: "Room ID", Value: fmt.Sprintf("%d", db.sneedClient.roomID), Inline: true}, + }, + } + + db.session.ChannelMessageEditEmbed(lastMessage.ChannelID, lastMessage.ID, embed) + + // Schedule cleanup of all outage messages after 2 minutes + go db.cleanupOutageMessages(2 * time.Minute) + } + db.outageMessagesMu.Unlock() + + // Flush queued messages + go db.flushQueuedMessages() +} + +func (db *DiscordBridge) cleanupOutageMessages(delay time.Duration) { + time.Sleep(delay) + + db.outageMessagesMu.Lock() + messagesToDelete := make([]*discordgo.Message, len(db.outageMessages)) + copy(messagesToDelete, db.outageMessages) + db.outageMessages = db.outageMessages[:0] // Clear the slice + db.outageMessagesMu.Unlock() + + log.Printf("๐Ÿงน Cleaning up %d old outage notification(s)", len(messagesToDelete)) + + for _, msg := range messagesToDelete { + err := db.session.ChannelMessageDelete(msg.ChannelID, msg.ID) + if err != nil { + log.Printf("Failed to delete outage message %s: %v", msg.ID, err) + } + } + + if len(messagesToDelete) > 0 { + log.Println("โœ… Outage notifications cleaned up") + } +} + +func (db *DiscordBridge) onSneedDisconnect() { + log.Println("๐Ÿ”ด Sneedchat disconnected") + db.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "idle"}) + + db.outageStart = time.Now() + embed := &discordgo.MessageEmbed{ + Title: "๐ŸŒ‰ Bridge Status", + Description: "โš ๏ธ **Sneedchat disconnected**", + Color: 0xFF0000, + Fields: []*discordgo.MessageEmbedField{ + {Name: "Outage Duration", Value: "0s", Inline: true}, + {Name: "Reconnect Attempts", Value: "0", Inline: true}, + {Name: "Room ID", Value: fmt.Sprintf("%d", db.sneedClient.roomID), Inline: true}, + }, + } + + msg, err := db.session.ChannelMessageSendEmbed(db.config.DiscordChannelID, embed) + if err != nil { + log.Printf("Failed to send outage notice: %v", err) + return + } + + // Add to outage messages list + db.outageMessagesMu.Lock() + db.outageMessages = append(db.outageMessages, msg) + db.outageMessagesMu.Unlock() + + // Start updater for this specific message + go db.outageUpdater(msg) +} + +func (db *DiscordBridge) outageUpdater(msg *discordgo.Message) { + ticker := time.NewTicker(OutageUpdateInterval) + defer ticker.Stop() + + for { + <-ticker.C + + // Stop updating if Sneedchat is connected + if db.sneedClient.connected { + return + } + + elapsed := int(time.Since(db.outageStart).Seconds()) + embed := &discordgo.MessageEmbed{ + Title: "๐ŸŒ‰ Bridge Status", + Description: "โš ๏ธ **Sneedchat outage ongoing**", + Color: 0xFF0000, + Fields: []*discordgo.MessageEmbedField{ + {Name: "Outage Duration", Value: fmt.Sprintf("%ds", elapsed), Inline: true}, + {Name: "Reconnect Attempts", Value: fmt.Sprintf("%d", db.sneedClient.reconnectAttempts), Inline: true}, + {Name: "Room ID", Value: fmt.Sprintf("%d", db.sneedClient.roomID), Inline: true}, + }, + } + + db.session.ChannelMessageEditEmbed(msg.ChannelID, msg.ID, embed) + } +} + +func (db *DiscordBridge) flushQueuedMessages() { + db.queuedOutboundMu.Lock() + queued := make([]QueuedMessage, len(db.queuedOutbound)) + copy(queued, db.queuedOutbound) + db.queuedOutbound = db.queuedOutbound[:0] + db.queuedOutboundMu.Unlock() + + if len(queued) == 0 { + return + } + + log.Printf("Flushing %d queued messages to Sneedchat", len(queued)) + + for _, msg := range queued { + age := time.Since(msg.Timestamp) + if age > QueuedMessageTTL { + db.session.ChannelMessageSend(msg.ChannelID, fmt.Sprintf("โŒ Failed to deliver message queued %ds ago (expired)", int(age.Seconds()))) + continue + } + + sent := db.sneedClient.SendMessage(msg.Content) + if sent { + db.recentOutboundMu.Lock() + db.recentOutbound = append(db.recentOutbound, OutboundEntry{ + DiscordID: msg.DiscordID, + Content: msg.Content, + Timestamp: time.Now(), + Mapped: false, + }) + if len(db.recentOutbound) > ProcessedCacheSize { + db.recentOutbound = db.recentOutbound[1:] + } + db.recentOutboundMu.Unlock() + + db.session.ChannelMessageSend(msg.ChannelID, "โœ… Queued message delivered to Sneedchat after reconnect.") + } + } +} + +func (db *DiscordBridge) recentOutboundIter() []map[string]interface{} { + db.recentOutboundMu.Lock() + defer db.recentOutboundMu.Unlock() + + result := make([]map[string]interface{}, len(db.recentOutbound)) + for i, entry := range db.recentOutbound { + result[i] = map[string]interface{}{ + "discord_id": entry.DiscordID, + "content": entry.Content, + "ts": entry.Timestamp, + "mapped": entry.Mapped, + } + } + return result +} + +func (db *DiscordBridge) mapDiscordSneed(discordID, sneedID int, username string) { + db.discordToSneed.Set(discordID, sneedID) + db.sneedToDiscord.Set(sneedID, discordID) + db.sneedUsernames.Set(sneedID, username) + log.Printf("Mapped sneed_id=%d <-> discord_id=%d (username='%s')", sneedID, discordID, username) +} + +// ----------------------------- +// Helper Functions +// ----------------------------- +func parseWebhookURL(webhookURL string) (string, string) { + parts := strings.Split(webhookURL, "/") + if len(parts) < 2 { + return "", "" + } + return parts[len(parts)-2], parts[len(parts)-1] +} + +func parseMessageID(id string) int { + parsed, _ := strconv.ParseInt(id, 10, 64) + return int(parsed) +} + +// ----------------------------- +// Main +// ----------------------------- +func main() { + envFile := ".env" + if len(os.Args) > 1 { + for i, arg := range os.Args { + if arg == "--env" && i+1 < len(os.Args) { + envFile = os.Args[i+1] + } + } + } + + config, err := loadConfig(envFile) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + log.Printf("Using .env file: %s", envFile) + log.Printf("Using Sneedchat room ID: %d", config.SneedchatRoomID) + log.Printf("Bridge username: %s", config.BridgeUsername) + + // Start cookie service + cookieService, err := NewCookieRefreshService(config.BridgeUsername, config.BridgePassword, "kiwifarms.st") + if err != nil { + log.Fatalf("Failed to create cookie service: %v", err) + } + + cookieService.Start() + log.Println("โณ Waiting for initial cookie...") + cookieService.WaitForCookie() + + initialCookie := cookieService.GetCurrentCookie() + if initialCookie == "" { + log.Fatal("โŒ Failed to obtain initial cookie, cannot start bridge") + } + + // Create Sneedchat client + sneedClient := NewSneedChatClient(initialCookie, config.SneedchatRoomID, cookieService) + + // Create Discord bridge + bridge, err := NewDiscordBridge(config, sneedClient) + if err != nil { + log.Fatalf("Failed to create Discord bridge: %v", err) + } + + // Start bridge + if err := bridge.Start(); err != nil { + log.Fatalf("Failed to start Discord bridge: %v", err) + } + + log.Println("๐ŸŒ‰ Discord-Sneedchat Bridge started successfully") + + // Wait for interrupt signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Println("Shutdown signal received, cleaning up...") + + // Cleanup + bridge.Stop() + sneedClient.Disconnect() + cookieService.Stop() + + log.Println("Bridge stopped successfully") +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1c26b29 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module sneedchat-discord-bridge + +go 1.21 + +require ( + github.com/bwmarrin/discordgo v0.27.1 + github.com/gorilla/websocket v1.5.1 + github.com/joho/godotenv v1.5.1 +) + +require ( + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect +)