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
# Interval between reconnect attempts if connection is lost
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,
SNEEDCHAT_ROOM_ID=1
# 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)
- ✅ Edit and delete synchronization
- ✅ Attachment uploads and BBcode formating via Litterbox
- ✅ Pluggable media upload services (Litterbox by default)
- ✅ BBCode → Markdown parsing
- ✅ Message queueing during outages
@@ -19,7 +20,7 @@ A high-performance bridge written in Go that synchronizes messages between Kiwi
## Requirements
- **Go 1.19 or higher**
- **Go 1.23 or higher**
- **Discord Bot Token** with proper permissions
- **Discord Webhook URL**
- **Kiwi Farms account** with Sneedchat access
@@ -34,7 +35,7 @@ sudo apt update
sudo apt install golang git
# Verify installation
go version # Should show 1.19 or higher
go version # Should show 1.23 or higher
```
### 2. Clone and Build
@@ -135,6 +136,7 @@ Create separate systemd services with unique names
**Important Notes:**
- Replace `BRIDGE_USERNAME` with your **Kiwi Farms username** (not email)
- `SNEEDCHAT_ROOM_ID=1` is the default Sneedchat room
- `MEDIA_UPLOAD_SERVICE` selects the attachment backend (currently only `litterbox`)
- Keep quotes out of values
- Don't share your `.env` file!
@@ -162,6 +164,9 @@ BRIDGE_USER_ID=12345
# Your Discord user ID (right-click yourself → Copy User ID)
DISCORD_PING_USER_ID=1234567890123456789
# Media upload backend (defaults to litterbox if unset)
MEDIA_UPLOAD_SERVICE=litterbox
# Optional: Enable file logging
ENABLE_FILE_LOGGING=false
```

View File

