1928 lines
51 KiB
Go
1928 lines
51 KiB
Go
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(`<html[^>]*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(`<div class="blockMessage blockMessage--error[^>]*>(.*?)</div>`),
|
||
regexp.MustCompile(`data-xf-init="auto-submit"[^>]*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")
|
||
} |