3 Commits

Author SHA1 Message Date
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
9 changed files with 627 additions and 596 deletions

View File

@@ -20,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
@@ -35,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
@@ -271,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

@@ -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

@@ -186,8 +186,9 @@ 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)
} }
} }
@@ -289,14 +290,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)))
} }
@@ -305,12 +306,13 @@ 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) onSneedMessage(msg map[string]interface{}) { func (b *Bridge) onSneedMessage(msg map[string]interface{}) {
@@ -348,17 +350,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
} }
@@ -371,11 +373,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
} }
@@ -386,10 +388,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() {
@@ -575,11 +577,11 @@ 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) { func (b *Bridge) sendUploadStatusMessage(channelID, mention string, attachmentCount int) (*discordgo.Message, error) {
@@ -629,7 +631,7 @@ func (b *Bridge) awaitSneedConfirmation(discordID int, channelID, statusMessageI
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
if _, ok := b.discordToSneed.Get(discordID); ok { if _, ok := b.discordToSneed.Get(strconv.Itoa(discordID)); ok {
desc := "Delivered to Sneedchat." desc := "Delivered to Sneedchat."
b.editUploadStatusMessage(channelID, statusMessageID, b.uploadStatusTitle("complete"), desc, UploadStatusColorSuccess) b.editUploadStatusMessage(channelID, statusMessageID, b.uploadStatusTitle("complete"), desc, UploadStatusColorSuccess)
b.scheduleUploadStatusDeletion(channelID, statusMessageID, UploadStatusCleanupDelay) b.scheduleUploadStatusDeletion(channelID, statusMessageID, UploadStatusCleanupDelay)

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

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,6 +2,7 @@ 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"`
MessageEditDate int `json:"message_edit_date"` MessageEditDate int `json:"message_edit_date"`
@@ -13,5 +14,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)