Initial Commit
This commit is contained in:
66
.env
Normal file
66
.env
Normal 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
77
config/config.go
Normal 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
440
cookie/fetcher.go
Normal 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
178
main.go
Normal 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
171
matrix/appservice_server.go
Normal 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
380
matrix/bridge.go
Normal 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
488
sneed/client.go
Normal 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
122
sneed/helpers.go
Normal 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
52
sneed/types.go
Normal 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
112
utils/bbcode.go
Normal 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, "")
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 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
150
utils/boundedmap.go
Normal 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
143
utils/helpers.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user