4 Commits
v1.4 ... master

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
Salastil
85de6d175f Restore functionality of edits, deletes and boundedmap uses UUID now
All checks were successful
Build & Release / build-latest (push) Successful in 9m50s
Build & Release / version-release (push) Has been skipped
2026-02-28 21:26:45 -05:00
Salastil
4b455eb58e 4hr cookie refresh + message_uuid changes 2026-02-28 18:37:52 -05:00
9 changed files with 278 additions and 106 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

@@ -20,7 +20,7 @@ A high-performance bridge written in Go that synchronizes messages between Kiwi
## Requirements ## Requirements
- **Go 1.23 or higher** - **Go 1.25.6 or higher**
- **Discord Bot Token** with proper permissions - **Discord Bot Token** with proper permissions
- **Discord Webhook URL** - **Discord Webhook URL**
- **Kiwi Farms account** with Sneedchat access - **Kiwi Farms account** with Sneedchat access
@@ -35,7 +35,7 @@ sudo apt update
sudo apt install golang git sudo apt install golang git
# Verify installation # Verify installation
go version # Should show 1.23 or higher go version # Should show 1.25.6 or higher
``` ```
### 2. Clone and Build ### 2. Clone and Build
@@ -271,10 +271,3 @@ If messages aren't appearing:
## License ## License
This bridge is provided as-is. Use responsibly and in accordance with Kiwi Farms and Discord Terms of Service. This bridge is provided as-is. Use responsibly and in accordance with Kiwi Farms and Discord Terms of Service.
## Credits
Built with:
- [discordgo](https://github.com/bwmarrin/discordgo) - Discord API
- [gorilla/websocket](https://github.com/gorilla/websocket) - WebSocket client
- [godotenv](https://github.com/joho/godotenv) - Environment loading

View File

@@ -13,6 +13,7 @@ import (
"slices" "slices"
"strings" "strings"
"sync" "sync"
"time"
) )
// SessionService manages XenForo session cookies as plain strings. // SessionService manages XenForo session cookies as plain strings.
@@ -25,6 +26,7 @@ type SessionService struct {
username string username string
password string password string
tr *http.Transport // shared transport, TLS config applied once tr *http.Transport // shared transport, TLS config applied once
stopCh chan struct{}
} }
// NewSessionService creates a service, performs initial login, and returns. // NewSessionService creates a service, performs initial login, and returns.
@@ -40,6 +42,7 @@ func NewSessionService(ctx context.Context, host, username, password string) (*S
username: username, username: username,
password: password, password: password,
tr: tr, tr: tr,
stopCh: make(chan struct{}),
} }
log.Println("⏳ Logging in to Kiwi Farms...") log.Println("⏳ Logging in to Kiwi Farms...")
@@ -47,9 +50,45 @@ func NewSessionService(ctx context.Context, host, username, password string) (*S
return nil, fmt.Errorf("initial login: %w", err) return nil, fmt.Errorf("initial login: %w", err)
} }
log.Println("✅ Login successful") log.Println("✅ Login successful")
go s.refreshLoop(ctx)
return s, nil return s, nil
} }
// Close stops the background refresh loop. Call at shutdown after
// sneedClient.Disconnect().
func (s *SessionService) Close() {
close(s.stopCh)
}
// refreshLoop proactively renews all session cookies every 4 hours.
// Prevents xf_session and xf_user from expiring mid-run so reconnect
// attempts always have valid credentials. ttrs_clearance is cleared
// so the next WebSocket dial solves it fresh.
func (s *SessionService) refreshLoop(ctx context.Context) {
ticker := time.NewTicker(4 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
log.Println("🔄 Proactive session refresh (4h timer)...")
s.mu.Lock()
s.deleteCookie("ttrs_clearance")
if err := s.login(ctx); err != nil {
log.Printf("⚠️ Proactive session refresh failed: %v", err)
} else {
log.Println("✅ Proactive session refresh complete")
}
s.mu.Unlock()
case <-s.stopCh:
return
case <-ctx.Done():
return
}
}
}
// tlsConfig mirrors sockchat's socketTLSConfig exactly: // tlsConfig mirrors sockchat's socketTLSConfig exactly:
// concatenate secure + insecure cipher suites so KiwiFlare TLS fingerprinting // concatenate secure + insecure cipher suites so KiwiFlare TLS fingerprinting
// doesn't trigger. "The insecure ones appear to be necessary for consistently // doesn't trigger. "The insecure ones appear to be necessary for consistently

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
} }
@@ -186,8 +194,9 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
if m.ReferencedMessage != nil { if m.ReferencedMessage != nil {
refDiscordID := parseMessageID(m.ReferencedMessage.ID) refDiscordID := parseMessageID(m.ReferencedMessage.ID)
if sneedIDInt, ok := b.discordToSneed.Get(refDiscordID); ok { if sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(refDiscordID)); ok {
if uname, ok2 := b.sneedUsernames.Get(sneedIDInt.(int)); ok2 { sneedUUID := sneedUUIDVal.(string)
if uname, ok2 := b.sneedUsernames.Get(sneedUUID); ok2 {
contentText = fmt.Sprintf("@%s, %s", uname.(string), contentText) contentText = fmt.Sprintf("@%s, %s", uname.(string), contentText)
} }
} }
@@ -289,14 +298,14 @@ func (b *Bridge) onDiscordMessageEdit(s *discordgo.Session, m *discordgo.Message
return return
} }
discordID := parseMessageID(m.ID) discordID := parseMessageID(m.ID)
sneedIDInt, ok := b.discordToSneed.Get(discordID) sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(discordID))
if !ok { if !ok {
return return
} }
sneedID := sneedIDInt.(int) sneedUUID := sneedUUIDVal.(string)
payload := map[string]interface{}{"id": sneedID, "message": strings.TrimSpace(m.Content)} payload := map[string]interface{}{"uuid": sneedUUID, "message": strings.TrimSpace(m.Content)}
data, _ := json.Marshal(payload) data, _ := json.Marshal(payload)
log.Printf("↩️ Discord edit -> Sneedchat (sneed_id=%d)", sneedID) log.Printf("↩️ Discord edit -> Sneedchat (sneed_uuid=%s)", sneedUUID)
b.sneed.Send(fmt.Sprintf("/edit %s", string(data))) b.sneed.Send(fmt.Sprintf("/edit %s", string(data)))
} }
@@ -305,12 +314,98 @@ func (b *Bridge) onDiscordMessageDelete(s *discordgo.Session, m *discordgo.Messa
return return
} }
discordID := parseMessageID(m.ID) discordID := parseMessageID(m.ID)
sneedIDInt, ok := b.discordToSneed.Get(discordID) sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(discordID))
if !ok { if !ok {
return return
} }
log.Printf("↩️ Discord delete -> Sneedchat (sneed_id=%d)", sneedIDInt.(int)) sneedUUID := sneedUUIDVal.(string)
b.sneed.Send(fmt.Sprintf("/delete %d", sneedIDInt.(int))) log.Printf("↩️ Discord delete -> Sneedchat (sneed_uuid=%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{}) {
@@ -348,17 +443,17 @@ func (b *Bridge) onSneedMessage(msg map[string]interface{}) {
log.Printf("✅ Sent Sneedchat → Discord: %s", username) log.Printf("✅ Sent Sneedchat → Discord: %s", username)
if sent != nil { if sent != nil {
if mid, ok := msg["message_id"].(int); ok && mid > 0 { if uuid, ok := msg["message_uuid"].(string); ok && uuid != "" {
discordMsgID := parseMessageID(sent.ID) discordMsgID := parseMessageID(sent.ID)
b.sneedToDiscord.Set(mid, discordMsgID) b.sneedToDiscord.Set(uuid, discordMsgID)
b.discordToSneed.Set(discordMsgID, mid) b.discordToSneed.Set(strconv.Itoa(discordMsgID), uuid)
b.sneedUsernames.Set(mid, username) b.sneedUsernames.Set(uuid, username)
} }
} }
} }
func (b *Bridge) handleSneedEdit(sneedID int, newContent string) { func (b *Bridge) handleSneedEdit(sneedUUID string, newContent string) {
discordIDInt, ok := b.sneedToDiscord.Get(sneedID) discordIDInt, ok := b.sneedToDiscord.Get(sneedUUID)
if !ok { if !ok {
return return
} }
@@ -371,11 +466,11 @@ func (b *Bridge) handleSneedEdit(sneedID int, newContent string) {
log.Printf("❌ Failed to edit Discord message id=%d: %v", discordID, err) log.Printf("❌ Failed to edit Discord message id=%d: %v", discordID, err)
return return
} }
log.Printf("✏️ Edited Discord (webhook) message id=%d (sneed_id=%d)", discordID, sneedID) log.Printf("✏️ Edited Discord (webhook) message id=%d (sneed_uuid=%s)", discordID, sneedUUID)
} }
func (b *Bridge) handleSneedDelete(sneedID int) { func (b *Bridge) handleSneedDelete(sneedUUID string) {
discordIDInt, ok := b.sneedToDiscord.Get(sneedID) discordIDInt, ok := b.sneedToDiscord.Get(sneedUUID)
if !ok { if !ok {
return return
} }
@@ -386,10 +481,10 @@ func (b *Bridge) handleSneedDelete(sneedID int) {
log.Printf("❌ Failed to delete Discord message id=%d: %v", discordID, err) log.Printf("❌ Failed to delete Discord message id=%d: %v", discordID, err)
return return
} }
log.Printf("🗑️ Deleted Discord (webhook) message id=%d (sneed_id=%d)", discordID, sneedID) log.Printf("🗑️ Deleted Discord (webhook) message id=%d (sneed_uuid=%s)", discordID, sneedUUID)
b.sneedToDiscord.Delete(sneedID) b.sneedToDiscord.Delete(sneedUUID)
b.discordToSneed.Delete(discordID) b.discordToSneed.Delete(strconv.Itoa(discordID))
b.sneedUsernames.Delete(sneedID) b.sneedUsernames.Delete(sneedUUID)
} }
func (b *Bridge) onSneedConnect() { func (b *Bridge) onSneedConnect() {
@@ -575,11 +670,11 @@ func (b *Bridge) recentOutboundIter() []map[string]interface{} {
return res return res
} }
func (b *Bridge) mapDiscordSneed(discordID, sneedID int, username string) { func (b *Bridge) mapDiscordSneed(sneedUUID string, discordID int, username string) {
b.discordToSneed.Set(discordID, sneedID) b.discordToSneed.Set(strconv.Itoa(discordID), sneedUUID)
b.sneedToDiscord.Set(sneedID, discordID) b.sneedToDiscord.Set(sneedUUID, discordID)
b.sneedUsernames.Set(sneedID, username) b.sneedUsernames.Set(sneedUUID, username)
log.Printf("Mapped sneed_id=%d <-> discord_id=%d (username='%s')", sneedID, discordID, username) log.Printf("Mapped sneed_uuid=%s <-> discord_id=%d (username='%s')", sneedUUID, discordID, username)
} }
func (b *Bridge) sendUploadStatusMessage(channelID, mention string, attachmentCount int) (*discordgo.Message, error) { func (b *Bridge) sendUploadStatusMessage(channelID, mention string, attachmentCount int) (*discordgo.Message, error) {
@@ -629,7 +724,7 @@ func (b *Bridge) awaitSneedConfirmation(discordID int, channelID, statusMessageI
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
if _, ok := b.discordToSneed.Get(discordID); ok { if _, ok := b.discordToSneed.Get(strconv.Itoa(discordID)); ok {
desc := "Delivered to Sneedchat." desc := "Delivered to Sneedchat."
b.editUploadStatusMessage(channelID, statusMessageID, b.uploadStatusTitle("complete"), desc, UploadStatusColorSuccess) b.editUploadStatusMessage(channelID, statusMessageID, b.uploadStatusTitle("complete"), desc, UploadStatusColorSuccess)
b.scheduleUploadStatusDeletion(channelID, statusMessageID, UploadStatusCleanupDelay) b.scheduleUploadStatusDeletion(channelID, statusMessageID, UploadStatusCleanupDelay)

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

@@ -15,16 +15,21 @@ import (
func main() { func main() {
envFile := ".env" envFile := ".env"
debugFlag := false
for i, a := range os.Args { for i, a := range os.Args {
if a == "--env" && i+1 < len(os.Args) { if a == "--env" && i+1 < len(os.Args) {
envFile = os.Args[i+1] envFile = os.Args[i+1]
} }
if a == "--debug" {
debugFlag = true
}
} }
cfg, err := config.Load(envFile) cfg, err := config.Load(envFile)
if err != nil { if err != nil {
log.Fatalf("Failed to load config: %v", err) log.Fatalf("Failed to load config: %v", err)
} }
cfg.Debug = cfg.Debug || debugFlag
log.Printf("Using .env file: %s", envFile) log.Printf("Using .env file: %s", envFile)
log.Printf("Using Sneedchat room ID: %d", cfg.SneedchatRoomID) log.Printf("Using Sneedchat room ID: %d", cfg.SneedchatRoomID)
log.Printf("Bridge username: %s", cfg.BridgeUsername) log.Printf("Bridge username: %s", cfg.BridgeUsername)
@@ -63,5 +68,6 @@ func main() {
log.Println("Shutdown signal received, cleaning up...") log.Println("Shutdown signal received, cleaning up...")
bridge.Stop() bridge.Stop()
sneedClient.Disconnect() sneedClient.Disconnect()
session.Close()
log.Println("Bridge stopped successfully") log.Println("Bridge stopped successfully")
} }

View File

@@ -53,18 +53,18 @@ type Client struct {
stopCh chan struct{} stopCh chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
processedMu sync.Mutex processedMu sync.Mutex
processedMessageIDs []int processedUUIDs []string
messageEditDates *utils.BoundedMap messageEditDates *utils.BoundedMap
OnMessage func(map[string]interface{}) OnMessage func(map[string]interface{})
OnEdit func(int, string) OnEdit func(string, string)
OnDelete func(int) OnDelete func(string)
OnConnect func() OnConnect func()
OnDisconnect func() OnDisconnect func()
recentOutboundIter func() []map[string]interface{} recentOutboundIter func() []map[string]interface{}
mapDiscordSneed func(int, int, string) mapDiscordSneed func(string, int, string)
bridgeUserID int bridgeUserID int
bridgeUsername string bridgeUsername string
@@ -86,7 +86,7 @@ func NewClient(roomID int, session *cookie.SessionService, debug bool) *Client {
TLSClientConfig: tr.TLSClientConfig, TLSClientConfig: tr.TLSClientConfig,
}, },
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
processedMessageIDs: make([]int, 0, ProcessedCacheSize), processedUUIDs: make([]string, 0, ProcessedCacheSize),
messageEditDates: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge), messageEditDates: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
lastMessage: time.Now(), lastMessage: time.Now(),
} }
@@ -367,24 +367,14 @@ func (c *Client) handleIncoming(raw string) {
log.Printf("📦 payload: msgs=%d msg=%v del=%v", len(payload.Messages), payload.Message != nil, payload.Delete != nil) log.Printf("📦 payload: msgs=%d msg=%v del=%v", len(payload.Messages), payload.Message != nil, payload.Delete != nil)
} }
if payload.Delete != nil { for _, uuid := range payload.Delete {
var ids []int if uuid == "" {
switch v := payload.Delete.(type) { continue
case float64:
ids = []int{int(v)}
case []interface{}:
for _, x := range v {
if fid, ok := x.(float64); ok {
ids = append(ids, int(fid))
}
}
} }
for _, id := range ids { c.messageEditDates.Delete(uuid)
c.messageEditDates.Delete(id) c.removeFromProcessed(uuid)
c.removeFromProcessed(id) if c.OnDelete != nil {
if c.OnDelete != nil { c.OnDelete(uuid)
c.OnDelete(id)
}
} }
} }
@@ -415,20 +405,21 @@ func (c *Client) processMessage(m SneedMessage) {
} }
messageText = html.UnescapeString(messageText) messageText = html.UnescapeString(messageText)
uuid := m.MessageUUID
editDate := m.MessageEditDate editDate := m.MessageEditDate
deleted := m.Deleted || m.IsDeleted deleted := m.Deleted || m.IsDeleted
if deleted { if deleted {
c.messageEditDates.Delete(m.MessageID) c.messageEditDates.Delete(uuid)
c.removeFromProcessed(m.MessageID) c.removeFromProcessed(uuid)
if c.OnDelete != nil { if c.OnDelete != nil {
c.OnDelete(m.MessageID) c.OnDelete(uuid)
} }
return return
} }
if (c.bridgeUserID > 0 && userID == c.bridgeUserID) || if (c.bridgeUserID > 0 && userID == c.bridgeUserID) ||
(c.bridgeUsername != "" && username == c.bridgeUsername) { (c.bridgeUsername != "" && username == c.bridgeUsername) {
if m.MessageID > 0 && c.recentOutboundIter != nil && c.mapDiscordSneed != nil { if uuid != "" && c.recentOutboundIter != nil && c.mapDiscordSneed != nil {
now := time.Now() now := time.Now()
for _, entry := range c.recentOutboundIter() { for _, entry := range c.recentOutboundIter() {
if mapped, ok := entry["mapped"].(bool); ok && mapped { if mapped, ok := entry["mapped"].(bool); ok && mapped {
@@ -438,7 +429,7 @@ func (c *Client) processMessage(m SneedMessage) {
if ts, ok := entry["ts"].(time.Time); ok { if ts, ok := entry["ts"].(time.Time); ok {
if content == messageText && now.Sub(ts) <= OutboundMatchWindow { if content == messageText && now.Sub(ts) <= OutboundMatchWindow {
if discordID, ok := entry["discord_id"].(int); ok { if discordID, ok := entry["discord_id"].(int); ok {
c.mapDiscordSneed(discordID, m.MessageID, username) c.mapDiscordSneed(uuid, discordID, username)
entry["mapped"] = true entry["mapped"] = true
break break
} }
@@ -446,64 +437,74 @@ func (c *Client) processMessage(m SneedMessage) {
} }
} }
} }
c.addToProcessed(m.MessageID) c.addToProcessed(uuid)
c.messageEditDates.Set(m.MessageID, editDate) c.messageEditDates.Set(uuid, editDate)
return return
} }
if c.isProcessed(m.MessageID) { if c.isProcessed(uuid) {
if prev, exists := c.messageEditDates.Get(m.MessageID); exists { if prev, exists := c.messageEditDates.Get(uuid); exists {
if editDate > prev.(int) { if editDate > prev.(int) {
c.messageEditDates.Set(m.MessageID, editDate) c.messageEditDates.Set(uuid, editDate)
if c.OnEdit != nil { if c.OnEdit != nil {
c.OnEdit(m.MessageID, messageText) c.OnEdit(uuid, messageText)
} }
} }
} }
return return
} }
c.addToProcessed(m.MessageID) c.addToProcessed(uuid)
c.messageEditDates.Set(m.MessageID, editDate) c.messageEditDates.Set(uuid, editDate)
if c.OnMessage != nil { if c.OnMessage != nil {
c.OnMessage(map[string]interface{}{ c.OnMessage(map[string]interface{}{
"username": username, "username": username,
"content": messageText, "content": messageText,
"message_id": m.MessageID, "message_uuid": uuid,
"author_id": userID, "message_id": m.MessageID,
"raw": m, "author_id": userID,
"raw": m,
}) })
} }
} }
func (c *Client) isProcessed(id int) bool { func (c *Client) isProcessed(uuid string) bool {
if uuid == "" {
return false
}
c.processedMu.Lock() c.processedMu.Lock()
defer c.processedMu.Unlock() defer c.processedMu.Unlock()
for _, x := range c.processedMessageIDs { for _, x := range c.processedUUIDs {
if x == id { if x == uuid {
return true return true
} }
} }
return false return false
} }
func (c *Client) addToProcessed(id int) { func (c *Client) addToProcessed(uuid string) {
if uuid == "" {
return
}
c.processedMu.Lock() c.processedMu.Lock()
defer c.processedMu.Unlock() defer c.processedMu.Unlock()
c.processedMessageIDs = append(c.processedMessageIDs, id) c.processedUUIDs = append(c.processedUUIDs, uuid)
if len(c.processedMessageIDs) > ProcessedCacheSize { if len(c.processedUUIDs) > ProcessedCacheSize {
excess := len(c.processedMessageIDs) - ProcessedCacheSize excess := len(c.processedUUIDs) - ProcessedCacheSize
c.processedMessageIDs = c.processedMessageIDs[excess:] c.processedUUIDs = c.processedUUIDs[excess:]
} }
} }
func (c *Client) removeFromProcessed(id int) { func (c *Client) removeFromProcessed(uuid string) {
if uuid == "" {
return
}
c.processedMu.Lock() c.processedMu.Lock()
defer c.processedMu.Unlock() defer c.processedMu.Unlock()
for i, x := range c.processedMessageIDs { for i, x := range c.processedUUIDs {
if x == id { if x == uuid {
c.processedMessageIDs = append(c.processedMessageIDs[:i], c.processedMessageIDs[i+1:]...) c.processedUUIDs = append(c.processedUUIDs[:i], c.processedUUIDs[i+1:]...)
return return
} }
} }
@@ -513,7 +514,7 @@ func (c *Client) SetOutboundIter(f func() []map[string]interface{}) {
c.recentOutboundIter = f c.recentOutboundIter = f
} }
func (c *Client) SetMapDiscordSneed(f func(int, int, string)) { func (c *Client) SetMapDiscordSneed(f func(string, int, string)) {
c.mapDiscordSneed = f c.mapDiscordSneed = f
} }

View File

@@ -2,8 +2,10 @@ package sneed
type SneedMessage struct { type SneedMessage struct {
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
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"`
@@ -13,5 +15,5 @@ type SneedMessage struct {
type SneedPayload struct { type SneedPayload struct {
Messages []SneedMessage `json:"messages"` Messages []SneedMessage `json:"messages"`
Message *SneedMessage `json:"message"` Message *SneedMessage `json:"message"`
Delete interface{} `json:"delete"` Delete []string `json:"delete"`
} }

View File

@@ -7,24 +7,24 @@ import (
type BoundedMap struct { type BoundedMap struct {
mu sync.RWMutex mu sync.RWMutex
data map[int]interface{} data map[string]interface{}
timestamps map[int]time.Time timestamps map[string]time.Time
maxSize int maxSize int
maxAge time.Duration maxAge time.Duration
keys []int keys []string
} }
func NewBoundedMap(maxSize int, maxAge time.Duration) *BoundedMap { func NewBoundedMap(maxSize int, maxAge time.Duration) *BoundedMap {
return &BoundedMap{ return &BoundedMap{
data: make(map[int]interface{}), data: make(map[string]interface{}),
timestamps: make(map[int]time.Time), timestamps: make(map[string]time.Time),
maxSize: maxSize, maxSize: maxSize,
maxAge: maxAge, maxAge: maxAge,
keys: make([]int, 0, maxSize), keys: make([]string, 0, maxSize),
} }
} }
func (bm *BoundedMap) Set(key int, value interface{}) { func (bm *BoundedMap) Set(key string, value interface{}) {
bm.mu.Lock() bm.mu.Lock()
defer bm.mu.Unlock() defer bm.mu.Unlock()
if _, ok := bm.data[key]; ok { if _, ok := bm.data[key]; ok {
@@ -50,14 +50,14 @@ func (bm *BoundedMap) Set(key int, value interface{}) {
} }
} }
func (bm *BoundedMap) Get(key int) (interface{}, bool) { func (bm *BoundedMap) Get(key string) (interface{}, bool) {
bm.mu.RLock() bm.mu.RLock()
defer bm.mu.RUnlock() defer bm.mu.RUnlock()
v, ok := bm.data[key] v, ok := bm.data[key]
return v, ok return v, ok
} }
func (bm *BoundedMap) Delete(key int) { func (bm *BoundedMap) Delete(key string) {
bm.mu.Lock() bm.mu.Lock()
defer bm.mu.Unlock() defer bm.mu.Unlock()
delete(bm.data, key) delete(bm.data, key)