diff --git a/.env.example b/.env.example index 8830c30..650fc41 100644 --- a/.env.example +++ b/.env.example @@ -6,14 +6,16 @@ DISCORD_GUILD_ID=your_discord_guild_id_here 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 +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) -ENABLE_FILE_LOGGING=false +# Enable logging to bridge.log file for debugging purposes(true/false, default: false) +ENABLE_FILE_LOGGING=false #Use your Kiwifarm crendeitals for here #This USER_ID number is in the url when you go to your profile, its required to prevent Discord from echoing your own messages back to you and to allow pings/push notifications work on Discord # BRIDGE_USER_ID=123456 -# BRIDGE_USERNAME=YourBridgeBot -# BRIDGE_PASSWORD=Password \ No newline at end of file +# BRIDGE_USERNAME=YourBridgeBot +# BRIDGE_PASSWORD=Password diff --git a/README.md b/README.md index 246f358..4cdd192 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/config/config.go b/config/config.go index e5b6d1d..d29f151 100644 --- a/config/config.go +++ b/config/config.go @@ -10,16 +10,17 @@ import ( ) type Config struct { - DiscordBotToken string - DiscordChannelID string - DiscordGuildID string - DiscordWebhookURL string - SneedchatRoomID int - BridgeUsername string - BridgePassword string - BridgeUserID int - DiscordPingUserID string - Debug bool + DiscordBotToken string + DiscordChannelID string + DiscordGuildID string + DiscordWebhookURL string + SneedchatRoomID int + MediaUploadService string + BridgeUsername string + BridgePassword string + BridgeUserID int + DiscordPingUserID string + Debug bool } 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) } cfg := &Config{ - DiscordBotToken: os.Getenv("DISCORD_BOT_TOKEN"), - DiscordChannelID: os.Getenv("DISCORD_CHANNEL_ID"), - DiscordGuildID: os.Getenv("DISCORD_GUILD_ID"), - DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"), - BridgeUsername: os.Getenv("BRIDGE_USERNAME"), - BridgePassword: os.Getenv("BRIDGE_PASSWORD"), - DiscordPingUserID: os.Getenv("DISCORD_PING_USER_ID"), + DiscordBotToken: os.Getenv("DISCORD_BOT_TOKEN"), + 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 { diff --git a/discord/bridge.go b/discord/bridge.go index b156e92..55f96a6 100644 --- a/discord/bridge.go +++ b/discord/bridge.go @@ -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 { diff --git a/media/litterbox.go b/media/litterbox.go new file mode 100644 index 0000000..c1afbdb --- /dev/null +++ b/media/litterbox.go @@ -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 +} diff --git a/media/service.go b/media/service.go new file mode 100644 index 0000000..08888d1 --- /dev/null +++ b/media/service.go @@ -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) + } +}