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")
}