5 Commits
v1.0 ... 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
Salastil
93e875ffb7 Ported outage notice functionality from Python version
All checks were successful
Build & Release / build-latest (push) Has been skipped
Build & Release / version-release (push) Successful in 10m28s
2025-11-18 11:31:11 -05:00
Salastil
4b2e7c0784 Version bump to Go 1.23
All checks were successful
Build & Release / build-latest (push) Successful in 9m51s
Build & Release / version-release (push) Has been skipped
2025-11-16 19:15:13 -05:00
Salastil
c602492b8c Only images now wrap in Bbcode to embed when uploaded to Litterbox and sent to Sneedchat. Images are also clickable urls via the double wrapping of [url][img]
All checks were successful
Build & Release / build-latest (push) Has been skipped
Build & Release / version-release (push) Successful in 10m10s
2025-11-16 18:33:41 -05:00
8 changed files with 570 additions and 115 deletions

View File

@@ -7,6 +7,8 @@ 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)

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
@@ -19,7 +20,7 @@ A high-performance bridge written in Go that synchronizes messages between Kiwi
## Requirements ## Requirements
- **Go 1.19 or higher** - **Go 1.23 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.19 or higher go version # Should show 1.23 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
``` ```

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,34 +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
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 {
@@ -46,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
@@ -61,9 +68,10 @@ type Bridge struct {
queuedOutbound []QueuedMessage queuedOutbound []QueuedMessage
queuedOutboundMu sync.Mutex queuedOutboundMu sync.Mutex
outageMessages []*discordgo.Message outageNotices []*discordgo.Message
outageMessagesMu sync.Mutex outageActiveMessage *discordgo.Message
outageStart time.Time outageMessagesMu sync.Mutex
outageStart time.Time
stopCh chan struct{} stopCh chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
@@ -74,18 +82,22 @@ 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),
recentOutbound: make([]OutboundEntry, 0, ProcessedCacheSize), recentOutbound: make([]OutboundEntry, 0, ProcessedCacheSize),
queuedOutbound: make([]QueuedMessage, 0), queuedOutbound: make([]QueuedMessage, 0),
outageMessages: make([]*discordgo.Message, 0), outageNotices: make([]*discordgo.Message, 0),
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
} }
// hook Sneed client callbacks // hook Sneed client callbacks
@@ -182,20 +194,50 @@ 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)
if strings.HasPrefix(ct, "video") || strings.HasSuffix(strings.ToLower(att.Filename), ".mp4") ||
strings.HasSuffix(strings.ToLower(att.Filename), ".webm") { // Images → wrap inside clickable URL with an image preview
attachmentsBB = append(attachmentsBB, fmt.Sprintf("[url=%s][video]%s[/video][/url]", url, url)) if strings.HasPrefix(ct, "image/") {
attachmentsBB = append(attachmentsBB,
fmt.Sprintf("[url=%s][img]%s[/img][/url]", url, url))
} else { } else {
attachmentsBB = append(attachmentsBB, fmt.Sprintf("[url=%s][img]%s[/img][/url]", url, url)) // Everything else → plain URL (videos, archives, PDFs, audio, etc)
attachmentsBB = append(attachmentsBB, url)
} }
} }
combined := contentText combined := contentText
@@ -206,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,
@@ -218,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)
}
} }
} }
@@ -344,12 +395,15 @@ func (b *Bridge) handleSneedDelete(sneedID int) {
func (b *Bridge) onSneedConnect() { func (b *Bridge) onSneedConnect() {
log.Println("🟢 Sneedchat connected") log.Println("🟢 Sneedchat connected")
b.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "online"}) b.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "online"})
queued := b.queuedMessageCount()
b.finishOutageNotification(queued)
go b.flushQueuedMessages() go b.flushQueuedMessages()
} }
func (b *Bridge) onSneedDisconnect() { func (b *Bridge) onSneedDisconnect() {
log.Println("🔴 Sneedchat disconnected") log.Println("🔴 Sneedchat disconnected")
b.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "idle"}) b.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "idle"})
b.startOutageNotificationLoop()
} }
func (b *Bridge) flushQueuedMessages() { func (b *Bridge) flushQueuedMessages() {
@@ -387,6 +441,125 @@ func (b *Bridge) flushQueuedMessages() {
} }
} }
func (b *Bridge) queuedMessageCount() int {
b.queuedOutboundMu.Lock()
defer b.queuedOutboundMu.Unlock()
return len(b.queuedOutbound)
}
func (b *Bridge) startOutageNotificationLoop() {
b.outageMessagesMu.Lock()
alreadyRunning := !b.outageStart.IsZero()
if !alreadyRunning {
b.outageStart = time.Now()
go b.outageNotificationLoop()
}
b.outageMessagesMu.Unlock()
}
func (b *Bridge) outageNotificationLoop() {
if !b.updateOutageMessage() {
return
}
ticker := time.NewTicker(OutageUpdateInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if !b.updateOutageMessage() {
return
}
case <-b.stopCh:
return
}
}
}
func (b *Bridge) updateOutageMessage() bool {
b.outageMessagesMu.Lock()
start := b.outageStart
existing := b.outageActiveMessage
b.outageMessagesMu.Unlock()
if start.IsZero() {
return false
}
duration := formatDuration(time.Since(start))
queued := b.queuedMessageCount()
desc := fmt.Sprintf("Connection lost **%s** ago.\n%d queued Discord message(s) will be sent automatically once Sneedchat returns.", duration, queued)
if existing == nil {
msg, err := b.sendOutageEmbed("Sneedchat outage", desc, OutageEmbedColorActive)
if err != nil {
log.Printf("❌ Failed to send outage notification: %v", err)
return true
}
b.outageMessagesMu.Lock()
b.outageActiveMessage = msg
toDelete := b.swapOutageNoticesLocked(msg)
b.outageMessagesMu.Unlock()
b.deleteOutageMessages(toDelete)
} else {
if err := b.editOutageEmbed(existing.ID, "Sneedchat outage", desc, OutageEmbedColorActive); err != nil {
log.Printf("❌ Failed to update outage notification: %v", err)
}
}
return true
}
func (b *Bridge) finishOutageNotification(queued int) {
b.outageMessagesMu.Lock()
start := b.outageStart
b.outageStart = time.Time{}
existing := b.outageActiveMessage
b.outageActiveMessage = nil
b.outageMessagesMu.Unlock()
if start.IsZero() {
return
}
duration := formatDuration(time.Since(start))
desc := fmt.Sprintf("Connection restored after **%s**.\n%d queued Discord message(s) are now being delivered.", duration, queued)
if existing != nil {
if err := b.editOutageEmbed(existing.ID, "Sneedchat outage resolved", desc, OutageEmbedColorFixed); err != nil {
log.Printf("❌ Failed to update outage resolution message: %v", err)
}
return
}
msg, err := b.sendOutageEmbed("Sneedchat outage resolved", desc, OutageEmbedColorFixed)
if err != nil {
log.Printf("❌ Failed to send outage resolution message: %v", err)
return
}
b.outageMessagesMu.Lock()
toDelete := b.swapOutageNoticesLocked(msg)
b.outageMessagesMu.Unlock()
b.deleteOutageMessages(toDelete)
}
func (b *Bridge) deleteOutageMessages(messages []*discordgo.Message) {
for _, msg := range messages {
if msg == nil {
continue
}
if err := b.deleteWebhookMessage(msg.ID); err != nil {
log.Printf("❌ Failed to prune outage notice %s: %v", msg.ID, err)
}
}
}
func (b *Bridge) swapOutageNoticesLocked(newMsg *discordgo.Message) []*discordgo.Message {
toDelete := make([]*discordgo.Message, len(b.outageNotices))
copy(toDelete, b.outageNotices)
b.outageNotices = []*discordgo.Message{newMsg}
return toDelete
}
func (b *Bridge) recentOutboundIter() []map[string]interface{} { func (b *Bridge) recentOutboundIter() []map[string]interface{} {
b.recentOutboundMu.Lock() b.recentOutboundMu.Lock()
defer b.recentOutboundMu.Unlock() defer b.recentOutboundMu.Unlock()
@@ -409,40 +582,129 @@ 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) uploadToLitterbox(fileURL, filename string) (string, error) { func (b *Bridge) sendUploadStatusMessage(channelID, mention string, attachmentCount int) (*discordgo.Message, error) {
resp, err := b.httpClient.Get(fileURL) desc := fmt.Sprintf("Uploading %d attachment(s) from %s…", attachmentCount, mention)
if err != nil { msg := &discordgo.MessageSend{
return "", err Embeds: []*discordgo.MessageEmbed{buildStatusEmbed(b.uploadStatusTitle(""), desc, UploadStatusColorPending)},
} }
defer resp.Body.Close() return b.session.ChannelMessageSendComplex(channelID, msg)
if resp.StatusCode != 200 { }
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
func (b *Bridge) uploadStatusTitle(suffix string) string {
base := fmt.Sprintf("%s upload", b.mediaServiceDisplayName())
if suffix == "" {
return base
} }
data, err := io.ReadAll(resp.Body) return fmt.Sprintf("%s %s", base, suffix)
if err != nil { }
return "", err
func (b *Bridge) mediaServiceDisplayName() string {
if b.mediaSvc == nil {
return "Media"
} }
body := &bytes.Buffer{} name := capitalizeWord(b.mediaSvc.Name())
w := multipart.NewWriter(body) if name == "" {
_ = w.WriteField("reqtype", "fileupload") return "Media"
_ = 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() return name
if uResp.StatusCode != 200 { }
return "", fmt.Errorf("Litterbox returned HTTP %d", uResp.StatusCode)
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},
} }
out, _ := io.ReadAll(uResp.Body) if _, err := b.session.ChannelMessageEditComplex(edit); err != nil {
url := strings.TrimSpace(string(out)) log.Printf("⚠️ Failed to edit upload status message %s: %v", messageID, err)
log.Printf("SUCCESS: Uploaded '%s' to Litterbox: %s", filename, url) }
return url, nil }
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) {
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
embed := buildOutageEmbed(title, description, color)
params := &discordgo.WebhookParams{Embeds: []*discordgo.MessageEmbed{embed}}
return b.session.WebhookExecute(webhookID, webhookToken, true, params)
}
func (b *Bridge) editOutageEmbed(messageID, title, description string, color int) error {
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
embed := buildOutageEmbed(title, description, color)
embeds := []*discordgo.MessageEmbed{embed}
edit := &discordgo.WebhookEdit{Embeds: &embeds}
_, err := b.session.WebhookMessageEdit(webhookID, webhookToken, messageID, edit)
return err
}
func buildOutageEmbed(title, description string, color int) *discordgo.MessageEmbed {
return &discordgo.MessageEmbed{
Title: title,
Description: description,
Color: color,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
func (b *Bridge) deleteWebhookMessage(messageID string) error {
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
return b.session.WebhookMessageDelete(webhookID, webhookToken, messageID)
} }
func parseWebhookURL(webhookURL string) (string, string) { func parseWebhookURL(webhookURL string) (string, string) {
@@ -457,3 +719,15 @@ func parseMessageID(id string) int {
parsed, _ := strconv.ParseInt(id, 10, 64) parsed, _ := strconv.ParseInt(id, 10, 64)
return int(parsed) return int(parsed)
} }
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60)
}
hours := int(d.Hours())
minutes := int(d.Minutes()) % 60
return fmt.Sprintf("%dh%dm", hours, minutes)
}

2
go.mod
View File

@@ -1,6 +1,6 @@
module local/sneedchatbridge module local/sneedchatbridge
go 1.19 go 1.23
require ( require (
github.com/bwmarrin/discordgo v0.27.1 github.com/bwmarrin/discordgo v0.27.1

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