Compare commits
6 Commits
v1.2
...
adba77b3e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adba77b3e5 | ||
|
|
a749e33737 | ||
|
|
85de6d175f | ||
|
|
4b455eb58e | ||
|
|
fba2b0e449 | ||
|
|
f954771c0c |
12
.env.example
12
.env.example
@@ -6,14 +6,16 @@ DISCORD_GUILD_ID=your_discord_guild_id_here
|
|||||||
DISCORD_PING_USER_ID=your_discord_user_id_here
|
DISCORD_PING_USER_ID=your_discord_user_id_here
|
||||||
DISCORD_WEBHOOK_URL=your_discord_webhook_url_here
|
DISCORD_WEBHOOK_URL=your_discord_webhook_url_here
|
||||||
# Interval between reconnect attempts if connection is lost
|
# Interval between reconnect attempts if connection is lost
|
||||||
RECONNECT_INTERVAL=5
|
RECONNECT_INTERVAL=5
|
||||||
|
# Media upload backend (currently only litterbox is supported)
|
||||||
|
MEDIA_UPLOAD_SERVICE=litterbox
|
||||||
# Which room will be bridged, append integer at the end of room name. Current options: general.1, gunt.8, keno-kasino.15, fishtank.16, beauty-parlor.18, sports.19,
|
# Which room will be bridged, append integer at the end of room name. Current options: general.1, gunt.8, keno-kasino.15, fishtank.16, beauty-parlor.18, sports.19,
|
||||||
SNEEDCHAT_ROOM_ID=1
|
SNEEDCHAT_ROOM_ID=1
|
||||||
# Enable logging to bridge.log file for debugging purposes(true/false, default: false)
|
# Enable logging to bridge.log file for debugging purposes(true/false, default: false)
|
||||||
ENABLE_FILE_LOGGING=false
|
ENABLE_FILE_LOGGING=false
|
||||||
|
|
||||||
#Use your Kiwifarm crendeitals for here
|
#Use your Kiwifarm crendeitals for here
|
||||||
#This USER_ID number is in the url when you go to your profile, its required to prevent Discord from echoing your own messages back to you and to allow pings/push notifications work on Discord
|
#This USER_ID number is in the url when you go to your profile, its required to prevent Discord from echoing your own messages back to you and to allow pings/push notifications work on Discord
|
||||||
# BRIDGE_USER_ID=123456
|
# BRIDGE_USER_ID=123456
|
||||||
# BRIDGE_USERNAME=YourBridgeBot
|
# BRIDGE_USERNAME=YourBridgeBot
|
||||||
# BRIDGE_PASSWORD=Password
|
# BRIDGE_PASSWORD=Password
|
||||||
|
|||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM golang:1.25.6-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN git clone https://github.com/Salastil/Sneedchat-Discord-Bridge-Go.git .
|
||||||
|
|
||||||
|
RUN go mod tidy && go build -o Sneedchat-Discord-Bridge .
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /build/Sneedchat-Discord-Bridge .
|
||||||
|
|
||||||
|
ENTRYPOINT ["./Sneedchat-Discord-Bridge"]
|
||||||
16
README.md
16
README.md
@@ -7,6 +7,7 @@ A high-performance bridge written in Go that synchronizes messages between Kiwi
|
|||||||
- ✅ Bidirectional message sync (Sneedchat ↔ Discord)
|
- ✅ Bidirectional message sync (Sneedchat ↔ Discord)
|
||||||
- ✅ Edit and delete synchronization
|
- ✅ Edit and delete synchronization
|
||||||
- ✅ Attachment uploads and BBcode formating via Litterbox
|
- ✅ Attachment uploads and BBcode formating via Litterbox
|
||||||
|
- ✅ Pluggable media upload services (Litterbox by default)
|
||||||
- ✅ BBCode → Markdown parsing
|
- ✅ BBCode → Markdown parsing
|
||||||
- ✅ Message queueing during outages
|
- ✅ Message queueing during outages
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ A high-performance bridge written in Go that synchronizes messages between Kiwi
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- **Go 1.23 or higher**
|
- **Go 1.25.6 or higher**
|
||||||
- **Discord Bot Token** with proper permissions
|
- **Discord Bot Token** with proper permissions
|
||||||
- **Discord Webhook URL**
|
- **Discord Webhook URL**
|
||||||
- **Kiwi Farms account** with Sneedchat access
|
- **Kiwi Farms account** with Sneedchat access
|
||||||
@@ -34,7 +35,7 @@ sudo apt update
|
|||||||
sudo apt install golang git
|
sudo apt install golang git
|
||||||
|
|
||||||
# Verify installation
|
# Verify installation
|
||||||
go version # Should show 1.23 or higher
|
go version # Should show 1.25.6 or higher
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Clone and Build
|
### 2. Clone and Build
|
||||||
@@ -135,6 +136,7 @@ Create separate systemd services with unique names
|
|||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
- Replace `BRIDGE_USERNAME` with your **Kiwi Farms username** (not email)
|
- Replace `BRIDGE_USERNAME` with your **Kiwi Farms username** (not email)
|
||||||
- `SNEEDCHAT_ROOM_ID=1` is the default Sneedchat room
|
- `SNEEDCHAT_ROOM_ID=1` is the default Sneedchat room
|
||||||
|
- `MEDIA_UPLOAD_SERVICE` selects the attachment backend (currently only `litterbox`)
|
||||||
- Keep quotes out of values
|
- Keep quotes out of values
|
||||||
- Don't share your `.env` file!
|
- Don't share your `.env` file!
|
||||||
|
|
||||||
@@ -162,6 +164,9 @@ BRIDGE_USER_ID=12345
|
|||||||
# Your Discord user ID (right-click yourself → Copy User ID)
|
# Your Discord user ID (right-click yourself → Copy User ID)
|
||||||
DISCORD_PING_USER_ID=1234567890123456789
|
DISCORD_PING_USER_ID=1234567890123456789
|
||||||
|
|
||||||
|
# Media upload backend (defaults to litterbox if unset)
|
||||||
|
MEDIA_UPLOAD_SERVICE=litterbox
|
||||||
|
|
||||||
# Optional: Enable file logging
|
# Optional: Enable file logging
|
||||||
ENABLE_FILE_LOGGING=false
|
ENABLE_FILE_LOGGING=false
|
||||||
```
|
```
|
||||||
@@ -266,10 +271,3 @@ If messages aren't appearing:
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
This bridge is provided as-is. Use responsibly and in accordance with Kiwi Farms and Discord Terms of Service.
|
This bridge is provided as-is. Use responsibly and in accordance with Kiwi Farms and Discord Terms of Service.
|
||||||
|
|
||||||
## Credits
|
|
||||||
|
|
||||||
Built with:
|
|
||||||
- [discordgo](https://github.com/bwmarrin/discordgo) - Discord API
|
|
||||||
- [gorilla/websocket](https://github.com/gorilla/websocket) - WebSocket client
|
|
||||||
- [godotenv](https://github.com/joho/godotenv) - Environment loading
|
|
||||||
@@ -10,16 +10,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DiscordBotToken string
|
DiscordBotToken string
|
||||||
DiscordChannelID string
|
DiscordChannelID string
|
||||||
DiscordGuildID string
|
DiscordGuildID string
|
||||||
DiscordWebhookURL string
|
DiscordWebhookURL string
|
||||||
SneedchatRoomID int
|
SneedchatRoomID int
|
||||||
BridgeUsername string
|
MediaUploadService string
|
||||||
BridgePassword string
|
BridgeUsername string
|
||||||
BridgeUserID int
|
BridgePassword string
|
||||||
DiscordPingUserID string
|
BridgeUserID int
|
||||||
Debug bool
|
DiscordPingUserID string
|
||||||
|
Debug bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(envFile string) (*Config, error) {
|
func Load(envFile string) (*Config, error) {
|
||||||
@@ -27,13 +28,17 @@ func Load(envFile string) (*Config, error) {
|
|||||||
log.Printf("Warning: error loading %s: %v", envFile, err)
|
log.Printf("Warning: error loading %s: %v", envFile, err)
|
||||||
}
|
}
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
DiscordBotToken: os.Getenv("DISCORD_BOT_TOKEN"),
|
DiscordBotToken: os.Getenv("DISCORD_BOT_TOKEN"),
|
||||||
DiscordChannelID: os.Getenv("DISCORD_CHANNEL_ID"),
|
DiscordChannelID: os.Getenv("DISCORD_CHANNEL_ID"),
|
||||||
DiscordGuildID: os.Getenv("DISCORD_GUILD_ID"),
|
DiscordGuildID: os.Getenv("DISCORD_GUILD_ID"),
|
||||||
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
|
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
|
||||||
BridgeUsername: os.Getenv("BRIDGE_USERNAME"),
|
MediaUploadService: os.Getenv("MEDIA_UPLOAD_SERVICE"),
|
||||||
BridgePassword: os.Getenv("BRIDGE_PASSWORD"),
|
BridgeUsername: os.Getenv("BRIDGE_USERNAME"),
|
||||||
DiscordPingUserID: os.Getenv("DISCORD_PING_USER_ID"),
|
BridgePassword: os.Getenv("BRIDGE_PASSWORD"),
|
||||||
|
DiscordPingUserID: os.Getenv("DISCORD_PING_USER_ID"),
|
||||||
|
}
|
||||||
|
if cfg.MediaUploadService == "" {
|
||||||
|
cfg.MediaUploadService = "litterbox"
|
||||||
}
|
}
|
||||||
roomID, err := strconv.Atoi(os.Getenv("SNEEDCHAT_ROOM_ID"))
|
roomID, err := strconv.Atoi(os.Getenv("SNEEDCHAT_ROOM_ID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,224 +1,252 @@
|
|||||||
package cookie
|
package cookie
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// SessionService manages XenForo session cookies as plain strings.
|
||||||
CookieRefreshInterval = 4 * time.Hour
|
// No http.Client jar is used anywhere — every cookie is stored explicitly
|
||||||
CookieRetryDelay = 5 * time.Second
|
// and overwritten on update, eliminating all accumulation bugs.
|
||||||
MaxCookieRetryDelay = 60 * time.Second
|
type SessionService struct {
|
||||||
)
|
mu sync.Mutex
|
||||||
|
cookies map[string]string // name → value, last write wins
|
||||||
type CookieRefreshService struct {
|
domain *url.URL
|
||||||
username string
|
username string
|
||||||
password string
|
password string
|
||||||
domain string
|
tr *http.Transport // shared transport, TLS config applied once
|
||||||
client *http.Client
|
stopCh chan struct{}
|
||||||
jar http.CookieJar
|
|
||||||
currentCookie string
|
|
||||||
|
|
||||||
debug bool
|
|
||||||
|
|
||||||
mu sync.RWMutex
|
|
||||||
readyOnce sync.Once
|
|
||||||
readyCh chan struct{}
|
|
||||||
stopCh chan struct{}
|
|
||||||
wg sync.WaitGroup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCookieRefreshService(username, password, domain string) (*CookieRefreshService, error) {
|
// NewSessionService creates a service, performs initial login, and returns.
|
||||||
return NewCookieRefreshServiceWithDebug(username, password, domain, false)
|
func NewSessionService(ctx context.Context, host, username, password string) (*SessionService, error) {
|
||||||
|
u, _ := url.Parse("https://" + host + "/")
|
||||||
|
|
||||||
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
tr.TLSClientConfig = tlsConfig()
|
||||||
|
|
||||||
|
s := &SessionService{
|
||||||
|
cookies: make(map[string]string),
|
||||||
|
domain: u,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
tr: tr,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("⏳ Logging in to Kiwi Farms...")
|
||||||
|
if err := s.Login(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("initial login: %w", err)
|
||||||
|
}
|
||||||
|
log.Println("✅ Login successful")
|
||||||
|
|
||||||
|
go s.refreshLoop(ctx)
|
||||||
|
|
||||||
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCookieRefreshServiceWithDebug(username, password, domain string, debug bool) (*CookieRefreshService, error) {
|
// Close stops the background refresh loop. Call at shutdown after
|
||||||
jar, err := cookiejar.New(nil)
|
// sneedClient.Disconnect().
|
||||||
|
func (s *SessionService) Close() {
|
||||||
|
close(s.stopCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshLoop proactively renews all session cookies every 4 hours.
|
||||||
|
// Prevents xf_session and xf_user from expiring mid-run so reconnect
|
||||||
|
// attempts always have valid credentials. ttrs_clearance is cleared
|
||||||
|
// so the next WebSocket dial solves it fresh.
|
||||||
|
func (s *SessionService) refreshLoop(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(4 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
log.Println("🔄 Proactive session refresh (4h timer)...")
|
||||||
|
s.mu.Lock()
|
||||||
|
s.deleteCookie("ttrs_clearance")
|
||||||
|
if err := s.login(ctx); err != nil {
|
||||||
|
log.Printf("⚠️ Proactive session refresh failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("✅ Proactive session refresh complete")
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tlsConfig mirrors sockchat's socketTLSConfig exactly:
|
||||||
|
// concatenate secure + insecure cipher suites so KiwiFlare TLS fingerprinting
|
||||||
|
// doesn't trigger. "The insecure ones appear to be necessary for consistently
|
||||||
|
// getting around 203s." — sockchat source comment.
|
||||||
|
func tlsConfig() *tls.Config {
|
||||||
|
all := slices.Concat(tls.CipherSuites(), tls.InsecureCipherSuites())
|
||||||
|
ids := make([]uint16, len(all))
|
||||||
|
for i, s := range all {
|
||||||
|
ids[i] = s.ID
|
||||||
|
}
|
||||||
|
return &tls.Config{CipherSuites: ids}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transport returns the shared *http.Transport for use in the WebSocket dialer.
|
||||||
|
// The caller should use tr.DialContext and tr.TLSClientConfig directly,
|
||||||
|
// mirroring sockchat's NewSocket pattern.
|
||||||
|
func (s *SessionService) Transport() *http.Transport {
|
||||||
|
return s.tr
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCookie stores a cookie by name, overwriting any previous value.
|
||||||
|
func (s *SessionService) setCookie(name, value string) {
|
||||||
|
s.cookies[name] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteCookie removes a cookie by name.
|
||||||
|
func (s *SessionService) deleteCookie(name string) {
|
||||||
|
delete(s.cookies, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// absorbResponse reads all Set-Cookie headers from resp and stores them,
|
||||||
|
// overwriting any existing values. Each call is idempotent per cookie name.
|
||||||
|
func (s *SessionService) absorbResponse(resp *http.Response) {
|
||||||
|
for _, sc := range resp.Header["Set-Cookie"] {
|
||||||
|
seg := strings.SplitN(sc, ";", 2)
|
||||||
|
kv := strings.SplitN(strings.TrimSpace(seg[0]), "=", 2)
|
||||||
|
if len(kv) == 2 {
|
||||||
|
s.cookies[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookieHeader returns the current cookie state as a "name=value; ..." string
|
||||||
|
// suitable for use as a Cookie HTTP header or WebSocket dial header.
|
||||||
|
func (s *SessionService) cookieHeader() string {
|
||||||
|
parts := make([]string, 0, len(s.cookies))
|
||||||
|
for k, v := range s.cookies {
|
||||||
|
if v != "" {
|
||||||
|
parts = append(parts, k+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// do performs a single HTTP round-trip via the shared transport.
|
||||||
|
// Injects current cookies, absorbs Set-Cookie from response.
|
||||||
|
// Does not follow redirects.
|
||||||
|
func (s *SessionService) do(req *http.Request) (*http.Response, error) {
|
||||||
|
if cookie := s.cookieHeader(); cookie != "" {
|
||||||
|
req.Header.Set("Cookie", cookie)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
resp, err := s.tr.RoundTrip(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tr := &http.Transport{}
|
s.absorbResponse(resp)
|
||||||
client := &http.Client{
|
return resp, nil
|
||||||
Jar: jar,
|
|
||||||
Transport: tr,
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}
|
|
||||||
return &CookieRefreshService{
|
|
||||||
username: username,
|
|
||||||
password: password,
|
|
||||||
domain: domain,
|
|
||||||
client: client,
|
|
||||||
jar: jar,
|
|
||||||
debug: debug,
|
|
||||||
readyCh: make(chan struct{}),
|
|
||||||
stopCh: make(chan struct{}),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CookieRefreshService) Start() {
|
// get performs a GET request, solving any KiwiFlare 203 challenge inline.
|
||||||
s.wg.Add(1)
|
// host can differ from s.domain.Host for non-standard port endpoints.
|
||||||
go func() {
|
func (s *SessionService) get(ctx context.Context, target *url.URL) (*http.Response, error) {
|
||||||
defer s.wg.Done()
|
req, err := http.NewRequestWithContext(ctx, "GET", target.String(), nil)
|
||||||
|
|
||||||
// Initial fetch
|
|
||||||
log.Println("⏳ Fetching initial cookie...")
|
|
||||||
c, err := s.FetchFreshCookie()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ Failed to obtain initial cookie: %v", err)
|
|
||||||
s.readyOnce.Do(func() { close(s.readyCh) })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
s.currentCookie = c
|
|
||||||
s.mu.Unlock()
|
|
||||||
s.readyOnce.Do(func() { close(s.readyCh) })
|
|
||||||
log.Println("✅ Initial cookie obtained")
|
|
||||||
|
|
||||||
// Continuous refresh loop
|
|
||||||
ticker := time.NewTicker(CookieRefreshInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
log.Println("🔄 Auto-refreshing cookie...")
|
|
||||||
newCookie, err := s.FetchFreshCookie()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("⚠️ Cookie auto-refresh failed: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
s.currentCookie = newCookie
|
|
||||||
s.mu.Unlock()
|
|
||||||
log.Println("✅ Cookie auto-refresh successful")
|
|
||||||
case <-s.stopCh:
|
|
||||||
log.Println("Cookie refresh service stopping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CookieRefreshService) WaitForCookie() {
|
|
||||||
<-s.readyCh
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CookieRefreshService) Stop() {
|
|
||||||
close(s.stopCh)
|
|
||||||
s.wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CookieRefreshService) GetCurrentCookie() string {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
return s.currentCookie
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CookieRefreshService) FetchFreshCookie() (string, error) {
|
|
||||||
if s.debug {
|
|
||||||
log.Println("💡 Stage: Starting FetchFreshCookie")
|
|
||||||
}
|
|
||||||
|
|
||||||
attempt := 0
|
|
||||||
delay := CookieRetryDelay
|
|
||||||
|
|
||||||
for {
|
|
||||||
attempt++
|
|
||||||
c, err := s.attemptFetchCookie()
|
|
||||||
if err == nil {
|
|
||||||
if s.debug {
|
|
||||||
log.Printf("✅ Successfully fetched fresh cookie with xf_user (attempt %d)", attempt)
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("⚠️ Cookie fetch attempt %d failed: %v", attempt, err)
|
|
||||||
// Exponential backoff, capped
|
|
||||||
time.Sleep(delay)
|
|
||||||
delay *= 2
|
|
||||||
if delay > MaxCookieRetryDelay {
|
|
||||||
delay = MaxCookieRetryDelay
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CookieRefreshService) attemptFetchCookie() (string, error) {
|
|
||||||
base := fmt.Sprintf("https://%s/", s.domain)
|
|
||||||
loginPage := fmt.Sprintf("https://%s/login", s.domain)
|
|
||||||
loginPost := fmt.Sprintf("https://%s/login/login", s.domain)
|
|
||||||
accountURL := fmt.Sprintf("https://%s/account/", s.domain)
|
|
||||||
rootURL, _ := url.Parse(base)
|
|
||||||
|
|
||||||
// Reset redirect policy for manual control
|
|
||||||
s.client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
||||||
// don't auto-follow on login POST so we can inspect cookies first
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Step 1: KiwiFlare
|
|
||||||
if s.debug {
|
|
||||||
log.Println("Step 1: Checking for KiwiFlare challenge...")
|
|
||||||
}
|
|
||||||
if err := s.solveKiwiFlareIfPresent(base); err != nil {
|
|
||||||
return "", fmt.Errorf("KiwiFlare solve failed: %w", err)
|
|
||||||
}
|
|
||||||
if s.debug {
|
|
||||||
log.Println("✅ KiwiFlare challenge solved")
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
|
|
||||||
// --- Step 2: GET /login ---
|
|
||||||
if s.debug {
|
|
||||||
log.Println("Step 2: Fetching login page...")
|
|
||||||
}
|
|
||||||
reqLogin, _ := http.NewRequest("GET", loginPage, nil)
|
|
||||||
reqLogin.Header.Set("Cache-Control", "no-cache")
|
|
||||||
reqLogin.Header.Set("Pragma", "no-cache")
|
|
||||||
reqLogin.Header.Set("User-Agent", "Mozilla/5.0")
|
|
||||||
respLogin, err := s.client.Do(reqLogin)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get login page: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
defer respLogin.Body.Close()
|
resp, err := s.do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 203 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
log.Printf("🔑 KiwiFlare 203 on %s — solving PoW...", target.Host)
|
||||||
|
if err := s.solveAndSubmit(ctx, body, target.Host); err != nil {
|
||||||
|
return nil, fmt.Errorf("PoW solve for %s: %w", target.Host, err)
|
||||||
|
}
|
||||||
|
return s.get(ctx, target) // retry after solve
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
bodyLogin, _ := io.ReadAll(respLogin.Body)
|
// solveAndSubmit parses a cerberus PoW challenge from the 203 response body,
|
||||||
if s.debug {
|
// solves it, and POSTs the solution to the issuing host (preserving port).
|
||||||
log.Printf("📄 Login page HTML (first 1024 bytes):\n%s", firstN(string(bodyLogin), 1024))
|
// The resulting ttrs_clearance is absorbed via absorbResponse.
|
||||||
|
func (s *SessionService) solveAndSubmit(ctx context.Context, body []byte, host string) error {
|
||||||
|
salt, diff, err := parseChallengeHTML(string(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse challenge: %w", err)
|
||||||
}
|
}
|
||||||
|
log.Printf("🔑 Challenge: salt=%s difficulty=%d", salt, diff)
|
||||||
|
|
||||||
// --- Step 3: Extract CSRF---
|
nonce, err := solvePoW(ctx, salt, diff)
|
||||||
if s.debug {
|
if err != nil {
|
||||||
log.Println("Step 3: Extracting CSRF token...")
|
return err
|
||||||
}
|
}
|
||||||
csrf := extractCSRF(string(bodyLogin))
|
log.Printf("✅ Solved: nonce=%d", nonce)
|
||||||
|
|
||||||
|
submitURL := fmt.Sprintf("https://%s/.ttrs/challenge", host)
|
||||||
|
body2 := fmt.Sprintf("salt=%s&redirect=/&nonce=%d", url.QueryEscape(salt), nonce)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", submitURL, strings.NewReader(body2))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := s.do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("submit: %w", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
log.Printf("✅ Challenge submitted to %s (HTTP %d)", host, resp.StatusCode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login performs a full XenForo login: solves KiwiFlare, gets CSRF, POSTs creds.
|
||||||
|
// Caller must hold mu or be in a single-threaded context (NewSessionService).
|
||||||
|
func (s *SessionService) Login(ctx context.Context) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.login(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionService) login(ctx context.Context) error {
|
||||||
|
base := s.domain.String()
|
||||||
|
|
||||||
|
log.Println("🔑 Priming KiwiFlare clearance...")
|
||||||
|
resp, err := s.get(ctx, s.domain)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("root GET: %w", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
log.Println("✅ Clearance primed")
|
||||||
|
|
||||||
|
loginURL, _ := url.Parse(base + "login")
|
||||||
|
resp, err = s.get(ctx, loginURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("login page GET: %w", err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
csrf := extractCSRF(string(body))
|
||||||
if csrf == "" {
|
if csrf == "" {
|
||||||
return "", fmt.Errorf("CSRF token not found in login page")
|
return fmt.Errorf("CSRF token not found")
|
||||||
}
|
|
||||||
if s.debug {
|
|
||||||
log.Printf("✅ Found CSRF token: %s...", abbreviate(csrf, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record if already have xf_user BEFORE login POST
|
|
||||||
preCookies := s.jar.Cookies(rootURL)
|
|
||||||
hadXfUserBefore := hasCookie(preCookies, "xf_user")
|
|
||||||
|
|
||||||
// --- Step 4: POST /login/login---
|
|
||||||
if s.debug {
|
|
||||||
log.Println("Step 4: Submitting login credentials...")
|
|
||||||
logCookies("Cookies before login POST", preCookies)
|
|
||||||
}
|
}
|
||||||
|
log.Printf("🔐 CSRF token obtained: %s...", csrf[:min(10, len(csrf))])
|
||||||
|
|
||||||
form := url.Values{
|
form := url.Values{
|
||||||
"_xfToken": {csrf},
|
"_xfToken": {csrf},
|
||||||
@@ -227,184 +255,156 @@ func (s *CookieRefreshService) attemptFetchCookie() (string, error) {
|
|||||||
"_xfRedirect": {base},
|
"_xfRedirect": {base},
|
||||||
"remember": {"1"},
|
"remember": {"1"},
|
||||||
}
|
}
|
||||||
postReq, _ := http.NewRequest("POST", loginPost, strings.NewReader(form.Encode()))
|
req, err := http.NewRequestWithContext(ctx, "POST", base+"login/login",
|
||||||
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
strings.NewReader(form.Encode()))
|
||||||
postReq.Header.Set("User-Agent", "Mozilla/5.0")
|
|
||||||
postReq.Header.Set("Referer", loginPage)
|
|
||||||
postReq.Header.Set("Origin", fmt.Sprintf("https://%s", s.domain))
|
|
||||||
postResp, err := s.client.Do(postReq)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("login POST failed: %w", err)
|
|
||||||
}
|
|
||||||
defer postResp.Body.Close()
|
|
||||||
|
|
||||||
if s.debug {
|
|
||||||
log.Printf("Login response status: %d", postResp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// XenForo often 303 when successful; 200 might still be fine (AJAX template), so we don't fail on 200 alone.
|
|
||||||
|
|
||||||
// small delay to let cookies propagate
|
|
||||||
if s.debug {
|
|
||||||
log.Println("⏳ Waiting 2 seconds for XenForo to issue cookies...")
|
|
||||||
}
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
|
|
||||||
postCookies := s.jar.Cookies(rootURL)
|
|
||||||
if s.debug {
|
|
||||||
for _, c := range postCookies {
|
|
||||||
log.Printf("Cookie after login: %s=%s...", c.Name, abbreviate(c.Value, 10))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for xf_user after login
|
|
||||||
if hasCookie(postCookies, "xf_user") {
|
|
||||||
return buildCookieString(postCookies), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Success path: If we had xf_user before and still no new xf_user now,
|
|
||||||
// try validating the existing session on /account/ and succeed if logged in.
|
|
||||||
if hadXfUserBefore {
|
|
||||||
if s.debug {
|
|
||||||
log.Println("🔍 Missing xf_user after login POST but it existed before; validating current session via /account/ ...")
|
|
||||||
}
|
|
||||||
ok, cookieStr := s.validateSessionUsingAccount(accountURL, rootURL)
|
|
||||||
if ok {
|
|
||||||
if s.debug {
|
|
||||||
log.Println("✅ /account/ shows logged-in; retaining existing session cookie")
|
|
||||||
}
|
|
||||||
return cookieStr, nil
|
|
||||||
}
|
|
||||||
if s.debug {
|
|
||||||
log.Println("⚠️ /account/ did not confirm logged-in; proceeding with failure")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not successful yet, read body for context & fail
|
|
||||||
bodyBytes, _ := io.ReadAll(postResp.Body)
|
|
||||||
bodyText := string(bodyBytes)
|
|
||||||
if s.debug {
|
|
||||||
log.Printf("📄 Login HTML snippet (first 500 chars):\n%s", firstN(bodyText, 500))
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("retry still missing xf_user cookie")
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------
|
|
||||||
// KiwiFlare handling
|
|
||||||
// -------------------------------------------
|
|
||||||
func (s *CookieRefreshService) solveKiwiFlareIfPresent(base string) error {
|
|
||||||
req, _ := http.NewRequest("GET", base, nil)
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
||||||
resp, err := s.client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Referer", base+"login")
|
||||||
|
req.Header.Set("Origin", strings.TrimSuffix(base, "/"))
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
postResp, err := s.do(req)
|
||||||
html := string(body)
|
|
||||||
|
|
||||||
// Look for data-sssg-challenge and difficulty
|
|
||||||
re := regexp.MustCompile(`data-sssg-challenge=["']([0-9a-fA-F]+)["'][^>]*data-sssg-difficulty=["'](\d+)["']`)
|
|
||||||
m := re.FindStringSubmatch(html)
|
|
||||||
if len(m) < 3 {
|
|
||||||
if s.debug {
|
|
||||||
log.Println("No KiwiFlare POW detected")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
token := m[1]
|
|
||||||
diff, _ := strconv.Atoi(m[2])
|
|
||||||
|
|
||||||
if s.debug {
|
|
||||||
log.Printf("Solving KiwiFlare challenge (difficulty=%d, token=%s...)", diff, abbreviate(token, 10))
|
|
||||||
}
|
|
||||||
nonce, dur, err := s.solvePoW(token, diff)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("login POST: %w", err)
|
||||||
}
|
|
||||||
if s.debug {
|
|
||||||
log.Printf("✅ KiwiFlare challenge solved in %v (nonce=%s)", dur, nonce)
|
|
||||||
}
|
}
|
||||||
|
postResp.Body.Close()
|
||||||
|
|
||||||
// Submit solution
|
if s.cookies["xf_user"] == "" {
|
||||||
answerURL := fmt.Sprintf("https://%s/.sssg/api/answer", s.domain)
|
return fmt.Errorf("xf_user not set after login — check credentials")
|
||||||
form := url.Values{"a": {token}, "b": {nonce}}
|
|
||||||
subReq, _ := http.NewRequest("POST", answerURL, strings.NewReader(form.Encode()))
|
|
||||||
subReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
subReq.Header.Set("User-Agent", "Mozilla/5.0")
|
|
||||||
subResp, err := s.client.Do(subReq)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
defer subResp.Body.Close()
|
log.Println("✅ xf_user cookie obtained")
|
||||||
|
|
||||||
if subResp.StatusCode != 200 {
|
|
||||||
body, _ := io.ReadAll(subResp.Body)
|
|
||||||
return fmt.Errorf("challenge solve HTTP %d (%s)", subResp.StatusCode, strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check jar for sssg_clearance
|
|
||||||
rootURL, _ := url.Parse(fmt.Sprintf("https://%s/", s.domain))
|
|
||||||
for _, c := range s.jar.Cookies(rootURL) {
|
|
||||||
if c.Name == "sssg_clearance" {
|
|
||||||
if s.debug {
|
|
||||||
log.Printf("✅ KiwiFlare clearance cookie confirmed: %s...", abbreviate(c.Value, 10))
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CookieRefreshService) solvePoW(token string, difficulty int) (string, time.Duration, error) {
|
// Refresh gets a fresh xf_session. Falls back to full re-login if xf_user lost.
|
||||||
start := time.Now()
|
// Mirrors sockchat's kf.RefreshSession() call in connect().
|
||||||
nonce := rand.Int63()
|
func (s *SessionService) Refresh(ctx context.Context) error {
|
||||||
requiredBytes := difficulty / 8
|
s.mu.Lock()
|
||||||
requiredBits := difficulty % 8
|
defer s.mu.Unlock()
|
||||||
const maxAttempts = 10_000_000
|
|
||||||
|
|
||||||
for attempts := 0; attempts < maxAttempts; attempts++ {
|
log.Println("🔄 Refreshing session...")
|
||||||
nonce++
|
s.setCookie("xf_session", "")
|
||||||
input := token + fmt.Sprintf("%d", nonce)
|
|
||||||
sum := sha256.Sum256([]byte(input))
|
|
||||||
|
|
||||||
// Check leading zero bits
|
resp, err := s.get(ctx, s.domain)
|
||||||
ok := true
|
if err != nil {
|
||||||
for i := 0; i < requiredBytes; i++ {
|
return err
|
||||||
if sum[i] != 0 {
|
|
||||||
ok = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ok && requiredBits > 0 && requiredBytes < len(sum) {
|
|
||||||
mask := byte(0xFF << (8 - requiredBits))
|
|
||||||
if sum[requiredBytes]&mask != 0 {
|
|
||||||
ok = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
// Stretch to >= ~1.7s to look human
|
|
||||||
if elapsed < 1700*time.Millisecond {
|
|
||||||
time.Sleep(1700*time.Millisecond - elapsed)
|
|
||||||
elapsed = 1700 * time.Millisecond
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d", nonce), elapsed, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return "", 0, fmt.Errorf("failed to solve PoW within %d attempts", maxAttempts)
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if s.cookies["xf_user"] == "" {
|
||||||
|
log.Println("⚠️ xf_user lost — re-logging in...")
|
||||||
|
return s.login(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("✅ Session refreshed")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CookieString returns current cookies as a Cookie header value.
|
||||||
|
// ttrs_clearance is intentionally excluded — it must be solved fresh
|
||||||
|
// per-endpoint before each WebSocket connection attempt.
|
||||||
|
func (s *SessionService) CookieString() string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.cookieHeader()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CookieStringForWS returns cookies for the WebSocket dial with ttrs_clearance
|
||||||
|
// excluded. The caller must solve the clearance challenge for the WS endpoint
|
||||||
|
// first and inject it directly into the dial headers.
|
||||||
|
func (s *SessionService) CookieStringForWS() string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
parts := make([]string, 0, len(s.cookies))
|
||||||
|
for k, v := range s.cookies {
|
||||||
|
if v != "" && k != "ttrs_clearance" {
|
||||||
|
parts = append(parts, k+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SolveForHost solves a KiwiFlare 203 challenge from a non-standard port
|
||||||
|
// endpoint (e.g. the WebSocket on 9443). The challenge body is parsed locally
|
||||||
|
// but the solution is always submitted to port 443 — mirroring sockchat's
|
||||||
|
// behaviour via cerberus, whose postSolution uses Hostname() (strips port).
|
||||||
|
// The clearance issued by 443 is accepted by 9443.
|
||||||
|
func (s *SessionService) SolveForHost(ctx context.Context, body []byte) (string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
before := s.cookies["ttrs_clearance"]
|
||||||
|
// Always submit to the base domain on port 443.
|
||||||
|
if err := s.solveAndSubmit(ctx, body, s.domain.Hostname()); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
after := s.cookies["ttrs_clearance"]
|
||||||
|
if after == before {
|
||||||
|
return "", fmt.Errorf("ttrs_clearance unchanged after solve — submit may have failed")
|
||||||
|
}
|
||||||
|
return after, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PoW solver ---
|
||||||
|
|
||||||
|
func parseChallengeHTML(body string) (salt string, diff uint32, err error) {
|
||||||
|
sm := regexp.MustCompile(`data-ttrs-challenge=["']([^"']+)["']`).FindStringSubmatch(body)
|
||||||
|
dm := regexp.MustCompile(`data-ttrs-difficulty=["'](\d+)["']`).FindStringSubmatch(body)
|
||||||
|
if len(sm) < 2 || len(dm) < 2 {
|
||||||
|
return "", 0, fmt.Errorf("challenge attributes not found in HTML")
|
||||||
|
}
|
||||||
|
var d int
|
||||||
|
fmt.Sscanf(dm[1], "%d", &d)
|
||||||
|
return sm[1], uint32(d), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func solvePoW(ctx context.Context, salt string, difficulty uint32) (uint64, error) {
|
||||||
|
nbytes := difficulty / 8
|
||||||
|
rem := difficulty % 8
|
||||||
|
var mask byte
|
||||||
|
for i := uint32(0); i < rem; i++ {
|
||||||
|
mask = (mask << 1) | 1
|
||||||
|
}
|
||||||
|
if rem > 0 {
|
||||||
|
mask <<= 8 - rem
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonce uint64
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return 0, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
nonce++
|
||||||
|
h := sha256.Sum256([]byte(fmt.Sprintf("%s%d", salt, nonce)))
|
||||||
|
if leadingZeros(h[:], nbytes, rem, mask) {
|
||||||
|
return nonce, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func leadingZeros(hash []byte, nbytes, rem uint32, mask byte) bool {
|
||||||
|
for i := uint32(0); i < nbytes; i++ {
|
||||||
|
if hash[i] != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rem == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return hash[nbytes]&mask == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
func extractCSRF(body string) string {
|
func extractCSRF(body string) string {
|
||||||
patterns := []*regexp.Regexp{
|
for _, re := range []*regexp.Regexp{
|
||||||
regexp.MustCompile(`data-csrf=["']([^"']+)["']`),
|
regexp.MustCompile(`data-csrf=["']([^"']+)["']`),
|
||||||
regexp.MustCompile(`"csrf":"([^"]+)"`),
|
regexp.MustCompile(`"csrf":"([^"]+)"`),
|
||||||
regexp.MustCompile(`XF\.config\.csrf\s*=\s*"([^"]+)"`),
|
regexp.MustCompile(`XF\.config\.csrf\s*=\s*"([^"]+)"`),
|
||||||
}
|
} {
|
||||||
for _, re := range patterns {
|
|
||||||
if m := re.FindStringSubmatch(body); len(m) >= 2 {
|
if m := re.FindStringSubmatch(body); len(m) >= 2 {
|
||||||
return m[1]
|
return m[1]
|
||||||
}
|
}
|
||||||
@@ -412,83 +412,9 @@ func extractCSRF(body string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasCookie(cookies []*http.Cookie, name string) bool {
|
func min(a, b int) int {
|
||||||
for _, c := range cookies {
|
if a < b {
|
||||||
if c.Name == name {
|
return a
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildCookieString(cookies []*http.Cookie) string {
|
|
||||||
want := map[string]bool{
|
|
||||||
"sssg_clearance": true,
|
|
||||||
"xf_csrf": true,
|
|
||||||
"xf_session": true,
|
|
||||||
"xf_user": true,
|
|
||||||
"xf_toggle": true,
|
|
||||||
}
|
|
||||||
var parts []string
|
|
||||||
for _, c := range cookies {
|
|
||||||
if want[c.Name] {
|
|
||||||
parts = append(parts, fmt.Sprintf("%s=%s", c.Name, c.Value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(parts, "; ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CookieRefreshService) validateSessionUsingAccount(accountURL string, rootURL *url.URL) (bool, string) {
|
|
||||||
if s.debug {
|
|
||||||
log.Println("🔍 Validating session via /account/ ...")
|
|
||||||
}
|
|
||||||
req, _ := http.NewRequest("GET", accountURL, nil)
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
||||||
resp, err := s.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
if s.debug {
|
|
||||||
log.Printf("⚠️ /account/ request error: %v", err)
|
|
||||||
}
|
|
||||||
return false, ""
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
snippet := firstN(string(body), 500)
|
|
||||||
|
|
||||||
if s.debug {
|
|
||||||
log.Printf("🔍 Accessed /account/ (%d)", resp.StatusCode)
|
|
||||||
log.Printf("📄 /account/ HTML snippet:\n%s", snippet)
|
|
||||||
for _, c := range s.jar.Cookies(rootURL) {
|
|
||||||
log.Printf("🍪 Cookie after /account/: %s=%s...", c.Name, abbreviate(c.Value, 10))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consider logged-in if data-logged-in="true" or the template isn't "login"
|
|
||||||
if strings.Contains(snippet, `data-logged-in="true"`) ||
|
|
||||||
(!strings.Contains(snippet, `data-template="login"`) && resp.StatusCode == 200) {
|
|
||||||
return true, buildCookieString(s.jar.Cookies(rootURL))
|
|
||||||
}
|
|
||||||
return false, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func logCookies(prefix string, cookies []*http.Cookie) {
|
|
||||||
log.Printf("%s (%d):", prefix, len(cookies))
|
|
||||||
for _, c := range cookies {
|
|
||||||
log.Printf(" - %s = %s...", c.Name, abbreviate(c.Value, 10))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstN(s string, n int) string {
|
|
||||||
if len(s) <= n {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:n]
|
|
||||||
}
|
|
||||||
|
|
||||||
func abbreviate(s string, n int) string {
|
|
||||||
if len(s) <= n {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:n]
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,44 @@
|
|||||||
package discord
|
package discord
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"local/sneedchatbridge/config"
|
"local/sneedchatbridge/config"
|
||||||
|
"local/sneedchatbridge/media"
|
||||||
"local/sneedchatbridge/sneed"
|
"local/sneedchatbridge/sneed"
|
||||||
"local/sneedchatbridge/utils"
|
"local/sneedchatbridge/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MaxAttachments = 4
|
MaxAttachments = 4
|
||||||
LitterboxTTL = "72h"
|
ProcessedCacheSize = 250
|
||||||
ProcessedCacheSize = 250
|
OverflowThreshold = 10
|
||||||
MappingCacheSize = 1000
|
OverflowRecoveryThreshold = 5
|
||||||
MappingMaxAge = 1 * time.Hour
|
OverflowBatchSize = 15
|
||||||
MappingCleanupInterval = 5 * time.Minute
|
MappingCacheSize = 1000
|
||||||
QueuedMessageTTL = 90 * time.Second
|
MappingMaxAge = 1 * time.Hour
|
||||||
OutageUpdateInterval = 10 * time.Second
|
MappingCleanupInterval = 5 * time.Minute
|
||||||
OutboundMatchWindow = 60 * time.Second
|
QueuedMessageTTL = 90 * time.Second
|
||||||
OutageEmbedColorActive = 0xF1C40F
|
OutageUpdateInterval = 10 * time.Second
|
||||||
OutageEmbedColorFixed = 0x2ECC71
|
OutboundMatchWindow = 60 * time.Second
|
||||||
|
OutageEmbedColorActive = 0xF1C40F
|
||||||
|
OutageEmbedColorFixed = 0x2ECC71
|
||||||
|
UploadStatusColorPending = 0x3498DB
|
||||||
|
UploadStatusColorSuccess = 0x2ECC71
|
||||||
|
UploadStatusColorFailed = 0xE74C3C
|
||||||
|
UploadDeliveryTimeout = 2 * time.Minute
|
||||||
|
UploadStatusCleanupDelay = 15 * time.Second
|
||||||
|
UploadStatusFailureLifetime = 60 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type OutboundEntry struct {
|
type OutboundEntry struct {
|
||||||
@@ -48,10 +56,10 @@ type QueuedMessage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Bridge struct {
|
type Bridge struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
session *discordgo.Session
|
session *discordgo.Session
|
||||||
sneed *sneed.Client
|
sneed *sneed.Client
|
||||||
httpClient *http.Client
|
mediaSvc media.Service
|
||||||
|
|
||||||
sneedToDiscord *utils.BoundedMap
|
sneedToDiscord *utils.BoundedMap
|
||||||
discordToSneed *utils.BoundedMap
|
discordToSneed *utils.BoundedMap
|
||||||
@@ -68,8 +76,10 @@ type Bridge struct {
|
|||||||
outageMessagesMu sync.Mutex
|
outageMessagesMu sync.Mutex
|
||||||
outageStart time.Time
|
outageStart time.Time
|
||||||
|
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
msgQueue chan map[string]interface{}
|
||||||
|
overflow bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
|
func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
|
||||||
@@ -77,11 +87,15 @@ func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
mediaSvc, err := media.NewService(cfg.MediaUploadService, &http.Client{Timeout: 60 * time.Second})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
b := &Bridge{
|
b := &Bridge{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
session: s,
|
session: s,
|
||||||
sneed: sneedClient,
|
sneed: sneedClient,
|
||||||
httpClient: &http.Client{Timeout: 60 * time.Second},
|
mediaSvc: mediaSvc,
|
||||||
sneedToDiscord: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
sneedToDiscord: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
||||||
discordToSneed: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
discordToSneed: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
||||||
sneedUsernames: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
sneedUsernames: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
||||||
@@ -89,10 +103,11 @@ func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
|
|||||||
queuedOutbound: make([]QueuedMessage, 0),
|
queuedOutbound: make([]QueuedMessage, 0),
|
||||||
outageNotices: make([]*discordgo.Message, 0),
|
outageNotices: make([]*discordgo.Message, 0),
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
|
msgQueue: make(chan map[string]interface{}, 500),
|
||||||
}
|
}
|
||||||
|
|
||||||
// hook Sneed client callbacks
|
// hook Sneed client callbacks
|
||||||
sneedClient.OnMessage = b.onSneedMessage
|
sneedClient.OnMessage = b.enqueueSneedMessage
|
||||||
sneedClient.OnEdit = b.handleSneedEdit
|
sneedClient.OnEdit = b.handleSneedEdit
|
||||||
sneedClient.OnDelete = b.handleSneedDelete
|
sneedClient.OnDelete = b.handleSneedDelete
|
||||||
sneedClient.OnConnect = b.onSneedConnect
|
sneedClient.OnConnect = b.onSneedConnect
|
||||||
@@ -115,8 +130,10 @@ func (b *Bridge) Start() error {
|
|||||||
if err := b.session.Open(); err != nil {
|
if err := b.session.Open(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
b.wg.Add(1)
|
b.wg.Add(3)
|
||||||
go b.cleanupLoop()
|
go b.cleanupLoop()
|
||||||
|
go b.messageWorker()
|
||||||
|
go b.messageWorker()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,22 +194,49 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
|
|||||||
|
|
||||||
if m.ReferencedMessage != nil {
|
if m.ReferencedMessage != nil {
|
||||||
refDiscordID := parseMessageID(m.ReferencedMessage.ID)
|
refDiscordID := parseMessageID(m.ReferencedMessage.ID)
|
||||||
if sneedIDInt, ok := b.discordToSneed.Get(refDiscordID); ok {
|
if sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(refDiscordID)); ok {
|
||||||
if uname, ok2 := b.sneedUsernames.Get(sneedIDInt.(int)); ok2 {
|
sneedUUID := sneedUUIDVal.(string)
|
||||||
|
if uname, ok2 := b.sneedUsernames.Get(sneedUUID); ok2 {
|
||||||
contentText = fmt.Sprintf("@%s, %s", uname.(string), contentText)
|
contentText = fmt.Sprintf("@%s, %s", uname.(string), contentText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var attachmentsBB []string
|
var attachmentsBB []string
|
||||||
|
var statusMsg *discordgo.Message
|
||||||
|
var statusErr error
|
||||||
|
if len(m.Attachments) > 0 {
|
||||||
|
mention := fmt.Sprintf("<@%s>", m.Author.ID)
|
||||||
|
statusMsg, statusErr = b.sendUploadStatusMessage(m.ChannelID, mention, len(m.Attachments))
|
||||||
|
if statusErr != nil {
|
||||||
|
log.Printf("⚠️ Failed to send upload status message: %v", statusErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(m.Attachments) > MaxAttachments {
|
if len(m.Attachments) > MaxAttachments {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, att := range m.Attachments {
|
ctx := context.Background()
|
||||||
url, err := b.uploadToLitterbox(att.URL, att.Filename)
|
for idx, att := range m.Attachments {
|
||||||
|
if statusMsg != nil {
|
||||||
|
desc := fmt.Sprintf("Uploading attachment %d/%d: `%s`", idx+1, len(m.Attachments), att.Filename)
|
||||||
|
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle(""), desc, UploadStatusColorPending)
|
||||||
|
}
|
||||||
|
url, statusCode, err := b.mediaSvc.Upload(ctx, att)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if statusMsg != nil {
|
||||||
|
failureDesc := fmt.Sprintf("Failed to upload `%s`: %v", att.Filename, err)
|
||||||
|
if statusCode > 0 {
|
||||||
|
failureDesc = fmt.Sprintf("%s\n%s response: HTTP %d", failureDesc, b.mediaServiceDisplayName(), statusCode)
|
||||||
|
}
|
||||||
|
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle("failed"), failureDesc, UploadStatusColorFailed)
|
||||||
|
b.scheduleUploadStatusDeletion(statusMsg.ChannelID, statusMsg.ID, UploadStatusFailureLifetime)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if statusMsg != nil {
|
||||||
|
desc := fmt.Sprintf("Uploaded %d/%d: `%s` (HTTP %d)", idx+1, len(m.Attachments), att.Filename, statusCode)
|
||||||
|
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle(""), desc, UploadStatusColorPending)
|
||||||
|
}
|
||||||
|
|
||||||
ct := strings.ToLower(att.ContentType)
|
ct := strings.ToLower(att.ContentType)
|
||||||
|
|
||||||
@@ -213,10 +257,11 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
|
|||||||
combined += strings.Join(attachmentsBB, "\n")
|
combined += strings.Join(attachmentsBB, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
discordMsgID := parseMessageID(m.ID)
|
||||||
if b.sneed.Send(combined) {
|
if b.sneed.Send(combined) {
|
||||||
b.recentOutboundMu.Lock()
|
b.recentOutboundMu.Lock()
|
||||||
b.recentOutbound = append(b.recentOutbound, OutboundEntry{
|
b.recentOutbound = append(b.recentOutbound, OutboundEntry{
|
||||||
DiscordID: parseMessageID(m.ID),
|
DiscordID: discordMsgID,
|
||||||
Content: combined,
|
Content: combined,
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Mapped: false,
|
Mapped: false,
|
||||||
@@ -225,15 +270,23 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
|
|||||||
b.recentOutbound = b.recentOutbound[1:]
|
b.recentOutbound = b.recentOutbound[1:]
|
||||||
}
|
}
|
||||||
b.recentOutboundMu.Unlock()
|
b.recentOutboundMu.Unlock()
|
||||||
|
if statusMsg != nil {
|
||||||
|
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle(""), "Uploads complete, awaiting Sneedchat confirmation…", UploadStatusColorPending)
|
||||||
|
go b.awaitSneedConfirmation(discordMsgID, statusMsg.ChannelID, statusMsg.ID)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
b.queuedOutboundMu.Lock()
|
b.queuedOutboundMu.Lock()
|
||||||
b.queuedOutbound = append(b.queuedOutbound, QueuedMessage{
|
b.queuedOutbound = append(b.queuedOutbound, QueuedMessage{
|
||||||
Content: combined,
|
Content: combined,
|
||||||
ChannelID: m.ChannelID,
|
ChannelID: m.ChannelID,
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
DiscordID: parseMessageID(m.ID),
|
DiscordID: discordMsgID,
|
||||||
})
|
})
|
||||||
b.queuedOutboundMu.Unlock()
|
b.queuedOutboundMu.Unlock()
|
||||||
|
if statusMsg != nil {
|
||||||
|
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle("queued"), "Uploads succeeded but Sneedchat is unavailable. Message queued for delivery once the bridge reconnects.", UploadStatusColorPending)
|
||||||
|
b.scheduleUploadStatusDeletion(statusMsg.ChannelID, statusMsg.ID, UploadStatusFailureLifetime)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,14 +298,14 @@ func (b *Bridge) onDiscordMessageEdit(s *discordgo.Session, m *discordgo.Message
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
discordID := parseMessageID(m.ID)
|
discordID := parseMessageID(m.ID)
|
||||||
sneedIDInt, ok := b.discordToSneed.Get(discordID)
|
sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(discordID))
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sneedID := sneedIDInt.(int)
|
sneedUUID := sneedUUIDVal.(string)
|
||||||
payload := map[string]interface{}{"id": sneedID, "message": strings.TrimSpace(m.Content)}
|
payload := map[string]interface{}{"uuid": sneedUUID, "message": strings.TrimSpace(m.Content)}
|
||||||
data, _ := json.Marshal(payload)
|
data, _ := json.Marshal(payload)
|
||||||
log.Printf("↩️ Discord edit -> Sneedchat (sneed_id=%d)", sneedID)
|
log.Printf("↩️ Discord edit -> Sneedchat (sneed_uuid=%s)", sneedUUID)
|
||||||
b.sneed.Send(fmt.Sprintf("/edit %s", string(data)))
|
b.sneed.Send(fmt.Sprintf("/edit %s", string(data)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,12 +314,98 @@ func (b *Bridge) onDiscordMessageDelete(s *discordgo.Session, m *discordgo.Messa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
discordID := parseMessageID(m.ID)
|
discordID := parseMessageID(m.ID)
|
||||||
sneedIDInt, ok := b.discordToSneed.Get(discordID)
|
sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(discordID))
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("↩️ Discord delete -> Sneedchat (sneed_id=%d)", sneedIDInt.(int))
|
sneedUUID := sneedUUIDVal.(string)
|
||||||
b.sneed.Send(fmt.Sprintf("/delete %d", sneedIDInt.(int)))
|
log.Printf("↩️ Discord delete -> Sneedchat (sneed_uuid=%s)", sneedUUID)
|
||||||
|
b.sneed.Send(fmt.Sprintf("/delete %s", sneedUUID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) messageWorker() {
|
||||||
|
defer b.wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-b.stopCh:
|
||||||
|
return
|
||||||
|
case msg, ok := <-b.msgQueue:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
depth := len(b.msgQueue)
|
||||||
|
if !b.overflow && depth >= OverflowThreshold {
|
||||||
|
b.overflow = true
|
||||||
|
log.Printf("⚠️ Message queue overflow (%d pending), switching to batch mode", depth)
|
||||||
|
} else if b.overflow && depth <= OverflowRecoveryThreshold {
|
||||||
|
b.overflow = false
|
||||||
|
log.Printf("✅ Message queue recovered (%d pending), switching to normal mode", depth)
|
||||||
|
}
|
||||||
|
if b.overflow {
|
||||||
|
batch := []map[string]interface{}{msg}
|
||||||
|
for len(batch) < OverflowBatchSize {
|
||||||
|
select {
|
||||||
|
case next, ok := <-b.msgQueue:
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
batch = append(batch, next)
|
||||||
|
default:
|
||||||
|
goto flushBatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushBatch:
|
||||||
|
b.sendOverflowBatch(batch)
|
||||||
|
} else {
|
||||||
|
b.onSneedMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) sendOverflowBatch(batch []map[string]interface{}) {
|
||||||
|
if len(batch) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var description strings.Builder
|
||||||
|
for _, msg := range batch {
|
||||||
|
username, _ := msg["username"].(string)
|
||||||
|
rawContent, _ := msg["content"].(string)
|
||||||
|
content := utils.BBCodeToMarkdown(rawContent)
|
||||||
|
content = sneed.ReplaceBridgeMention(content, b.cfg.BridgeUsername, b.cfg.DiscordPingUserID)
|
||||||
|
|
||||||
|
var ts string
|
||||||
|
if raw, ok := msg["raw"].(sneed.SneedMessage); ok && raw.MessageDate > 0 {
|
||||||
|
ts = time.Unix(int64(raw.MessageDate), 0).Format("3:04:05 PM")
|
||||||
|
}
|
||||||
|
|
||||||
|
description.WriteString(fmt.Sprintf("**%s** %s\n%s\n\n", username, ts, content))
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "overflow mode",
|
||||||
|
Description: strings.TrimSpace(description.String()),
|
||||||
|
Color: OutageEmbedColorActive,
|
||||||
|
}
|
||||||
|
params := &discordgo.WebhookParams{
|
||||||
|
Embeds: []*discordgo.MessageEmbed{embed},
|
||||||
|
}
|
||||||
|
_, err := b.session.WebhookExecute(webhookID, webhookToken, false, params)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Failed to send overflow batch: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("📦 Sent overflow batch of %d messages", len(batch))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) enqueueSneedMessage(msg map[string]interface{}) {
|
||||||
|
select {
|
||||||
|
case b.msgQueue <- msg:
|
||||||
|
default:
|
||||||
|
log.Printf("⚠️ Message queue full, dropping message from %s", msg["username"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) onSneedMessage(msg map[string]interface{}) {
|
func (b *Bridge) onSneedMessage(msg map[string]interface{}) {
|
||||||
@@ -304,17 +443,17 @@ func (b *Bridge) onSneedMessage(msg map[string]interface{}) {
|
|||||||
log.Printf("✅ Sent Sneedchat → Discord: %s", username)
|
log.Printf("✅ Sent Sneedchat → Discord: %s", username)
|
||||||
|
|
||||||
if sent != nil {
|
if sent != nil {
|
||||||
if mid, ok := msg["message_id"].(int); ok && mid > 0 {
|
if uuid, ok := msg["message_uuid"].(string); ok && uuid != "" {
|
||||||
discordMsgID := parseMessageID(sent.ID)
|
discordMsgID := parseMessageID(sent.ID)
|
||||||
b.sneedToDiscord.Set(mid, discordMsgID)
|
b.sneedToDiscord.Set(uuid, discordMsgID)
|
||||||
b.discordToSneed.Set(discordMsgID, mid)
|
b.discordToSneed.Set(strconv.Itoa(discordMsgID), uuid)
|
||||||
b.sneedUsernames.Set(mid, username)
|
b.sneedUsernames.Set(uuid, username)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) handleSneedEdit(sneedID int, newContent string) {
|
func (b *Bridge) handleSneedEdit(sneedUUID string, newContent string) {
|
||||||
discordIDInt, ok := b.sneedToDiscord.Get(sneedID)
|
discordIDInt, ok := b.sneedToDiscord.Get(sneedUUID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -327,11 +466,11 @@ func (b *Bridge) handleSneedEdit(sneedID int, newContent string) {
|
|||||||
log.Printf("❌ Failed to edit Discord message id=%d: %v", discordID, err)
|
log.Printf("❌ Failed to edit Discord message id=%d: %v", discordID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("✏️ Edited Discord (webhook) message id=%d (sneed_id=%d)", discordID, sneedID)
|
log.Printf("✏️ Edited Discord (webhook) message id=%d (sneed_uuid=%s)", discordID, sneedUUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) handleSneedDelete(sneedID int) {
|
func (b *Bridge) handleSneedDelete(sneedUUID string) {
|
||||||
discordIDInt, ok := b.sneedToDiscord.Get(sneedID)
|
discordIDInt, ok := b.sneedToDiscord.Get(sneedUUID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -342,10 +481,10 @@ func (b *Bridge) handleSneedDelete(sneedID int) {
|
|||||||
log.Printf("❌ Failed to delete Discord message id=%d: %v", discordID, err)
|
log.Printf("❌ Failed to delete Discord message id=%d: %v", discordID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("🗑️ Deleted Discord (webhook) message id=%d (sneed_id=%d)", discordID, sneedID)
|
log.Printf("🗑️ Deleted Discord (webhook) message id=%d (sneed_uuid=%s)", discordID, sneedUUID)
|
||||||
b.sneedToDiscord.Delete(sneedID)
|
b.sneedToDiscord.Delete(sneedUUID)
|
||||||
b.discordToSneed.Delete(discordID)
|
b.discordToSneed.Delete(strconv.Itoa(discordID))
|
||||||
b.sneedUsernames.Delete(sneedID)
|
b.sneedUsernames.Delete(sneedUUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) onSneedConnect() {
|
func (b *Bridge) onSneedConnect() {
|
||||||
@@ -531,11 +670,106 @@ func (b *Bridge) recentOutboundIter() []map[string]interface{} {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) mapDiscordSneed(discordID, sneedID int, username string) {
|
func (b *Bridge) mapDiscordSneed(sneedUUID string, discordID int, username string) {
|
||||||
b.discordToSneed.Set(discordID, sneedID)
|
b.discordToSneed.Set(strconv.Itoa(discordID), sneedUUID)
|
||||||
b.sneedToDiscord.Set(sneedID, discordID)
|
b.sneedToDiscord.Set(sneedUUID, discordID)
|
||||||
b.sneedUsernames.Set(sneedID, username)
|
b.sneedUsernames.Set(sneedUUID, username)
|
||||||
log.Printf("Mapped sneed_id=%d <-> discord_id=%d (username='%s')", sneedID, discordID, username)
|
log.Printf("Mapped sneed_uuid=%s <-> discord_id=%d (username='%s')", sneedUUID, discordID, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) sendUploadStatusMessage(channelID, mention string, attachmentCount int) (*discordgo.Message, error) {
|
||||||
|
desc := fmt.Sprintf("Uploading %d attachment(s) from %s…", attachmentCount, mention)
|
||||||
|
msg := &discordgo.MessageSend{
|
||||||
|
Embeds: []*discordgo.MessageEmbed{buildStatusEmbed(b.uploadStatusTitle(""), desc, UploadStatusColorPending)},
|
||||||
|
}
|
||||||
|
return b.session.ChannelMessageSendComplex(channelID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) uploadStatusTitle(suffix string) string {
|
||||||
|
base := fmt.Sprintf("%s upload", b.mediaServiceDisplayName())
|
||||||
|
if suffix == "" {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %s", base, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) mediaServiceDisplayName() string {
|
||||||
|
if b.mediaSvc == nil {
|
||||||
|
return "Media"
|
||||||
|
}
|
||||||
|
name := capitalizeWord(b.mediaSvc.Name())
|
||||||
|
if name == "" {
|
||||||
|
return "Media"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) editUploadStatusMessage(channelID, messageID, title, description string, color int) {
|
||||||
|
embed := buildStatusEmbed(title, description, color)
|
||||||
|
edit := &discordgo.MessageEdit{
|
||||||
|
ID: messageID,
|
||||||
|
Channel: channelID,
|
||||||
|
Embeds: []*discordgo.MessageEmbed{embed},
|
||||||
|
}
|
||||||
|
if _, err := b.session.ChannelMessageEditComplex(edit); err != nil {
|
||||||
|
log.Printf("⚠️ Failed to edit upload status message %s: %v", messageID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) awaitSneedConfirmation(discordID int, channelID, statusMessageID string) {
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
timer := time.NewTimer(UploadDeliveryTimeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if _, ok := b.discordToSneed.Get(strconv.Itoa(discordID)); ok {
|
||||||
|
desc := "Delivered to Sneedchat."
|
||||||
|
b.editUploadStatusMessage(channelID, statusMessageID, b.uploadStatusTitle("complete"), desc, UploadStatusColorSuccess)
|
||||||
|
b.scheduleUploadStatusDeletion(channelID, statusMessageID, UploadStatusCleanupDelay)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-timer.C:
|
||||||
|
desc := "Uploads finished but no Sneedchat confirmation was observed. Message may have been dropped."
|
||||||
|
b.editUploadStatusMessage(channelID, statusMessageID, b.uploadStatusTitle("warning"), desc, UploadStatusColorFailed)
|
||||||
|
b.scheduleUploadStatusDeletion(channelID, statusMessageID, UploadStatusFailureLifetime)
|
||||||
|
return
|
||||||
|
case <-b.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) scheduleUploadStatusDeletion(channelID, messageID string, delay time.Duration) {
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-time.After(delay):
|
||||||
|
if err := b.session.ChannelMessageDelete(channelID, messageID); err != nil {
|
||||||
|
log.Printf("⚠️ Failed to delete upload status message %s: %v", messageID, err)
|
||||||
|
}
|
||||||
|
case <-b.stopCh:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStatusEmbed(title, description string, color int) *discordgo.MessageEmbed {
|
||||||
|
return &discordgo.MessageEmbed{
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
Color: color,
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func capitalizeWord(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(s)
|
||||||
|
runes[0] = unicode.ToUpper(runes[0])
|
||||||
|
return string(runes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) sendOutageEmbed(title, description string, color int) (*discordgo.Message, error) {
|
func (b *Bridge) sendOutageEmbed(title, description string, color int) (*discordgo.Message, error) {
|
||||||
@@ -568,42 +802,6 @@ func (b *Bridge) deleteWebhookMessage(messageID string) error {
|
|||||||
return b.session.WebhookMessageDelete(webhookID, webhookToken, messageID)
|
return b.session.WebhookMessageDelete(webhookID, webhookToken, messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) uploadToLitterbox(fileURL, filename string) (string, error) {
|
|
||||||
resp, err := b.httpClient.Get(fileURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
body := &bytes.Buffer{}
|
|
||||||
w := multipart.NewWriter(body)
|
|
||||||
_ = w.WriteField("reqtype", "fileupload")
|
|
||||||
_ = w.WriteField("time", LitterboxTTL)
|
|
||||||
part, _ := w.CreateFormFile("fileToUpload", filename)
|
|
||||||
_, _ = part.Write(data)
|
|
||||||
_ = w.Close()
|
|
||||||
req, _ := http.NewRequest("POST", "https://litterbox.catbox.moe/resources/internals/api.php", body)
|
|
||||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
|
||||||
uResp, err := b.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer uResp.Body.Close()
|
|
||||||
if uResp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("Litterbox returned HTTP %d", uResp.StatusCode)
|
|
||||||
}
|
|
||||||
out, _ := io.ReadAll(uResp.Body)
|
|
||||||
url := strings.TrimSpace(string(out))
|
|
||||||
log.Printf("SUCCESS: Uploaded '%s' to Litterbox: %s", filename, url)
|
|
||||||
return url, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseWebhookURL(webhookURL string) (string, string) {
|
func parseWebhookURL(webhookURL string) (string, string) {
|
||||||
parts := strings.Split(webhookURL, "/")
|
parts := strings.Split(webhookURL, "/")
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
|
|||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
sneedchat-bridge:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
|
||||||
|
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ID}
|
||||||
|
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
||||||
|
- DISCORD_PING_USER_ID=${DISCORD_PING_USER_ID}
|
||||||
|
- DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL}
|
||||||
|
- RECONNECT_INTERVAL=${RECONNECT_INTERVAL:-5}
|
||||||
|
- MEDIA_UPLOAD_SERVICE=${MEDIA_UPLOAD_SERVICE:-litterbox}
|
||||||
|
- SNEEDCHAT_ROOM_ID=${SNEEDCHAT_ROOM_ID:-1}
|
||||||
|
- ENABLE_FILE_LOGGING=${ENABLE_FILE_LOGGING:-false}
|
||||||
|
- BRIDGE_USER_ID=${BRIDGE_USER_ID}
|
||||||
|
- BRIDGE_USERNAME=${BRIDGE_USERNAME}
|
||||||
|
- BRIDGE_PASSWORD=${BRIDGE_PASSWORD}
|
||||||
12
go.mod
12
go.mod
@@ -1,15 +1,19 @@
|
|||||||
module local/sneedchatbridge
|
module local/sneedchatbridge
|
||||||
|
|
||||||
go 1.23
|
go 1.25.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
gitgud.io/yats/libkiwi v0.0.0-20260214165635-8e0720d58701
|
||||||
github.com/bwmarrin/discordgo v0.27.1
|
github.com/bwmarrin/discordgo v0.27.1
|
||||||
github.com/gorilla/websocket v1.5.1
|
github.com/gorilla/websocket v1.5.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/crypto v0.14.0 // indirect
|
gitgud.io/yats/cerberus v0.0.0-20260214165307-66e6f74a4be9 // indirect
|
||||||
golang.org/x/net v0.17.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
|
||||||
golang.org/x/sys v0.13.0 // indirect
|
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||||
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
|
golang.org/x/net v0.49.0 // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
21
go.sum
21
go.sum
@@ -1,3 +1,7 @@
|
|||||||
|
gitgud.io/yats/cerberus v0.0.0-20260214165307-66e6f74a4be9 h1:OSYrnxTeCuvaX6O8/AHUE4Xndb76vtcVwdvdLtGfp4Q=
|
||||||
|
gitgud.io/yats/cerberus v0.0.0-20260214165307-66e6f74a4be9/go.mod h1:WVfXXYUHR8x5hX0cpRUOlaeRqxR/9JxYhLbjFSb/jjc=
|
||||||
|
gitgud.io/yats/libkiwi v0.0.0-20260214165635-8e0720d58701 h1:cKIfr4ko4VlrCR4cbVpBJ1/1p9D4IWpGQnXWiPWoHZU=
|
||||||
|
gitgud.io/yats/libkiwi v0.0.0-20260214165635-8e0720d58701/go.mod h1:7oHOXzBQep8VqlnmDeq/ItH3FkrXJY1IWWAQNG89MVM=
|
||||||
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
|
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
|
||||||
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
@@ -5,15 +9,20 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/
|
|||||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
|
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||||
|
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||||
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
41
main.go
41
main.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -14,35 +15,40 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
envFile := ".env"
|
envFile := ".env"
|
||||||
|
debugFlag := false
|
||||||
for i, a := range os.Args {
|
for i, a := range os.Args {
|
||||||
if a == "--env" && i+1 < len(os.Args) {
|
if a == "--env" && i+1 < len(os.Args) {
|
||||||
envFile = os.Args[i+1]
|
envFile = os.Args[i+1]
|
||||||
}
|
}
|
||||||
|
if a == "--debug" {
|
||||||
|
debugFlag = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(envFile)
|
cfg, err := config.Load(envFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
cfg.Debug = cfg.Debug || debugFlag
|
||||||
log.Printf("Using .env file: %s", envFile)
|
log.Printf("Using .env file: %s", envFile)
|
||||||
log.Printf("Using Sneedchat room ID: %d", cfg.SneedchatRoomID)
|
log.Printf("Using Sneedchat room ID: %d", cfg.SneedchatRoomID)
|
||||||
log.Printf("Bridge username: %s", cfg.BridgeUsername)
|
log.Printf("Bridge username: %s", cfg.BridgeUsername)
|
||||||
|
|
||||||
// Cookie service (now handles its own refresh loop)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
cookieSvc, err := cookie.NewCookieRefreshService(cfg.BridgeUsername, cfg.BridgePassword, "kiwifarms.st")
|
defer cancel()
|
||||||
|
|
||||||
|
// SessionService owns the TLS config, cookie store, and shared transport.
|
||||||
|
// Login is performed here; cookies are stored as plain strings, no jar.
|
||||||
|
session, err := cookie.NewSessionService(ctx, "kiwifarms.st", cfg.BridgeUsername, cfg.BridgePassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create cookie service: %v", err)
|
log.Fatalf("❌ Failed to establish session: %v", err)
|
||||||
}
|
|
||||||
cookieSvc.Start()
|
|
||||||
cookieSvc.WaitForCookie()
|
|
||||||
if cookieSvc.GetCurrentCookie() == "" {
|
|
||||||
log.Fatal("❌ Failed to obtain initial cookie, cannot start bridge")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sneedchat client
|
// NewClient uses session.Transport() for the WebSocket dialer,
|
||||||
sneedClient := sneed.NewClient(cfg.SneedchatRoomID, cookieSvc)
|
// mirroring sockchat's NewSocket pattern exactly.
|
||||||
|
sneedClient := sneed.NewClient(cfg.SneedchatRoomID, session, cfg.Debug)
|
||||||
|
sneedClient.SetBridgeIdentity(cfg.BridgeUserID, cfg.BridgeUsername)
|
||||||
|
|
||||||
// Discord bridge
|
|
||||||
bridge, err := discord.NewBridge(cfg, sneedClient)
|
bridge, err := discord.NewBridge(cfg, sneedClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create Discord bridge: %v", err)
|
log.Fatalf("Failed to create Discord bridge: %v", err)
|
||||||
@@ -52,21 +58,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Println("🌉 Discord-Sneedchat Bridge started successfully")
|
log.Println("🌉 Discord-Sneedchat Bridge started successfully")
|
||||||
|
|
||||||
// Connect to Sneedchat
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := sneedClient.Connect(); err != nil {
|
if err := sneedClient.Connect(ctx); err != nil {
|
||||||
log.Printf("Initial Sneedchat connect failed: %v", err)
|
log.Printf("Initial Sneedchat connect failed: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Graceful shutdown
|
<-ctx.Done()
|
||||||
sig := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-sig
|
|
||||||
|
|
||||||
log.Println("Shutdown signal received, cleaning up...")
|
log.Println("Shutdown signal received, cleaning up...")
|
||||||
bridge.Stop()
|
bridge.Stop()
|
||||||
sneedClient.Disconnect()
|
sneedClient.Disconnect()
|
||||||
cookieSvc.Stop()
|
session.Close()
|
||||||
log.Println("Bridge stopped successfully")
|
log.Println("Bridge stopped successfully")
|
||||||
}
|
}
|
||||||
|
|||||||
84
media/litterbox.go
Normal file
84
media/litterbox.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
litterboxTTL = "72h"
|
||||||
|
litterboxEndpoint = "https://litterbox.catbox.moe/resources/internals/api.php"
|
||||||
|
defaultHTTPTimeout = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type LitterboxService struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LitterboxService) Name() string { return DefaultService }
|
||||||
|
|
||||||
|
func (s *LitterboxService) Upload(ctx context.Context, attachment *discordgo.MessageAttachment) (string, int, error) {
|
||||||
|
client := s.client
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: defaultHTTPTimeout}
|
||||||
|
}
|
||||||
|
if client.Timeout == 0 {
|
||||||
|
client.Timeout = defaultHTTPTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, attachment.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", resp.StatusCode, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
_ = writer.WriteField("reqtype", "fileupload")
|
||||||
|
_ = writer.WriteField("time", litterboxTTL)
|
||||||
|
part, _ := writer.CreateFormFile("fileToUpload", attachment.Filename)
|
||||||
|
_, _ = part.Write(data)
|
||||||
|
_ = writer.Close()
|
||||||
|
|
||||||
|
uploadReq, err := http.NewRequestWithContext(ctx, http.MethodPost, litterboxEndpoint, body)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
uploadResp, err := client.Do(uploadReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
defer uploadResp.Body.Close()
|
||||||
|
if uploadResp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(uploadResp.Body)
|
||||||
|
reason := strings.TrimSpace(string(bodyBytes))
|
||||||
|
if reason != "" {
|
||||||
|
return "", uploadResp.StatusCode, fmt.Errorf("Litterbox returned HTTP %d: %s", uploadResp.StatusCode, reason)
|
||||||
|
}
|
||||||
|
return "", uploadResp.StatusCode, fmt.Errorf("Litterbox returned HTTP %d", uploadResp.StatusCode)
|
||||||
|
}
|
||||||
|
out, _ := io.ReadAll(uploadResp.Body)
|
||||||
|
url := strings.TrimSpace(string(out))
|
||||||
|
return url, uploadResp.StatusCode, nil
|
||||||
|
}
|
||||||
36
media/service.go
Normal file
36
media/service.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultService = "litterbox"
|
||||||
|
|
||||||
|
// Service defines a pluggable uploader for Discord attachments.
|
||||||
|
type Service interface {
|
||||||
|
Name() string
|
||||||
|
Upload(ctx context.Context, attachment *discordgo.MessageAttachment) (url string, statusCode int, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService returns the configured media upload backend. Currently only
|
||||||
|
// Litterbox is supported but the hook point allows future expansion.
|
||||||
|
func NewService(name string, httpClient *http.Client) (Service, error) {
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = &http.Client{}
|
||||||
|
}
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if normalized == "" {
|
||||||
|
normalized = DefaultService
|
||||||
|
}
|
||||||
|
switch normalized {
|
||||||
|
case DefaultService:
|
||||||
|
return &LitterboxService{client: httpClient}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown media upload service: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
283
sneed/client.go
283
sneed/client.go
@@ -1,12 +1,15 @@
|
|||||||
package sneed
|
package sneed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,7 +19,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ProcessedCacheSize = 1000 // Increased from 250
|
ProcessedCacheSize = 1000
|
||||||
ReconnectInterval = 7 * time.Second
|
ReconnectInterval = 7 * time.Second
|
||||||
MappingCacheSize = 1000
|
MappingCacheSize = 1000
|
||||||
MappingCleanupInterval = 5 * time.Minute
|
MappingCleanupInterval = 5 * time.Minute
|
||||||
@@ -24,11 +27,24 @@ const (
|
|||||||
OutboundMatchWindow = 60 * time.Second
|
OutboundMatchWindow = 60 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// socketTLSConfig mirrors the config used by sockchat: broad cipher suite list
|
||||||
|
// to avoid KiwiFlare TLS fingerprint detection on the WebSocket upgrade.
|
||||||
|
func socketTLSConfig() *tls.Config {
|
||||||
|
suites := slices.Concat(tls.CipherSuites(), tls.InsecureCipherSuites())
|
||||||
|
ids := make([]uint16, len(suites))
|
||||||
|
for i, s := range suites {
|
||||||
|
ids[i] = s.ID
|
||||||
|
}
|
||||||
|
return &tls.Config{CipherSuites: ids}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
wsURL string
|
wsURL string
|
||||||
roomID int
|
roomID int
|
||||||
cookies *cookie.CookieRefreshService
|
session *cookie.SessionService
|
||||||
|
|
||||||
|
dialer websocket.Dialer
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
connected bool
|
connected bool
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -37,31 +53,40 @@ type Client struct {
|
|||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
|
||||||
processedMu sync.Mutex
|
processedMu sync.Mutex
|
||||||
processedMessageIDs []int
|
processedUUIDs []string
|
||||||
messageEditDates *utils.BoundedMap
|
messageEditDates *utils.BoundedMap
|
||||||
|
|
||||||
OnMessage func(map[string]interface{})
|
OnMessage func(map[string]interface{})
|
||||||
OnEdit func(int, string)
|
OnEdit func(string, string)
|
||||||
OnDelete func(int)
|
OnDelete func(string)
|
||||||
OnConnect func()
|
OnConnect func()
|
||||||
OnDisconnect func()
|
OnDisconnect func()
|
||||||
|
|
||||||
recentOutboundIter func() []map[string]interface{}
|
recentOutboundIter func() []map[string]interface{}
|
||||||
mapDiscordSneed func(int, int, string)
|
mapDiscordSneed func(string, int, string)
|
||||||
|
|
||||||
bridgeUserID int
|
bridgeUserID int
|
||||||
bridgeUsername string
|
bridgeUsername string
|
||||||
baseLoopsStarted bool
|
baseLoopsStarted bool
|
||||||
|
debug bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(roomID int, cookieSvc *cookie.CookieRefreshService) *Client {
|
func NewClient(roomID int, session *cookie.SessionService, debug bool) *Client {
|
||||||
|
tr := session.Transport()
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
wsURL: "wss://kiwifarms.st:9443/chat.ws",
|
wsURL: "wss://kiwifarms.st:9443/chat.ws",
|
||||||
roomID: roomID,
|
roomID: roomID,
|
||||||
cookies: cookieSvc,
|
session: session,
|
||||||
|
debug: debug,
|
||||||
|
dialer: websocket.Dialer{
|
||||||
|
EnableCompression: true,
|
||||||
|
NetDialContext: tr.DialContext,
|
||||||
|
TLSClientConfig: tr.TLSClientConfig,
|
||||||
|
},
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
processedMessageIDs: make([]int, 0, ProcessedCacheSize),
|
processedUUIDs: make([]string, 0, ProcessedCacheSize),
|
||||||
messageEditDates: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
messageEditDates: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
||||||
lastMessage: time.Now(),
|
lastMessage: time.Now(),
|
||||||
}
|
}
|
||||||
@@ -72,7 +97,7 @@ func (c *Client) SetBridgeIdentity(userID int, username string) {
|
|||||||
c.bridgeUsername = username
|
c.bridgeUsername = username
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Connect() error {
|
func (c *Client) Connect(ctx context.Context) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if c.connected {
|
if c.connected {
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
@@ -80,15 +105,43 @@ func (c *Client) Connect() error {
|
|||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
headers := http.Header{}
|
// Exclude ttrs_clearance — must be solved fresh for port 9443.
|
||||||
if ck := c.cookies.GetCurrentCookie(); ck != "" {
|
headers := http.Header{
|
||||||
headers.Add("Cookie", ck)
|
"Cookie": {c.session.CookieStringForWS()},
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Connecting to Sneedchat room %d", c.roomID)
|
log.Printf("Connecting to Sneedchat room %d", c.roomID)
|
||||||
conn, _, err := websocket.DefaultDialer.Dial(c.wsURL, headers)
|
if c.debug {
|
||||||
|
log.Printf("🍪 Dial cookies: %s", headers.Get("Cookie"))
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, resp, err := c.dialer.DialContext(ctx, c.wsURL, headers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("websocket connection failed: %w", err)
|
if resp != nil && resp.StatusCode == 203 {
|
||||||
|
log.Println("⚠️ Got 203 on WebSocket dial — solving challenge...")
|
||||||
|
body := make([]byte, 0)
|
||||||
|
if resp.Body != nil {
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
n, _ := resp.Body.Read(buf)
|
||||||
|
body = buf[:n]
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
clearance, serr := c.session.SolveForHost(ctx, body)
|
||||||
|
if serr != nil {
|
||||||
|
return fmt.Errorf("PoW solve for WS endpoint: %w", serr)
|
||||||
|
}
|
||||||
|
wsBase := c.session.CookieStringForWS()
|
||||||
|
headers["Cookie"] = []string{wsBase + "; ttrs_clearance=" + clearance}
|
||||||
|
if c.debug {
|
||||||
|
log.Printf("🍪 Retry dial cookies: %s", headers.Get("Cookie"))
|
||||||
|
}
|
||||||
|
conn, _, err = c.dialer.DialContext(ctx, c.wsURL, headers)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("websocket connection failed after PoW solve: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("websocket connection failed: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -105,7 +158,7 @@ func (c *Client) Connect() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.wg.Add(1)
|
c.wg.Add(1)
|
||||||
go c.readLoop()
|
go c.readLoop(ctx)
|
||||||
|
|
||||||
c.Send(fmt.Sprintf("/join %d", c.roomID))
|
c.Send(fmt.Sprintf("/join %d", c.roomID))
|
||||||
log.Printf("✅ Successfully connected to Sneedchat room %d", c.roomID)
|
log.Printf("✅ Successfully connected to Sneedchat room %d", c.roomID)
|
||||||
@@ -119,7 +172,7 @@ func (c *Client) joinRoom() {
|
|||||||
c.Send(fmt.Sprintf("/join %d", c.roomID))
|
c.Send(fmt.Sprintf("/join %d", c.roomID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) readLoop() {
|
func (c *Client) readLoop(ctx context.Context) {
|
||||||
defer c.wg.Done()
|
defer c.wg.Done()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -138,14 +191,47 @@ func (c *Client) readLoop() {
|
|||||||
_, message, err := conn.ReadMessage()
|
_, message, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Sneedchat read error: %v", err)
|
log.Printf("Sneedchat read error: %v", err)
|
||||||
c.handleDisconnect()
|
c.handleDisconnect(ctx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
raw := string(message)
|
||||||
|
|
||||||
|
// Server sends plaintext "cannot join" when session has expired.
|
||||||
|
if isCannotJoin(raw) {
|
||||||
|
log.Println("⚠️ 'cannot join' received — refreshing session and reconnecting...")
|
||||||
|
if rerr := c.session.Refresh(ctx); rerr != nil {
|
||||||
|
log.Printf("❌ Session refresh failed: %v", rerr)
|
||||||
|
}
|
||||||
|
c.handleDisconnect(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
c.lastMessage = time.Now()
|
c.lastMessage = time.Now()
|
||||||
c.handleIncoming(string(message))
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.debug {
|
||||||
|
preview := raw
|
||||||
|
if len(preview) > 200 {
|
||||||
|
preview = preview[:200] + "..."
|
||||||
|
}
|
||||||
|
log.Printf("📨 WS recv: %s", preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.handleIncoming(raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isCannotJoin(msg string) bool {
|
||||||
|
return len(msg) > 0 && !isJSON(msg) && len(msg) >= 11 && msg == "cannot join"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isJSON(s string) bool {
|
||||||
|
var js json.RawMessage
|
||||||
|
return json.Unmarshal([]byte(s), &js) == nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) heartbeatLoop() {
|
func (c *Client) heartbeatLoop() {
|
||||||
defer c.wg.Done()
|
defer c.wg.Done()
|
||||||
t := time.NewTicker(30 * time.Second)
|
t := time.NewTicker(30 * time.Second)
|
||||||
@@ -195,10 +281,17 @@ func (c *Client) Send(s string) bool {
|
|||||||
log.Printf("Sneedchat write error: %v", err)
|
log.Printf("Sneedchat write error: %v", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if c.debug {
|
||||||
|
preview := s
|
||||||
|
if len(preview) > 120 {
|
||||||
|
preview = preview[:120] + "..."
|
||||||
|
}
|
||||||
|
log.Printf("📤 WS sent: %s", preview)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) handleDisconnect() {
|
func (c *Client) handleDisconnect(ctx context.Context) {
|
||||||
select {
|
select {
|
||||||
case <-c.stopCh:
|
case <-c.stopCh:
|
||||||
return
|
return
|
||||||
@@ -218,7 +311,6 @@ func (c *Client) handleDisconnect() {
|
|||||||
c.OnDisconnect()
|
c.OnDisconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconnection loop with exponential backoff
|
|
||||||
delay := ReconnectInterval
|
delay := ReconnectInterval
|
||||||
maxDelay := 2 * time.Minute
|
maxDelay := 2 * time.Minute
|
||||||
attempt := 0
|
attempt := 0
|
||||||
@@ -226,16 +318,14 @@ func (c *Client) handleDisconnect() {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-c.stopCh:
|
case <-c.stopCh:
|
||||||
log.Println("Reconnection cancelled - bridge stopping")
|
log.Println("Reconnection cancelled — bridge stopping")
|
||||||
return
|
return
|
||||||
case <-time.After(delay):
|
case <-time.After(delay):
|
||||||
attempt++
|
attempt++
|
||||||
log.Printf("🔄 Reconnection attempt #%d...", attempt)
|
log.Printf("🔄 Reconnection attempt #%d...", attempt)
|
||||||
|
|
||||||
if err := c.Connect(); err != nil {
|
if err := c.Connect(ctx); err != nil {
|
||||||
log.Printf("⚠️ Reconnect attempt #%d failed: %v", attempt, err)
|
log.Printf("⚠️ Reconnect attempt #%d failed: %v", attempt, err)
|
||||||
|
|
||||||
// Exponential backoff
|
|
||||||
delay *= 2
|
delay *= 2
|
||||||
if delay > maxDelay {
|
if delay > maxDelay {
|
||||||
delay = maxDelay
|
delay = maxDelay
|
||||||
@@ -244,11 +334,7 @@ func (c *Client) handleDisconnect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Println("🟢 Reconnected successfully")
|
log.Println("🟢 Reconnected successfully")
|
||||||
|
|
||||||
// Allow websocket to stabilize
|
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
// Re-join room
|
|
||||||
c.joinRoom()
|
c.joinRoom()
|
||||||
c.Send("/ping")
|
c.Send("/ping")
|
||||||
log.Printf("📍 Rejoined Sneedchat room %d after reconnect", c.roomID)
|
log.Printf("📍 Rejoined Sneedchat room %d after reconnect", c.roomID)
|
||||||
@@ -271,27 +357,24 @@ func (c *Client) Disconnect() {
|
|||||||
func (c *Client) handleIncoming(raw string) {
|
func (c *Client) handleIncoming(raw string) {
|
||||||
var payload SneedPayload
|
var payload SneedPayload
|
||||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||||
|
if c.debug {
|
||||||
|
log.Printf("⚠️ WS parse error: %v | raw: %.100s", err, raw)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Delete != nil {
|
if c.debug {
|
||||||
var ids []int
|
log.Printf("📦 payload: msgs=%d msg=%v del=%v", len(payload.Messages), payload.Message != nil, payload.Delete != nil)
|
||||||
switch v := payload.Delete.(type) {
|
}
|
||||||
case float64:
|
|
||||||
ids = []int{int(v)}
|
for _, uuid := range payload.Delete {
|
||||||
case []interface{}:
|
if uuid == "" {
|
||||||
for _, x := range v {
|
continue
|
||||||
if fid, ok := x.(float64); ok {
|
|
||||||
ids = append(ids, int(fid))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for _, id := range ids {
|
c.messageEditDates.Delete(uuid)
|
||||||
c.messageEditDates.Delete(id)
|
c.removeFromProcessed(uuid)
|
||||||
c.removeFromProcessed(id)
|
if c.OnDelete != nil {
|
||||||
if c.OnDelete != nil {
|
c.OnDelete(uuid)
|
||||||
c.OnDelete(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,20 +405,21 @@ func (c *Client) processMessage(m SneedMessage) {
|
|||||||
}
|
}
|
||||||
messageText = html.UnescapeString(messageText)
|
messageText = html.UnescapeString(messageText)
|
||||||
|
|
||||||
|
uuid := m.MessageUUID
|
||||||
editDate := m.MessageEditDate
|
editDate := m.MessageEditDate
|
||||||
deleted := m.Deleted || m.IsDeleted
|
deleted := m.Deleted || m.IsDeleted
|
||||||
if deleted {
|
if deleted {
|
||||||
c.messageEditDates.Delete(m.MessageID)
|
c.messageEditDates.Delete(uuid)
|
||||||
c.removeFromProcessed(m.MessageID)
|
c.removeFromProcessed(uuid)
|
||||||
if c.OnDelete != nil {
|
if c.OnDelete != nil {
|
||||||
c.OnDelete(m.MessageID)
|
c.OnDelete(uuid)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.bridgeUserID > 0 && userID == c.bridgeUserID) ||
|
if (c.bridgeUserID > 0 && userID == c.bridgeUserID) ||
|
||||||
(c.bridgeUsername != "" && username == c.bridgeUsername) {
|
(c.bridgeUsername != "" && username == c.bridgeUsername) {
|
||||||
if m.MessageID > 0 && c.recentOutboundIter != nil && c.mapDiscordSneed != nil {
|
if uuid != "" && c.recentOutboundIter != nil && c.mapDiscordSneed != nil {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, entry := range c.recentOutboundIter() {
|
for _, entry := range c.recentOutboundIter() {
|
||||||
if mapped, ok := entry["mapped"].(bool); ok && mapped {
|
if mapped, ok := entry["mapped"].(bool); ok && mapped {
|
||||||
@@ -345,7 +429,7 @@ func (c *Client) processMessage(m SneedMessage) {
|
|||||||
if ts, ok := entry["ts"].(time.Time); ok {
|
if ts, ok := entry["ts"].(time.Time); ok {
|
||||||
if content == messageText && now.Sub(ts) <= OutboundMatchWindow {
|
if content == messageText && now.Sub(ts) <= OutboundMatchWindow {
|
||||||
if discordID, ok := entry["discord_id"].(int); ok {
|
if discordID, ok := entry["discord_id"].(int); ok {
|
||||||
c.mapDiscordSneed(discordID, m.MessageID, username)
|
c.mapDiscordSneed(uuid, discordID, username)
|
||||||
entry["mapped"] = true
|
entry["mapped"] = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -353,72 +437,74 @@ func (c *Client) processMessage(m SneedMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.addToProcessed(m.MessageID)
|
c.addToProcessed(uuid)
|
||||||
c.messageEditDates.Set(m.MessageID, editDate)
|
c.messageEditDates.Set(uuid, editDate)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.isProcessed(m.MessageID) {
|
if c.isProcessed(uuid) {
|
||||||
if prev, exists := c.messageEditDates.Get(m.MessageID); exists {
|
if prev, exists := c.messageEditDates.Get(uuid); exists {
|
||||||
if editDate > prev.(int) {
|
if editDate > prev.(int) {
|
||||||
c.messageEditDates.Set(m.MessageID, editDate)
|
c.messageEditDates.Set(uuid, editDate)
|
||||||
if c.OnEdit != nil {
|
if c.OnEdit != nil {
|
||||||
c.OnEdit(m.MessageID, messageText)
|
c.OnEdit(uuid, messageText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.addToProcessed(m.MessageID)
|
c.addToProcessed(uuid)
|
||||||
c.messageEditDates.Set(m.MessageID, editDate)
|
c.messageEditDates.Set(uuid, editDate)
|
||||||
|
|
||||||
if c.OnMessage != nil {
|
if c.OnMessage != nil {
|
||||||
c.OnMessage(map[string]interface{}{
|
c.OnMessage(map[string]interface{}{
|
||||||
"username": username,
|
"username": username,
|
||||||
"content": messageText,
|
"content": messageText,
|
||||||
"message_id": m.MessageID,
|
"message_uuid": uuid,
|
||||||
"author_id": userID,
|
"message_id": m.MessageID,
|
||||||
"raw": m,
|
"author_id": userID,
|
||||||
|
"raw": m,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) isProcessed(id int) bool {
|
func (c *Client) isProcessed(uuid string) bool {
|
||||||
|
if uuid == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
c.processedMu.Lock()
|
c.processedMu.Lock()
|
||||||
defer c.processedMu.Unlock()
|
defer c.processedMu.Unlock()
|
||||||
for _, x := range c.processedMessageIDs {
|
for _, x := range c.processedUUIDs {
|
||||||
if x == id {
|
if x == uuid {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) addToProcessed(id int) {
|
func (c *Client) addToProcessed(uuid string) {
|
||||||
|
if uuid == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
c.processedMu.Lock()
|
c.processedMu.Lock()
|
||||||
defer c.processedMu.Unlock()
|
defer c.processedMu.Unlock()
|
||||||
|
c.processedUUIDs = append(c.processedUUIDs, uuid)
|
||||||
c.processedMessageIDs = append(c.processedMessageIDs, id)
|
if len(c.processedUUIDs) > ProcessedCacheSize {
|
||||||
|
excess := len(c.processedUUIDs) - ProcessedCacheSize
|
||||||
// Hard cap: keep only the most recent 1000 messages (FIFO)
|
c.processedUUIDs = c.processedUUIDs[excess:]
|
||||||
if len(c.processedMessageIDs) > ProcessedCacheSize {
|
|
||||||
excess := len(c.processedMessageIDs) - ProcessedCacheSize
|
|
||||||
c.processedMessageIDs = c.processedMessageIDs[excess:]
|
|
||||||
|
|
||||||
// Log when significant eviction happens
|
|
||||||
if excess > 50 {
|
|
||||||
log.Printf("⚠️ Processed message cache full, evicted %d old entries", excess)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) removeFromProcessed(id int) {
|
func (c *Client) removeFromProcessed(uuid string) {
|
||||||
|
if uuid == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
c.processedMu.Lock()
|
c.processedMu.Lock()
|
||||||
defer c.processedMu.Unlock()
|
defer c.processedMu.Unlock()
|
||||||
for i, x := range c.processedMessageIDs {
|
for i, x := range c.processedUUIDs {
|
||||||
if x == id {
|
if x == uuid {
|
||||||
c.processedMessageIDs = append(c.processedMessageIDs[:i], c.processedMessageIDs[i+1:]...)
|
c.processedUUIDs = append(c.processedUUIDs[:i], c.processedUUIDs[i+1:]...)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,14 +514,23 @@ func (c *Client) SetOutboundIter(f func() []map[string]interface{}) {
|
|||||||
c.recentOutboundIter = f
|
c.recentOutboundIter = f
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SetMapDiscordSneed(f func(int, int, string)) {
|
func (c *Client) SetMapDiscordSneed(f func(string, int, string)) {
|
||||||
c.mapDiscordSneed = f
|
c.mapDiscordSneed = f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) IsConnected() bool {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.connected
|
||||||
|
}
|
||||||
|
|
||||||
func ReplaceBridgeMention(content, bridgeUsername, pingID string) string {
|
func ReplaceBridgeMention(content, bridgeUsername, pingID string) string {
|
||||||
if bridgeUsername == "" || pingID == "" {
|
if bridgeUsername == "" || pingID == "" {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
pat := regexp.MustCompile(fmt.Sprintf(`(?i)@%s(?:\W|$)`, regexp.QuoteMeta(bridgeUsername)))
|
pat := regexp.MustCompile(fmt.Sprintf(`(?i)@%s(?:\W|$)`, regexp.QuoteMeta(bridgeUsername)))
|
||||||
return pat.ReplaceAllString(content, fmt.Sprintf("<@%s>", pingID))
|
return pat.ReplaceAllString(content, fmt.Sprintf("<@%s>", pingID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure socketTLSConfig is used (referenced in NewClient via session.Transport()).
|
||||||
|
var _ = socketTLSConfig
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package sneed
|
|||||||
|
|
||||||
type SneedMessage struct {
|
type SneedMessage struct {
|
||||||
MessageID int `json:"message_id"`
|
MessageID int `json:"message_id"`
|
||||||
|
MessageUUID string `json:"message_uuid"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
MessageRaw string `json:"message_raw"`
|
MessageRaw string `json:"message_raw"`
|
||||||
|
MessageDate int `json:"message_date"`
|
||||||
MessageEditDate int `json:"message_edit_date"`
|
MessageEditDate int `json:"message_edit_date"`
|
||||||
Author map[string]interface{} `json:"author"`
|
Author map[string]interface{} `json:"author"`
|
||||||
Deleted bool `json:"deleted"`
|
Deleted bool `json:"deleted"`
|
||||||
@@ -13,5 +15,5 @@ type SneedMessage struct {
|
|||||||
type SneedPayload struct {
|
type SneedPayload struct {
|
||||||
Messages []SneedMessage `json:"messages"`
|
Messages []SneedMessage `json:"messages"`
|
||||||
Message *SneedMessage `json:"message"`
|
Message *SneedMessage `json:"message"`
|
||||||
Delete interface{} `json:"delete"`
|
Delete []string `json:"delete"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,24 +7,24 @@ import (
|
|||||||
|
|
||||||
type BoundedMap struct {
|
type BoundedMap struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
data map[int]interface{}
|
data map[string]interface{}
|
||||||
timestamps map[int]time.Time
|
timestamps map[string]time.Time
|
||||||
maxSize int
|
maxSize int
|
||||||
maxAge time.Duration
|
maxAge time.Duration
|
||||||
keys []int
|
keys []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBoundedMap(maxSize int, maxAge time.Duration) *BoundedMap {
|
func NewBoundedMap(maxSize int, maxAge time.Duration) *BoundedMap {
|
||||||
return &BoundedMap{
|
return &BoundedMap{
|
||||||
data: make(map[int]interface{}),
|
data: make(map[string]interface{}),
|
||||||
timestamps: make(map[int]time.Time),
|
timestamps: make(map[string]time.Time),
|
||||||
maxSize: maxSize,
|
maxSize: maxSize,
|
||||||
maxAge: maxAge,
|
maxAge: maxAge,
|
||||||
keys: make([]int, 0, maxSize),
|
keys: make([]string, 0, maxSize),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bm *BoundedMap) Set(key int, value interface{}) {
|
func (bm *BoundedMap) Set(key string, value interface{}) {
|
||||||
bm.mu.Lock()
|
bm.mu.Lock()
|
||||||
defer bm.mu.Unlock()
|
defer bm.mu.Unlock()
|
||||||
if _, ok := bm.data[key]; ok {
|
if _, ok := bm.data[key]; ok {
|
||||||
@@ -50,14 +50,14 @@ func (bm *BoundedMap) Set(key int, value interface{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bm *BoundedMap) Get(key int) (interface{}, bool) {
|
func (bm *BoundedMap) Get(key string) (interface{}, bool) {
|
||||||
bm.mu.RLock()
|
bm.mu.RLock()
|
||||||
defer bm.mu.RUnlock()
|
defer bm.mu.RUnlock()
|
||||||
v, ok := bm.data[key]
|
v, ok := bm.data[key]
|
||||||
return v, ok
|
return v, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bm *BoundedMap) Delete(key int) {
|
func (bm *BoundedMap) Delete(key string) {
|
||||||
bm.mu.Lock()
|
bm.mu.Lock()
|
||||||
defer bm.mu.Unlock()
|
defer bm.mu.Unlock()
|
||||||
delete(bm.data, key)
|
delete(bm.data, key)
|
||||||
|
|||||||
Reference in New Issue
Block a user