2 Commits
v1.2 ... master

Author SHA1 Message Date
Salastil
60ed8c3ca0 Fix: Silent failure on reconnect, self healing via /join when chat is silent after an outage
All checks were successful
Build & Release / build-latest (push) Has been skipped
Build & Release / version-release (push) Successful in 10m26s
2025-11-18 20:21:58 -05:00
Salastil
f954771c0c Added a MediaUploadService option to the configuration loader and documented it in the README so operators can pick the attachment backend (defaulting to Litterbox) straight from .env.
All checks were successful
Build & Release / build-latest (push) Successful in 9m53s
Build & Release / version-release (push) Has been skipped
Refactored the Discord bridge to build a media service during initialization, route attachment uploads through it, and dynamically label the status embeds and error diagnostics based on the selected provider while preserving the existing progress messaging flow.

Introduced a new media package that defines the uploader interface and ships a Litterbox implementation responsible for fetching Discord attachments and posting them to Catbox while reporting HTTP status codes back to the bridge.
2025-11-18 17:33:42 -05:00
7 changed files with 388 additions and 104 deletions

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,41 @@
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 MappingCacheSize = 1000
MappingCacheSize = 1000 MappingMaxAge = 1 * time.Hour
MappingMaxAge = 1 * time.Hour MappingCleanupInterval = 5 * time.Minute
MappingCleanupInterval = 5 * time.Minute QueuedMessageTTL = 90 * time.Second
QueuedMessageTTL = 90 * time.Second OutageUpdateInterval = 10 * time.Second
OutageUpdateInterval = 10 * time.Second OutboundMatchWindow = 60 * time.Second
OutboundMatchWindow = 60 * time.Second OutageEmbedColorActive = 0xF1C40F
OutageEmbedColorActive = 0xF1C40F OutageEmbedColorFixed = 0x2ECC71
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 +53,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
@@ -77,11 +82,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),
@@ -185,14 +194,40 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
} }
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 +248,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 +261,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)
}
} }
} }
@@ -538,6 +582,101 @@ func (b *Bridge) mapDiscordSneed(discordID, sneedID int, username string) {
log.Printf("Mapped sneed_id=%d <-> discord_id=%d (username='%s')", sneedID, discordID, username) log.Printf("Mapped sneed_id=%d <-> discord_id=%d (username='%s')", sneedID, 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(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) {
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL) webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
embed := buildOutageEmbed(title, description, color) embed := buildOutageEmbed(title, description, color)
@@ -568,42 +707,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 {

84
media/litterbox.go Normal file
View 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
View 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)
}
}

View File

