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.
This commit is contained in:
Salastil
2025-11-18 17:33:42 -05:00
parent 93e875ffb7
commit f954771c0c
6 changed files with 316 additions and 81 deletions

View File

@@ -1,36 +1,41 @@
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
MappingCleanupInterval = 5 * time.Minute
QueuedMessageTTL = 90 * time.Second
OutageUpdateInterval = 10 * time.Second
OutboundMatchWindow = 60 * time.Second
OutageEmbedColorActive = 0xF1C40F
OutageEmbedColorFixed = 0x2ECC71
MaxAttachments = 4
ProcessedCacheSize = 250
MappingCacheSize = 1000
MappingMaxAge = 1 * time.Hour
MappingCleanupInterval = 5 * time.Minute
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 {
@@ -48,10 +53,10 @@ type QueuedMessage struct {
}
type Bridge struct {
cfg *config.Config
session *discordgo.Session
sneed *sneed.Client
httpClient *http.Client
cfg *config.Config
session *discordgo.Session
sneed *sneed.Client
mediaSvc media.Service
sneedToDiscord *utils.BoundedMap
discordToSneed *utils.BoundedMap
@@ -77,11 +82,15 @@ 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),
@@ -185,14 +194,40 @@ 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)
@@ -213,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,
@@ -225,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)
}
}
}
@@ -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)
}
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) {
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
embed := buildOutageEmbed(title, description, color)
@@ -568,42 +707,6 @@ func (b *Bridge) deleteWebhookMessage(messageID string) error {
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) {
parts := strings.Split(webhookURL, "/")
if len(parts) < 2 {