diff --git a/README.md b/README.md index a36cb2d..fae7160 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,6 @@ go version # Should show 1.19 or higher ```bash # Create installation directory -sudo mkdir -p /opt/sneedchat-bridge cd /opt/sneedchat-bridge # Clone repository diff --git a/Sneedchat-Discord-Bridge.go b/Sneedchat-Discord-Bridge.go deleted file mode 100644 index fbd7134..0000000 --- a/Sneedchat-Discord-Bridge.go +++ /dev/null @@ -1,1928 +0,0 @@ -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 - } - - // Add User-Agent to mimic browser - transport := &http.Transport{} - - return &CookieRefreshService{ - username: username, - password: password, - domain: domain, - client: &http.Client{ - Jar: jar, - Timeout: 30 * time.Second, - Transport: transport, - }, - cookieReady: make(chan struct{}), - stopChan: make(chan struct{}), - }, nil -} - - - -func (crs *CookieRefreshService) getClearanceToken() (string, error) { - baseURL := fmt.Sprintf("https://%s/", crs.domain) - - req, _ := http.NewRequest("GET", baseURL, nil) - req.Header.Set("User-Agent", randomUserAgent()) - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - req.Header.Set("Connection", "keep-alive") - - resp, err := crs.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - // Detect challenge - pattern := regexp.MustCompile(`data-sssg-challenge=["']([^"']+)["'][^>]*data-sssg-difficulty=["'](\d+)["']`) - m := pattern.FindStringSubmatch(string(body)) - if len(m) < 3 { - log.Println("No KiwiFlare challenge required") - return "", nil - } - - salt := m[1] - difficulty, _ := strconv.Atoi(m[2]) - if difficulty == 0 { - return "", nil - } - - log.Printf("Solving KiwiFlare challenge (difficulty=%d)", difficulty) - time.Sleep(time.Duration(500+rand.Intn(750)) * time.Millisecond) // human delay before compute - - nonce, err := crs.solvePoW(salt, difficulty) - if err != nil { - return "", err - } - - // Delay between solve and submit to mimic browser JS runtime - time.Sleep(time.Duration(700+rand.Intn(900)) * time.Millisecond) - - submitURL := fmt.Sprintf("https://%s/.sssg/api/answer", crs.domain) - formData := url.Values{"a": {salt}, "b": {nonce}} - - submitReq, _ := http.NewRequest("POST", submitURL, strings.NewReader(formData.Encode())) - submitReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - submitReq.Header.Set("User-Agent", randomUserAgent()) - submitReq.Header.Set("Origin", fmt.Sprintf("https://%s", crs.domain)) - submitReq.Header.Set("Referer", baseURL) - - submitResp, err := crs.client.Do(submitReq) - if err != nil { - return "", err - } - defer submitResp.Body.Close() - - if submitResp.StatusCode != http.StatusOK { - return "", fmt.Errorf("KiwiFlare submit returned HTTP %d", submitResp.StatusCode) - } - - // Pause before reading cookie jar - time.Sleep(time.Duration(1200+rand.Intn(800)) * time.Millisecond) - - cookieURL, _ := url.Parse(baseURL) - cookies := crs.client.Jar.Cookies(cookieURL) - for _, c := range cookies { - if c.Name == "sssg_clearance" { - log.Printf("βœ… KiwiFlare clearance cookie confirmed: %s...", c.Value[:min(10, len(c.Value))]) - return c.Value, nil - } - } - - return "", fmt.Errorf("no sssg_clearance cookie after solve") -} - -func (crs *CookieRefreshService) solvePoW(salt string, difficulty int) (string, error) { - start := time.Now() - requiredBytes := difficulty / 8 - requiredBits := difficulty % 8 - - for nonce := rand.Int63(); ; 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 { - elapsed := time.Since(start) - // Minimum 2–4 second human-like response window - minDelay := time.Duration(2+rand.Intn(3)) * time.Second - if elapsed < minDelay { - time.Sleep(minDelay - elapsed) - } - return fmt.Sprintf("%d", nonce), nil - } - } -} - -func randomUserAgent() string { - agents := []string{ - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", - } - return agents[rand.Intn(len(agents))] -} - - -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) { - // DON'T create a fresh jar - reuse the existing one so clearance cookie persists - // Only reset the transport for fresh TLS - crs.client.Transport = &http.Transport{} - - 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") - log.Println("⏳ Waiting 3 seconds for cookie propagation...") - time.Sleep(3 * time.Second) - } - - // 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.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - 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) - - // Small delay after getting login page - log.Println("⏳ Waiting 1 second before processing login page...") - time.Sleep(1 * time.Second) - - // 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"}, - } - - // Debug: Show cookies being sent - debugCookieURL, _ := url.Parse(fmt.Sprintf("https://%s/", crs.domain)) - currentCookies := crs.client.Jar.Cookies(debugCookieURL) - log.Printf("Cookies before login POST (%d):", len(currentCookies)) - for _, c := range currentCookies { - log.Printf(" - %s = %s...", c.Name, c.Value[:min(10, len(c.Value))]) - } - - crs.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - - // Create POST request manually to add User-Agent - postReq, _ := http.NewRequest("POST", postURL, strings.NewReader(formData.Encode())) - postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - postReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - postReq.Header.Set("Referer", loginURL) - postReq.Header.Set("Origin", fmt.Sprintf("https://%s", crs.domain)) - - loginResp, err := crs.client.Do(postReq) - if err != nil { - return "", fmt.Errorf("login POST failed: %w", err) - } - defer loginResp.Body.Close() - - log.Printf("Login response status: %d", loginResp.StatusCode) - - // Debug: Check if we got Set-Cookie headers - setCookies := loginResp.Header.Values("Set-Cookie") - if len(setCookies) > 0 { - log.Printf("Set-Cookie headers received: %d", len(setCookies)) - for _, sc := range setCookies { - log.Printf(" - %s", sc[:min(80, len(sc))]) - } - } else { - log.Println("⚠️ No Set-Cookie headers in login response!") - } - - // If we got HTTP 200, login failed - read the error - if loginResp.StatusCode == 200 { - bodyBytes, _ := io.ReadAll(loginResp.Body) - bodyText := string(bodyBytes) - - // Look for XenForo error messages - errorPatterns := []*regexp.Regexp{ - regexp.MustCompile(`
]*data-message="([^"]+)"`), - regexp.MustCompile(`"errors":\s*\[(.*?)\]`), - } - - var errorMsg string - for _, pattern := range errorPatterns { - if matches := pattern.FindStringSubmatch(bodyText); len(matches) >= 2 { - errorMsg = matches[1] - // Strip HTML tags - errorMsg = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(errorMsg, "") - break - } - } - - if errorMsg != "" { - log.Printf("❌ Login error from server: %s", strings.TrimSpace(errorMsg)) - } else { - log.Printf("❌ Login failed (HTTP 200). Response snippet:\n%s", bodyText[:min(1000, len(bodyText))]) - } - - return "", fmt.Errorf("login failed with HTTP 200 - server rejected credentials or challenge not accepted") - } - - // Delay before checking cookies - log.Println("⏳ Waiting 1 second for login to process...") - time.Sleep(1 * time.Second) - - // 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) - time.Sleep(1 * time.Second) // Wait before following redirect - - followURL := loc - if !strings.HasPrefix(loc, "http") { - followURL = fmt.Sprintf("https://%s%s", crs.domain, loc) - } - - followReq, _ := http.NewRequest("GET", followURL, nil) - followReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - - followResp, err := crs.client.Do(followReq) - if err == nil { - followResp.Body.Close() - time.Sleep(1 * time.Second) // Wait after redirect - 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/config/config.go b/config/config.go new file mode 100644 index 0000000..25bace0 --- /dev/null +++ b/config/config.go @@ -0,0 +1,52 @@ +package config + +import ( + "fmt" + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + DiscordBotToken string + DiscordChannelID string + DiscordGuildID string + DiscordWebhookURL string + SneedchatRoomID int + BridgeUsername string + BridgePassword string + BridgeUserID int + DiscordPingUserID string + Debug bool +} + +func Load(envFile string) (*Config, error) { + if err := godotenv.Load(envFile); err != nil { + log.Printf("Warning: error loading %s: %v", envFile, err) + } + cfg := &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"), + DiscordPingUserID: os.Getenv("DISCORD_PING_USER_ID"), + } + + roomID, err := strconv.Atoi(os.Getenv("SNEEDCHAT_ROOM_ID")) + if err != nil { + return nil, fmt.Errorf("invalid SNEEDCHAT_ROOM_ID: %w", err) + } + cfg.SneedchatRoomID = roomID + + if v := os.Getenv("BRIDGE_USER_ID"); v != "" { + cfg.BridgeUserID, _ = strconv.Atoi(v) + } + if os.Getenv("DEBUG") == "1" || os.Getenv("DEBUG") == "true" { + cfg.Debug = true + } + return cfg, nil +} \ No newline at end of file diff --git a/cookie/fetcher.go b/cookie/fetcher.go new file mode 100644 index 0000000..6fdd634 --- /dev/null +++ b/cookie/fetcher.go @@ -0,0 +1,302 @@ +package cookie + +import ( + "compress/gzip" + "crypto/tls" + "fmt" + "io" + "log" + "math/rand" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "strings" + "sync" + "time" +) + +const ( + CookieRetryDelay = 5 * time.Second + MaxCookieRetryDelay = 60 * time.Second + CookieRefreshEvery = 4 * time.Hour +) + +type RefreshService struct { + username, password, domain string + client *http.Client + + cookieMu sync.RWMutex + currentCookie string + + readyOnce sync.Once + readyCh chan struct{} + + stopCh chan struct{} + wg sync.WaitGroup +} + +func NewRefreshService(username, password, domain string) *RefreshService { + jar, _ := cookiejar.New(nil) + tr := &http.Transport{ + // Force HTTP/1.1 (avoid ALPN h2 differences) + TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), + } + client := &http.Client{ + Jar: jar, + Timeout: 30 * time.Second, + Transport: tr, + } + return &RefreshService{ + username: username, + password: password, + domain: domain, + client: client, + readyCh: make(chan struct{}), + stopCh: make(chan struct{}), + } +} + +func (r *RefreshService) Start() { + r.wg.Add(1) + go r.loop() +} + +func (r *RefreshService) Stop() { + close(r.stopCh) + r.wg.Wait() +} + +func (r *RefreshService) WaitForCookie() { <-r.readyCh } + +func (r *RefreshService) GetCurrentCookie() string { + r.cookieMu.RLock() + defer r.cookieMu.RUnlock() + return r.currentCookie +} + +func (r *RefreshService) loop() { + defer r.wg.Done() + + log.Println("πŸ”‘ Fetching initial cookie...") + c, err := r.FetchFreshCookie() + if err != nil { + log.Printf("❌ Failed to acquire initial cookie: %v", err) + return + } + r.cookieMu.Lock() + r.currentCookie = c + r.cookieMu.Unlock() + r.readyOnce.Do(func() { close(r.readyCh) }) + log.Println("βœ… Initial cookie acquired") +} + +func (r *RefreshService) FetchFreshCookie() (string, error) { + attempt := 0 + delay := CookieRetryDelay + for { + select { + case <-r.stopCh: + return "", fmt.Errorf("stopped") + default: + } + + attempt++ + if attempt > 1 { + log.Printf("πŸ”„ Cookie fetch retry attempt %d (waiting %v)...", attempt, delay) + time.Sleep(delay) + delay *= 2 + if delay > MaxCookieRetryDelay { + delay = MaxCookieRetryDelay + } + } + + c, err := r.attemptFetchCookie() + if err != nil { + log.Printf("⚠️ Cookie fetch attempt %d failed: %v", attempt, err) + continue + } + if strings.Contains(c, "xf_user=") { + log.Printf("βœ… Successfully fetched fresh cookie with xf_user (attempt %d)", attempt) + r.cookieMu.Lock() + r.currentCookie = c + r.cookieMu.Unlock() + return c, nil + } + log.Printf("❌ Cookie fetch attempt %d missing xf_user β€” retrying...", attempt) + } +} + +func (r *RefreshService) attemptFetchCookie() (string, error) { + // Step 1: KiwiFlare + log.Println("Step 1: Checking for KiwiFlare challenge...") + clearance, err := r.getClearanceToken() + if err != nil { + return "", fmt.Errorf("clearance token error: %w", err) + } + if clearance != "" { + log.Println("βœ… KiwiFlare challenge solved") + log.Println("⏳ Waiting 2 seconds for cookie propagation...") + time.Sleep(2 * time.Second) + } + + // Step 2: GET /login + log.Println("Step 2: Fetching login page...") + loginURL := fmt.Sprintf("https://%s/login/", r.domain) + req, _ := http.NewRequest("GET", loginURL, nil) + req.Header.Set("User-Agent", randomUserAgent()) + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Pragma", "no-cache") + req.URL.RawQuery = fmt.Sprintf("r=%d", rand.Intn(1_000_000)) + + resp, err := r.client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to get login page: %w", err) + } + defer resp.Body.Close() + log.Printf("β†’ Using protocol for login page: %s", resp.Proto) + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + log.Println("⏳ Waiting 1 second before processing login page...") + time.Sleep(1 * time.Second) + + // Step 3: Extract CSRF + log.Println("Step 3: Extracting CSRF token...") + var csrf string + for _, pat := range []*regexp.Regexp{ + regexp.MustCompile(`]*data-csrf=["']([^"']+)["']`), + regexp.MustCompile(`name="_xfToken" value="([^"]+)"`), + regexp.MustCompile(`data-csrf=["']([^"']+)["']`), + regexp.MustCompile(`"csrf":"([^"]+)"`), + regexp.MustCompile(`XF\.config\.csrf\s*=\s*"([^"]+)"`), + } { + if m := pat.FindStringSubmatch(bodyStr); len(m) >= 2 { + csrf = m[1] + break + } + } + if csrf == "" { + 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...", csrf[:min(10, len(csrf))]) + + // Step 4: POST /login/login + log.Println("Step 4: Submitting login credentials...") + postURL := fmt.Sprintf("https://%s/login/login", r.domain) + form := url.Values{ + "_xfToken": {csrf}, + "_xfRequestUri": {"/"}, + "_xfWithData": {"1"}, + "login": {r.username}, + "password": {r.password}, + "_xfRedirect": {fmt.Sprintf("https://%s/", r.domain)}, + "remember": {"1"}, + } + + // ensure GET cookies are kept + cookieURL, _ := url.Parse(fmt.Sprintf("https://%s/", r.domain)) + if resp.Cookies() != nil { + r.client.Jar.SetCookies(cookieURL, resp.Cookies()) + } + + postReq, _ := http.NewRequest("POST", postURL, strings.NewReader(form.Encode())) + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + postReq.Header.Set("User-Agent", randomUserAgent()) + postReq.Header.Set("Referer", loginURL) + postReq.Header.Set("Origin", fmt.Sprintf("https://%s", r.domain)) + postReq.Header.Set("X-XF-Token", csrf) + postReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + postReq.Header.Set("Accept-Language", "en-US,en;q=0.9") + postReq.Header.Set("Accept-Encoding", "gzip, deflate") // avoid br + + loginResp, err := r.client.Do(postReq) + if err != nil { + return "", fmt.Errorf("login POST failed: %w", err) + } + defer loginResp.Body.Close() + log.Printf("Login response status: %d", loginResp.StatusCode) + + // Follow a single redirect (XenForo usually sets xf_user on redirect target) + if 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) + url2 := loc + if !strings.HasPrefix(loc, "http") { + url2 = fmt.Sprintf("https://%s%s", r.domain, loc) + } + time.Sleep(1 * time.Second) + if fr, err := r.client.Get(url2); err == nil { + fr.Body.Close() + time.Sleep(500 * time.Millisecond) + } + } + } + + // Decode response (gzip) + var reader io.ReadCloser + if loginResp.Header.Get("Content-Encoding") == "gzip" { + gz, ge := gzip.NewReader(loginResp.Body) + if ge == nil { + reader = gz + defer gz.Close() + } else { + reader = io.NopCloser(loginResp.Body) + } + } else { + reader = io.NopCloser(loginResp.Body) + } + respHTML, _ := io.ReadAll(reader) + if strings.Contains(string(respHTML), `data-logged-in="false"`) { + log.Println("⚠️ HTML indicates still logged out (data-logged-in=false)") + time.Sleep(1 * time.Second) + return r.retryWithFreshCSRF() + } + + // Normalize cookie domains and compose cookie string + cookies := r.client.Jar.Cookies(cookieURL) + for _, c := range cookies { + c.Domain = strings.TrimPrefix(c.Domain, ".") + } + r.client.Jar.SetCookies(cookieURL, cookies) + + want := map[string]bool{ + "xf_user": true, + "xf_toggle": true, + "xf_csrf": true, + "xf_session": true, + "sssg_clearance": true, + } + var parts []string + hasUser := false + for _, c := range cookies { + if want[c.Name] { + parts = append(parts, fmt.Sprintf("%s=%s", c.Name, c.Value)) + if c.Name == "xf_user" { + hasUser = true + } + } + } + if !hasUser { + return "", fmt.Errorf("xf_user cookie missing β€” authentication failed, will retry") + } + return strings.Join(parts, "; "), nil +} + +func randomUserAgent() string { + agents := []string{ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + } + return agents[rand.Intn(len(agents))] +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/cookie/retry.go b/cookie/retry.go new file mode 100644 index 0000000..da198eb --- /dev/null +++ b/cookie/retry.go @@ -0,0 +1,78 @@ +package cookie + +import ( + "compress/gzip" + "fmt" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" +) + +func (r *RefreshService) retryWithFreshCSRF() (string, error) { + loginURL := fmt.Sprintf("https://%s/login/", r.domain) + resp, err := r.client.Get(loginURL) + if err != nil { + return "", fmt.Errorf("failed to refetch login page: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + re := regexp.MustCompile(`name="_xfToken" value="([^"]+)"`) + m := re.FindSubmatch(body) + if len(m) < 2 { + return "", fmt.Errorf("csrf retry token not found") + } + csrf := string(m[1]) + log.Printf("βœ… Retry CSRF token: %.10s...", csrf) + + postURL := fmt.Sprintf("https://%s/login/login", r.domain) + form := url.Values{ + "login": {r.username}, + "password": {r.password}, + "_xfToken": {csrf}, + "_xfRedirect": {"/"}, + } + req, _ := http.NewRequest("POST", postURL, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", randomUserAgent()) + req.Header.Set("Referer", loginURL) + req.Header.Set("Origin", fmt.Sprintf("https://%s", r.domain)) + req.Header.Set("X-XF-Token", csrf) + req.Header.Set("Accept-Encoding", "gzip, deflate") + + resp2, err := r.client.Do(req) + if err != nil { + return "", fmt.Errorf("retry POST failed: %v", err) + } + defer resp2.Body.Close() + + var reader io.ReadCloser + if resp2.Header.Get("Content-Encoding") == "gzip" { + gz, ge := gzip.NewReader(resp2.Body) + if ge == nil { + reader = gz + defer gz.Close() + } else { + reader = io.NopCloser(resp2.Body) + } + } else { + reader = io.NopCloser(resp2.Body) + } + b, _ := io.ReadAll(reader) + if strings.Contains(string(b), `data-logged-in="true"`) { + log.Println("βœ… Retry indicates logged in successfully") + } + + cookieURL, _ := url.Parse(fmt.Sprintf("https://%s/", r.domain)) + for _, c := range r.client.Jar.Cookies(cookieURL) { + if c.Name == "xf_user" { + log.Printf("βœ… Successfully fetched fresh cookie with xf_user: %.12s...", c.Value) + // Rebuild cookie header with known-good set (reuse attemptFetchCookie’s logic if you want) + return "xf_user=" + c.Value, nil + } + } + return "", fmt.Errorf("retry still missing xf_user cookie") +} diff --git a/cookie/solver.go b/cookie/solver.go new file mode 100644 index 0000000..2ddad81 --- /dev/null +++ b/cookie/solver.go @@ -0,0 +1,138 @@ +package cookie + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "log" + "math/rand" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +func (r *RefreshService) getClearanceToken() (string, error) { + baseURL := fmt.Sprintf("https://%s/", r.domain) + req, _ := http.NewRequest("GET", baseURL, nil) + req.Header.Set("User-Agent", randomUserAgent()) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Connection", "keep-alive") + + resp, err := r.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + // Detect challenge (several patterns) + 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, p := range patterns { + if m := p.FindStringSubmatch(string(body)); len(m) >= 3 { + if i == 1 { + difficulty, _ = strconv.Atoi(m[1]) + salt = m[2] + } else { + salt = m[1] + difficulty, _ = strconv.Atoi(m[2]) + } + found = true + break + } + } + if !found || difficulty == 0 || salt == "" { + return "", nil + } + + log.Printf("Solving KiwiFlare challenge (difficulty=%d)", difficulty) + time.Sleep(time.Duration(500+rand.Intn(750)) * time.Millisecond) + + nonce, err := r.solvePoW(salt, difficulty) + if err != nil { + return "", err + } + + time.Sleep(time.Duration(700+rand.Intn(900)) * time.Millisecond) + + submitURL := fmt.Sprintf("https://%s/.sssg/api/answer", r.domain) + form := url.Values{"a": {salt}, "b": {nonce}} + post, _ := http.NewRequest("POST", submitURL, strings.NewReader(form.Encode())) + post.Header.Set("Content-Type", "application/x-www-form-urlencoded") + post.Header.Set("User-Agent", randomUserAgent()) + post.Header.Set("Origin", baseURL) + post.Header.Set("Referer", baseURL) + + resp2, err := r.client.Do(post) + if err != nil { + return "", err + } + defer resp2.Body.Close() + + // Some deployments return JSON like {"auth":"..."} + var result map[string]any + _ = json.NewDecoder(resp2.Body).Decode(&result) + + time.Sleep(time.Duration(1200+rand.Intn(800)) * time.Millisecond) + + cookieURL, _ := url.Parse(baseURL) + for _, c := range r.client.Jar.Cookies(cookieURL) { + if c.Name == "sssg_clearance" { + log.Printf("βœ… KiwiFlare clearance cookie confirmed: %s...", c.Value[:min(10, len(c.Value))]) + return c.Value, nil + } + } + if v, ok := result["auth"].(string); ok && v != "" { + // Fallback: manually add + r.client.Jar.SetCookies(cookieURL, []*http.Cookie{{ + Name: "sssg_clearance", + Value: v, + Path: "/", + Domain: r.domain, + }}) + return v, nil + } + return "", fmt.Errorf("clearance cookie missing after solve") +} + +func (r *RefreshService) solvePoW(salt string, difficulty int) (string, error) { + start := time.Now() + bytes := difficulty / 8 + bits := difficulty % 8 + + for nonce := rand.Int63(); ; nonce++ { + sum := sha256.Sum256([]byte(fmt.Sprintf("%s%d", salt, nonce))) + ok := true + for i := 0; i < bytes; i++ { + if sum[i] != 0 { + ok = false + break + } + } + if ok && bits > 0 && bytes < len(sum) { + mask := byte(0xFF << (8 - bits)) + if sum[bytes]&mask != 0 { + ok = false + } + } + if ok { + delay := time.Duration(2+rand.Intn(3))*time.Second - time.Since(start) + if delay > 0 { + time.Sleep(delay) + } + return fmt.Sprintf("%d", nonce), nil + } + } +} diff --git a/discord/bridge.go b/discord/bridge.go new file mode 100644 index 0000000..605ade8 --- /dev/null +++ b/discord/bridge.go @@ -0,0 +1,60 @@ +package discord + +import ( + "fmt" + "log" + + "github.com/bwmarrin/discordgo" + "local/sneedchatbridge/config" + "local/sneedchatbridge/sneed" +) + +type Bridge struct { + cfg *config.Config + session *discordgo.Session + sneed *sneed.Client +} + +func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) { + s, err := discordgo.New("Bot " + cfg.DiscordBotToken) + if err != nil { + return nil, err + } + b := &Bridge{cfg: cfg, session: s, sneed: sneedClient} + s.AddHandler(b.onReady) + s.AddHandler(b.onMessage) + return b, nil +} + +func (b *Bridge) Start() error { + b.session.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent + if err := b.session.Open(); err != nil { + return err + } + return nil +} + +func (b *Bridge) Stop() { + b.session.Close() +} + +func (b *Bridge) onReady(s *discordgo.Session, r *discordgo.Ready) { + log.Printf("πŸ€– Discord bot ready: %s (%s)", r.User.Username, r.User.ID) +} + +func (b *Bridge) onMessage(s *discordgo.Session, m *discordgo.MessageCreate) { + if m.Author == nil || m.Author.Bot { + return + } + if m.ChannelID != b.cfg.DiscordChannelID { + return + } + // Simple pass-through to Sneedchat (extend with attachments, mapping, etc., as in your original) + if ok := b.sneed.Send(m.Content); !ok { + s.ChannelMessageSend(m.ChannelID, "⚠️ Sneedchat appears offline. Message not sent.") + return + } + log.Printf("πŸ“€ Discord β†’ Sneedchat: %s: %s", m.Author.Username, m.Content) + // Basic echo confirmation + _, _ = s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("βœ… Sent to Sneedchat: %s", m.Content)) +} diff --git a/go.mod b/go.mod index 5e60588..4e294e1 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module sneedchat-discord-bridge +module local/sneedchatbridge go 1.19 diff --git a/main.go b/main.go new file mode 100644 index 0000000..f2b8129 --- /dev/null +++ b/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + "time" + + "local/sneedchatbridge/config" + "local/sneedchatbridge/cookie" + "local/sneedchatbridge/discord" + "local/sneedchatbridge/sneed" +) + +func main() { + envFile := ".env" + // allow: ./bin --env /path/to/.env + for i, a := range os.Args { + if a == "--env" && i+1 < len(os.Args) { + envFile = os.Args[i+1] + } + } + + cfg, err := config.Load(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", cfg.SneedchatRoomID) + log.Printf("Bridge username: %s", cfg.BridgeUsername) + + // Cookie service + cookieSvc := cookie.NewRefreshService(cfg.BridgeUsername, cfg.BridgePassword, "kiwifarms.st") + cookieSvc.Start() + log.Println("⏳ Waiting for initial cookie...") + cookieSvc.WaitForCookie() + + initialCookie := cookieSvc.GetCurrentCookie() + if initialCookie == "" { + log.Fatal("❌ Failed to obtain initial cookie, cannot start bridge") + } + + // Sneedchat client + sneedClient := sneed.NewClient(cfg.SneedchatRoomID, cookieSvc) + + // Discord bridge + bridge, err := discord.NewBridge(cfg, sneedClient) + if err != nil { + log.Fatalf("Failed to create Discord bridge: %v", err) + } + if err := bridge.Start(); err != nil { + log.Fatalf("Failed to start Discord bridge: %v", err) + } + log.Println("πŸŒ‰ Discord-Sneedchat Bridge started successfully") + + // Periodic cookie refresh + go func() { + t := time.NewTicker(4 * time.Hour) + defer t.Stop() + for range t.C { + log.Println("πŸ”„ Starting automatic cookie refresh") + if _, err := cookieSvc.FetchFreshCookie(); err != nil { + log.Printf("⚠️ Cookie refresh failed: %v", err) + continue + } + log.Println("βœ… Cookie refresh completed") + } + }() + + // Connect to Sneedchat + go func() { + if err := sneedClient.Connect(); err != nil { + log.Printf("Initial Sneedchat connect failed: %v", err) + } + }() + + // Graceful shutdown + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + + log.Println("Shutdown signal received, cleaning up...") + bridge.Stop() + sneedClient.Disconnect() + cookieSvc.Stop() + log.Println("Bridge stopped successfully") +} diff --git a/sneed/client.go b/sneed/client.go new file mode 100644 index 0000000..47a0a45 --- /dev/null +++ b/sneed/client.go @@ -0,0 +1,163 @@ +package sneed + +import ( + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "local/sneedchatbridge/cookie" +) + +type Client struct { + wsURL string + roomID int + cookies *cookie.RefreshService + + conn *websocket.Conn + connected bool + mu sync.RWMutex + + lastMessage time.Time + stopCh chan struct{} + wg sync.WaitGroup +} + +func NewClient(roomID int, cookieSvc *cookie.RefreshService) *Client { + return &Client{ + wsURL: "wss://kiwifarms.st:9443/chat.ws", + roomID: roomID, + cookies: cookieSvc, + stopCh: make(chan struct{}), + } +} + +func (c *Client) Connect() error { + c.mu.Lock() + if c.connected { + c.mu.Unlock() + return nil + } + c.mu.Unlock() + + headers := http.Header{} + headers.Add("Cookie", c.cookies.GetCurrentCookie()) + + log.Printf("Connecting to Sneedchat room %d", c.roomID) + conn, _, err := websocket.DefaultDialer.Dial(c.wsURL, headers) + if err != nil { + return fmt.Errorf("websocket connection failed: %w", err) + } + + c.mu.Lock() + c.conn = conn + c.connected = true + c.lastMessage = time.Now() + c.mu.Unlock() + + c.wg.Add(3) + go c.readLoop() + go c.heartbeatLoop() + go c.joinRoom() + + log.Printf("βœ… Successfully connected to Sneedchat room %d", c.roomID) + return nil +} + +func (c *Client) joinRoom() { + defer c.wg.Done() + c.Send(fmt.Sprintf("/join %d", c.roomID)) +} + +func (c *Client) readLoop() { + defer c.wg.Done() + defer c.handleDisconnect() + + for { + select { + case <-c.stopCh: + return + default: + } + + c.mu.RLock() + conn := c.conn + c.mu.RUnlock() + if conn == nil { + return + } + + _, message, err := conn.ReadMessage() + if err != nil { + log.Printf("Sneedchat read error: %v", err) + return + } + c.lastMessage = time.Now() + _ = message // plug in your existing JSON handling if needed + } +} + +func (c *Client) heartbeatLoop() { + defer c.wg.Done() + t := time.NewTicker(30 * time.Second) + defer t.Stop() + for { + select { + case <-t.C: + c.mu.RLock() + connected := c.connected + conn := c.conn + c.mu.RUnlock() + if connected && time.Since(c.lastMessage) > 60*time.Second && conn != nil { + _ = conn.WriteMessage(websocket.TextMessage, []byte("/ping")) + } + case <-c.stopCh: + return + } + } +} + +func (c *Client) Send(s string) bool { + c.mu.RLock() + conn := c.conn + ok := c.connected && conn != nil + c.mu.RUnlock() + if !ok { + return false + } + if err := conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + log.Printf("Sneedchat write error: %v", err) + return false + } + return true +} + +func (c *Client) handleDisconnect() { + select { + case <-c.stopCh: + return + default: + } + c.mu.Lock() + c.connected = false + if c.conn != nil { + c.conn.Close() + } + c.mu.Unlock() + log.Println("πŸ”΄ Sneedchat disconnected") + time.Sleep(7 * time.Second) + _ = c.Connect() // try once; your original had a loop β€” add if desired +} + +func (c *Client) Disconnect() { + close(c.stopCh) + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + } + c.connected = false + c.mu.Unlock() + c.wg.Wait() +} diff --git a/sneed/types.go b/sneed/types.go new file mode 100644 index 0000000..1c8747c --- /dev/null +++ b/sneed/types.go @@ -0,0 +1,17 @@ +package sneed + +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"` +} diff --git a/utils/bbcode.go b/utils/bbcode.go new file mode 100644 index 0000000..448cb31 --- /dev/null +++ b/utils/bbcode.go @@ -0,0 +1,61 @@ +package utils + +import ( + "regexp" + "strings" +) + +func BBCodeToMarkdown(text string) string { + if text == "" { + return "" + } + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + + text = regexp.MustCompile(`(?i)\[img\](.*?)\[/img\]`).ReplaceAllString(text, "$1") + text = regexp.MustCompile(`(?i)\[video\](.*?)\[/video\]`).ReplaceAllString(text, "$1") + + 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 "[" + txt + "](" + link + ")" + }) + + text = regexp.MustCompile(`(?i)\[url\](.*?)\[/url\]`).ReplaceAllString(text, "$1") + 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~~") + text = regexp.MustCompile(`(?i)\[code\](.*?)\[/code\]`).ReplaceAllString(text, "`$1`") + text = regexp.MustCompile(`(?i)\[(?:php|plain|code=\w+)\](.*?)\[/(?:php|plain|code)\]`).ReplaceAllString(text, "```$1```") + + 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") + }) + + text = regexp.MustCompile(`(?i)\[spoiler\](.*?)\[/spoiler\]`).ReplaceAllString(text, "||$1||") + text = regexp.MustCompile(`(?i)\[(?:color|size)=.*?\](.*?)\[/\s*(?:color|size)\]`).ReplaceAllString(text, "$1") + text = regexp.MustCompile(`(?m)^\[\*\]\s*`).ReplaceAllString(text, "β€’ ") + text = regexp.MustCompile(`(?i)\[/?list\]`).ReplaceAllString(text, "") + text = regexp.MustCompile(`\[/?[A-Za-z0-9\-=_]+\]`).ReplaceAllString(text, "") + + return strings.TrimSpace(text) +} diff --git a/utils/boundedmap.go b/utils/boundedmap.go new file mode 100644 index 0000000..e621232 --- /dev/null +++ b/utils/boundedmap.go @@ -0,0 +1,98 @@ +package utils + +import ( + "sync" + "time" +) + +type BoundedMap struct { + mu sync.RWMutex + data map[int]interface{} + timestamps map[int]time.Time + maxSize int + maxAge time.Duration + keys []int +} + +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 _, ok := bm.data[key]; ok { + bm.data[key] = value + bm.timestamps[key] = time.Now() + 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 + } + bm.data[key] = value + bm.timestamps[key] = time.Now() + bm.keys = append(bm.keys, key) + 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() + v, ok := bm.data[key] + return v, ok +} + +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) +} diff --git a/utils/helpers.go b/utils/helpers.go new file mode 100644 index 0000000..43d30e7 --- /dev/null +++ b/utils/helpers.go @@ -0,0 +1,8 @@ +package utils + +func Min(a, b int) int { + if a < b { + return a + } + return b +}