Initial Commit

This commit is contained in:
Salastil
2025-11-18 02:07:08 -05:00
parent b3ebf32a75
commit 1c5418edf6
12 changed files with 2379 additions and 0 deletions

66
.env Normal file
View File

@@ -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_<username>: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

77
config/config.go Normal file
View File

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

440
cookie/fetcher.go Normal file
View File

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

178
main.go Normal file
View File

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

171
matrix/appservice_server.go Normal file
View File

@@ -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/<txn_id> - 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 <token>
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(`{}`))
}

380
matrix/bridge.go Normal file
View File

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

488
sneed/client.go Normal file
View File

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

122
sneed/helpers.go Normal file
View File

@@ -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 <json error: %v>", label, err)
return
}
log.Printf("%s: %s", label, string(b))
}

52
sneed/types.go Normal file
View File

@@ -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"`
}

112
utils/bbcode.go Normal file
View File

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

150
utils/boundedmap.go Normal file
View File

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

143
utils/helpers.go Normal file
View File

@@ -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 "<json error>"
}
return string(b)
}
// PrettyJSON pretty prints JSON map / slice items.
func PrettyJSON(v interface{}) string {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "<json error>"
}
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
}