@@ -15,6 +15,7 @@ type Config struct {
DiscordGuildID string
DiscordWebhookURL string
SneedchatRoomID int
MediaUploadService string
BridgeUsername string
BridgePassword string
BridgeUserID int
@@ -31,10 +32,14 @@ func Load(envFile string) (*Config, error) {
DiscordChannelID: os.Getenv("DISCORD_CHANNEL_ID"),
DiscordGuildID: os.Getenv("DISCORD_GUILD_ID"),
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
MediaUploadService: os.Getenv("MEDIA_UPLOAD_SERVICE"),
BridgeUsername: os.Getenv("BRIDGE_USERNAME"),
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"))
if err != nil {
return nil, fmt.Errorf("invalid SNEEDCHAT_ROOM_ID: %w", err)

View File

@@ -1,27 +1,26 @@
package discord
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"strconv"
"strings"
"sync"
"time"
"unicode"
"github.com/bwmarrin/discordgo"
"local/sneedchatbridge/config"
"local/sneedchatbridge/media"
"local/sneedchatbridge/sneed"
"local/sneedchatbridge/utils"
)
const (
MaxAttachments = 4
LitterboxTTL = "72h"
ProcessedCacheSize = 250
MappingCacheSize = 1000
MappingMaxAge = 1 * time.Hour
@@ -29,6 +28,14 @@ const (
QueuedMessageTTL = 90 * time.Second
OutageUpdateInterval = 10 * 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 {
@@ -49,7 +56,7 @@ type Bridge struct {
cfg *config.Config
session *discordgo.Session
sneed *sneed.Client
httpClient *http.Client
mediaSvc media.Service
sneedToDiscord *utils.BoundedMap
discordToSneed *utils.BoundedMap
@@ -61,7 +68,8 @@ type Bridge struct {
queuedOutbound []QueuedMessage
queuedOutboundMu sync.Mutex
outageMessages []*discordgo.Message
outageNotices []*discordgo.Message
outageActiveMessage *discordgo.Message
outageMessagesMu sync.Mutex
outageStart time.Time
@@ -74,17 +82,21 @@ func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
if err != nil {
return nil, err
}
mediaSvc, err := media.NewService(cfg.MediaUploadService, &http.Client{Timeout: 60 * time.Second})
if err != nil {
return nil, err
}
b := &Bridge{
cfg: cfg,
session: s,
sneed: sneedClient,
httpClient: &http.Client{Timeout: 60 * time.Second},
mediaSvc: mediaSvc,
sneedToDiscord: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
discordToSneed: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
sneedUsernames: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
recentOutbound: make([]OutboundEntry, 0, ProcessedCacheSize),
queuedOutbound: make([]QueuedMessage, 0),
outageMessages: make([]*discordgo.Message, 0),
outageNotices: make([]*discordgo.Message, 0),
stopCh: make(chan struct{}),
}
@@ -182,20 +194,50 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
}
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 {
return
}
for _, att := range m.Attachments {
url, err := b.uploadToLitterbox(att.URL, att.Filename)
ctx := context.Background()
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 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
}
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)
if strings.HasPrefix(ct, "video") || strings.HasSuffix(strings.ToLower(att.Filename), ".mp4") ||
strings.HasSuffix(strings.ToLower(att.Filename), ".webm") {
attachmentsBB = append(attachmentsBB, fmt.Sprintf("[url=%s][video]%s[/video][/url]", url, url))
// Images → wrap inside clickable URL with an image preview
if strings.HasPrefix(ct, "image/") {
attachmentsBB = append(attachmentsBB,
fmt.Sprintf("[url=%s][img]%s[/img][/url]", url, url))
} 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
@@ -206,10 +248,11 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
combined += strings.Join(attachmentsBB, "\n")
}
discordMsgID := parseMessageID(m.ID)
if b.sneed.Send(combined) {
b.recentOutboundMu.Lock()
b.recentOutbound = append(b.recentOutbound, OutboundEntry{
DiscordID: parseMessageID(m.ID),
DiscordID: discordMsgID,
Content: combined,
Timestamp: time.Now(),
Mapped: false,
@@ -218,15 +261,23 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
b.recentOutbound = b.recentOutbound[1:]
}
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 {
b.queuedOutboundMu.Lock()
b.queuedOutbound = append(b.queuedOutbound, QueuedMessage{
Content: combined,
ChannelID: m.ChannelID,
Timestamp: time.Now(),
DiscordID: parseMessageID(m.ID),
DiscordID: discordMsgID,
})
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() {
log.Println("🟢 Sneedchat connected")
b.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "online"})
queued := b.queuedMessageCount()
b.finishOutageNotification(queued)
go b.flushQueuedMessages()
}
func (b *Bridge) onSneedDisconnect() {
log.Println("🔴 Sneedchat disconnected")
b.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "idle"})
b.startOutageNotificationLoop()
}
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{} {
b.recentOutboundMu.Lock()
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)
}
func (b *Bridge) uploadToLitterbox(fileURL, filename string) (string, error) {
resp, err := b.httpClient.Get(fileURL)
if err != nil {
return "", err
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)},
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
return b.session.ChannelMessageSendComplex(channelID, msg)
}
func (b *Bridge) uploadStatusTitle(suffix string) string {
base := fmt.Sprintf("%s upload", b.mediaServiceDisplayName())
if suffix == "" {
return base
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
return fmt.Sprintf("%s %s", base, suffix)
}
func (b *Bridge) mediaServiceDisplayName() string {
if b.mediaSvc == nil {
return "Media"
}
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
name := capitalizeWord(b.mediaSvc.Name())
if name == "" {
return "Media"
}
defer uResp.Body.Close()
if uResp.StatusCode != 200 {
return "", fmt.Errorf("Litterbox returned HTTP %d", uResp.StatusCode)
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},
}
out, _ := io.ReadAll(uResp.Body)
url := strings.TrimSpace(string(out))
log.Printf("SUCCESS: Uploaded '%s' to Litterbox: %s", filename, url)
return url, nil
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) {
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) {
@@ -457,3 +719,15 @@ func parseMessageID(id string) int {
parsed, _ := strconv.ParseInt(id, 10, 64)
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
go 1.19
go 1.23
require (
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

@@ -22,6 +22,10 @@ const (
MappingCleanupInterval = 5 * time.Minute
MappingMaxAge = 1 * time.Hour
OutboundMatchWindow = 60 * time.Second
PingIdleThreshold = 60 * time.Second
StaleRejoinThreshold = 90 * time.Second
StaleReconnectThreshold = 3 * time.Minute
RejoinCooldown = 30 * time.Second
)
type Client struct {
@@ -34,6 +38,7 @@ type Client struct {
mu sync.RWMutex
lastMessage time.Time
lastJoinAttempt time.Time
stopCh chan struct{}
wg sync.WaitGroup
@@ -107,7 +112,7 @@ func (c *Client) Connect() error {
c.wg.Add(1)
go c.readLoop()
c.Send(fmt.Sprintf("/join %d", c.roomID))
c.joinRoom()
log.Printf("✅ Successfully connected to Sneedchat room %d", c.roomID)
if c.OnConnect != nil {
c.OnConnect()
@@ -115,8 +120,14 @@ func (c *Client) Connect() error {
return nil
}
func (c *Client) joinRoom() {
c.Send(fmt.Sprintf("/join %d", c.roomID))
func (c *Client) joinRoom() bool {
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() {
@@ -148,7 +159,7 @@ func (c *Client) readLoop() {
func (c *Client) heartbeatLoop() {
defer c.wg.Done()
t := time.NewTicker(30 * time.Second)
t := time.NewTicker(15 * time.Second)
defer t.Stop()
for {
select {
@@ -157,15 +168,46 @@ func (c *Client) heartbeatLoop() {
connected := c.connected
conn := c.conn
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"))
}
c.handleStaleState(silence)
case <-c.stopCh:
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() {
defer c.wg.Done()
t := time.NewTicker(MappingCleanupInterval)
@@ -199,6 +241,13 @@ func (c *Client) Send(s string) bool {
}
func (c *Client) handleDisconnect() {
c.mu.RLock()
alreadyDisconnected := !c.connected
c.mu.RUnlock()
if alreadyDisconnected {
return
}
select {
case <-c.stopCh:
return