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.
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user