@@ -16,12 +16,16 @@ import (
) )
const ( const (
ProcessedCacheSize = 1000 // Increased from 250 ProcessedCacheSize = 1000 // Increased from 250
ReconnectInterval = 7 * time.Second ReconnectInterval = 7 * time.Second
MappingCacheSize = 1000 MappingCacheSize = 1000
MappingCleanupInterval = 5 * time.Minute MappingCleanupInterval = 5 * time.Minute
MappingMaxAge = 1 * time.Hour MappingMaxAge = 1 * time.Hour
OutboundMatchWindow = 60 * time.Second OutboundMatchWindow = 60 * time.Second
PingIdleThreshold = 60 * time.Second
StaleRejoinThreshold = 90 * time.Second
StaleReconnectThreshold = 3 * time.Minute
RejoinCooldown = 30 * time.Second
) )
type Client struct { type Client struct {
@@ -33,9 +37,10 @@ type Client struct {
connected bool connected bool
mu sync.RWMutex mu sync.RWMutex
lastMessage time.Time lastMessage time.Time
stopCh chan struct{} lastJoinAttempt time.Time
wg sync.WaitGroup stopCh chan struct{}
wg sync.WaitGroup
processedMu sync.Mutex processedMu sync.Mutex
processedMessageIDs []int processedMessageIDs []int
@@ -50,9 +55,9 @@ type Client struct {
recentOutboundIter func() []map[string]interface{} recentOutboundIter func() []map[string]interface{}
mapDiscordSneed func(int, int, string) mapDiscordSneed func(int, int, string)
bridgeUserID int bridgeUserID int
bridgeUsername string bridgeUsername string
baseLoopsStarted bool baseLoopsStarted bool
} }
func NewClient(roomID int, cookieSvc *cookie.CookieRefreshService) *Client { func NewClient(roomID int, cookieSvc *cookie.CookieRefreshService) *Client {
@@ -107,7 +112,7 @@ func (c *Client) Connect() error {
c.wg.Add(1) c.wg.Add(1)
go c.readLoop() go c.readLoop()
c.Send(fmt.Sprintf("/join %d", c.roomID)) c.joinRoom()
log.Printf("✅ Successfully connected to Sneedchat room %d", c.roomID) log.Printf("✅ Successfully connected to Sneedchat room %d", c.roomID)
if c.OnConnect != nil { if c.OnConnect != nil {
c.OnConnect() c.OnConnect()
@@ -115,8 +120,14 @@ func (c *Client) Connect() error {
return nil return nil
} }
func (c *Client) joinRoom() { func (c *Client) joinRoom() bool {
c.Send(fmt.Sprintf("/join %d", c.roomID)) sent := c.Send(fmt.Sprintf("/join %d", c.roomID))
if sent {
c.mu.Lock()
c.lastJoinAttempt = time.Now()
c.mu.Unlock()
}
return sent
} }
func (c *Client) readLoop() { func (c *Client) readLoop() {
@@ -148,7 +159,7 @@ func (c *Client) readLoop() {
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(15 * time.Second)
defer t.Stop() defer t.Stop()
for { for {
select { select {
@@ -157,15 +168,46 @@ func (c *Client) heartbeatLoop() {
connected := c.connected connected := c.connected
conn := c.conn conn := c.conn
c.mu.RUnlock() c.mu.RUnlock()
if connected && time.Since(c.lastMessage) > 60*time.Second && conn != nil { if !connected || conn == nil {
continue
}
silence := time.Since(c.lastMessage)
if silence > PingIdleThreshold {
_ = conn.WriteMessage(websocket.TextMessage, []byte("/ping")) _ = conn.WriteMessage(websocket.TextMessage, []byte("/ping"))
} }
c.handleStaleState(silence)
case <-c.stopCh: case <-c.stopCh:
return return
} }
} }
} }
func (c *Client) handleStaleState(silence time.Duration) {
if silence < StaleRejoinThreshold {
return
}
if silence >= StaleReconnectThreshold {
log.Printf("⚠️ No Sneedchat messages for %s; recycling websocket", silence.Round(time.Second))
c.handleDisconnect()
return
}
c.mu.RLock()
lastJoin := c.lastJoinAttempt
c.mu.RUnlock()
if time.Since(lastJoin) < RejoinCooldown {
return
}
if c.joinRoom() {
log.Printf("⚠️ Sneedchat feed silent for %s, reasserted /join %d", silence.Round(time.Second), c.roomID)
} else {
log.Printf("⚠️ Sneedchat feed silent for %s but websocket not writable; waiting", silence.Round(time.Second))
}
}
func (c *Client) cleanupLoop() { func (c *Client) cleanupLoop() {
defer c.wg.Done() defer c.wg.Done()
t := time.NewTicker(MappingCleanupInterval) t := time.NewTicker(MappingCleanupInterval)
@@ -199,6 +241,13 @@ func (c *Client) Send(s string) bool {
} }
func (c *Client) handleDisconnect() { func (c *Client) handleDisconnect() {
c.mu.RLock()
alreadyDisconnected := !c.connected
c.mu.RUnlock()
if alreadyDisconnected {
return
}
select { select {
case <-c.stopCh: case <-c.stopCh:
return return
@@ -231,10 +280,10 @@ func (c *Client) handleDisconnect() {
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(); err != nil {
log.Printf("⚠️ Reconnect attempt #%d failed: %v", attempt, err) log.Printf("⚠️ Reconnect attempt #%d failed: %v", attempt, err)
// Exponential backoff // Exponential backoff
delay *= 2 delay *= 2
if delay > maxDelay { if delay > maxDelay {
@@ -398,14 +447,14 @@ func (c *Client) isProcessed(id int) bool {
func (c *Client) addToProcessed(id int) { func (c *Client) addToProcessed(id int) {
c.processedMu.Lock() c.processedMu.Lock()
defer c.processedMu.Unlock() defer c.processedMu.Unlock()
c.processedMessageIDs = append(c.processedMessageIDs, id) c.processedMessageIDs = append(c.processedMessageIDs, id)
// Hard cap: keep only the most recent 1000 messages (FIFO) // Hard cap: keep only the most recent 1000 messages (FIFO)
if len(c.processedMessageIDs) > ProcessedCacheSize { if len(c.processedMessageIDs) > ProcessedCacheSize {
excess := len(c.processedMessageIDs) - ProcessedCacheSize excess := len(c.processedMessageIDs) - ProcessedCacheSize
c.processedMessageIDs = c.processedMessageIDs[excess:] c.processedMessageIDs = c.processedMessageIDs[excess:]
// Log when significant eviction happens // Log when significant eviction happens
if excess > 50 { if excess > 50 {
log.Printf("⚠️ Processed message cache full, evicted %d old entries", excess) log.Printf("⚠️ Processed message cache full, evicted %d old entries", excess)
@@ -438,4 +487,4 @@ func ReplaceBridgeMention(content, bridgeUsername, pingID string) string {
} }
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))
} }