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
+}