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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -31,6 +30,12 @@ const (
|
||||
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 {
|
||||
@@ -51,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
|
||||
@@ -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 {
|
||||
|
||||
84
media/litterbox.go
Normal file
84
media/litterbox.go
Normal 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
36
media/service.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user