2 Commits

Author SHA1 Message Date
Salastil
adba77b3e5 Overflow mode when server exceeds Discord's ratelimiting. Will compact many messages into one embed with timestamps
All checks were successful
Build & Release / build-latest (push) Successful in 10m16s
Build & Release / version-release (push) Has been skipped
2026-03-21 21:05:30 -04:00
Salastil
a749e33737 Docker 2026-02-28 22:32:55 -05:00
4 changed files with 134 additions and 4 deletions

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM golang:1.25.6-alpine AS builder
RUN apk add --no-cache git
WORKDIR /build
RUN git clone https://github.com/Salastil/Sneedchat-Discord-Bridge-Go.git .
RUN go mod tidy && go build -o Sneedchat-Discord-Bridge .
FROM alpine:latest
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /build/Sneedchat-Discord-Bridge .
ENTRYPOINT ["./Sneedchat-Discord-Bridge"]

View File

@@ -22,6 +22,9 @@ import (
const ( const (
MaxAttachments = 4 MaxAttachments = 4
ProcessedCacheSize = 250 ProcessedCacheSize = 250
OverflowThreshold = 10
OverflowRecoveryThreshold = 5
OverflowBatchSize = 15
MappingCacheSize = 1000 MappingCacheSize = 1000
MappingMaxAge = 1 * time.Hour MappingMaxAge = 1 * time.Hour
MappingCleanupInterval = 5 * time.Minute MappingCleanupInterval = 5 * time.Minute
@@ -73,8 +76,10 @@ type Bridge struct {
outageMessagesMu sync.Mutex outageMessagesMu sync.Mutex
outageStart time.Time outageStart time.Time
stopCh chan struct{} stopCh chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
msgQueue chan map[string]interface{}
overflow bool
} }
func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) { func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
@@ -98,10 +103,11 @@ func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
queuedOutbound: make([]QueuedMessage, 0), queuedOutbound: make([]QueuedMessage, 0),
outageNotices: make([]*discordgo.Message, 0), outageNotices: make([]*discordgo.Message, 0),
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
msgQueue: make(chan map[string]interface{}, 500),
} }
// hook Sneed client callbacks // hook Sneed client callbacks
sneedClient.OnMessage = b.onSneedMessage sneedClient.OnMessage = b.enqueueSneedMessage
sneedClient.OnEdit = b.handleSneedEdit sneedClient.OnEdit = b.handleSneedEdit
sneedClient.OnDelete = b.handleSneedDelete sneedClient.OnDelete = b.handleSneedDelete
sneedClient.OnConnect = b.onSneedConnect sneedClient.OnConnect = b.onSneedConnect
@@ -124,8 +130,10 @@ func (b *Bridge) Start() error {
if err := b.session.Open(); err != nil { if err := b.session.Open(); err != nil {
return err return err
} }
b.wg.Add(1) b.wg.Add(3)
go b.cleanupLoop() go b.cleanupLoop()
go b.messageWorker()
go b.messageWorker()
return nil return nil
} }
@@ -315,6 +323,91 @@ func (b *Bridge) onDiscordMessageDelete(s *discordgo.Session, m *discordgo.Messa
b.sneed.Send(fmt.Sprintf("/delete %s", sneedUUID)) b.sneed.Send(fmt.Sprintf("/delete %s", sneedUUID))
} }
func (b *Bridge) messageWorker() {
defer b.wg.Done()
for {
select {
case <-b.stopCh:
return
case msg, ok := <-b.msgQueue:
if !ok {
return
}
depth := len(b.msgQueue)
if !b.overflow && depth >= OverflowThreshold {
b.overflow = true
log.Printf("⚠️ Message queue overflow (%d pending), switching to batch mode", depth)
} else if b.overflow && depth <= OverflowRecoveryThreshold {
b.overflow = false
log.Printf("✅ Message queue recovered (%d pending), switching to normal mode", depth)
}
if b.overflow {
batch := []map[string]interface{}{msg}
for len(batch) < OverflowBatchSize {
select {
case next, ok := <-b.msgQueue:
if !ok {
break
}
batch = append(batch, next)
default:
goto flushBatch
}
}
flushBatch:
b.sendOverflowBatch(batch)
} else {
b.onSneedMessage(msg)
}
}
}
}
func (b *Bridge) sendOverflowBatch(batch []map[string]interface{}) {
if len(batch) == 0 {
return
}
var description strings.Builder
for _, msg := range batch {
username, _ := msg["username"].(string)
rawContent, _ := msg["content"].(string)
content := utils.BBCodeToMarkdown(rawContent)
content = sneed.ReplaceBridgeMention(content, b.cfg.BridgeUsername, b.cfg.DiscordPingUserID)
var ts string
if raw, ok := msg["raw"].(sneed.SneedMessage); ok && raw.MessageDate > 0 {
ts = time.Unix(int64(raw.MessageDate), 0).Format("3:04:05 PM")
}
description.WriteString(fmt.Sprintf("**%s** %s\n%s\n\n", username, ts, content))
}
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
embed := &discordgo.MessageEmbed{
Title: "overflow mode",
Description: strings.TrimSpace(description.String()),
Color: OutageEmbedColorActive,
}
params := &discordgo.WebhookParams{
Embeds: []*discordgo.MessageEmbed{embed},
}
_, err := b.session.WebhookExecute(webhookID, webhookToken, false, params)
if err != nil {
log.Printf("❌ Failed to send overflow batch: %v", err)
return
}
log.Printf("📦 Sent overflow batch of %d messages", len(batch))
}
func (b *Bridge) enqueueSneedMessage(msg map[string]interface{}) {
select {
case b.msgQueue <- msg:
default:
log.Printf("⚠️ Message queue full, dropping message from %s", msg["username"])
}
}
func (b *Bridge) onSneedMessage(msg map[string]interface{}) { func (b *Bridge) onSneedMessage(msg map[string]interface{}) {
username, _ := msg["username"].(string) username, _ := msg["username"].(string)
rawContent, _ := msg["content"].(string) rawContent, _ := msg["content"].(string)

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
sneedchat-bridge:
build: .
restart: unless-stopped
environment:
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ID}
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
- DISCORD_PING_USER_ID=${DISCORD_PING_USER_ID}
- DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL}
- RECONNECT_INTERVAL=${RECONNECT_INTERVAL:-5}
- MEDIA_UPLOAD_SERVICE=${MEDIA_UPLOAD_SERVICE:-litterbox}
- SNEEDCHAT_ROOM_ID=${SNEEDCHAT_ROOM_ID:-1}
- ENABLE_FILE_LOGGING=${ENABLE_FILE_LOGGING:-false}
- BRIDGE_USER_ID=${BRIDGE_USER_ID}
- BRIDGE_USERNAME=${BRIDGE_USERNAME}
- BRIDGE_PASSWORD=${BRIDGE_PASSWORD}

View File

@@ -5,6 +5,7 @@ type SneedMessage struct {
MessageUUID string `json:"message_uuid"` MessageUUID string `json:"message_uuid"`
Message string `json:"message"` Message string `json:"message"`
MessageRaw string `json:"message_raw"` MessageRaw string `json:"message_raw"`
MessageDate int `json:"message_date"`
MessageEditDate int `json:"message_edit_date"` MessageEditDate int `json:"message_edit_date"`
Author map[string]interface{} `json:"author"` Author map[string]interface{} `json:"author"`
Deleted bool `json:"deleted"` Deleted bool `json:"deleted"`