diff --git a/.env b/.env new file mode 100644 index 0000000..13d097d --- /dev/null +++ b/.env @@ -0,0 +1,66 @@ +############################################### +# SNEEDCHAT LOGIN / ROOM CONFIGURATION +############################################### + +# Your Sneedchat login username (same as XenForo account) +BRIDGE_USERNAME= + +# Your Sneedchat login password +BRIDGE_PASSWORD= + +# Optional: your numeric XenForo user_id for the bridge account +# Used ONLY for echo-suppression (detecting bridge's own posts) +BRIDGE_USER_ID=0 + +# The Sneedchat room number you want to join +# Example: 69 (General Room) +SNEEDCHAT_ROOM_ID= + + +############################################### +# MATRIX CLIENT MODE (normal user token) +############################################### + +# Your Matrix homeserver (no trailing slash) +# Example: https://matrix.org +MATRIX_HOMESERVER= + +# Access token for the Matrix bot/user account +MATRIX_ACCESS_TOKEN= + +# MXID of the Matrix bot/user account +# Example: @bridge:example.org +MATRIX_USER_ID= + +# Matrix room ID to bridge to +# Example: !yourroomid:example.org +MATRIX_ROOM_ID= + + +############################################### +# MATRIX APPSERVICE MODE (ghost user spawning) +############################################### + +# Enable: 1 / true +# Disable: 0 / false +MATRIX_APPSERVICE_MODE=false + +# Your appservice token (required only if APPSERVICE_MODE=true) +MATRIX_APPSERVICE_TOKEN= + +# Default domain for ghost MXIDs spawned by the bridge +# Example output: @john:sneedchat.kiwifarms.net +MATRIX_GHOST_USER_DOMAIN=sneedchat.kiwifarms.net + +# Prefix for generated ghost MXIDs +# Final MXID format: @sneed_:sneedchat.kiwifarms.net +# If you prefer @username:sneedchat.kiwifarms.net, set prefix="" +MATRIX_GHOST_USER_PREFIX= + + +############################################### +# DEBUG SETTINGS +############################################### +# Enable verbose debugging: 1 / true +# Disable: 0 / false +DEBUG=false diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..b8552d0 --- /dev/null +++ b/config/config.go @@ -0,0 +1,77 @@ +package config + +import ( + "fmt" + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +// Config holds all configuration for the pure Matrix Appservice ↔ Sneedchat bridge. +type Config struct { + // --- Sneedchat --- + SneedchatRoomID int // numeric Siropu/Sneedchat room ID + BridgeUsername string // XenForo username used to authenticate + BridgePassword string // XenForo password + BridgeUserID int // numeric XenForo user_id (optional, used for echo-suppression) + Debug bool // verbose logging + + // --- Matrix Appservice --- + MatrixAppserviceToken string // token used to authenticate HS → AS + MatrixGhostUserDomain string // domain for generated ghost MXIDs + MatrixGhostUserPrefix string // prefix for ghost MXIDs ("" = none) + AppserviceListenAddr string // HTTP listen address, e.g. ":29333" +} + +// Load loads all settings from .env into a Config struct. +func Load(envFile string) (*Config, error) { + if err := godotenv.Load(envFile); err != nil { + log.Printf("⚠️ Warning: could not load %s: %v", envFile, err) + } + + cfg := &Config{ + // Sneedchat credentials + BridgeUsername: getenv("BRIDGE_USERNAME", ""), + BridgePassword: getenv("BRIDGE_PASSWORD", ""), + + // Matrix Appservice fields + MatrixAppserviceToken: getenv("MATRIX_APPSERVICE_TOKEN", ""), + MatrixGhostUserDomain: getenv("MATRIX_GHOST_USER_DOMAIN", "sneedchat.kiwifarms.net"), + MatrixGhostUserPrefix: getenv("MATRIX_GHOST_USER_PREFIX", ""), + + // Appservice listen address + AppserviceListenAddr: getenv("APPSERVICE_LISTEN_ADDR", ":29333"), + } + + // Debug flag + switch v := getenv("DEBUG", ""); v { + case "1", "true", "TRUE", "True": + cfg.Debug = true + } + + // Required Sneedchat room ID + roomRaw := getenv("SNEEDCHAT_ROOM_ID", "") + roomID, err := strconv.Atoi(roomRaw) + if err != nil { + return nil, fmt.Errorf("invalid SNEEDCHAT_ROOM_ID: %s", roomRaw) + } + cfg.SneedchatRoomID = roomID + + // Optional: user ID for echo suppression + if v := getenv("BRIDGE_USER_ID", ""); v != "" { + id, _ := strconv.Atoi(v) + cfg.BridgeUserID = id + } + + return cfg, nil +} + +// getenv returns environment variable k or default def. +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} diff --git a/cookie/fetcher.go b/cookie/fetcher.go new file mode 100644 index 0000000..0758244 --- /dev/null +++ b/cookie/fetcher.go @@ -0,0 +1,440 @@ +package cookie + +import ( + "crypto/sha256" + "fmt" + "io" + "log" + "math/rand" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +const ( + CookieRefreshInterval = 4 * time.Hour + CookieRetryDelay = 5 * time.Second + MaxCookieRetryDelay = 60 * time.Second +) + +type CookieRefreshService struct { + username string + password string + domain string + client *http.Client + 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) { + return NewCookieRefreshServiceWithDebug(username, password, domain, false) +} + +func NewCookieRefreshServiceWithDebug(username, password, domain string, debug bool) (*CookieRefreshService, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + tr := &http.Transport{} + client := &http.Client{ + 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() { + s.wg.Add(1) + go func() { + defer s.wg.Done() + + 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") + + 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) + + 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) + + s.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + 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) + + 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: + return "", fmt.Errorf("failed to get login page: %w", err) + } + defer respLogin.Body.Close() + + bodyLogin, _ := io.ReadAll(respLogin.Body) + if s.debug: + log.Printf("📄 Login page HTML (first 1024 bytes): +%s", firstN(string(bodyLogin), 1024)) + } + + if s.debug: + log.Println("Step 3: Extracting CSRF token...") + } + csrf := extractCSRF(string(bodyLogin)) + if csrf == "": + return "", fmt.Errorf("CSRF token not found in login page") + } + if s.debug: + log.Printf("✅ Found CSRF token: %s...", abbreviate(csrf, 10)) + } + + preCookies := s.jar.Cookies(rootURL) + hadXfUserBefore := hasCookie(preCookies, "xf_user") + + + if s.debug { + log.Println("Step 4: Submitting login credentials...") + logCookies("Cookies before login POST", preCookies) + } + + form := url.Values{ + "_xfToken": {csrf}, + "login": {s.username}, + "password": {s.password}, + "_xfRedirect": {base}, + "remember": {"1"}, + } + postReq, _ := http.NewRequest("POST", loginPost, strings.NewReader(form.Encode())) + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + 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) + } + + 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)) + } + } + + if hasCookie(postCookies, "xf_user") { + return buildCookieString(postCookies), nil + } + + if hadXfUserBefore { + if s.debug { + log.Println("🔍 Missing xf_user after login POST but we had one before; checking /account/") + } + ok, cookieStr := s.validateSessionUsingAccount(accountURL, rootURL) + if ok { + return cookieStr, nil + } + } + + bodyBytes, _ := io.ReadAll(postResp.Body) + bodyText := string(bodyBytes) + if s.debug { + log.Printf("📄 Login HTML snippet: +%s", firstN(bodyText, 500)) + } + + return "", fmt.Errorf("retry still missing xf_user cookie") +} + + +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: + return err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + html := string(body) + + re := regexp.MustCompile(`data-sssg-challenge=["']([0-9a-fA-F]+)["'][^>]*data-sssg-difficulty=["'](\d+)["']`) + m := re.FindStringSubmatch(html) + if len(m) < 3: + return nil + } + token := m[1] + diff, _ := strconv.Atoi(m[2]) + + nonce, _, err := s.solvePoW(token, diff) + if err != nil: + return err + } + + form := url.Values{"a": {token}, "b": {nonce}} + answerURL := fmt.Sprintf("https://%s/.sssg/api/answer", s.domain) + 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() + + if subResp.StatusCode != 200: + body, _ := io.ReadAll(subResp.Body) + return fmt.Errorf("challenge solve HTTP %d (%s)", subResp.StatusCode, strings.TrimSpace(string(body))) + } + + time.Sleep(2 * time.Second) + return nil +} + + +func (s *CookieRefreshService) solvePoW(token string, difficulty int) (string, time.Duration, error) { + start := time.Now() + nonce := rand.Int63() + requiredBytes := difficulty / 8 + requiredBits := difficulty % 8 + + for attempts := 0; attempts < 10_000_000; attempts++ { + nonce++ + input := token + fmt.Sprintf("%d", nonce) + sum := sha256.Sum256([]byte(input)) + + ok := true + for i := 0; i < requiredBytes; i++ { + if sum[i] != 0 { + ok = false + break + } + } + if ok && requiredBits > 0 { + mask := byte(0xFF << (8 - requiredBits)) + if sum[requiredBytes]&mask != 0: + ok = false + } + } + + if ok { + elapsed := time.Since(start) + 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") +} + + +func extractCSRF(body string) string { + patterns := []*regexp.Regexp{ + regexp.MustCompile(`data-csrf=["']([^"']+)["']`), + regexp.MustCompile(`"csrf":"([^"]+)"`), + regexp.MustCompile(`XF\.config\.csrf\s*=\s*"([^"]+)"`), + } + for _, re := range patterns { + if m := re.FindStringSubmatch(body); len(m) >= 2: + return m[1] + } + } + return "" +} + +func hasCookie(cookies []*http.Cookie, name string) bool { + for _, c := range cookies { + if c.Name == name { + return true + } + } + return false +} + +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) { + req, _ := http.NewRequest("GET", accountURL, nil) + req.Header.Set("User-Agent", "Mozilla/5.0") + + resp, err := s.client.Do(req) + if err != nil { + return false, "" + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + snippet := firstN(string(body), 500) + + 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] +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c592e42 --- /dev/null +++ b/main.go @@ -0,0 +1,178 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "local/sneedchatbridge/config" + "local/sneedchatbridge/cookie" + "local/sneedchatbridge/matrix" + "local/sneedchatbridge/sneed" +) + +func main() { + // ------------------------------------------------------------ + // COMMAND-LINE FLAG HANDLING + // ------------------------------------------------------------ + genReg := flag.Bool("generate-registration", false, "Generate registration.yaml and exit") + outPath := flag.String("out", "registration.yaml", "Output path for generated registration.yaml") + flag.Parse() + + // ------------------------------------------------------------ + // LOAD CONFIG + // ------------------------------------------------------------ + cfg, err := config.Load(".env") + if err != nil { + log.Fatalf("❌ Failed to load configuration: %v", err) + } + + // If --generate-registration was passed, output registration.yaml and exit. + if *genReg { + err := generateRegistrationFile(cfg, *outPath) + if err != nil { + log.Fatalf("❌ Failed to generate registration file: %v", err) + } + log.Printf("🟢 Registration file written to %s", *outPath) + return + } + + // ------------------------------------------------------------ + // NORMAL BRIDGE STARTUP (APPSERVICE MODE ONLY) + // ------------------------------------------------------------ + fmt.Println("===============================================") + fmt.Println(" Matrix Appservice ↔ Sneedchat Bridge") + fmt.Println("===============================================") + + log.Printf("Using Sneedchat room ID: %d", cfg.SneedchatRoomID) + log.Printf("Matrix Appservice listen address: %s", cfg.AppserviceListenAddr) + log.Printf("Ghost MXID domain: %s (prefix=%q)", cfg.MatrixGhostUserDomain, cfg.MatrixGhostUserPrefix) + + // ------------------------------------------------------------ + // COOKIE REFRESH SERVICE + // ------------------------------------------------------------ + ck, err := cookie.NewCookieRefreshServiceWithDebug( + cfg.BridgeUsername, + cfg.BridgePassword, + kiwiDomain(), + cfg.Debug, + ) + if err != nil { + log.Fatalf("❌ Cannot create cookie refresh service: %v", err) + } + + ck.Start() + ck.WaitForCookie() + log.Println("🟢 Initial XenForo session cookie acquired.") + + // ------------------------------------------------------------ + // SNEEDCHAT CLIENT INIT + // ------------------------------------------------------------ + sneedClient := sneed.NewClient(cfg.SneedchatRoomID, ck) + + // ------------------------------------------------------------ + // MATRIX BRIDGE INIT (APPSERVICE) + // ------------------------------------------------------------ + bridge := matrix.NewBridge(cfg, sneedClient) + + // ------------------------------------------------------------ + // START COMPONENTS + // ------------------------------------------------------------ + + // Start Sneedchat WebSocket client + if err := sneedClient.Connect(); err != nil { + log.Fatalf("❌ Sneedchat initial connect failed: %v", err) + } + + // Start Matrix Appservice HTTP server + go func() { + if err := bridge.StartAppserviceServer(cfg); err != nil { + log.Fatalf("❌ Appservice HTTP server error: %v", err) + } + }() + + // ------------------------------------------------------------ + // WAIT FOR SHUTDOWN SIGNAL + // ------------------------------------------------------------ + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + s := <-sigCh + log.Printf("🔻 Shutdown signal received: %v", s) + + // ------------------------------------------------------------ + // SHUT DOWN GRACEFULLY + // ------------------------------------------------------------ + // If Bridge implements Stop() we call it. (Optional) + if stopper, ok := interface{}(bridge).(interface{ Stop() }); ok { + stopper.Stop() + } + + sneedClient.Disconnect() + ck.Stop() + + log.Println("🟡 Bridge stopped.") +} + +// ------------------------------------------------------------ +// REGISTRATION GENERATOR +// ------------------------------------------------------------ + +// generateRegistrationFile writes the appservice registration.yaml +// based on values loaded from .env. +func generateRegistrationFile(cfg *config.Config, outPath string) error { + template := `id: sneedchat +url: "http://127.0.0.1:29333" +as_token: "%s" +hs_token: "%s" +sender_localpart: "sneedbridge" +rate_limited: false + +namespaces: + users: + - regex: "^@sneedbridge:%s$" + exclusive: true + - regex: "^@.*:%s$" + exclusive: true + aliases: [] + rooms: [] + +push_ephemeral: true +de.sorunome.msc2409.push_ephemeral: true +` + + var buf bytes.Buffer + + hsToken := randomHex(64) // Synapse expects strong token + + buf.WriteString(fmt.Sprintf( + template, + cfg.MatrixAppserviceToken, + hsToken, + cfg.MatrixGhostUserDomain, + cfg.MatrixGhostUserDomain, + )) + + return os.WriteFile(outPath, buf.Bytes(), 0600) +} + +// randomHex generates a cryptographically random hex token. +func randomHex(n int) string { + b := make([]byte, n/2) + _, err := rand.Read(b) + if err != nil { + panic(err) + } + return hex.EncodeToString(b) +} + +// kiwiDomain returns the XenForo/Sneedchat domain name. +func kiwiDomain() string { + return "kiwifarms.st" +} diff --git a/matrix/appservice_server.go b/matrix/appservice_server.go new file mode 100644 index 0000000..7333796 --- /dev/null +++ b/matrix/appservice_server.go @@ -0,0 +1,171 @@ +package matrix + +import ( + "encoding/json" + "log" + "net/http" + "strings" + + "local/sneedchatbridge/config" +) + +// StartAppserviceServer starts the Appservice HTTP server on the configured +// listen address. Synapse will POST room events here as transactions, and +// GET user queries (ghost user checks). +func (b *Bridge) StartAppserviceServer(cfg *config.Config) error { + mux := http.NewServeMux() + + // ----------------------------------------------------------- + // /transactions/ - Synapse pushes events here + // ----------------------------------------------------------- + mux.HandleFunc("/_matrix/appservice/v1/transactions/", func(w http.ResponseWriter, r *http.Request) { + if !b.checkAppserviceAuth(r, cfg.MatrixAppserviceToken) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPut && r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + b.handleTransaction(w, r) + }) + + // ----------------------------------------------------------- + // /users/@user:domain - Synapse asks if ghost user is allowed + // ----------------------------------------------------------- + mux.HandleFunc("/_matrix/appservice/v1/users/", func(w http.ResponseWriter, r *http.Request) { + if !b.checkAppserviceAuth(r, cfg.MatrixAppserviceToken) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + b.handleUserQuery(w, r, cfg) + }) + + addr := cfg.AppserviceListenAddr + log.Printf("🟢 Matrix Appservice HTTP server listening on %s", addr) + return http.ListenAndServe(addr, mux) +} + +// +// AUTHENTICATION +// + +// checkAppserviceAuth enforces authorization using the appservice token +// provided in registration.yaml as hs_token and in .env as MATRIX_APPSERVICE_TOKEN. +func (b *Bridge) checkAppserviceAuth(r *http.Request, token string) bool { + // Try ?access_token=... + if q := r.URL.Query().Get("access_token"); q != "" { + return q == token + } + // Try Authorization: Bearer + if h := r.Header.Get("Authorization"); h != "" { + if strings.HasPrefix(h, "Bearer ") { + return strings.TrimPrefix(h, "Bearer ") == token + } + } + return false +} + +// +// TRANSACTIONS +// + +// handleTransaction receives a list of events from Synapse. +// Synapse POSTs a JSON body like: +// { "events": [ { ... }, { ... } ] } +func (b *Bridge) handleTransaction(w http.ResponseWriter, r *http.Request) { + var payload struct { + Events []map[string]interface{} `json:"events"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + log.Printf("❌ Appservice transaction decode error: %v", err) + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + for _, ev := range payload.Events { + b.routeEvent(ev) + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("{}")) +} + +// routeEvent takes a raw Matrix event (map[string]interface{}) +// and converts it into a strongly-typed RoomEvent struct, +// then forwards it to the bridge core. +func (b *Bridge) routeEvent(ev map[string]interface{}) { + evType, _ := ev["type"].(string) + roomID, _ := ev["room_id"].(string) + sender, _ := ev["sender"].(string) + eventID, _ := ev["event_id"].(string) + + // Extract content if present + content := map[string]interface{}{} + if c, ok := ev["content"].(map[string]interface{}); ok { + content = c + } + + // Extract unsigned if present + unsigned := map[string]interface{}{} + if u, ok := ev["unsigned"].(map[string]interface{}); ok { + unsigned = u + } + + // Redaction ID if present + redacts, _ := ev["redacts"].(string) + + // Assemble our internal event struct + re := RoomEvent{ + RoomID: roomID, + EventID: eventID, + Sender: sender, + Type: evType, + Content: content, + Unsigned: unsigned, + Redacts: redacts, + } + + b.HandleMatrixEvent(re) +} + +// +// USER QUERY ENDPOINT +// + +// handleUserQuery answers the Appservice ghost-user existence query. +// Synapse queries: +// +// GET /_matrix/appservice/v1/users/@username:sneedchat.kiwifarms.net +// +// Returning `{}` approves ghost user creation. +// Returning 404 denies. +func (b *Bridge) handleUserQuery(w http.ResponseWriter, r *http.Request, cfg *config.Config) { + path := r.URL.Path + parts := strings.Split(path, "/") + if len(parts) == 0 { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + mxid := parts[len(parts)-1] + if mxid == "" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + // Allow only the configured ghost-user domain + if !strings.HasSuffix(mxid, ":"+cfg.MatrixGhostUserDomain) { + http.NotFound(w, r) + return + } + + // Approve creation + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) +} diff --git a/matrix/bridge.go b/matrix/bridge.go new file mode 100644 index 0000000..f468472 --- /dev/null +++ b/matrix/bridge.go @@ -0,0 +1,380 @@ +package matrix + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "local/sneedchatbridge/config" + "local/sneedchatbridge/sneed" + "local/sneedchatbridge/utils" +) + +// +// RoomEvent — internal Matrix event representation +// + +type RoomEvent struct { + RoomID string + EventID string + Sender string + Type string + Content map[string]interface{} + Unsigned map[string]interface{} + Redacts string +} + +// +// Bridge structure +// + +type Bridge struct { + cfg *config.Config + sneedc *sneed.Client + httpClient *http.Client + + roomID string + + muOutbound sync.Mutex + outboundSent []map[string]interface{} // recent outbound messages for echo suppression + + muMap sync.Mutex + idMap map[int]int // Matrix synthetic ID → Sneed message ID + sneedTo map[int]int // Sneed message ID → Matrix synthetic ID + + muGhost sync.Mutex + remoteUserToGhost map[int]string // user_id → MXID +} + +// +// Constructor +// + +func NewBridge(cfg *config.Config, sneedc *sneed.Client) *Bridge { + b := &Bridge{ + cfg: cfg, + sneedc: sneedc, + httpClient: &http.Client{Timeout: 10 * time.Second}, + + // roomID must come from Synapse: invite your appservice bot into the room + roomID: cfg.MatrixRoomID, + + idMap: make(map[int]int), + sneedTo: make(map[int]int), + + remoteUserToGhost: make(map[int]string), + outboundSent: make([]map[string]interface{}, 0, 64), + } + + // Wire Sneedchat callbacks + sneedc.OnMessage = b.onSneedMessage + sneedc.OnEdit = b.onSneedEdit + sneedc.OnDelete = b.onSneedDelete + + return b +} + +// +// MATRIX → SNEEDCHAT ENTRYPOINT +// + +func (b *Bridge) HandleMatrixEvent(ev RoomEvent) { + // ignore our own appservice ghost users + if strings.HasSuffix(ev.Sender, ":"+b.cfg.MatrixGhostUserDomain) { + return + } + + switch ev.Type { + case "m.room.message": + b.handleMatrixMessage(ev) + + case "m.room.redaction": + b.handleMatrixRedaction(ev) + } +} + +// +// MATRIX → SNEEDCHAT Message +// + +func (b *Bridge) handleMatrixMessage(ev RoomEvent) { + body, _ := ev.Content["body"].(string) + body = utils.CleanSpaces(body) + if body == "" { + return + } + + // Track outbound for echo suppression + b.trackOutbound(ev.EventID, body, time.Now()) + + // If only image link + if utils.IsImageURL(body) { + wrapped := utils.WrapImageForSneed(body) + b.sneedc.Say(wrapped) + return + } + + // Otherwise text + b.sneedc.Say(body) +} + +// +// MATRIX → SNEEDCHAT Redaction +// + +func (b *Bridge) handleMatrixRedaction(ev RoomEvent) { + raw := ev.Redacts + if raw == "" { + return + } + + synthID, ok := b.eventIDToInt(raw) + if !ok { + return + } + + b.muMap.Lock() + sneedID, ok := b.idMap[synthID] + b.muMap.Unlock() + + if ok { + b.sneedc.Say(fmt.Sprintf("/delete %d", sneedID)) + } +} + +// +// Synthetic ID converter +// + +func (b *Bridge) eventIDToInt(evID string) (int, bool) { + evID = strings.TrimPrefix(evID, "$") + if len(evID) < 8 { + return 0, false + } + i, err := strconv.Atoi(evID[:8]) + return i, err == nil +} + +// +// Outbound tracking to avoid echoing Matrix messages back +// + +func (b *Bridge) trackOutbound(eventID string, content string, ts time.Time) { + sid, ok := b.eventIDToInt(eventID) + if !ok { + return + } + + b.muOutbound.Lock() + defer b.muOutbound.Unlock() + + b.outboundSent = append(b.outboundSent, map[string]interface{}{ + "synthetic_id": sid, + "content": content, + "ts": ts, + }) + + // prune entries older than 60 seconds + cut := time.Now().Add(-60 * time.Second) + pruned := b.outboundSent[:0] + for _, e := range b.outboundSent { + if e["ts"].(time.Time).After(cut) { + pruned = append(pruned, e) + } + } + b.outboundSent = pruned +} + +// +// SNEEDCHAT → MATRIX Message +// + +func (b *Bridge) onSneedMessage(msgID int, userID int, username string, content string) { + mxid := b.makeGhostMXID(username, userID) + body := utils.BBCodeToMarkdown(content) + + if err := b.sendMatrixMessage(mxid, b.roomID, body, msgID); err != nil { + log.Printf("❌ Error sending Matrix message: %v", err) + } +} + +// +// SNEEDCHAT → MATRIX Edit +// + +func (b *Bridge) onSneedEdit(msgID int, userID int, newText string) { + b.muMap.Lock() + synthID, ok := b.sneedTo[msgID] + b.muMap.Unlock() + if !ok { + return + } + + mxEventID := fmt.Sprintf("$%08d:sneed", synthID) + body := utils.BBCodeToMarkdown(newText) + + _ = b.sendMatrixEdit(b.roomID, mxEventID, body) +} + +// +// SNEEDCHAT → MATRIX Delete +// + +func (b *Bridge) onSneedDelete(msgID int, userID int) { + b.muMap.Lock() + synthID, ok := b.sneedTo[msgID] + b.muMap.Unlock() + if !ok { + return + } + + mxEventID := fmt.Sprintf("$%08d:sneed", synthID) + _ = b.sendMatrixRedaction(b.roomID, mxEventID) +} + +// +// GHOST USER GENERATION (Collision-Safe) +// + +func (b *Bridge) makeGhostMXID(username string, userID int) string { + b.muGhost.Lock() + defer b.muGhost.Unlock() + + // Already assigned? + if mx, ok := b.remoteUserToGhost[userID]; ok { + return mx + } + + base := utils.NormalizeUsername(username) + domain := b.cfg.MatrixGhostUserDomain + prefix := b.cfg.MatrixGhostUserPrefix + + // Try @base + mxid := fmt.Sprintf("@%s%s:%s", prefix, base, domain) + if !b.mxidInUse(mxid) { + b.remoteUserToGhost[userID] = mxid + return mxid + } + + // Try suffixes + for i := 2; i < 10000; i++ { + candidate := fmt.Sprintf("@%s%s_%d:%s", prefix, base, i, domain) + if !b.mxidInUse(candidate) { + b.remoteUserToGhost[userID] = candidate + return candidate + } + } + + // Fallback + fallback := fmt.Sprintf("@%suid_%d:%s", prefix, userID, domain) + b.remoteUserToGhost[userID] = fallback + return fallback +} + +func (b *Bridge) mxidInUse(mxid string) bool { + for _, x := range b.remoteUserToGhost { + if x == mxid { + return true + } + } + return false +} + +// +// MATRIX SEND HELPERS +// + +func (b *Bridge) sendMatrixMessage(mxid, roomID, body string, sneedID int) error { + url := fmt.Sprintf( + "%s/_matrix/client/r0/rooms/%s/send/m.room.message/%d?access_token=%s", + b.getHS(), + roomID, + time.Now().UnixNano(), + b.cfg.MatrixAppserviceToken, + ) + + payload := map[string]interface{}{ + "msgtype": "m.text", + "body": body, + "sender": mxid, + } + + return b.httpPutJSON(url, payload) +} + +func (b *Bridge) sendMatrixEdit(roomID, targetEventID, newBody string) error { + url := fmt.Sprintf( + "%s/_matrix/client/r0/rooms/%s/send/m.room.message/%d?access_token=%s", + b.getHS(), + roomID, + time.Now().UnixNano(), + b.cfg.MatrixAppserviceToken, + ) + + payload := map[string]interface{}{ + "msgtype": "m.text", + "body": newBody, + "m.new_content": map[string]interface{}{ + "msgtype": "m.text", + "body": newBody, + }, + "m.relates_to": map[string]interface{}{ + "rel_type": "m.replace", + "event_id": targetEventID, + }, + } + + return b.httpPutJSON(url, payload) +} + +func (b *Bridge) sendMatrixRedaction(roomID, targetEventID string) error { + url := fmt.Sprintf( + "%s/_matrix/client/r0/rooms/%s/redact/%s/%d?access_token=%s", + b.getHS(), + roomID, + targetEventID, + time.Now().UnixNano(), + b.cfg.MatrixAppserviceToken, + ) + + payload := map[string]interface{}{ + "reason": "Deleted on Sneedchat", + } + + return b.httpPutJSON(url, payload) +} + +// +// HTTP PUT helper +// + +func (b *Bridge) httpPutJSON(url string, payload map[string]interface{}) error { + data, _ := json.Marshal(payload) + req, _ := http.NewRequest(http.MethodPut, url, bytes.NewReader(data)) + req.Header.Set("Content-Type", "application/json") + + resp, err := b.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return fmt.Errorf("Matrix returned HTTP %d", resp.StatusCode) + } + return nil +} + +// +// Homeserver URL (static unless you add config) +// + +func (b *Bridge) getHS() string { + return "http://localhost:8008" +} diff --git a/sneed/client.go b/sneed/client.go new file mode 100644 index 0000000..06da0ee --- /dev/null +++ b/sneed/client.go @@ -0,0 +1,488 @@ +package sneed + +import ( + "encoding/json" + "fmt" + "html" + "log" + "net/http" + "regexp" + "sync" + "time" + + "github.com/gorilla/websocket" + "local/sneedchatbridge/cookie" + "local/sneedchatbridge/utils" +) + +const ( + ProcessedCacheSize = 1000 + ReconnectInterval = 7 * time.Second + MappingCacheSize = 1000 + MappingCleanupInterval = 5 * time.Minute + MappingMaxAge = 1 * time.Hour + OutboundMatchWindow = 60 * time.Second +) + +// ------------------------------------------------------------ +// Client structure +// ------------------------------------------------------------ + +type Client struct { + wsURL string + roomID int + cookies *cookie.CookieRefreshService + + conn *websocket.Conn + connected bool + mu sync.RWMutex + + lastMessage time.Time + stopCh chan struct{} + wg sync.WaitGroup + + processedMu sync.Mutex + processedMessageIDs []int + messageEditDates *utils.BoundedMap + + // APP-SERVICE CALLBACKS (Discord removed) + OnMessage func(msgID int, userID int, username string, content string) + OnEdit func(msgID int, userID int, newText string) + OnDelete func(msgID int, userID int) + OnConnect func() + OnDisconnect func() + + recentOutboundIter func() []map[string]interface{} + + bridgeUserID int + bridgeUsername string + baseLoopsStarted bool +} + +// ------------------------------------------------------------ +// Constructor +// ------------------------------------------------------------ + +func NewClient(roomID int, cookieSvc *cookie.CookieRefreshService) *Client { + return &Client{ + wsURL: "wss://kiwifarms.st:9443/chat.ws", + roomID: roomID, + cookies: cookieSvc, + stopCh: make(chan struct{}), + processedMessageIDs: make([]int, 0, ProcessedCacheSize), + messageEditDates: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge), + lastMessage: time.Now(), + } +} + +func (c *Client) SetBridgeIdentity(userID int, username string) { + c.bridgeUserID = userID + c.bridgeUsername = username +} + +func (c *Client) SetOutboundIter(f func() []map[string]interface{}) { + c.recentOutboundIter = f +} + +// ------------------------------------------------------------ +// Connect + Reconnect +// ------------------------------------------------------------ + +func (c *Client) Connect() error { + c.mu.Lock() + if c.connected { + c.mu.Unlock() + return nil + } + c.mu.Unlock() + + headers := http.Header{} + if ck := c.cookies.GetCurrentCookie(); ck != "" { + headers.Add("Cookie", ck) + } + + log.Printf("Connecting to Sneedchat room %d", c.roomID) + conn, _, err := websocket.DefaultDialer.Dial(c.wsURL, headers) + if err != nil { + return fmt.Errorf("websocket connection failed: %w", err) + } + + c.mu.Lock() + c.conn = conn + c.connected = true + c.lastMessage = time.Now() + c.mu.Unlock() + + if !c.baseLoopsStarted { + c.baseLoopsStarted = true + c.wg.Add(2) + go c.heartbeatLoop() + go c.cleanupLoop() + } + + c.wg.Add(1) + go c.readLoop() + + c.Send(fmt.Sprintf("/join %d", c.roomID)) + log.Printf("✅ Successfully connected to Sneedchat room %d", c.roomID) + + if c.OnConnect != nil { + c.OnConnect() + } + return nil +} + +func (c *Client) joinRoom() { + c.Send(fmt.Sprintf("/join %d", c.roomID)) +} + +// ------------------------------------------------------------ +// Read loop +// ------------------------------------------------------------ + +func (c *Client) readLoop() { + defer c.wg.Done() + + for { + select { + case <-c.stopCh: + return + default: + } + + c.mu.RLock() + conn := c.conn + c.mu.RUnlock() + + if conn == nil { + return + } + + _, raw, err := conn.ReadMessage() + if err != nil { + log.Printf("Sneedchat read error: %v", err) + c.handleDisconnect() + return + } + + c.lastMessage = time.Now() + c.handleIncoming(string(raw)) + } +} + +// ------------------------------------------------------------ +// Heartbeat +// ------------------------------------------------------------ + +func (c *Client) heartbeatLoop() { + defer c.wg.Done() + + t := time.NewTicker(30 * time.Second) + defer t.Stop() + + for { + select { + case <-t.C: + c.mu.RLock() + connected := c.connected + conn := c.conn + c.mu.RUnlock() + + if connected && time.Since(c.lastMessage) > 60*time.Second && conn != nil { + _ = conn.WriteMessage(websocket.TextMessage, []byte("/ping")) + } + + case <-c.stopCh: + return + } + } +} + +// ------------------------------------------------------------ +// Cleanup loop +// ------------------------------------------------------------ + +func (c *Client) cleanupLoop() { + defer c.wg.Done() + + t := time.NewTicker(MappingCleanupInterval) + defer t.Stop() + + for { + select { + case <-t.C: + removed := c.messageEditDates.CleanupOldEntries() + if removed > 0 { + log.Printf("🧹 Cleaned %d old edit tracking entries", removed) + } + case <-c.stopCh: + return + } + } +} + +// ------------------------------------------------------------ +// Send +// ------------------------------------------------------------ + +func (c *Client) Send(s string) bool { + c.mu.RLock() + conn := c.conn + ok := c.connected && conn != nil + c.mu.RUnlock() + + if !ok { + return false + } + + if err := conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + log.Printf("Sneedchat write error: %v", err) + return false + } + return true +} + +// ------------------------------------------------------------ +// Disconnect + Reconnect +// ------------------------------------------------------------ + +func (c *Client) handleDisconnect() { + select { + case <-c.stopCh: + return + default: + } + + c.mu.Lock() + c.connected = false + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() + + log.Println("🔴 Sneedchat disconnected") + if c.OnDisconnect != nil { + c.OnDisconnect() + } + + delay := ReconnectInterval + maxDelay := 2 * time.Minute + attempt := 0 + + for { + select { + case <-c.stopCh: + log.Println("Reconnection cancelled - bridge stopping") + return + + case <-time.After(delay): + attempt++ + log.Printf("🔄 Reconnection attempt #%d...", attempt) + + if err := c.Connect(); err != nil { + log.Printf("⚠️ Reconnect attempt #%d failed: %v", attempt, err) + + delay *= 2 + if delay > maxDelay { + delay = maxDelay + } + continue + } + + log.Println("🟢 Reconnected successfully") + time.Sleep(2 * time.Second) + + c.joinRoom() + c.Send("/ping") + + log.Printf("📍 Rejoined Sneedchat room %d after reconnect", c.roomID) + return + } + } +} + +func (c *Client) Disconnect() { + close(c.stopCh) + + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + } + c.connected = false + c.mu.Unlock() + + c.wg.Wait() +} + +// ------------------------------------------------------------ +// Incoming Message Parsing (Siropu Format) +// ------------------------------------------------------------ + +func (c *Client) handleIncoming(raw string) { + var payload SneedPayload + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return + } + + // Handle Delete + if payload.Delete != nil { + var ids []int + switch v := payload.Delete.(type) { + case float64: + ids = []int{int(v)} + case []interface{}: + for _, x := range v { + if fid, ok := x.(float64); ok { + ids = append(ids, int(fid)) + } + } + } + for _, id := range ids { + c.messageEditDates.Delete(id) + c.removeFromProcessed(id) + if c.OnDelete != nil { + c.OnDelete(id, 0) // userID unknown for batch deletes + } + } + } + + // Messages + var msgs []SneedMessage + if len(payload.Messages) > 0 { + msgs = payload.Messages + } else if payload.Message != nil { + msgs = []SneedMessage{*payload.Message} + } + + for _, m := range msgs { + c.processMessage(m) + } +} + +// ------------------------------------------------------------ +// Process a Sneedchat message +// ------------------------------------------------------------ + +func (c *Client) processMessage(m SneedMessage) { + username := "Unknown" + userID := 0 + + if a, ok := m.Author["username"].(string); ok { + username = a + } + if id, ok := m.Author["id"].(float64); ok { + userID = int(id) + } + + msg := m.MessageRaw + if msg == "" { + msg = m.Message + } + msg = html.UnescapeString(msg) + + editDate := m.MessageEditDate + deleted := m.Deleted || m.IsDeleted + + // Delete + if deleted { + c.messageEditDates.Delete(m.MessageID) + c.removeFromProcessed(m.MessageID) + if c.OnDelete != nil { + c.OnDelete(m.MessageID, userID) + } + return + } + + // Bridge echo suppression (Matrix only now; Discord logic removed) + if (c.bridgeUserID > 0 && userID == c.bridgeUserID) || + (c.bridgeUsername != "" && username == c.bridgeUsername) { + + c.addToProcessed(m.MessageID) + c.messageEditDates.Set(m.MessageID, editDate) + return + } + + // Edits + if c.isProcessed(m.MessageID) { + if prev, exists := c.messageEditDates.Get(m.MessageID); exists { + if editDate > prev.(int) { + c.messageEditDates.Set(m.MessageID, editDate) + if c.OnEdit != nil { + c.OnEdit(m.MessageID, userID, msg) + } + } + } + return + } + + // Fresh message + c.addToProcessed(m.MessageID) + c.messageEditDates.Set(m.MessageID, editDate) + + if c.OnMessage != nil { + c.OnMessage( + m.MessageID, + userID, + username, + msg, + ) + } +} + +// ------------------------------------------------------------ +// Message Processed Cache +// ------------------------------------------------------------ + +func (c *Client) isProcessed(id int) bool { + c.processedMu.Lock() + defer c.processedMu.Unlock() + for _, x := range c.processedMessageIDs { + if x == id { + return true + } + } + return false +} + +func (c *Client) addToProcessed(id int) { + c.processedMu.Lock() + defer c.processedMu.Unlock() + + c.processedMessageIDs = append(c.processedMessageIDs, id) + + if len(c.processedMessageIDs) > ProcessedCacheSize { + excess := len(c.processedMessageIDs) - ProcessedCacheSize + c.processedMessageIDs = c.processedMessageIDs[excess:] + if excess > 50 { + log.Printf("⚠️ Processed message cache full, evicted %d entries", excess) + } + } +} + +func (c *Client) removeFromProcessed(id int) { + c.processedMu.Lock() + defer c.processedMu.Unlock() + + for i, x := range c.processedMessageIDs { + if x == id { + c.processedMessageIDs = append( + c.processedMessageIDs[:i], + c.processedMessageIDs[i+1:]..., + ) + return + } + } +} + +// ------------------------------------------------------------ +// Utility +// ------------------------------------------------------------ + +func ReplaceBridgeMention(content, bridgeUsername, pingID string) string { + if bridgeUsername == "" || pingID == "" { + return content + } + pat := regexp.MustCompile(fmt.Sprintf(`(?i)@%s(?:\W|$)`, regexp.QuoteMeta(bridgeUsername))) + return pat.ReplaceAllString(content, fmt.Sprintf("<@%s>", pingID)) +} diff --git a/sneed/helpers.go b/sneed/helpers.go new file mode 100644 index 0000000..4e245b4 --- /dev/null +++ b/sneed/helpers.go @@ -0,0 +1,122 @@ +package sneed + +import ( + "encoding/json" + "log" + "strconv" + "strings" + "time" +) + +// ------------------------------------------------------------- +// GENERIC EXTRACTION HELPERS +// ------------------------------------------------------------- + +// str extracts a string from any JSON value if possible. +func str(v interface{}) string { + switch t := v.(type) { + case string: + return t + case []byte: + return string(t) + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + case int: + return strconv.Itoa(t) + case int64: + return strconv.FormatInt(t, 10) + case bool: + if t { + return "true" + } + return "false" + default: + return "" + } +} + +// toInt attempts to pull an integer from JSON input. +func toInt(v interface{}) (int, bool) { + switch t := v.(type) { + case int: + return t, true + case int64: + return int(t), true + case float64: + return int(t), true + case string: + i, err := strconv.Atoi(strings.TrimSpace(t)) + if err == nil { + return i, true + } + } + return 0, false +} + +// toInt64 extracts an int64 from various types. +func toInt64(v interface{}) (int64, bool) { + switch t := v.(type) { + case int64: + return t, true + case int: + return int64(t), true + case float64: + return int64(t), true + case string: + i, err := strconv.ParseInt(strings.TrimSpace(t), 10, 64) + if err == nil { + return i, true + } + } + return 0, false +} + +// ------------------------------------------------------------- +// TIMESTAMP HELPERS +// ------------------------------------------------------------- + +// nowMS returns Unix milliseconds. +func nowMS() int64 { + return time.Now().UnixMilli() +} + +// newerTimestamp returns true if tsNew > tsOld. +func newerTimestamp(tsNew, tsOld int64) bool { + return tsNew > tsOld +} + +// ------------------------------------------------------------- +// CONTENT CLEANUP +// ------------------------------------------------------------- + +// cleanContent normalizes message text received from Sneedchat. +func cleanContent(s string) string { + if s == "" { + return "" + } + + // Remove weird control chars + s = strings.Map(func(r rune) rune { + if r < 32 || r == 127 { + return -1 + } + return r + }, s) + + // Trim whitespace + return strings.TrimSpace(s) +} + +// ------------------------------------------------------------- +// SAFE JSON LOGGING HELPERS +// ------------------------------------------------------------- + +// dumpJSON prints JSON for debugging without crashing. +func dumpJSON(label string, v interface{}) { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + log.Printf("%s ", label, err) + return + } + log.Printf("%s: %s", label, string(b)) +} diff --git a/sneed/types.go b/sneed/types.go new file mode 100644 index 0000000..0d6433f --- /dev/null +++ b/sneed/types.go @@ -0,0 +1,52 @@ +package sneed + +// SneedPayload represents a full incoming WS payload. +// Siropu Chat can send one message, multiple messages, +// edits, deletes, or system events in a single payload. +type SneedPayload struct { + Type string `json:"type"` // "message", "edit", "delete", etc. + Room int `json:"room"` // chat room ID + Delete interface{} `json:"delete"` // can be int or []int + Message *SneedMessage `json:"message"` // single message + Messages []SneedMessage `json:"messages"` // batch + User map[string]any `json:"user"` // sometimes present + System string `json:"system"` // system messages + Join string `json:"join"` // join notifications + Leave string `json:"leave"` // leave notifications + EventID string `json:"event_id"` // may exist for replies + Timestamp int64 `json:"timestamp"` // unix timestamp +} + +// SneedMessage represents an actual user message +// as delivered by Siropu Chat's backend via WS. +type SneedMessage struct { + MessageID int `json:"id"` + Message string `json:"message"` + MessageRaw string `json:"message_raw"` + Author map[string]interface{} `json:"author"` // contains: id, username, avatar, group, etc. + Deleted bool `json:"deleted"` + IsDeleted bool `json:"is_deleted"` + MessageEditDate int `json:"edit_date"` // unix timestamp (0 if not edited) + RoomID int `json:"room_id"` + IPID int `json:"ipid"` // internal Siropu user/visitor ID +} + +// Convenience structures for fast handlers. +// These are used only after we split out the type. +type IncomingMessage struct { + ID int `json:"id"` + UserID int `json:"user_id"` + Username string `json:"username"` + Message string `json:"message"` +} + +type IncomingEdit struct { + ID int `json:"id"` + UserID int `json:"user_id"` + Message string `json:"message"` +} + +type IncomingDelete struct { + ID int `json:"id"` + UserID int `json:"user_id"` +} diff --git a/utils/bbcode.go b/utils/bbcode.go new file mode 100644 index 0000000..8eaa621 --- /dev/null +++ b/utils/bbcode.go @@ -0,0 +1,112 @@ +package utils + +import ( + "regexp" + "strings" +) + +// BBCodeToMarkdown converts simplified Siropu-style BBCode into Matrix-safe Markdown. +// +// RULES: +// - [img]URL[/img] remains an image but without video tagging +// - [url] and [url=...] become markdown links +// - All [video]...[/video] wrappers are REMOVED entirely (your requirement) +// - Bold/italic/underline basic BBCode converted to markdown +// - Unknown tags stripped and inner text preserved +// +func BBCodeToMarkdown(in string) string { + if in == "" { + return "" + } + s := in + + // ---------------------------------------------------- + // STRIP VIDEO TAGS COMPLETELY + // ---------------------------------------------------- + s = stripTagCompletely(s, "video") + + // ---------------------------------------------------- + // IMAGE TAGS → leave as-is, but sanitize formatting + // ---------------------------------------------------- + s = regexp.MustCompile(`(?i)\[img\](.*?)\[/img\]`).ReplaceAllString(s, "![]($1)") + + // ---------------------------------------------------- + // URL TAGS → Markdown links + // ---------------------------------------------------- + // [url]http://x[/url] + s = regexp.MustCompile(`(?i)\[url\](.*?)\[/url\]`).ReplaceAllString(s, "[$1]($1)") + + // [url=http://x]label[/url] + s = regexp.MustCompile(`(?i)\[url=(.*?)\](.*?)\[/url\]`).ReplaceAllString(s, "[$2]($1)") + + // ---------------------------------------------------- + // BASIC FORMATTING → Markdown + // ---------------------------------------------------- + replacements := map[*regexp.Regexp]string{ + regexp.MustCompile(`(?i)\[b\](.*?)\[/b\]`): "**$1**", + regexp.MustCompile(`(?i)\[i\](.*?)\[/i\]`): "*$1*", + regexp.MustCompile(`(?i)\[u\](.*?)\[/u\]`): "__$1__", + regexp.MustCompile(`(?i)\[s\](.*?)\[/s\]`): "~~$1~~", + regexp.MustCompile(`(?i)\[quote\](.*?)\[/quote\]`): "> $1", + } + + for re, repl := range replacements { + s = re.ReplaceAllString(s, repl) + } + + // ---------------------------------------------------- + // REMOVE ANY OTHER BBCODE TAGS, KEEP CONTENT + // ---------------------------------------------------- + s = regexp.MustCompile(`(?i)\[(\/?)[a-zA-Z0-9\=\#]+?\]`).ReplaceAllString(s, "") + + // Cleanup whitespace + return strings.TrimSpace(s) +} + +// stripTagCompletely removes [tag]...[/tag] entirely, preserving inner text only if desired. +// Here we drop everything inside video tags. +func stripTagCompletely(s, tag string) string { + re := regexp.MustCompile(`(?is)\[` + tag + `(?:=[^\]]*)?\].*?\[\/` + tag + `\]`) + return re.ReplaceAllString(s, "") +} +// IsImageURL determines whether a string looks like an image link. +// Used by both Matrix → Sneed and Sneed → Matrix paths. +func IsImageURL(u string) bool { + u = strings.ToLower(strings.TrimSpace(u)) + if !(strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://")) { + return false + } + + // strip query + if i := strings.Index(u, "?"); i > 0 { + u = u[:i] + } + + return strings.HasSuffix(u, ".png") || + strings.HasSuffix(u, ".jpg") || + strings.HasSuffix(u, ".jpeg") || + strings.HasSuffix(u, ".gif") || + strings.HasSuffix(u, ".webp") +} + +// WrapImageForSneed produces the BBCode wrapper used for outbound +// Matrix → Sneed image messages. +// +// Example: +// input: "https://example.com/img.jpg" +// output: "[url=https://example.com/img.jpg][img]https://example.com/img.jpg[/img][/url]" +// +func WrapImageForSneed(url string) string { + if url == "" { + return "" + } + return "[url=" + url + "][img]" + url + "[/img][/url]" +} + +// ExtractFirstURL finds the first URL-like token in a message. +// Useful for deciding if a message is an image-only post. +func ExtractFirstURL(s string) string { + re := regexp.MustCompile(`https?://[^\s]+`) + found := re.FindString(s) + return found +} diff --git a/utils/boundedmap.go b/utils/boundedmap.go new file mode 100644 index 0000000..6f00b70 --- /dev/null +++ b/utils/boundedmap.go @@ -0,0 +1,150 @@ +package utils + +import ( + "sync" + "time" +) + +// BoundedMap is a size-limited and time-limited map. +// Entries automatically expire after a TTL, and older +// entries are removed when exceeding MaxSize. +type BoundedMap struct { + mu sync.Mutex + entries map[interface{}]entry + MaxSize int + TTL time.Duration +} + +type entry struct { + Value interface{} + Created time.Time +} + +// NewBoundedMap creates a new bounded map with max size and TTL. +func NewBoundedMap(maxSize int, ttl time.Duration) *BoundedMap { + return &BoundedMap{ + entries: make(map[interface{}]entry), + MaxSize: maxSize, + TTL: ttl, + } +} + +// Set stores a key/value pair, replacing old entry if needed. +func (b *BoundedMap) Set(key, value interface{}) { + b.mu.Lock() + defer b.mu.Unlock() + + b.entries[key] = entry{ + Value: value, + Created: time.Now(), + } + + // If map is growing too large, evict oldest items. + if len(b.entries) > b.MaxSize { + b.evictOldest() + } +} + +// Get retrieves a value if it exists and is not expired. +func (b *BoundedMap) Get(key interface{}) (interface{}, bool) { + b.mu.Lock() + defer b.mu.Unlock() + + e, ok := b.entries[key] + if !ok { + return nil, false + } + + if time.Since(e.Created) > b.TTL { + delete(b.entries, key) + return nil, false + } + + return e.Value, true +} + +// Delete removes an entry. +func (b *BoundedMap) Delete(key interface{}) { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.entries, key) +} + +// CleanupOldEntries removes expired entries and returns number removed. +func (b *BoundedMap) CleanupOldEntries() int { + b.mu.Lock() + defer b.mu.Unlock() + + count := 0 + now := time.Now() + + for k, e := range b.entries { + if now.Sub(e.Created) > b.TTL { + delete(b.entries, k) + count++ + } + } + + return count +} +// evictOldest removes the single oldest entry from the map. +// Called automatically when the map exceeds MaxSize. +func (b *BoundedMap) evictOldest() { + if len(b.entries) == 0 { + return + } + + var ( + oldestKey interface{} + oldestTS time.Time + first = true + ) + + for k, e := range b.entries { + if first { + oldestKey = k + oldestTS = e.Created + first = false + continue + } + if e.Created.Before(oldestTS) { + oldestKey = k + oldestTS = e.Created + } + } + + delete(b.entries, oldestKey) +} + +// Size returns the current number of live entries. +// Note: expired entries are not removed until Get() or CleanupOldEntries(). +func (b *BoundedMap) Size() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.entries) +} + +// Keys returns a slice of all keys (including expired ones). +// Expired keys will be filtered during normal Get/Cleanup operations. +func (b *BoundedMap) Keys() []interface{} { + b.mu.Lock() + defer b.mu.Unlock() + + out := make([]interface{}, 0, len(b.entries)) + for k := range b.entries { + out = append(out, k) + } + return out +} + +// Values returns all values in the map (including expired ones). +func (b *BoundedMap) Values() []interface{} { + b.mu.Lock() + defer b.mu.Unlock() + + out := make([]interface{}, 0, len(b.entries)) + for _, e := range b.entries { + out = append(out, e.Value) + } + return out +} \ No newline at end of file diff --git a/utils/helpers.go b/utils/helpers.go new file mode 100644 index 0000000..5611f0d --- /dev/null +++ b/utils/helpers.go @@ -0,0 +1,143 @@ +package utils + +import ( + "encoding/json" + "regexp" + "strconv" + "strings" + "time" +) + +// --------------------------------------------------------- +// STRING HELPERS +// --------------------------------------------------------- + +// Truncate returns the first N runes of s, safely. +func Truncate(s string, n int) string { + rs := []rune(s) + if len(rs) <= n { + return s + } + return string(rs[:n]) +} + +// NormalizeUsername lowers and strips unsafe characters. +// Used by the Matrix ghost-user generator and Sneed mapping. +func NormalizeUsername(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.ReplaceAll(s, " ", "_") + s = strings.ReplaceAll(s, "@", "") + s = strings.ReplaceAll(s, ":", "") + s = strings.ReplaceAll(s, "#", "") + s = strings.ReplaceAll(s, "/", "") + s = strings.ReplaceAll(s, "\\", "") + return s +} + +// CleanSpaces reduces all whitespace clusters to a single space. +func CleanSpaces(s string) string { + space := regexp.MustCompile(`\s+`) + return space.ReplaceAllString(strings.TrimSpace(s), " ") +} + +// StripControlChars removes non-printable or weird control characters. +func StripControlChars(s string) string { + re := regexp.MustCompile(`[\x00-\x1F\x7F]`) + return re.ReplaceAllString(s, "") +} + +// --------------------------------------------------------- +// URL HELPERS +// --------------------------------------------------------- + +// NormalizeURL trims and strips unused trailing punctuation. +func NormalizeURL(u string) string { + u = strings.TrimSpace(u) + if strings.HasSuffix(u, ")") || strings.HasSuffix(u, "]") { + u = u[:len(u)-1] + } + return u +} + +// IsLikelyURL checks for a simple URL pattern. +func IsLikelyURL(s string) bool { + s = strings.TrimSpace(s) + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +// --------------------------------------------------------- +// NUMERIC CONVERSIONS +// --------------------------------------------------------- + +// ToInt attempts to convert any JSON-type number to int. +func ToInt(v interface{}) (int, bool) { + switch t := v.(type) { + case int: + return t, true + case int64: + return int(t), true + case float64: + return int(t), true + case string: + i, err := strconv.Atoi(t) + if err == nil { + return i, true + } + } + return 0, false +} + +// --------------------------------------------------------- +// JSON SERIALIZATION +// --------------------------------------------------------- + +// MustJSON marshals v or returns a placeholder string. +func MustJSON(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + return "" + } + return string(b) +} + +// PrettyJSON pretty prints JSON map / slice items. +func PrettyJSON(v interface{}) string { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "" + } + return string(b) +} +// --------------------------------------------------------- +// TIMESTAMP HELPERS +// --------------------------------------------------------- + +// NowMS returns the current Unix time in milliseconds. +func NowMS() int64 { + return time.Now().UnixNano() / int64(time.Millisecond) +} + +// IsFresher compares two timestamps (ms). +// Returns true if tNew is strictly newer than tOld. +func IsFresher(tNew, tOld int64) bool { + return tNew > tOld +} + +// Age returns the duration since a timestamp in ms. +func Age(ts int64) time.Duration { + return time.Since(time.UnixMilli(ts)) +} + +// --------------------------------------------------------- +// MESSAGE ID HELPERS +// --------------------------------------------------------- + +// IsValidMessageID checks that a Sneedchat message_id is safe. +func IsValidMessageID(id int) bool { + return id > 0 && id < 1_000_000_000 +} + +// IsValidSyntheticID verifies that bridge synthetic IDs are nonzero. +func IsValidSyntheticID(id int) bool { + return id > 0 +}