6 Commits

Author SHA1 Message Date
Salastil
adba77b3e5 Overflow mode when server exceeds Discord's ratelimiting. Will compact many messages into one embed with timestamps
All checks were successful
Build & Release / build-latest (push) Successful in 10m16s
Build & Release / version-release (push) Has been skipped
2026-03-21 21:05:30 -04:00
Salastil
a749e33737 Docker 2026-02-28 22:32:55 -05:00
Salastil
85de6d175f Restore functionality of edits, deletes and boundedmap uses UUID now
All checks were successful
Build & Release / build-latest (push) Successful in 9m50s
Build & Release / version-release (push) Has been skipped
2026-02-28 21:26:45 -05:00
Salastil
4b455eb58e 4hr cookie refresh + message_uuid changes 2026-02-28 18:37:52 -05:00
Salastil
fba2b0e449 Kiwiflare->Tartarus change, refactored to use libkiwi and cerebus libraries.
All checks were successful
Build & Release / build-latest (push) Successful in 9m51s
Build & Release / version-release (push) Has been skipped
2026-02-22 21:25:08 -05:00
Salastil
f954771c0c Added a MediaUploadService option to the configuration loader and documented it in the README so operators can pick the attachment backend (defaulting to Litterbox) straight from .env.
All checks were successful
Build & Release / build-latest (push) Successful in 9m53s
Build & Release / version-release (push) Has been skipped
Refactored the Discord bridge to build a media service during initialization, route attachment uploads through it, and dynamically label the status embeds and error diagnostics based on the selected provider while preserving the existing progress messaging flow.

Introduced a new media package that defines the uploader interface and ships a Litterbox implementation responsible for fetching Discord attachments and posting them to Catbox while reporting HTTP status codes back to the bridge.
2025-11-18 17:33:42 -05:00
15 changed files with 1076 additions and 680 deletions

View File

@@ -6,14 +6,16 @@ DISCORD_GUILD_ID=your_discord_guild_id_here
DISCORD_PING_USER_ID=your_discord_user_id_here DISCORD_PING_USER_ID=your_discord_user_id_here
DISCORD_WEBHOOK_URL=your_discord_webhook_url_here DISCORD_WEBHOOK_URL=your_discord_webhook_url_here
# Interval between reconnect attempts if connection is lost # Interval between reconnect attempts if connection is lost
RECONNECT_INTERVAL=5 RECONNECT_INTERVAL=5
# Media upload backend (currently only litterbox is supported)
MEDIA_UPLOAD_SERVICE=litterbox
# Which room will be bridged, append integer at the end of room name. Current options: general.1, gunt.8, keno-kasino.15, fishtank.16, beauty-parlor.18, sports.19, # Which room will be bridged, append integer at the end of room name. Current options: general.1, gunt.8, keno-kasino.15, fishtank.16, beauty-parlor.18, sports.19,
SNEEDCHAT_ROOM_ID=1 SNEEDCHAT_ROOM_ID=1
# Enable logging to bridge.log file for debugging purposes(true/false, default: false) # Enable logging to bridge.log file for debugging purposes(true/false, default: false)
ENABLE_FILE_LOGGING=false ENABLE_FILE_LOGGING=false
#Use your Kiwifarm crendeitals for here #Use your Kiwifarm crendeitals for here
#This USER_ID number is in the url when you go to your profile, its required to prevent Discord from echoing your own messages back to you and to allow pings/push notifications work on Discord #This USER_ID number is in the url when you go to your profile, its required to prevent Discord from echoing your own messages back to you and to allow pings/push notifications work on Discord
# BRIDGE_USER_ID=123456 # BRIDGE_USER_ID=123456
# BRIDGE_USERNAME=YourBridgeBot # BRIDGE_USERNAME=YourBridgeBot
# BRIDGE_PASSWORD=Password # BRIDGE_PASSWORD=Password

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM golang:1.25.6-alpine AS builder
RUN apk add --no-cache git
WORKDIR /build
RUN git clone https://github.com/Salastil/Sneedchat-Discord-Bridge-Go.git .
RUN go mod tidy && go build -o Sneedchat-Discord-Bridge .
FROM alpine:latest
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /build/Sneedchat-Discord-Bridge .
ENTRYPOINT ["./Sneedchat-Discord-Bridge"]

View File

@@ -7,6 +7,7 @@ A high-performance bridge written in Go that synchronizes messages between Kiwi
- ✅ Bidirectional message sync (Sneedchat ↔ Discord) - ✅ Bidirectional message sync (Sneedchat ↔ Discord)
- ✅ Edit and delete synchronization - ✅ Edit and delete synchronization
- ✅ Attachment uploads and BBcode formating via Litterbox - ✅ Attachment uploads and BBcode formating via Litterbox
- ✅ Pluggable media upload services (Litterbox by default)
- ✅ BBCode → Markdown parsing - ✅ BBCode → Markdown parsing
- ✅ Message queueing during outages - ✅ Message queueing during outages
@@ -19,7 +20,7 @@ A high-performance bridge written in Go that synchronizes messages between Kiwi
## Requirements ## Requirements
- **Go 1.23 or higher** - **Go 1.25.6 or higher**
- **Discord Bot Token** with proper permissions - **Discord Bot Token** with proper permissions
- **Discord Webhook URL** - **Discord Webhook URL**
- **Kiwi Farms account** with Sneedchat access - **Kiwi Farms account** with Sneedchat access
@@ -34,7 +35,7 @@ sudo apt update
sudo apt install golang git sudo apt install golang git
# Verify installation # Verify installation
go version # Should show 1.23 or higher go version # Should show 1.25.6 or higher
``` ```
### 2. Clone and Build ### 2. Clone and Build
@@ -135,6 +136,7 @@ Create separate systemd services with unique names
**Important Notes:** **Important Notes:**
- Replace `BRIDGE_USERNAME` with your **Kiwi Farms username** (not email) - Replace `BRIDGE_USERNAME` with your **Kiwi Farms username** (not email)
- `SNEEDCHAT_ROOM_ID=1` is the default Sneedchat room - `SNEEDCHAT_ROOM_ID=1` is the default Sneedchat room
- `MEDIA_UPLOAD_SERVICE` selects the attachment backend (currently only `litterbox`)
- Keep quotes out of values - Keep quotes out of values
- Don't share your `.env` file! - Don't share your `.env` file!
@@ -162,6 +164,9 @@ BRIDGE_USER_ID=12345
# Your Discord user ID (right-click yourself → Copy User ID) # Your Discord user ID (right-click yourself → Copy User ID)
DISCORD_PING_USER_ID=1234567890123456789 DISCORD_PING_USER_ID=1234567890123456789
# Media upload backend (defaults to litterbox if unset)
MEDIA_UPLOAD_SERVICE=litterbox
# Optional: Enable file logging # Optional: Enable file logging
ENABLE_FILE_LOGGING=false ENABLE_FILE_LOGGING=false
``` ```
@@ -266,10 +271,3 @@ If messages aren't appearing:
## License ## License
This bridge is provided as-is. Use responsibly and in accordance with Kiwi Farms and Discord Terms of Service. This bridge is provided as-is. Use responsibly and in accordance with Kiwi Farms and Discord Terms of Service.
## Credits
Built with:
- [discordgo](https://github.com/bwmarrin/discordgo) - Discord API
- [gorilla/websocket](https://github.com/gorilla/websocket) - WebSocket client
- [godotenv](https://github.com/joho/godotenv) - Environment loading

View File

@@ -10,16 +10,17 @@ import (
) )
type Config struct { type Config struct {
DiscordBotToken string DiscordBotToken string
DiscordChannelID string DiscordChannelID string
DiscordGuildID string DiscordGuildID string
DiscordWebhookURL string DiscordWebhookURL string
SneedchatRoomID int SneedchatRoomID int
BridgeUsername string MediaUploadService string
BridgePassword string BridgeUsername string
BridgeUserID int BridgePassword string
DiscordPingUserID string BridgeUserID int
Debug bool DiscordPingUserID string
Debug bool
} }
func Load(envFile string) (*Config, error) { func Load(envFile string) (*Config, error) {
@@ -27,13 +28,17 @@ func Load(envFile string) (*Config, error) {
log.Printf("Warning: error loading %s: %v", envFile, err) log.Printf("Warning: error loading %s: %v", envFile, err)
} }
cfg := &Config{ cfg := &Config{
DiscordBotToken: os.Getenv("DISCORD_BOT_TOKEN"), DiscordBotToken: os.Getenv("DISCORD_BOT_TOKEN"),
DiscordChannelID: os.Getenv("DISCORD_CHANNEL_ID"), DiscordChannelID: os.Getenv("DISCORD_CHANNEL_ID"),
DiscordGuildID: os.Getenv("DISCORD_GUILD_ID"), DiscordGuildID: os.Getenv("DISCORD_GUILD_ID"),
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"), DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
BridgeUsername: os.Getenv("BRIDGE_USERNAME"), MediaUploadService: os.Getenv("MEDIA_UPLOAD_SERVICE"),
BridgePassword: os.Getenv("BRIDGE_PASSWORD"), BridgeUsername: os.Getenv("BRIDGE_USERNAME"),
DiscordPingUserID: os.Getenv("DISCORD_PING_USER_ID"), BridgePassword: os.Getenv("BRIDGE_PASSWORD"),
DiscordPingUserID: os.Getenv("DISCORD_PING_USER_ID"),
}
if cfg.MediaUploadService == "" {
cfg.MediaUploadService = "litterbox"
} }
roomID, err := strconv.Atoi(os.Getenv("SNEEDCHAT_ROOM_ID")) roomID, err := strconv.Atoi(os.Getenv("SNEEDCHAT_ROOM_ID"))
if err != nil { if err != nil {

View File

@@ -1,224 +1,252 @@
package cookie package cookie
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"crypto/tls"
"fmt" "fmt"
"io" "io"
"log" "log"
"math/rand"
"net/http" "net/http"
"net/http/cookiejar"
"net/url" "net/url"
"regexp" "regexp"
"strconv" "slices"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
const ( // SessionService manages XenForo session cookies as plain strings.
CookieRefreshInterval = 4 * time.Hour // No http.Client jar is used anywhere — every cookie is stored explicitly
CookieRetryDelay = 5 * time.Second // and overwritten on update, eliminating all accumulation bugs.
MaxCookieRetryDelay = 60 * time.Second type SessionService struct {
) mu sync.Mutex
cookies map[string]string // name → value, last write wins
type CookieRefreshService struct { domain *url.URL
username string username string
password string password string
domain string tr *http.Transport // shared transport, TLS config applied once
client *http.Client stopCh chan struct{}
jar http.CookieJar
currentCookie string
debug bool
mu sync.RWMutex
readyOnce sync.Once
readyCh chan struct{}
stopCh chan struct{}
wg sync.WaitGroup
} }
func NewCookieRefreshService(username, password, domain string) (*CookieRefreshService, error) { // NewSessionService creates a service, performs initial login, and returns.
return NewCookieRefreshServiceWithDebug(username, password, domain, false) func NewSessionService(ctx context.Context, host, username, password string) (*SessionService, error) {
u, _ := url.Parse("https://" + host + "/")
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = tlsConfig()
s := &SessionService{
cookies: make(map[string]string),
domain: u,
username: username,
password: password,
tr: tr,
stopCh: make(chan struct{}),
}
log.Println("⏳ Logging in to Kiwi Farms...")
if err := s.Login(ctx); err != nil {
return nil, fmt.Errorf("initial login: %w", err)
}
log.Println("✅ Login successful")
go s.refreshLoop(ctx)
return s, nil
} }
func NewCookieRefreshServiceWithDebug(username, password, domain string, debug bool) (*CookieRefreshService, error) { // Close stops the background refresh loop. Call at shutdown after
jar, err := cookiejar.New(nil) // sneedClient.Disconnect().
func (s *SessionService) Close() {
close(s.stopCh)
}
// refreshLoop proactively renews all session cookies every 4 hours.
// Prevents xf_session and xf_user from expiring mid-run so reconnect
// attempts always have valid credentials. ttrs_clearance is cleared
// so the next WebSocket dial solves it fresh.
func (s *SessionService) refreshLoop(ctx context.Context) {
ticker := time.NewTicker(4 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
log.Println("🔄 Proactive session refresh (4h timer)...")
s.mu.Lock()
s.deleteCookie("ttrs_clearance")
if err := s.login(ctx); err != nil {
log.Printf("⚠️ Proactive session refresh failed: %v", err)
} else {
log.Println("✅ Proactive session refresh complete")
}
s.mu.Unlock()
case <-s.stopCh:
return
case <-ctx.Done():
return
}
}
}
// tlsConfig mirrors sockchat's socketTLSConfig exactly:
// concatenate secure + insecure cipher suites so KiwiFlare TLS fingerprinting
// doesn't trigger. "The insecure ones appear to be necessary for consistently
// getting around 203s." — sockchat source comment.
func tlsConfig() *tls.Config {
all := slices.Concat(tls.CipherSuites(), tls.InsecureCipherSuites())
ids := make([]uint16, len(all))
for i, s := range all {
ids[i] = s.ID
}
return &tls.Config{CipherSuites: ids}
}
// Transport returns the shared *http.Transport for use in the WebSocket dialer.
// The caller should use tr.DialContext and tr.TLSClientConfig directly,
// mirroring sockchat's NewSocket pattern.
func (s *SessionService) Transport() *http.Transport {
return s.tr
}
// setCookie stores a cookie by name, overwriting any previous value.
func (s *SessionService) setCookie(name, value string) {
s.cookies[name] = value
}
// deleteCookie removes a cookie by name.
func (s *SessionService) deleteCookie(name string) {
delete(s.cookies, name)
}
// absorbResponse reads all Set-Cookie headers from resp and stores them,
// overwriting any existing values. Each call is idempotent per cookie name.
func (s *SessionService) absorbResponse(resp *http.Response) {
for _, sc := range resp.Header["Set-Cookie"] {
seg := strings.SplitN(sc, ";", 2)
kv := strings.SplitN(strings.TrimSpace(seg[0]), "=", 2)
if len(kv) == 2 {
s.cookies[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
}
}
// cookieHeader returns the current cookie state as a "name=value; ..." string
// suitable for use as a Cookie HTTP header or WebSocket dial header.
func (s *SessionService) cookieHeader() string {
parts := make([]string, 0, len(s.cookies))
for k, v := range s.cookies {
if v != "" {
parts = append(parts, k+"="+v)
}
}
return strings.Join(parts, "; ")
}
// do performs a single HTTP round-trip via the shared transport.
// Injects current cookies, absorbs Set-Cookie from response.
// Does not follow redirects.
func (s *SessionService) do(req *http.Request) (*http.Response, error) {
if cookie := s.cookieHeader(); cookie != "" {
req.Header.Set("Cookie", cookie)
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := s.tr.RoundTrip(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
tr := &http.Transport{} s.absorbResponse(resp)
client := &http.Client{ return resp, nil
Jar: jar,
Transport: tr,
Timeout: 30 * time.Second,
}
return &CookieRefreshService{
username: username,
password: password,
domain: domain,
client: client,
jar: jar,
debug: debug,
readyCh: make(chan struct{}),
stopCh: make(chan struct{}),
}, nil
} }
func (s *CookieRefreshService) Start() { // get performs a GET request, solving any KiwiFlare 203 challenge inline.
s.wg.Add(1) // host can differ from s.domain.Host for non-standard port endpoints.
go func() { func (s *SessionService) get(ctx context.Context, target *url.URL) (*http.Response, error) {
defer s.wg.Done() req, err := http.NewRequestWithContext(ctx, "GET", target.String(), nil)
// Initial fetch
log.Println("⏳ Fetching initial cookie...")
c, err := s.FetchFreshCookie()
if err != nil {
log.Printf("❌ Failed to obtain initial cookie: %v", err)
s.readyOnce.Do(func() { close(s.readyCh) })
return
}
s.mu.Lock()
s.currentCookie = c
s.mu.Unlock()
s.readyOnce.Do(func() { close(s.readyCh) })
log.Println("✅ Initial cookie obtained")
// Continuous refresh loop
ticker := time.NewTicker(CookieRefreshInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
log.Println("🔄 Auto-refreshing cookie...")
newCookie, err := s.FetchFreshCookie()
if err != nil {
log.Printf("⚠️ Cookie auto-refresh failed: %v", err)
continue
}
s.mu.Lock()
s.currentCookie = newCookie
s.mu.Unlock()
log.Println("✅ Cookie auto-refresh successful")
case <-s.stopCh:
log.Println("Cookie refresh service stopping")
return
}
}
}()
}
func (s *CookieRefreshService) WaitForCookie() {
<-s.readyCh
}
func (s *CookieRefreshService) Stop() {
close(s.stopCh)
s.wg.Wait()
}
func (s *CookieRefreshService) GetCurrentCookie() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.currentCookie
}
func (s *CookieRefreshService) FetchFreshCookie() (string, error) {
if s.debug {
log.Println("💡 Stage: Starting FetchFreshCookie")
}
attempt := 0
delay := CookieRetryDelay
for {
attempt++
c, err := s.attemptFetchCookie()
if err == nil {
if s.debug {
log.Printf("✅ Successfully fetched fresh cookie with xf_user (attempt %d)", attempt)
}
return c, nil
}
log.Printf("⚠️ Cookie fetch attempt %d failed: %v", attempt, err)
// Exponential backoff, capped
time.Sleep(delay)
delay *= 2
if delay > MaxCookieRetryDelay {
delay = MaxCookieRetryDelay
}
}
}
func (s *CookieRefreshService) attemptFetchCookie() (string, error) {
base := fmt.Sprintf("https://%s/", s.domain)
loginPage := fmt.Sprintf("https://%s/login", s.domain)
loginPost := fmt.Sprintf("https://%s/login/login", s.domain)
accountURL := fmt.Sprintf("https://%s/account/", s.domain)
rootURL, _ := url.Parse(base)
// Reset redirect policy for manual control
s.client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
// don't auto-follow on login POST so we can inspect cookies first
return http.ErrUseLastResponse
}
// --- Step 1: KiwiFlare
if s.debug {
log.Println("Step 1: Checking for KiwiFlare challenge...")
}
if err := s.solveKiwiFlareIfPresent(base); err != nil {
return "", fmt.Errorf("KiwiFlare solve failed: %w", err)
}
if s.debug {
log.Println("✅ KiwiFlare challenge solved")
}
time.Sleep(2 * time.Second)
// --- Step 2: GET /login ---
if s.debug {
log.Println("Step 2: Fetching login page...")
}
reqLogin, _ := http.NewRequest("GET", loginPage, nil)
reqLogin.Header.Set("Cache-Control", "no-cache")
reqLogin.Header.Set("Pragma", "no-cache")
reqLogin.Header.Set("User-Agent", "Mozilla/5.0")
respLogin, err := s.client.Do(reqLogin)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get login page: %w", err) return nil, err
} }
defer respLogin.Body.Close() resp, err := s.do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == 203 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
log.Printf("🔑 KiwiFlare 203 on %s — solving PoW...", target.Host)
if err := s.solveAndSubmit(ctx, body, target.Host); err != nil {
return nil, fmt.Errorf("PoW solve for %s: %w", target.Host, err)
}
return s.get(ctx, target) // retry after solve
}
return resp, nil
}
bodyLogin, _ := io.ReadAll(respLogin.Body) // solveAndSubmit parses a cerberus PoW challenge from the 203 response body,
if s.debug { // solves it, and POSTs the solution to the issuing host (preserving port).
log.Printf("📄 Login page HTML (first 1024 bytes):\n%s", firstN(string(bodyLogin), 1024)) // The resulting ttrs_clearance is absorbed via absorbResponse.
func (s *SessionService) solveAndSubmit(ctx context.Context, body []byte, host string) error {
salt, diff, err := parseChallengeHTML(string(body))
if err != nil {
return fmt.Errorf("parse challenge: %w", err)
} }
log.Printf("🔑 Challenge: salt=%s difficulty=%d", salt, diff)
// --- Step 3: Extract CSRF--- nonce, err := solvePoW(ctx, salt, diff)
if s.debug { if err != nil {
log.Println("Step 3: Extracting CSRF token...") return err
} }
csrf := extractCSRF(string(bodyLogin)) log.Printf("✅ Solved: nonce=%d", nonce)
submitURL := fmt.Sprintf("https://%s/.ttrs/challenge", host)
body2 := fmt.Sprintf("salt=%s&redirect=/&nonce=%d", url.QueryEscape(salt), nonce)
req, err := http.NewRequestWithContext(ctx, "POST", submitURL, strings.NewReader(body2))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.do(req)
if err != nil {
return fmt.Errorf("submit: %w", err)
}
resp.Body.Close()
log.Printf("✅ Challenge submitted to %s (HTTP %d)", host, resp.StatusCode)
return nil
}
// Login performs a full XenForo login: solves KiwiFlare, gets CSRF, POSTs creds.
// Caller must hold mu or be in a single-threaded context (NewSessionService).
func (s *SessionService) Login(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.login(ctx)
}
func (s *SessionService) login(ctx context.Context) error {
base := s.domain.String()
log.Println("🔑 Priming KiwiFlare clearance...")
resp, err := s.get(ctx, s.domain)
if err != nil {
return fmt.Errorf("root GET: %w", err)
}
resp.Body.Close()
log.Println("✅ Clearance primed")
loginURL, _ := url.Parse(base + "login")
resp, err = s.get(ctx, loginURL)
if err != nil {
return fmt.Errorf("login page GET: %w", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
csrf := extractCSRF(string(body))
if csrf == "" { if csrf == "" {
return "", fmt.Errorf("CSRF token not found in login page") return fmt.Errorf("CSRF token not found")
}
if s.debug {
log.Printf("✅ Found CSRF token: %s...", abbreviate(csrf, 10))
}
// Record if already have xf_user BEFORE login POST
preCookies := s.jar.Cookies(rootURL)
hadXfUserBefore := hasCookie(preCookies, "xf_user")
// --- Step 4: POST /login/login---
if s.debug {
log.Println("Step 4: Submitting login credentials...")
logCookies("Cookies before login POST", preCookies)
} }
log.Printf("🔐 CSRF token obtained: %s...", csrf[:min(10, len(csrf))])
form := url.Values{ form := url.Values{
"_xfToken": {csrf}, "_xfToken": {csrf},
@@ -227,184 +255,156 @@ func (s *CookieRefreshService) attemptFetchCookie() (string, error) {
"_xfRedirect": {base}, "_xfRedirect": {base},
"remember": {"1"}, "remember": {"1"},
} }
postReq, _ := http.NewRequest("POST", loginPost, strings.NewReader(form.Encode())) req, err := http.NewRequestWithContext(ctx, "POST", base+"login/login",
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") strings.NewReader(form.Encode()))
postReq.Header.Set("User-Agent", "Mozilla/5.0")
postReq.Header.Set("Referer", loginPage)
postReq.Header.Set("Origin", fmt.Sprintf("https://%s", s.domain))
postResp, err := s.client.Do(postReq)
if err != nil {
return "", fmt.Errorf("login POST failed: %w", err)
}
defer postResp.Body.Close()
if s.debug {
log.Printf("Login response status: %d", postResp.StatusCode)
}
// XenForo often 303 when successful; 200 might still be fine (AJAX template), so we don't fail on 200 alone.
// small delay to let cookies propagate
if s.debug {
log.Println("⏳ Waiting 2 seconds for XenForo to issue cookies...")
}
time.Sleep(2 * time.Second)
postCookies := s.jar.Cookies(rootURL)
if s.debug {
for _, c := range postCookies {
log.Printf("Cookie after login: %s=%s...", c.Name, abbreviate(c.Value, 10))
}
}
// Check for xf_user after login
if hasCookie(postCookies, "xf_user") {
return buildCookieString(postCookies), nil
}
// ---- Success path: If we had xf_user before and still no new xf_user now,
// try validating the existing session on /account/ and succeed if logged in.
if hadXfUserBefore {
if s.debug {
log.Println("🔍 Missing xf_user after login POST but it existed before; validating current session via /account/ ...")
}
ok, cookieStr := s.validateSessionUsingAccount(accountURL, rootURL)
if ok {
if s.debug {
log.Println("✅ /account/ shows logged-in; retaining existing session cookie")
}
return cookieStr, nil
}
if s.debug {
log.Println("⚠️ /account/ did not confirm logged-in; proceeding with failure")
}
}
// If not successful yet, read body for context & fail
bodyBytes, _ := io.ReadAll(postResp.Body)
bodyText := string(bodyBytes)
if s.debug {
log.Printf("📄 Login HTML snippet (first 500 chars):\n%s", firstN(bodyText, 500))
}
return "", fmt.Errorf("retry still missing xf_user cookie")
}
// -------------------------------------------
// KiwiFlare handling
// -------------------------------------------
func (s *CookieRefreshService) solveKiwiFlareIfPresent(base string) error {
req, _ := http.NewRequest("GET", base, nil)
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := s.client.Do(req)
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", base+"login")
req.Header.Set("Origin", strings.TrimSuffix(base, "/"))
body, _ := io.ReadAll(resp.Body) postResp, err := s.do(req)
html := string(body)
// Look for data-sssg-challenge and difficulty
re := regexp.MustCompile(`data-sssg-challenge=["']([0-9a-fA-F]+)["'][^>]*data-sssg-difficulty=["'](\d+)["']`)
m := re.FindStringSubmatch(html)
if len(m) < 3 {
if s.debug {
log.Println("No KiwiFlare POW detected")
}
return nil
}
token := m[1]
diff, _ := strconv.Atoi(m[2])
if s.debug {
log.Printf("Solving KiwiFlare challenge (difficulty=%d, token=%s...)", diff, abbreviate(token, 10))
}
nonce, dur, err := s.solvePoW(token, diff)
if err != nil { if err != nil {
return err return fmt.Errorf("login POST: %w", err)
}
if s.debug {
log.Printf("✅ KiwiFlare challenge solved in %v (nonce=%s)", dur, nonce)
} }
postResp.Body.Close()
// Submit solution if s.cookies["xf_user"] == "" {
answerURL := fmt.Sprintf("https://%s/.sssg/api/answer", s.domain) return fmt.Errorf("xf_user not set after login — check credentials")
form := url.Values{"a": {token}, "b": {nonce}}
subReq, _ := http.NewRequest("POST", answerURL, strings.NewReader(form.Encode()))
subReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
subReq.Header.Set("User-Agent", "Mozilla/5.0")
subResp, err := s.client.Do(subReq)
if err != nil {
return err
} }
defer subResp.Body.Close() log.Println("✅ xf_user cookie obtained")
if subResp.StatusCode != 200 {
body, _ := io.ReadAll(subResp.Body)
return fmt.Errorf("challenge solve HTTP %d (%s)", subResp.StatusCode, strings.TrimSpace(string(body)))
}
// Check jar for sssg_clearance
rootURL, _ := url.Parse(fmt.Sprintf("https://%s/", s.domain))
for _, c := range s.jar.Cookies(rootURL) {
if c.Name == "sssg_clearance" {
if s.debug {
log.Printf("✅ KiwiFlare clearance cookie confirmed: %s...", abbreviate(c.Value, 10))
}
break
}
}
time.Sleep(2 * time.Second)
return nil return nil
} }
func (s *CookieRefreshService) solvePoW(token string, difficulty int) (string, time.Duration, error) { // Refresh gets a fresh xf_session. Falls back to full re-login if xf_user lost.
start := time.Now() // Mirrors sockchat's kf.RefreshSession() call in connect().
nonce := rand.Int63() func (s *SessionService) Refresh(ctx context.Context) error {
requiredBytes := difficulty / 8 s.mu.Lock()
requiredBits := difficulty % 8 defer s.mu.Unlock()
const maxAttempts = 10_000_000
for attempts := 0; attempts < maxAttempts; attempts++ { log.Println("🔄 Refreshing session...")
nonce++ s.setCookie("xf_session", "")
input := token + fmt.Sprintf("%d", nonce)
sum := sha256.Sum256([]byte(input))
// Check leading zero bits resp, err := s.get(ctx, s.domain)
ok := true if err != nil {
for i := 0; i < requiredBytes; i++ { return err
if sum[i] != 0 {
ok = false
break
}
}
if ok && requiredBits > 0 && requiredBytes < len(sum) {
mask := byte(0xFF << (8 - requiredBits))
if sum[requiredBytes]&mask != 0 {
ok = false
}
}
if ok {
elapsed := time.Since(start)
// Stretch to >= ~1.7s to look human
if elapsed < 1700*time.Millisecond {
time.Sleep(1700*time.Millisecond - elapsed)
elapsed = 1700 * time.Millisecond
}
return fmt.Sprintf("%d", nonce), elapsed, nil
}
} }
return "", 0, fmt.Errorf("failed to solve PoW within %d attempts", maxAttempts) resp.Body.Close()
if s.cookies["xf_user"] == "" {
log.Println("⚠️ xf_user lost — re-logging in...")
return s.login(ctx)
}
log.Println("✅ Session refreshed")
return nil
} }
// CookieString returns current cookies as a Cookie header value.
// ttrs_clearance is intentionally excluded — it must be solved fresh
// per-endpoint before each WebSocket connection attempt.
func (s *SessionService) CookieString() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.cookieHeader()
}
// CookieStringForWS returns cookies for the WebSocket dial with ttrs_clearance
// excluded. The caller must solve the clearance challenge for the WS endpoint
// first and inject it directly into the dial headers.
func (s *SessionService) CookieStringForWS() string {
s.mu.Lock()
defer s.mu.Unlock()
parts := make([]string, 0, len(s.cookies))
for k, v := range s.cookies {
if v != "" && k != "ttrs_clearance" {
parts = append(parts, k+"="+v)
}
}
return strings.Join(parts, "; ")
}
// SolveForHost solves a KiwiFlare 203 challenge from a non-standard port
// endpoint (e.g. the WebSocket on 9443). The challenge body is parsed locally
// but the solution is always submitted to port 443 — mirroring sockchat's
// behaviour via cerberus, whose postSolution uses Hostname() (strips port).
// The clearance issued by 443 is accepted by 9443.
func (s *SessionService) SolveForHost(ctx context.Context, body []byte) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
before := s.cookies["ttrs_clearance"]
// Always submit to the base domain on port 443.
if err := s.solveAndSubmit(ctx, body, s.domain.Hostname()); err != nil {
return "", err
}
after := s.cookies["ttrs_clearance"]
if after == before {
return "", fmt.Errorf("ttrs_clearance unchanged after solve — submit may have failed")
}
return after, nil
}
// --- PoW solver ---
func parseChallengeHTML(body string) (salt string, diff uint32, err error) {
sm := regexp.MustCompile(`data-ttrs-challenge=["']([^"']+)["']`).FindStringSubmatch(body)
dm := regexp.MustCompile(`data-ttrs-difficulty=["'](\d+)["']`).FindStringSubmatch(body)
if len(sm) < 2 || len(dm) < 2 {
return "", 0, fmt.Errorf("challenge attributes not found in HTML")
}
var d int
fmt.Sscanf(dm[1], "%d", &d)
return sm[1], uint32(d), nil
}
func solvePoW(ctx context.Context, salt string, difficulty uint32) (uint64, error) {
nbytes := difficulty / 8
rem := difficulty % 8
var mask byte
for i := uint32(0); i < rem; i++ {
mask = (mask << 1) | 1
}
if rem > 0 {
mask <<= 8 - rem
}
var nonce uint64
for {
select {
case <-ctx.Done():
return 0, ctx.Err()
default:
}
nonce++
h := sha256.Sum256([]byte(fmt.Sprintf("%s%d", salt, nonce)))
if leadingZeros(h[:], nbytes, rem, mask) {
return nonce, nil
}
}
}
func leadingZeros(hash []byte, nbytes, rem uint32, mask byte) bool {
for i := uint32(0); i < nbytes; i++ {
if hash[i] != 0 {
return false
}
}
if rem == 0 {
return true
}
return hash[nbytes]&mask == 0
}
// --- helpers ---
func extractCSRF(body string) string { func extractCSRF(body string) string {
patterns := []*regexp.Regexp{ for _, re := range []*regexp.Regexp{
regexp.MustCompile(`data-csrf=["']([^"']+)["']`), regexp.MustCompile(`data-csrf=["']([^"']+)["']`),
regexp.MustCompile(`"csrf":"([^"]+)"`), regexp.MustCompile(`"csrf":"([^"]+)"`),
regexp.MustCompile(`XF\.config\.csrf\s*=\s*"([^"]+)"`), regexp.MustCompile(`XF\.config\.csrf\s*=\s*"([^"]+)"`),
} } {
for _, re := range patterns {
if m := re.FindStringSubmatch(body); len(m) >= 2 { if m := re.FindStringSubmatch(body); len(m) >= 2 {
return m[1] return m[1]
} }
@@ -412,83 +412,9 @@ func extractCSRF(body string) string {
return "" return ""
} }
func hasCookie(cookies []*http.Cookie, name string) bool { func min(a, b int) int {
for _, c := range cookies { if a < b {
if c.Name == name { return a
return true
}
} }
return false return b
} }
func buildCookieString(cookies []*http.Cookie) string {
want := map[string]bool{
"sssg_clearance": true,
"xf_csrf": true,
"xf_session": true,
"xf_user": true,
"xf_toggle": true,
}
var parts []string
for _, c := range cookies {
if want[c.Name] {
parts = append(parts, fmt.Sprintf("%s=%s", c.Name, c.Value))
}
}
return strings.Join(parts, "; ")
}
func (s *CookieRefreshService) validateSessionUsingAccount(accountURL string, rootURL *url.URL) (bool, string) {
if s.debug {
log.Println("🔍 Validating session via /account/ ...")
}
req, _ := http.NewRequest("GET", accountURL, nil)
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := s.client.Do(req)
if err != nil {
if s.debug {
log.Printf("⚠️ /account/ request error: %v", err)
}
return false, ""
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
snippet := firstN(string(body), 500)
if s.debug {
log.Printf("🔍 Accessed /account/ (%d)", resp.StatusCode)
log.Printf("📄 /account/ HTML snippet:\n%s", snippet)
for _, c := range s.jar.Cookies(rootURL) {
log.Printf("🍪 Cookie after /account/: %s=%s...", c.Name, abbreviate(c.Value, 10))
}
}
// Consider logged-in if data-logged-in="true" or the template isn't "login"
if strings.Contains(snippet, `data-logged-in="true"`) ||
(!strings.Contains(snippet, `data-template="login"`) && resp.StatusCode == 200) {
return true, buildCookieString(s.jar.Cookies(rootURL))
}
return false, ""
}
func logCookies(prefix string, cookies []*http.Cookie) {
log.Printf("%s (%d):", prefix, len(cookies))
for _, c := range cookies {
log.Printf(" - %s = %s...", c.Name, abbreviate(c.Value, 10))
}
}
func firstN(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func abbreviate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}

View File

@@ -1,36 +1,44 @@
package discord package discord
import ( import (
"bytes" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"mime/multipart"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"local/sneedchatbridge/config" "local/sneedchatbridge/config"
"local/sneedchatbridge/media"
"local/sneedchatbridge/sneed" "local/sneedchatbridge/sneed"
"local/sneedchatbridge/utils" "local/sneedchatbridge/utils"
) )
const ( const (
MaxAttachments = 4 MaxAttachments = 4
LitterboxTTL = "72h" ProcessedCacheSize = 250
ProcessedCacheSize = 250 OverflowThreshold = 10
MappingCacheSize = 1000 OverflowRecoveryThreshold = 5
MappingMaxAge = 1 * time.Hour OverflowBatchSize = 15
MappingCleanupInterval = 5 * time.Minute MappingCacheSize = 1000
QueuedMessageTTL = 90 * time.Second MappingMaxAge = 1 * time.Hour
OutageUpdateInterval = 10 * time.Second MappingCleanupInterval = 5 * time.Minute
OutboundMatchWindow = 60 * time.Second QueuedMessageTTL = 90 * time.Second
OutageEmbedColorActive = 0xF1C40F OutageUpdateInterval = 10 * time.Second
OutageEmbedColorFixed = 0x2ECC71 OutboundMatchWindow = 60 * time.Second
OutageEmbedColorActive = 0xF1C40F
OutageEmbedColorFixed = 0x2ECC71
UploadStatusColorPending = 0x3498DB
UploadStatusColorSuccess = 0x2ECC71
UploadStatusColorFailed = 0xE74C3C
UploadDeliveryTimeout = 2 * time.Minute
UploadStatusCleanupDelay = 15 * time.Second
UploadStatusFailureLifetime = 60 * time.Second
) )
type OutboundEntry struct { type OutboundEntry struct {
@@ -48,10 +56,10 @@ type QueuedMessage struct {
} }
type Bridge struct { type Bridge struct {
cfg *config.Config cfg *config.Config
session *discordgo.Session session *discordgo.Session
sneed *sneed.Client sneed *sneed.Client
httpClient *http.Client mediaSvc media.Service
sneedToDiscord *utils.BoundedMap sneedToDiscord *utils.BoundedMap
discordToSneed *utils.BoundedMap discordToSneed *utils.BoundedMap
@@ -68,8 +76,10 @@ type Bridge struct {
outageMessagesMu sync.Mutex outageMessagesMu sync.Mutex
outageStart time.Time outageStart time.Time
stopCh chan struct{} stopCh chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
msgQueue chan map[string]interface{}
overflow bool
} }
func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) { func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
@@ -77,11 +87,15 @@ func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
mediaSvc, err := media.NewService(cfg.MediaUploadService, &http.Client{Timeout: 60 * time.Second})
if err != nil {
return nil, err
}
b := &Bridge{ b := &Bridge{
cfg: cfg, cfg: cfg,
session: s, session: s,
sneed: sneedClient, sneed: sneedClient,
httpClient: &http.Client{Timeout: 60 * time.Second}, mediaSvc: mediaSvc,
sneedToDiscord: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge), sneedToDiscord: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
discordToSneed: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge), discordToSneed: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
sneedUsernames: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge), sneedUsernames: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
@@ -89,10 +103,11 @@ func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
queuedOutbound: make([]QueuedMessage, 0), queuedOutbound: make([]QueuedMessage, 0),
outageNotices: make([]*discordgo.Message, 0), outageNotices: make([]*discordgo.Message, 0),
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
msgQueue: make(chan map[string]interface{}, 500),
} }
// hook Sneed client callbacks // hook Sneed client callbacks
sneedClient.OnMessage = b.onSneedMessage sneedClient.OnMessage = b.enqueueSneedMessage
sneedClient.OnEdit = b.handleSneedEdit sneedClient.OnEdit = b.handleSneedEdit
sneedClient.OnDelete = b.handleSneedDelete sneedClient.OnDelete = b.handleSneedDelete
sneedClient.OnConnect = b.onSneedConnect sneedClient.OnConnect = b.onSneedConnect
@@ -115,8 +130,10 @@ func (b *Bridge) Start() error {
if err := b.session.Open(); err != nil { if err := b.session.Open(); err != nil {
return err return err
} }
b.wg.Add(1) b.wg.Add(3)
go b.cleanupLoop() go b.cleanupLoop()
go b.messageWorker()
go b.messageWorker()
return nil return nil
} }
@@ -177,22 +194,49 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
if m.ReferencedMessage != nil { if m.ReferencedMessage != nil {
refDiscordID := parseMessageID(m.ReferencedMessage.ID) refDiscordID := parseMessageID(m.ReferencedMessage.ID)
if sneedIDInt, ok := b.discordToSneed.Get(refDiscordID); ok { if sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(refDiscordID)); ok {
if uname, ok2 := b.sneedUsernames.Get(sneedIDInt.(int)); ok2 { sneedUUID := sneedUUIDVal.(string)
if uname, ok2 := b.sneedUsernames.Get(sneedUUID); ok2 {
contentText = fmt.Sprintf("@%s, %s", uname.(string), contentText) contentText = fmt.Sprintf("@%s, %s", uname.(string), contentText)
} }
} }
} }
var attachmentsBB []string var attachmentsBB []string
var statusMsg *discordgo.Message
var statusErr error
if len(m.Attachments) > 0 {
mention := fmt.Sprintf("<@%s>", m.Author.ID)
statusMsg, statusErr = b.sendUploadStatusMessage(m.ChannelID, mention, len(m.Attachments))
if statusErr != nil {
log.Printf("⚠️ Failed to send upload status message: %v", statusErr)
}
}
if len(m.Attachments) > MaxAttachments { if len(m.Attachments) > MaxAttachments {
return return
} }
for _, att := range m.Attachments { ctx := context.Background()
url, err := b.uploadToLitterbox(att.URL, att.Filename) for idx, att := range m.Attachments {
if statusMsg != nil {
desc := fmt.Sprintf("Uploading attachment %d/%d: `%s`", idx+1, len(m.Attachments), att.Filename)
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle(""), desc, UploadStatusColorPending)
}
url, statusCode, err := b.mediaSvc.Upload(ctx, att)
if err != nil { if err != nil {
if statusMsg != nil {
failureDesc := fmt.Sprintf("Failed to upload `%s`: %v", att.Filename, err)
if statusCode > 0 {
failureDesc = fmt.Sprintf("%s\n%s response: HTTP %d", failureDesc, b.mediaServiceDisplayName(), statusCode)
}
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle("failed"), failureDesc, UploadStatusColorFailed)
b.scheduleUploadStatusDeletion(statusMsg.ChannelID, statusMsg.ID, UploadStatusFailureLifetime)
}
return return
} }
if statusMsg != nil {
desc := fmt.Sprintf("Uploaded %d/%d: `%s` (HTTP %d)", idx+1, len(m.Attachments), att.Filename, statusCode)
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle(""), desc, UploadStatusColorPending)
}
ct := strings.ToLower(att.ContentType) ct := strings.ToLower(att.ContentType)
@@ -213,10 +257,11 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
combined += strings.Join(attachmentsBB, "\n") combined += strings.Join(attachmentsBB, "\n")
} }
discordMsgID := parseMessageID(m.ID)
if b.sneed.Send(combined) { if b.sneed.Send(combined) {
b.recentOutboundMu.Lock() b.recentOutboundMu.Lock()
b.recentOutbound = append(b.recentOutbound, OutboundEntry{ b.recentOutbound = append(b.recentOutbound, OutboundEntry{
DiscordID: parseMessageID(m.ID), DiscordID: discordMsgID,
Content: combined, Content: combined,
Timestamp: time.Now(), Timestamp: time.Now(),
Mapped: false, Mapped: false,
@@ -225,15 +270,23 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
b.recentOutbound = b.recentOutbound[1:] b.recentOutbound = b.recentOutbound[1:]
} }
b.recentOutboundMu.Unlock() b.recentOutboundMu.Unlock()
if statusMsg != nil {
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle(""), "Uploads complete, awaiting Sneedchat confirmation…", UploadStatusColorPending)
go b.awaitSneedConfirmation(discordMsgID, statusMsg.ChannelID, statusMsg.ID)
}
} else { } else {
b.queuedOutboundMu.Lock() b.queuedOutboundMu.Lock()
b.queuedOutbound = append(b.queuedOutbound, QueuedMessage{ b.queuedOutbound = append(b.queuedOutbound, QueuedMessage{
Content: combined, Content: combined,
ChannelID: m.ChannelID, ChannelID: m.ChannelID,
Timestamp: time.Now(), Timestamp: time.Now(),
DiscordID: parseMessageID(m.ID), DiscordID: discordMsgID,
}) })
b.queuedOutboundMu.Unlock() b.queuedOutboundMu.Unlock()
if statusMsg != nil {
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle("queued"), "Uploads succeeded but Sneedchat is unavailable. Message queued for delivery once the bridge reconnects.", UploadStatusColorPending)
b.scheduleUploadStatusDeletion(statusMsg.ChannelID, statusMsg.ID, UploadStatusFailureLifetime)
}
} }
} }
@@ -245,14 +298,14 @@ func (b *Bridge) onDiscordMessageEdit(s *discordgo.Session, m *discordgo.Message
return return
} }
discordID := parseMessageID(m.ID) discordID := parseMessageID(m.ID)
sneedIDInt, ok := b.discordToSneed.Get(discordID) sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(discordID))
if !ok { if !ok {
return return
} }
sneedID := sneedIDInt.(int) sneedUUID := sneedUUIDVal.(string)
payload := map[string]interface{}{"id": sneedID, "message": strings.TrimSpace(m.Content)} payload := map[string]interface{}{"uuid": sneedUUID, "message": strings.TrimSpace(m.Content)}
data, _ := json.Marshal(payload) data, _ := json.Marshal(payload)
log.Printf("↩️ Discord edit -> Sneedchat (sneed_id=%d)", sneedID) log.Printf("↩️ Discord edit -> Sneedchat (sneed_uuid=%s)", sneedUUID)
b.sneed.Send(fmt.Sprintf("/edit %s", string(data))) b.sneed.Send(fmt.Sprintf("/edit %s", string(data)))
} }
@@ -261,12 +314,98 @@ func (b *Bridge) onDiscordMessageDelete(s *discordgo.Session, m *discordgo.Messa
return return
} }
discordID := parseMessageID(m.ID) discordID := parseMessageID(m.ID)
sneedIDInt, ok := b.discordToSneed.Get(discordID) sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(discordID))
if !ok { if !ok {
return return
} }
log.Printf("↩️ Discord delete -> Sneedchat (sneed_id=%d)", sneedIDInt.(int)) sneedUUID := sneedUUIDVal.(string)
b.sneed.Send(fmt.Sprintf("/delete %d", sneedIDInt.(int))) log.Printf("↩️ Discord delete -> Sneedchat (sneed_uuid=%s)", sneedUUID)
b.sneed.Send(fmt.Sprintf("/delete %s", sneedUUID))
}
func (b *Bridge) messageWorker() {
defer b.wg.Done()
for {
select {
case <-b.stopCh:
return
case msg, ok := <-b.msgQueue:
if !ok {
return
}
depth := len(b.msgQueue)
if !b.overflow && depth >= OverflowThreshold {
b.overflow = true
log.Printf("⚠️ Message queue overflow (%d pending), switching to batch mode", depth)
} else if b.overflow && depth <= OverflowRecoveryThreshold {
b.overflow = false
log.Printf("✅ Message queue recovered (%d pending), switching to normal mode", depth)
}
if b.overflow {
batch := []map[string]interface{}{msg}
for len(batch) < OverflowBatchSize {
select {
case next, ok := <-b.msgQueue:
if !ok {
break
}
batch = append(batch, next)
default:
goto flushBatch
}
}
flushBatch:
b.sendOverflowBatch(batch)
} else {
b.onSneedMessage(msg)
}
}
}
}
func (b *Bridge) sendOverflowBatch(batch []map[string]interface{}) {
if len(batch) == 0 {
return
}
var description strings.Builder
for _, msg := range batch {
username, _ := msg["username"].(string)
rawContent, _ := msg["content"].(string)
content := utils.BBCodeToMarkdown(rawContent)
content = sneed.ReplaceBridgeMention(content, b.cfg.BridgeUsername, b.cfg.DiscordPingUserID)
var ts string
if raw, ok := msg["raw"].(sneed.SneedMessage); ok && raw.MessageDate > 0 {
ts = time.Unix(int64(raw.MessageDate), 0).Format("3:04:05 PM")
}
description.WriteString(fmt.Sprintf("**%s** %s\n%s\n\n", username, ts, content))
}
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
embed := &discordgo.MessageEmbed{
Title: "overflow mode",
Description: strings.TrimSpace(description.String()),
Color: OutageEmbedColorActive,
}
params := &discordgo.WebhookParams{
Embeds: []*discordgo.MessageEmbed{embed},
}
_, err := b.session.WebhookExecute(webhookID, webhookToken, false, params)
if err != nil {
log.Printf("❌ Failed to send overflow batch: %v", err)
return
}
log.Printf("📦 Sent overflow batch of %d messages", len(batch))
}
func (b *Bridge) enqueueSneedMessage(msg map[string]interface{}) {
select {
case b.msgQueue <- msg:
default:
log.Printf("⚠️ Message queue full, dropping message from %s", msg["username"])
}
} }
func (b *Bridge) onSneedMessage(msg map[string]interface{}) { func (b *Bridge) onSneedMessage(msg map[string]interface{}) {
@@ -304,17 +443,17 @@ func (b *Bridge) onSneedMessage(msg map[string]interface{}) {
log.Printf("✅ Sent Sneedchat → Discord: %s", username) log.Printf("✅ Sent Sneedchat → Discord: %s", username)
if sent != nil { if sent != nil {
if mid, ok := msg["message_id"].(int); ok && mid > 0 { if uuid, ok := msg["message_uuid"].(string); ok && uuid != "" {
discordMsgID := parseMessageID(sent.ID) discordMsgID := parseMessageID(sent.ID)
b.sneedToDiscord.Set(mid, discordMsgID) b.sneedToDiscord.Set(uuid, discordMsgID)
b.discordToSneed.Set(discordMsgID, mid) b.discordToSneed.Set(strconv.Itoa(discordMsgID), uuid)
b.sneedUsernames.Set(mid, username) b.sneedUsernames.Set(uuid, username)
} }
} }
} }
func (b *Bridge) handleSneedEdit(sneedID int, newContent string) { func (b *Bridge) handleSneedEdit(sneedUUID string, newContent string) {
discordIDInt, ok := b.sneedToDiscord.Get(sneedID) discordIDInt, ok := b.sneedToDiscord.Get(sneedUUID)
if !ok { if !ok {
return return
} }
@@ -327,11 +466,11 @@ func (b *Bridge) handleSneedEdit(sneedID int, newContent string) {
log.Printf("❌ Failed to edit Discord message id=%d: %v", discordID, err) log.Printf("❌ Failed to edit Discord message id=%d: %v", discordID, err)
return return
} }
log.Printf("✏️ Edited Discord (webhook) message id=%d (sneed_id=%d)", discordID, sneedID) log.Printf("✏️ Edited Discord (webhook) message id=%d (sneed_uuid=%s)", discordID, sneedUUID)
} }
func (b *Bridge) handleSneedDelete(sneedID int) { func (b *Bridge) handleSneedDelete(sneedUUID string) {
discordIDInt, ok := b.sneedToDiscord.Get(sneedID) discordIDInt, ok := b.sneedToDiscord.Get(sneedUUID)
if !ok { if !ok {
return return
} }
@@ -342,10 +481,10 @@ func (b *Bridge) handleSneedDelete(sneedID int) {
log.Printf("❌ Failed to delete Discord message id=%d: %v", discordID, err) log.Printf("❌ Failed to delete Discord message id=%d: %v", discordID, err)
return return
} }
log.Printf("🗑️ Deleted Discord (webhook) message id=%d (sneed_id=%d)", discordID, sneedID) log.Printf("🗑️ Deleted Discord (webhook) message id=%d (sneed_uuid=%s)", discordID, sneedUUID)
b.sneedToDiscord.Delete(sneedID) b.sneedToDiscord.Delete(sneedUUID)
b.discordToSneed.Delete(discordID) b.discordToSneed.Delete(strconv.Itoa(discordID))
b.sneedUsernames.Delete(sneedID) b.sneedUsernames.Delete(sneedUUID)
} }
func (b *Bridge) onSneedConnect() { func (b *Bridge) onSneedConnect() {
@@ -531,11 +670,106 @@ func (b *Bridge) recentOutboundIter() []map[string]interface{} {
return res return res
} }
func (b *Bridge) mapDiscordSneed(discordID, sneedID int, username string) { func (b *Bridge) mapDiscordSneed(sneedUUID string, discordID int, username string) {
b.discordToSneed.Set(discordID, sneedID) b.discordToSneed.Set(strconv.Itoa(discordID), sneedUUID)
b.sneedToDiscord.Set(sneedID, discordID) b.sneedToDiscord.Set(sneedUUID, discordID)
b.sneedUsernames.Set(sneedID, username) b.sneedUsernames.Set(sneedUUID, username)
log.Printf("Mapped sneed_id=%d <-> discord_id=%d (username='%s')", sneedID, discordID, username) log.Printf("Mapped sneed_uuid=%s <-> discord_id=%d (username='%s')", sneedUUID, discordID, username)
}
func (b *Bridge) sendUploadStatusMessage(channelID, mention string, attachmentCount int) (*discordgo.Message, error) {
desc := fmt.Sprintf("Uploading %d attachment(s) from %s…", attachmentCount, mention)
msg := &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{buildStatusEmbed(b.uploadStatusTitle(""), desc, UploadStatusColorPending)},
}
return b.session.ChannelMessageSendComplex(channelID, msg)
}
func (b *Bridge) uploadStatusTitle(suffix string) string {
base := fmt.Sprintf("%s upload", b.mediaServiceDisplayName())
if suffix == "" {
return base
}
return fmt.Sprintf("%s %s", base, suffix)
}
func (b *Bridge) mediaServiceDisplayName() string {
if b.mediaSvc == nil {
return "Media"
}
name := capitalizeWord(b.mediaSvc.Name())
if name == "" {
return "Media"
}
return name
}
func (b *Bridge) editUploadStatusMessage(channelID, messageID, title, description string, color int) {
embed := buildStatusEmbed(title, description, color)
edit := &discordgo.MessageEdit{
ID: messageID,
Channel: channelID,
Embeds: []*discordgo.MessageEmbed{embed},
}
if _, err := b.session.ChannelMessageEditComplex(edit); err != nil {
log.Printf("⚠️ Failed to edit upload status message %s: %v", messageID, err)
}
}
func (b *Bridge) awaitSneedConfirmation(discordID int, channelID, statusMessageID string) {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
timer := time.NewTimer(UploadDeliveryTimeout)
defer timer.Stop()
for {
select {
case <-ticker.C:
if _, ok := b.discordToSneed.Get(strconv.Itoa(discordID)); ok {
desc := "Delivered to Sneedchat."
b.editUploadStatusMessage(channelID, statusMessageID, b.uploadStatusTitle("complete"), desc, UploadStatusColorSuccess)
b.scheduleUploadStatusDeletion(channelID, statusMessageID, UploadStatusCleanupDelay)
return
}
case <-timer.C:
desc := "Uploads finished but no Sneedchat confirmation was observed. Message may have been dropped."
b.editUploadStatusMessage(channelID, statusMessageID, b.uploadStatusTitle("warning"), desc, UploadStatusColorFailed)
b.scheduleUploadStatusDeletion(channelID, statusMessageID, UploadStatusFailureLifetime)
return
case <-b.stopCh:
return
}
}
}
func (b *Bridge) scheduleUploadStatusDeletion(channelID, messageID string, delay time.Duration) {
go func() {
select {
case <-time.After(delay):
if err := b.session.ChannelMessageDelete(channelID, messageID); err != nil {
log.Printf("⚠️ Failed to delete upload status message %s: %v", messageID, err)
}
case <-b.stopCh:
}
}()
}
func buildStatusEmbed(title, description string, color int) *discordgo.MessageEmbed {
return &discordgo.MessageEmbed{
Title: title,
Description: description,
Color: color,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
func capitalizeWord(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
} }
func (b *Bridge) sendOutageEmbed(title, description string, color int) (*discordgo.Message, error) { func (b *Bridge) sendOutageEmbed(title, description string, color int) (*discordgo.Message, error) {
@@ -568,42 +802,6 @@ func (b *Bridge) deleteWebhookMessage(messageID string) error {
return b.session.WebhookMessageDelete(webhookID, webhookToken, messageID) return b.session.WebhookMessageDelete(webhookID, webhookToken, messageID)
} }
func (b *Bridge) uploadToLitterbox(fileURL, filename string) (string, error) {
resp, err := b.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
}
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
_ = w.WriteField("reqtype", "fileupload")
_ = w.WriteField("time", LitterboxTTL)
part, _ := w.CreateFormFile("fileToUpload", filename)
_, _ = part.Write(data)
_ = w.Close()
req, _ := http.NewRequest("POST", "https://litterbox.catbox.moe/resources/internals/api.php", body)
req.Header.Set("Content-Type", w.FormDataContentType())
uResp, err := b.httpClient.Do(req)
if err != nil {
return "", err
}
defer uResp.Body.Close()
if uResp.StatusCode != 200 {
return "", fmt.Errorf("Litterbox returned HTTP %d", uResp.StatusCode)
}
out, _ := io.ReadAll(uResp.Body)
url := strings.TrimSpace(string(out))
log.Printf("SUCCESS: Uploaded '%s' to Litterbox: %s", filename, url)
return url, nil
}
func parseWebhookURL(webhookURL string) (string, string) { func parseWebhookURL(webhookURL string) (string, string) {
parts := strings.Split(webhookURL, "/") parts := strings.Split(webhookURL, "/")
if len(parts) < 2 { if len(parts) < 2 {

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
sneedchat-bridge:
build: .
restart: unless-stopped
environment:
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ID}
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
- DISCORD_PING_USER_ID=${DISCORD_PING_USER_ID}
- DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL}
- RECONNECT_INTERVAL=${RECONNECT_INTERVAL:-5}
- MEDIA_UPLOAD_SERVICE=${MEDIA_UPLOAD_SERVICE:-litterbox}
- SNEEDCHAT_ROOM_ID=${SNEEDCHAT_ROOM_ID:-1}
- ENABLE_FILE_LOGGING=${ENABLE_FILE_LOGGING:-false}
- BRIDGE_USER_ID=${BRIDGE_USER_ID}
- BRIDGE_USERNAME=${BRIDGE_USERNAME}
- BRIDGE_PASSWORD=${BRIDGE_PASSWORD}

12
go.mod
View File

@@ -1,15 +1,19 @@
module local/sneedchatbridge module local/sneedchatbridge
go 1.23 go 1.25.6
require ( require (
gitgud.io/yats/libkiwi v0.0.0-20260214165635-8e0720d58701
github.com/bwmarrin/discordgo v0.27.1 github.com/bwmarrin/discordgo v0.27.1
github.com/gorilla/websocket v1.5.1 github.com/gorilla/websocket v1.5.1
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
) )
require ( require (
golang.org/x/crypto v0.14.0 // indirect gitgud.io/yats/cerberus v0.0.0-20260214165307-66e6f74a4be9 // indirect
golang.org/x/net v0.17.0 // indirect github.com/klauspost/cpuid/v2 v2.2.3 // indirect
golang.org/x/sys v0.13.0 // indirect github.com/minio/sha256-simd v1.0.1 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
) )

21
go.sum
View File

@@ -1,3 +1,7 @@
gitgud.io/yats/cerberus v0.0.0-20260214165307-66e6f74a4be9 h1:OSYrnxTeCuvaX6O8/AHUE4Xndb76vtcVwdvdLtGfp4Q=
gitgud.io/yats/cerberus v0.0.0-20260214165307-66e6f74a4be9/go.mod h1:WVfXXYUHR8x5hX0cpRUOlaeRqxR/9JxYhLbjFSb/jjc=
gitgud.io/yats/libkiwi v0.0.0-20260214165635-8e0720d58701 h1:cKIfr4ko4VlrCR4cbVpBJ1/1p9D4IWpGQnXWiPWoHZU=
gitgud.io/yats/libkiwi v0.0.0-20260214165635-8e0720d58701/go.mod h1:7oHOXzBQep8VqlnmDeq/ItH3FkrXJY1IWWAQNG89MVM=
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -5,15 +9,20 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

41
main.go
View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"log" "log"
"os" "os"
"os/signal" "os/signal"
@@ -14,35 +15,40 @@ import (
func main() { func main() {
envFile := ".env" envFile := ".env"
debugFlag := false
for i, a := range os.Args { for i, a := range os.Args {
if a == "--env" && i+1 < len(os.Args) { if a == "--env" && i+1 < len(os.Args) {
envFile = os.Args[i+1] envFile = os.Args[i+1]
} }
if a == "--debug" {
debugFlag = true
}
} }
cfg, err := config.Load(envFile) cfg, err := config.Load(envFile)
if err != nil { if err != nil {
log.Fatalf("Failed to load config: %v", err) log.Fatalf("Failed to load config: %v", err)
} }
cfg.Debug = cfg.Debug || debugFlag
log.Printf("Using .env file: %s", envFile) log.Printf("Using .env file: %s", envFile)
log.Printf("Using Sneedchat room ID: %d", cfg.SneedchatRoomID) log.Printf("Using Sneedchat room ID: %d", cfg.SneedchatRoomID)
log.Printf("Bridge username: %s", cfg.BridgeUsername) log.Printf("Bridge username: %s", cfg.BridgeUsername)
// Cookie service (now handles its own refresh loop) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
cookieSvc, err := cookie.NewCookieRefreshService(cfg.BridgeUsername, cfg.BridgePassword, "kiwifarms.st") defer cancel()
// SessionService owns the TLS config, cookie store, and shared transport.
// Login is performed here; cookies are stored as plain strings, no jar.
session, err := cookie.NewSessionService(ctx, "kiwifarms.st", cfg.BridgeUsername, cfg.BridgePassword)
if err != nil { if err != nil {
log.Fatalf("Failed to create cookie service: %v", err) log.Fatalf("Failed to establish session: %v", err)
}
cookieSvc.Start()
cookieSvc.WaitForCookie()
if cookieSvc.GetCurrentCookie() == "" {
log.Fatal("❌ Failed to obtain initial cookie, cannot start bridge")
} }
// Sneedchat client // NewClient uses session.Transport() for the WebSocket dialer,
sneedClient := sneed.NewClient(cfg.SneedchatRoomID, cookieSvc) // mirroring sockchat's NewSocket pattern exactly.
sneedClient := sneed.NewClient(cfg.SneedchatRoomID, session, cfg.Debug)
sneedClient.SetBridgeIdentity(cfg.BridgeUserID, cfg.BridgeUsername)
// Discord bridge
bridge, err := discord.NewBridge(cfg, sneedClient) bridge, err := discord.NewBridge(cfg, sneedClient)
if err != nil { if err != nil {
log.Fatalf("Failed to create Discord bridge: %v", err) log.Fatalf("Failed to create Discord bridge: %v", err)
@@ -52,21 +58,16 @@ func main() {
} }
log.Println("🌉 Discord-Sneedchat Bridge started successfully") log.Println("🌉 Discord-Sneedchat Bridge started successfully")
// Connect to Sneedchat
go func() { go func() {
if err := sneedClient.Connect(); err != nil { if err := sneedClient.Connect(ctx); err != nil {
log.Printf("Initial Sneedchat connect failed: %v", err) log.Printf("Initial Sneedchat connect failed: %v", err)
} }
}() }()
// Graceful shutdown <-ctx.Done()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Println("Shutdown signal received, cleaning up...") log.Println("Shutdown signal received, cleaning up...")
bridge.Stop() bridge.Stop()
sneedClient.Disconnect() sneedClient.Disconnect()
cookieSvc.Stop() session.Close()
log.Println("Bridge stopped successfully") log.Println("Bridge stopped successfully")
} }

84
media/litterbox.go Normal file
View File

@@ -0,0 +1,84 @@
package media
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"time"
"github.com/bwmarrin/discordgo"
)
const (
litterboxTTL = "72h"
litterboxEndpoint = "https://litterbox.catbox.moe/resources/internals/api.php"
defaultHTTPTimeout = 60 * time.Second
)
type LitterboxService struct {
client *http.Client
}
func (s *LitterboxService) Name() string { return DefaultService }
func (s *LitterboxService) Upload(ctx context.Context, attachment *discordgo.MessageAttachment) (string, int, error) {
client := s.client
if client == nil {
client = &http.Client{Timeout: defaultHTTPTimeout}
}
if client.Timeout == 0 {
client.Timeout = defaultHTTPTimeout
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, attachment.URL, nil)
if err != nil {
return "", 0, err
}
resp, err := client.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", resp.StatusCode, fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", 0, err
}
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("reqtype", "fileupload")
_ = writer.WriteField("time", litterboxTTL)
part, _ := writer.CreateFormFile("fileToUpload", attachment.Filename)
_, _ = part.Write(data)
_ = writer.Close()
uploadReq, err := http.NewRequestWithContext(ctx, http.MethodPost, litterboxEndpoint, body)
if err != nil {
return "", 0, err
}
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
uploadResp, err := client.Do(uploadReq)
if err != nil {
return "", 0, err
}
defer uploadResp.Body.Close()
if uploadResp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(uploadResp.Body)
reason := strings.TrimSpace(string(bodyBytes))
if reason != "" {
return "", uploadResp.StatusCode, fmt.Errorf("Litterbox returned HTTP %d: %s", uploadResp.StatusCode, reason)
}
return "", uploadResp.StatusCode, fmt.Errorf("Litterbox returned HTTP %d", uploadResp.StatusCode)
}
out, _ := io.ReadAll(uploadResp.Body)
url := strings.TrimSpace(string(out))
return url, uploadResp.StatusCode, nil
}

36
media/service.go Normal file
View File

@@ -0,0 +1,36 @@
package media
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/bwmarrin/discordgo"
)
const DefaultService = "litterbox"
// Service defines a pluggable uploader for Discord attachments.
type Service interface {
Name() string
Upload(ctx context.Context, attachment *discordgo.MessageAttachment) (url string, statusCode int, err error)
}
// NewService returns the configured media upload backend. Currently only
// Litterbox is supported but the hook point allows future expansion.
func NewService(name string, httpClient *http.Client) (Service, error) {
if httpClient == nil {
httpClient = &http.Client{}
}
normalized := strings.ToLower(strings.TrimSpace(name))
if normalized == "" {
normalized = DefaultService
}
switch normalized {
case DefaultService:
return &LitterboxService{client: httpClient}, nil
default:
return nil, fmt.Errorf("unknown media upload service: %s", name)
}
}

View File

@@ -1,12 +1,15 @@
package sneed package sneed
import ( import (
"context"
"crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html" "html"
"log" "log"
"net/http" "net/http"
"regexp" "regexp"
"slices"
"sync" "sync"
"time" "time"
@@ -16,7 +19,7 @@ import (
) )
const ( const (
ProcessedCacheSize = 1000 // Increased from 250 ProcessedCacheSize = 1000
ReconnectInterval = 7 * time.Second ReconnectInterval = 7 * time.Second
MappingCacheSize = 1000 MappingCacheSize = 1000
MappingCleanupInterval = 5 * time.Minute MappingCleanupInterval = 5 * time.Minute
@@ -24,11 +27,24 @@ const (
OutboundMatchWindow = 60 * time.Second OutboundMatchWindow = 60 * time.Second
) )
// socketTLSConfig mirrors the config used by sockchat: broad cipher suite list
// to avoid KiwiFlare TLS fingerprint detection on the WebSocket upgrade.
func socketTLSConfig() *tls.Config {
suites := slices.Concat(tls.CipherSuites(), tls.InsecureCipherSuites())
ids := make([]uint16, len(suites))
for i, s := range suites {
ids[i] = s.ID
}
return &tls.Config{CipherSuites: ids}
}
type Client struct { type Client struct {
wsURL string wsURL string
roomID int roomID int
cookies *cookie.CookieRefreshService session *cookie.SessionService
dialer websocket.Dialer
conn *websocket.Conn conn *websocket.Conn
connected bool connected bool
mu sync.RWMutex mu sync.RWMutex
@@ -37,31 +53,40 @@ type Client struct {
stopCh chan struct{} stopCh chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
processedMu sync.Mutex processedMu sync.Mutex
processedMessageIDs []int processedUUIDs []string
messageEditDates *utils.BoundedMap messageEditDates *utils.BoundedMap
OnMessage func(map[string]interface{}) OnMessage func(map[string]interface{})
OnEdit func(int, string) OnEdit func(string, string)
OnDelete func(int) OnDelete func(string)
OnConnect func() OnConnect func()
OnDisconnect func() OnDisconnect func()
recentOutboundIter func() []map[string]interface{} recentOutboundIter func() []map[string]interface{}
mapDiscordSneed func(int, int, string) mapDiscordSneed func(string, int, string)
bridgeUserID int bridgeUserID int
bridgeUsername string bridgeUsername string
baseLoopsStarted bool baseLoopsStarted bool
debug bool
} }
func NewClient(roomID int, cookieSvc *cookie.CookieRefreshService) *Client { func NewClient(roomID int, session *cookie.SessionService, debug bool) *Client {
tr := session.Transport()
return &Client{ return &Client{
wsURL: "wss://kiwifarms.st:9443/chat.ws", wsURL: "wss://kiwifarms.st:9443/chat.ws",
roomID: roomID, roomID: roomID,
cookies: cookieSvc, session: session,
debug: debug,
dialer: websocket.Dialer{
EnableCompression: true,
NetDialContext: tr.DialContext,
TLSClientConfig: tr.TLSClientConfig,
},
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
processedMessageIDs: make([]int, 0, ProcessedCacheSize), processedUUIDs: make([]string, 0, ProcessedCacheSize),
messageEditDates: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge), messageEditDates: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
lastMessage: time.Now(), lastMessage: time.Now(),
} }
@@ -72,7 +97,7 @@ func (c *Client) SetBridgeIdentity(userID int, username string) {
c.bridgeUsername = username c.bridgeUsername = username
} }
func (c *Client) Connect() error { func (c *Client) Connect(ctx context.Context) error {
c.mu.Lock() c.mu.Lock()
if c.connected { if c.connected {
c.mu.Unlock() c.mu.Unlock()
@@ -80,15 +105,43 @@ func (c *Client) Connect() error {
} }
c.mu.Unlock() c.mu.Unlock()
headers := http.Header{} // Exclude ttrs_clearance — must be solved fresh for port 9443.
if ck := c.cookies.GetCurrentCookie(); ck != "" { headers := http.Header{
headers.Add("Cookie", ck) "Cookie": {c.session.CookieStringForWS()},
} }
log.Printf("Connecting to Sneedchat room %d", c.roomID) log.Printf("Connecting to Sneedchat room %d", c.roomID)
conn, _, err := websocket.DefaultDialer.Dial(c.wsURL, headers) if c.debug {
log.Printf("🍪 Dial cookies: %s", headers.Get("Cookie"))
}
conn, resp, err := c.dialer.DialContext(ctx, c.wsURL, headers)
if err != nil { if err != nil {
return fmt.Errorf("websocket connection failed: %w", err) if resp != nil && resp.StatusCode == 203 {
log.Println("⚠️ Got 203 on WebSocket dial — solving challenge...")
body := make([]byte, 0)
if resp.Body != nil {
buf := make([]byte, 8192)
n, _ := resp.Body.Read(buf)
body = buf[:n]
resp.Body.Close()
}
clearance, serr := c.session.SolveForHost(ctx, body)
if serr != nil {
return fmt.Errorf("PoW solve for WS endpoint: %w", serr)
}
wsBase := c.session.CookieStringForWS()
headers["Cookie"] = []string{wsBase + "; ttrs_clearance=" + clearance}
if c.debug {
log.Printf("🍪 Retry dial cookies: %s", headers.Get("Cookie"))
}
conn, _, err = c.dialer.DialContext(ctx, c.wsURL, headers)
if err != nil {
return fmt.Errorf("websocket connection failed after PoW solve: %w", err)
}
} else {
return fmt.Errorf("websocket connection failed: %w", err)
}
} }
c.mu.Lock() c.mu.Lock()
@@ -105,7 +158,7 @@ func (c *Client) Connect() error {
} }
c.wg.Add(1) c.wg.Add(1)
go c.readLoop() go c.readLoop(ctx)
c.Send(fmt.Sprintf("/join %d", c.roomID)) c.Send(fmt.Sprintf("/join %d", c.roomID))
log.Printf("✅ Successfully connected to Sneedchat room %d", c.roomID) log.Printf("✅ Successfully connected to Sneedchat room %d", c.roomID)
@@ -119,7 +172,7 @@ func (c *Client) joinRoom() {
c.Send(fmt.Sprintf("/join %d", c.roomID)) c.Send(fmt.Sprintf("/join %d", c.roomID))
} }
func (c *Client) readLoop() { func (c *Client) readLoop(ctx context.Context) {
defer c.wg.Done() defer c.wg.Done()
for { for {
select { select {
@@ -138,14 +191,47 @@ func (c *Client) readLoop() {
_, message, err := conn.ReadMessage() _, message, err := conn.ReadMessage()
if err != nil { if err != nil {
log.Printf("Sneedchat read error: %v", err) log.Printf("Sneedchat read error: %v", err)
c.handleDisconnect() c.handleDisconnect(ctx)
return return
} }
raw := string(message)
// Server sends plaintext "cannot join" when session has expired.
if isCannotJoin(raw) {
log.Println("⚠️ 'cannot join' received — refreshing session and reconnecting...")
if rerr := c.session.Refresh(ctx); rerr != nil {
log.Printf("❌ Session refresh failed: %v", rerr)
}
c.handleDisconnect(ctx)
return
}
c.mu.Lock()
c.lastMessage = time.Now() c.lastMessage = time.Now()
c.handleIncoming(string(message)) c.mu.Unlock()
if c.debug {
preview := raw
if len(preview) > 200 {
preview = preview[:200] + "..."
}
log.Printf("📨 WS recv: %s", preview)
}
c.handleIncoming(raw)
} }
} }
func isCannotJoin(msg string) bool {
return len(msg) > 0 && !isJSON(msg) && len(msg) >= 11 && msg == "cannot join"
}
func isJSON(s string) bool {
var js json.RawMessage
return json.Unmarshal([]byte(s), &js) == nil
}
func (c *Client) heartbeatLoop() { func (c *Client) heartbeatLoop() {
defer c.wg.Done() defer c.wg.Done()
t := time.NewTicker(30 * time.Second) t := time.NewTicker(30 * time.Second)
@@ -195,10 +281,17 @@ func (c *Client) Send(s string) bool {
log.Printf("Sneedchat write error: %v", err) log.Printf("Sneedchat write error: %v", err)
return false return false
} }
if c.debug {
preview := s
if len(preview) > 120 {
preview = preview[:120] + "..."
}
log.Printf("📤 WS sent: %s", preview)
}
return true return true
} }
func (c *Client) handleDisconnect() { func (c *Client) handleDisconnect(ctx context.Context) {
select { select {
case <-c.stopCh: case <-c.stopCh:
return return
@@ -218,7 +311,6 @@ func (c *Client) handleDisconnect() {
c.OnDisconnect() c.OnDisconnect()
} }
// Reconnection loop with exponential backoff
delay := ReconnectInterval delay := ReconnectInterval
maxDelay := 2 * time.Minute maxDelay := 2 * time.Minute
attempt := 0 attempt := 0
@@ -226,16 +318,14 @@ func (c *Client) handleDisconnect() {
for { for {
select { select {
case <-c.stopCh: case <-c.stopCh:
log.Println("Reconnection cancelled - bridge stopping") log.Println("Reconnection cancelled bridge stopping")
return return
case <-time.After(delay): case <-time.After(delay):
attempt++ attempt++
log.Printf("🔄 Reconnection attempt #%d...", attempt) log.Printf("🔄 Reconnection attempt #%d...", attempt)
if err := c.Connect(); err != nil { if err := c.Connect(ctx); err != nil {
log.Printf("⚠️ Reconnect attempt #%d failed: %v", attempt, err) log.Printf("⚠️ Reconnect attempt #%d failed: %v", attempt, err)
// Exponential backoff
delay *= 2 delay *= 2
if delay > maxDelay { if delay > maxDelay {
delay = maxDelay delay = maxDelay
@@ -244,11 +334,7 @@ func (c *Client) handleDisconnect() {
} }
log.Println("🟢 Reconnected successfully") log.Println("🟢 Reconnected successfully")
// Allow websocket to stabilize
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
// Re-join room
c.joinRoom() c.joinRoom()
c.Send("/ping") c.Send("/ping")
log.Printf("📍 Rejoined Sneedchat room %d after reconnect", c.roomID) log.Printf("📍 Rejoined Sneedchat room %d after reconnect", c.roomID)
@@ -271,27 +357,24 @@ func (c *Client) Disconnect() {
func (c *Client) handleIncoming(raw string) { func (c *Client) handleIncoming(raw string) {
var payload SneedPayload var payload SneedPayload
if err := json.Unmarshal([]byte(raw), &payload); err != nil { if err := json.Unmarshal([]byte(raw), &payload); err != nil {
if c.debug {
log.Printf("⚠️ WS parse error: %v | raw: %.100s", err, raw)
}
return return
} }
if payload.Delete != nil { if c.debug {
var ids []int log.Printf("📦 payload: msgs=%d msg=%v del=%v", len(payload.Messages), payload.Message != nil, payload.Delete != nil)
switch v := payload.Delete.(type) { }
case float64:
ids = []int{int(v)} for _, uuid := range payload.Delete {
case []interface{}: if uuid == "" {
for _, x := range v { continue
if fid, ok := x.(float64); ok {
ids = append(ids, int(fid))
}
}
} }
for _, id := range ids { c.messageEditDates.Delete(uuid)
c.messageEditDates.Delete(id) c.removeFromProcessed(uuid)
c.removeFromProcessed(id) if c.OnDelete != nil {
if c.OnDelete != nil { c.OnDelete(uuid)
c.OnDelete(id)
}
} }
} }
@@ -322,20 +405,21 @@ func (c *Client) processMessage(m SneedMessage) {
} }
messageText = html.UnescapeString(messageText) messageText = html.UnescapeString(messageText)
uuid := m.MessageUUID
editDate := m.MessageEditDate editDate := m.MessageEditDate
deleted := m.Deleted || m.IsDeleted deleted := m.Deleted || m.IsDeleted
if deleted { if deleted {
c.messageEditDates.Delete(m.MessageID) c.messageEditDates.Delete(uuid)
c.removeFromProcessed(m.MessageID) c.removeFromProcessed(uuid)
if c.OnDelete != nil { if c.OnDelete != nil {
c.OnDelete(m.MessageID) c.OnDelete(uuid)
} }
return return
} }
if (c.bridgeUserID > 0 && userID == c.bridgeUserID) || if (c.bridgeUserID > 0 && userID == c.bridgeUserID) ||
(c.bridgeUsername != "" && username == c.bridgeUsername) { (c.bridgeUsername != "" && username == c.bridgeUsername) {
if m.MessageID > 0 && c.recentOutboundIter != nil && c.mapDiscordSneed != nil { if uuid != "" && c.recentOutboundIter != nil && c.mapDiscordSneed != nil {
now := time.Now() now := time.Now()
for _, entry := range c.recentOutboundIter() { for _, entry := range c.recentOutboundIter() {
if mapped, ok := entry["mapped"].(bool); ok && mapped { if mapped, ok := entry["mapped"].(bool); ok && mapped {
@@ -345,7 +429,7 @@ func (c *Client) processMessage(m SneedMessage) {
if ts, ok := entry["ts"].(time.Time); ok { if ts, ok := entry["ts"].(time.Time); ok {
if content == messageText && now.Sub(ts) <= OutboundMatchWindow { if content == messageText && now.Sub(ts) <= OutboundMatchWindow {
if discordID, ok := entry["discord_id"].(int); ok { if discordID, ok := entry["discord_id"].(int); ok {
c.mapDiscordSneed(discordID, m.MessageID, username) c.mapDiscordSneed(uuid, discordID, username)
entry["mapped"] = true entry["mapped"] = true
break break
} }
@@ -353,72 +437,74 @@ func (c *Client) processMessage(m SneedMessage) {
} }
} }
} }
c.addToProcessed(m.MessageID) c.addToProcessed(uuid)
c.messageEditDates.Set(m.MessageID, editDate) c.messageEditDates.Set(uuid, editDate)
return return
} }
if c.isProcessed(m.MessageID) { if c.isProcessed(uuid) {
if prev, exists := c.messageEditDates.Get(m.MessageID); exists { if prev, exists := c.messageEditDates.Get(uuid); exists {
if editDate > prev.(int) { if editDate > prev.(int) {
c.messageEditDates.Set(m.MessageID, editDate) c.messageEditDates.Set(uuid, editDate)
if c.OnEdit != nil { if c.OnEdit != nil {
c.OnEdit(m.MessageID, messageText) c.OnEdit(uuid, messageText)
} }
} }
} }
return return
} }
c.addToProcessed(m.MessageID) c.addToProcessed(uuid)
c.messageEditDates.Set(m.MessageID, editDate) c.messageEditDates.Set(uuid, editDate)
if c.OnMessage != nil { if c.OnMessage != nil {
c.OnMessage(map[string]interface{}{ c.OnMessage(map[string]interface{}{
"username": username, "username": username,
"content": messageText, "content": messageText,
"message_id": m.MessageID, "message_uuid": uuid,
"author_id": userID, "message_id": m.MessageID,
"raw": m, "author_id": userID,
"raw": m,
}) })
} }
} }
func (c *Client) isProcessed(id int) bool { func (c *Client) isProcessed(uuid string) bool {
if uuid == "" {
return false
}
c.processedMu.Lock() c.processedMu.Lock()
defer c.processedMu.Unlock() defer c.processedMu.Unlock()
for _, x := range c.processedMessageIDs { for _, x := range c.processedUUIDs {
if x == id { if x == uuid {
return true return true
} }
} }
return false return false
} }
func (c *Client) addToProcessed(id int) { func (c *Client) addToProcessed(uuid string) {
if uuid == "" {
return
}
c.processedMu.Lock() c.processedMu.Lock()
defer c.processedMu.Unlock() defer c.processedMu.Unlock()
c.processedUUIDs = append(c.processedUUIDs, uuid)
c.processedMessageIDs = append(c.processedMessageIDs, id) if len(c.processedUUIDs) > ProcessedCacheSize {
excess := len(c.processedUUIDs) - ProcessedCacheSize
// Hard cap: keep only the most recent 1000 messages (FIFO) c.processedUUIDs = c.processedUUIDs[excess:]
if len(c.processedMessageIDs) > ProcessedCacheSize {
excess := len(c.processedMessageIDs) - ProcessedCacheSize
c.processedMessageIDs = c.processedMessageIDs[excess:]
// Log when significant eviction happens
if excess > 50 {
log.Printf("⚠️ Processed message cache full, evicted %d old entries", excess)
}
} }
} }
func (c *Client) removeFromProcessed(id int) { func (c *Client) removeFromProcessed(uuid string) {
if uuid == "" {
return
}
c.processedMu.Lock() c.processedMu.Lock()
defer c.processedMu.Unlock() defer c.processedMu.Unlock()
for i, x := range c.processedMessageIDs { for i, x := range c.processedUUIDs {
if x == id { if x == uuid {
c.processedMessageIDs = append(c.processedMessageIDs[:i], c.processedMessageIDs[i+1:]...) c.processedUUIDs = append(c.processedUUIDs[:i], c.processedUUIDs[i+1:]...)
return return
} }
} }
@@ -428,14 +514,23 @@ func (c *Client) SetOutboundIter(f func() []map[string]interface{}) {
c.recentOutboundIter = f c.recentOutboundIter = f
} }
func (c *Client) SetMapDiscordSneed(f func(int, int, string)) { func (c *Client) SetMapDiscordSneed(f func(string, int, string)) {
c.mapDiscordSneed = f c.mapDiscordSneed = f
} }
func (c *Client) IsConnected() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.connected
}
func ReplaceBridgeMention(content, bridgeUsername, pingID string) string { func ReplaceBridgeMention(content, bridgeUsername, pingID string) string {
if bridgeUsername == "" || pingID == "" { if bridgeUsername == "" || pingID == "" {
return content return content
} }
pat := regexp.MustCompile(fmt.Sprintf(`(?i)@%s(?:\W|$)`, regexp.QuoteMeta(bridgeUsername))) pat := regexp.MustCompile(fmt.Sprintf(`(?i)@%s(?:\W|$)`, regexp.QuoteMeta(bridgeUsername)))
return pat.ReplaceAllString(content, fmt.Sprintf("<@%s>", pingID)) return pat.ReplaceAllString(content, fmt.Sprintf("<@%s>", pingID))
} }
// Ensure socketTLSConfig is used (referenced in NewClient via session.Transport()).
var _ = socketTLSConfig

View File

@@ -2,8 +2,10 @@ package sneed
type SneedMessage struct { type SneedMessage struct {
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
MessageUUID string `json:"message_uuid"`
Message string `json:"message"` Message string `json:"message"`
MessageRaw string `json:"message_raw"` MessageRaw string `json:"message_raw"`
MessageDate int `json:"message_date"`
MessageEditDate int `json:"message_edit_date"` MessageEditDate int `json:"message_edit_date"`
Author map[string]interface{} `json:"author"` Author map[string]interface{} `json:"author"`
Deleted bool `json:"deleted"` Deleted bool `json:"deleted"`
@@ -13,5 +15,5 @@ type SneedMessage struct {
type SneedPayload struct { type SneedPayload struct {
Messages []SneedMessage `json:"messages"` Messages []SneedMessage `json:"messages"`
Message *SneedMessage `json:"message"` Message *SneedMessage `json:"message"`
Delete interface{} `json:"delete"` Delete []string `json:"delete"`
} }

View File

@@ -7,24 +7,24 @@ import (
type BoundedMap struct { type BoundedMap struct {
mu sync.RWMutex mu sync.RWMutex
data map[int]interface{} data map[string]interface{}
timestamps map[int]time.Time timestamps map[string]time.Time
maxSize int maxSize int
maxAge time.Duration maxAge time.Duration
keys []int keys []string
} }
func NewBoundedMap(maxSize int, maxAge time.Duration) *BoundedMap { func NewBoundedMap(maxSize int, maxAge time.Duration) *BoundedMap {
return &BoundedMap{ return &BoundedMap{
data: make(map[int]interface{}), data: make(map[string]interface{}),
timestamps: make(map[int]time.Time), timestamps: make(map[string]time.Time),
maxSize: maxSize, maxSize: maxSize,
maxAge: maxAge, maxAge: maxAge,
keys: make([]int, 0, maxSize), keys: make([]string, 0, maxSize),
} }
} }
func (bm *BoundedMap) Set(key int, value interface{}) { func (bm *BoundedMap) Set(key string, value interface{}) {
bm.mu.Lock() bm.mu.Lock()
defer bm.mu.Unlock() defer bm.mu.Unlock()
if _, ok := bm.data[key]; ok { if _, ok := bm.data[key]; ok {
@@ -50,14 +50,14 @@ func (bm *BoundedMap) Set(key int, value interface{}) {
} }
} }
func (bm *BoundedMap) Get(key int) (interface{}, bool) { func (bm *BoundedMap) Get(key string) (interface{}, bool) {
bm.mu.RLock() bm.mu.RLock()
defer bm.mu.RUnlock() defer bm.mu.RUnlock()
v, ok := bm.data[key] v, ok := bm.data[key]
return v, ok return v, ok
} }
func (bm *BoundedMap) Delete(key int) { func (bm *BoundedMap) Delete(key string) {
bm.mu.Lock() bm.mu.Lock()
defer bm.mu.Unlock() defer bm.mu.Unlock()
delete(bm.data, key) delete(bm.data, key)