6 Commits

Author SHA1 Message Date
Salastil a492edb649 Merge pull request #5 from Salastil/claude/websocket-debug-tool-pm4m6d
Build & Release / build-latest (push) Waiting to run
Build & Release / version-release (push) Waiting to run
Add wsdebug tool for WebSocket frame inspection
2026-08-01 20:21:35 -04:00
Claude 98eeb2e2ba Add standalone wsdebug tool for inspecting the raw Sneedchat WebSocket stream
Loads the same .env credentials as the bridge, connects to the Sneedchat
WebSocket independent of Discord, and prints every raw frame to stdout
while also appending it to a log file. Adds an OnRaw hook to sneed.Client
so the full, untruncated frame is available to callers.
2026-08-02 00:04:53 +00:00
Salastil adba77b3e5 Overflow mode when server exceeds Discord's ratelimiting. Will compact many messages into one embed with timestamps
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
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
11 changed files with 406 additions and 106 deletions
+1
View File
@@ -1,2 +1,3 @@
Sneedchat-Discord-Bridge
file.log
wsdebug.log
+19
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"]
+27 -9
View File
@@ -20,7 +20,7 @@ A high-performance bridge written in Go that synchronizes messages between Kiwi
## Requirements
- **Go 1.23 or higher**
- **Go 1.25.6 or higher**
- **Discord Bot Token** with proper permissions
- **Discord Webhook URL**
- **Kiwi Farms account** with Sneedchat access
@@ -35,7 +35,7 @@ sudo apt update
sudo apt install golang git
# Verify installation
go version # Should show 1.23 or higher
go version # Should show 1.25.6 or higher
```
### 2. Clone and Build
@@ -245,6 +245,31 @@ sudo systemctl status sneedchat-bridge
---
## Debugging Tools
### wsdebug — standalone WebSocket inspector
`wsdebug` is a separate binary that logs into Kiwi Farms using the same `.env` file as the bridge, connects directly to the Sneedchat WebSocket, and prints every raw frame it receives to stdout while also writing it to a log file. It doesn't touch Discord at all — use it to see exactly what Sneedchat is sending, independent of the bridge.
Build it:
```bash
go build -o wsdebug ./cmd/wsdebug
```
Run it:
```bash
./wsdebug --env .env --log wsdebug.log --room 1
```
Flags:
- `--env` - path to the `.env` file to load (default `.env`)
- `--log` - path to the log file to append output to (default `wsdebug.log`)
- `--room` - override `SNEEDCHAT_ROOM_ID` from the `.env` file (optional)
Press `Ctrl+C` to stop. Every raw WebSocket frame, plus connect/disconnect/message/edit/delete events, are printed to the terminal and appended to the log file simultaneously.
## Troubleshooting
### Cookie Issues
@@ -271,10 +296,3 @@ If messages aren't appearing:
## License
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
+93
View File
@@ -0,0 +1,93 @@
// Command wsdebug is a standalone debugging tool that logs into Kiwi Farms
// using the same .env credentials as the bridge, opens the Sneedchat
// WebSocket connection, and prints/logs every raw frame received. It does
// not touch Discord at all — it exists purely to inspect what the
// WebSocket is sending.
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"os"
"os/signal"
"syscall"
"time"
"local/sneedchatbridge/config"
"local/sneedchatbridge/cookie"
"local/sneedchatbridge/sneed"
)
func main() {
envFile := flag.String("env", ".env", "path to .env file")
logFile := flag.String("log", "wsdebug.log", "path to log file")
room := flag.Int("room", 0, "override SNEEDCHAT_ROOM_ID from .env")
flag.Parse()
cfg, err := config.Load(*envFile)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
if *room != 0 {
cfg.SneedchatRoomID = *room
}
f, err := os.OpenFile(*logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
log.Fatalf("Failed to open log file %s: %v", *logFile, err)
}
defer f.Close()
logger := log.New(io.MultiWriter(os.Stdout, f), "", log.LstdFlags|log.Lmicroseconds)
log.SetOutput(io.MultiWriter(os.Stdout, f))
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
logger.Printf("wsdebug starting — env=%s log=%s room=%d", *envFile, *logFile, cfg.SneedchatRoomID)
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
session, err := cookie.NewSessionService(ctx, "kiwifarms.st", cfg.BridgeUsername, cfg.BridgePassword)
if err != nil {
log.Fatalf("Failed to establish session: %v", err)
}
defer session.Close()
client := sneed.NewClient(cfg.SneedchatRoomID, session, true)
client.SetBridgeIdentity(cfg.BridgeUserID, cfg.BridgeUsername)
client.OnRaw = func(raw string) {
logger.Printf("RECV: %s", raw)
}
client.OnConnect = func() {
logger.Printf("=== CONNECTED (room %d) ===", cfg.SneedchatRoomID)
}
client.OnDisconnect = func() {
logger.Printf("=== DISCONNECTED ===")
}
client.OnMessage = func(m map[string]interface{}) {
logger.Printf("MESSAGE: user=%v content=%q uuid=%v", m["username"], m["content"], m["message_uuid"])
}
client.OnEdit = func(uuid, content string) {
logger.Printf("EDIT: uuid=%s content=%q", uuid, content)
}
client.OnDelete = func(uuid string) {
logger.Printf("DELETE: uuid=%s", uuid)
}
if err := client.Connect(ctx); err != nil {
log.Fatalf("Initial connect failed: %v", err)
}
logger.Println("wsdebug running — press Ctrl+C to stop")
<-ctx.Done()
stopTime := time.Now().Format(time.RFC3339)
fmt.Fprintf(f, "--- shutdown requested at %s ---\n", stopTime)
logger.Println("Shutdown signal received, disconnecting...")
client.Disconnect()
logger.Println("wsdebug stopped")
}
+39
View File
@@ -13,6 +13,7 @@ import (
"slices"
"strings"
"sync"
"time"
)
// SessionService manages XenForo session cookies as plain strings.
@@ -25,6 +26,7 @@ type SessionService struct {
username string
password string
tr *http.Transport // shared transport, TLS config applied once
stopCh chan struct{}
}
// 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,
password: password,
tr: tr,
stopCh: make(chan struct{}),
}
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)
}
log.Println("✅ Login successful")
go s.refreshLoop(ctx)
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:
// concatenate secure + insecure cipher suites so KiwiFlare TLS fingerprinting
// doesn't trigger. "The insecure ones appear to be necessary for consistently
+127 -32
View File
@@ -22,6 +22,9 @@ import (
const (
MaxAttachments = 4
ProcessedCacheSize = 250
OverflowThreshold = 10
OverflowRecoveryThreshold = 5
OverflowBatchSize = 15
MappingCacheSize = 1000
MappingMaxAge = 1 * time.Hour
MappingCleanupInterval = 5 * time.Minute
@@ -73,8 +76,10 @@ type Bridge struct {
outageMessagesMu sync.Mutex
outageStart time.Time
stopCh chan struct{}
wg sync.WaitGroup
stopCh chan struct{}
wg sync.WaitGroup
msgQueue chan map[string]interface{}
overflow bool
}
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),
outageNotices: make([]*discordgo.Message, 0),
stopCh: make(chan struct{}),
msgQueue: make(chan map[string]interface{}, 500),
}
// hook Sneed client callbacks
sneedClient.OnMessage = b.onSneedMessage
sneedClient.OnMessage = b.enqueueSneedMessage
sneedClient.OnEdit = b.handleSneedEdit
sneedClient.OnDelete = b.handleSneedDelete
sneedClient.OnConnect = b.onSneedConnect
@@ -124,8 +130,10 @@ func (b *Bridge) Start() error {
if err := b.session.Open(); err != nil {
return err
}
b.wg.Add(1)
b.wg.Add(3)
go b.cleanupLoop()
go b.messageWorker()
go b.messageWorker()
return nil
}
@@ -186,8 +194,9 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
if m.ReferencedMessage != nil {
refDiscordID := parseMessageID(m.ReferencedMessage.ID)
if sneedIDInt, ok := b.discordToSneed.Get(refDiscordID); ok {
if uname, ok2 := b.sneedUsernames.Get(sneedIDInt.(int)); ok2 {
if sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(refDiscordID)); ok {
sneedUUID := sneedUUIDVal.(string)
if uname, ok2 := b.sneedUsernames.Get(sneedUUID); ok2 {
contentText = fmt.Sprintf("@%s, %s", uname.(string), contentText)
}
}
@@ -289,14 +298,14 @@ func (b *Bridge) onDiscordMessageEdit(s *discordgo.Session, m *discordgo.Message
return
}
discordID := parseMessageID(m.ID)
sneedIDInt, ok := b.discordToSneed.Get(discordID)
sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(discordID))
if !ok {
return
}
sneedID := sneedIDInt.(int)
payload := map[string]interface{}{"id": sneedID, "message": strings.TrimSpace(m.Content)}
sneedUUID := sneedUUIDVal.(string)
payload := map[string]interface{}{"uuid": sneedUUID, "message": strings.TrimSpace(m.Content)}
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)))
}
@@ -305,12 +314,98 @@ func (b *Bridge) onDiscordMessageDelete(s *discordgo.Session, m *discordgo.Messa
return
}
discordID := parseMessageID(m.ID)
sneedIDInt, ok := b.discordToSneed.Get(discordID)
sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(discordID))
if !ok {
return
}
log.Printf("↩️ Discord delete -> Sneedchat (sneed_id=%d)", sneedIDInt.(int))
b.sneed.Send(fmt.Sprintf("/delete %d", sneedIDInt.(int)))
sneedUUID := sneedUUIDVal.(string)
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{}) {
@@ -348,17 +443,17 @@ func (b *Bridge) onSneedMessage(msg map[string]interface{}) {
log.Printf("✅ Sent Sneedchat → Discord: %s", username)
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)
b.sneedToDiscord.Set(mid, discordMsgID)
b.discordToSneed.Set(discordMsgID, mid)
b.sneedUsernames.Set(mid, username)
b.sneedToDiscord.Set(uuid, discordMsgID)
b.discordToSneed.Set(strconv.Itoa(discordMsgID), uuid)
b.sneedUsernames.Set(uuid, username)
}
}
}
func (b *Bridge) handleSneedEdit(sneedID int, newContent string) {
discordIDInt, ok := b.sneedToDiscord.Get(sneedID)
func (b *Bridge) handleSneedEdit(sneedUUID string, newContent string) {
discordIDInt, ok := b.sneedToDiscord.Get(sneedUUID)
if !ok {
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)
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) {
discordIDInt, ok := b.sneedToDiscord.Get(sneedID)
func (b *Bridge) handleSneedDelete(sneedUUID string) {
discordIDInt, ok := b.sneedToDiscord.Get(sneedUUID)
if !ok {
return
}
@@ -386,10 +481,10 @@ func (b *Bridge) handleSneedDelete(sneedID int) {
log.Printf("❌ Failed to delete Discord message id=%d: %v", discordID, err)
return
}
log.Printf("🗑️ Deleted Discord (webhook) message id=%d (sneed_id=%d)", discordID, sneedID)
b.sneedToDiscord.Delete(sneedID)
b.discordToSneed.Delete(discordID)
b.sneedUsernames.Delete(sneedID)
log.Printf("🗑️ Deleted Discord (webhook) message id=%d (sneed_uuid=%s)", discordID, sneedUUID)
b.sneedToDiscord.Delete(sneedUUID)
b.discordToSneed.Delete(strconv.Itoa(discordID))
b.sneedUsernames.Delete(sneedUUID)
}
func (b *Bridge) onSneedConnect() {
@@ -575,11 +670,11 @@ func (b *Bridge) recentOutboundIter() []map[string]interface{} {
return res
}
func (b *Bridge) mapDiscordSneed(discordID, sneedID int, username string) {
b.discordToSneed.Set(discordID, sneedID)
b.sneedToDiscord.Set(sneedID, discordID)
b.sneedUsernames.Set(sneedID, username)
log.Printf("Mapped sneed_id=%d <-> discord_id=%d (username='%s')", sneedID, discordID, username)
func (b *Bridge) mapDiscordSneed(sneedUUID string, discordID int, username string) {
b.discordToSneed.Set(strconv.Itoa(discordID), sneedUUID)
b.sneedToDiscord.Set(sneedUUID, discordID)
b.sneedUsernames.Set(sneedUUID, 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) {
@@ -629,7 +724,7 @@ func (b *Bridge) awaitSneedConfirmation(discordID int, channelID, statusMessageI
for {
select {
case <-ticker.C:
if _, ok := b.discordToSneed.Get(discordID); ok {
if _, ok := b.discordToSneed.Get(strconv.Itoa(discordID)); ok {
desc := "Delivered to Sneedchat."
b.editUploadStatusMessage(channelID, statusMessageID, b.uploadStatusTitle("complete"), desc, UploadStatusColorSuccess)
b.scheduleUploadStatusDeletion(channelID, statusMessageID, UploadStatusCleanupDelay)
+17
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}
+6
View File
@@ -15,16 +15,21 @@ import (
func main() {
envFile := ".env"
debugFlag := false
for i, a := range os.Args {
if a == "--env" && i+1 < len(os.Args) {
envFile = os.Args[i+1]
}
if a == "--debug" {
debugFlag = true
}
}
cfg, err := config.Load(envFile)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
cfg.Debug = cfg.Debug || debugFlag
log.Printf("Using .env file: %s", envFile)
log.Printf("Using Sneedchat room ID: %d", cfg.SneedchatRoomID)
log.Printf("Bridge username: %s", cfg.BridgeUsername)
@@ -63,5 +68,6 @@ func main() {
log.Println("Shutdown signal received, cleaning up...")
bridge.Stop()
sneedClient.Disconnect()
session.Close()
log.Println("Bridge stopped successfully")
}
+65 -55
View File
@@ -53,18 +53,23 @@ type Client struct {
stopCh chan struct{}
wg sync.WaitGroup
processedMu sync.Mutex
processedMessageIDs []int
messageEditDates *utils.BoundedMap
processedMu sync.Mutex
processedUUIDs []string
messageEditDates *utils.BoundedMap
OnMessage func(map[string]interface{})
OnEdit func(int, string)
OnDelete func(int)
OnEdit func(string, string)
OnDelete func(string)
OnConnect func()
OnDisconnect func()
// OnRaw, if set, is called with every raw WebSocket frame exactly as
// received, before any parsing or truncation. Intended for debugging
// tools that need to observe the unfiltered stream.
OnRaw func(string)
recentOutboundIter func() []map[string]interface{}
mapDiscordSneed func(int, int, string)
mapDiscordSneed func(string, int, string)
bridgeUserID int
bridgeUsername string
@@ -86,7 +91,7 @@ func NewClient(roomID int, session *cookie.SessionService, debug bool) *Client {
TLSClientConfig: tr.TLSClientConfig,
},
stopCh: make(chan struct{}),
processedMessageIDs: make([]int, 0, ProcessedCacheSize),
processedUUIDs: make([]string, 0, ProcessedCacheSize),
messageEditDates: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
lastMessage: time.Now(),
}
@@ -197,6 +202,10 @@ func (c *Client) readLoop(ctx context.Context) {
raw := string(message)
if c.OnRaw != nil {
c.OnRaw(raw)
}
// Server sends plaintext "cannot join" when session has expired.
if isCannotJoin(raw) {
log.Println("⚠️ 'cannot join' received — refreshing session and reconnecting...")
@@ -367,24 +376,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)
}
if payload.Delete != nil {
var ids []int
switch v := payload.Delete.(type) {
case float64:
ids = []int{int(v)}
case []interface{}:
for _, x := range v {
if fid, ok := x.(float64); ok {
ids = append(ids, int(fid))
}
}
for _, uuid := range payload.Delete {
if uuid == "" {
continue
}
for _, id := range ids {
c.messageEditDates.Delete(id)
c.removeFromProcessed(id)
if c.OnDelete != nil {
c.OnDelete(id)
}
c.messageEditDates.Delete(uuid)
c.removeFromProcessed(uuid)
if c.OnDelete != nil {
c.OnDelete(uuid)
}
}
@@ -415,20 +414,21 @@ func (c *Client) processMessage(m SneedMessage) {
}
messageText = html.UnescapeString(messageText)
uuid := m.MessageUUID
editDate := m.MessageEditDate
deleted := m.Deleted || m.IsDeleted
if deleted {
c.messageEditDates.Delete(m.MessageID)
c.removeFromProcessed(m.MessageID)
c.messageEditDates.Delete(uuid)
c.removeFromProcessed(uuid)
if c.OnDelete != nil {
c.OnDelete(m.MessageID)
c.OnDelete(uuid)
}
return
}
if (c.bridgeUserID > 0 && userID == c.bridgeUserID) ||
(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()
for _, entry := range c.recentOutboundIter() {
if mapped, ok := entry["mapped"].(bool); ok && mapped {
@@ -438,7 +438,7 @@ func (c *Client) processMessage(m SneedMessage) {
if ts, ok := entry["ts"].(time.Time); ok {
if content == messageText && now.Sub(ts) <= OutboundMatchWindow {
if discordID, ok := entry["discord_id"].(int); ok {
c.mapDiscordSneed(discordID, m.MessageID, username)
c.mapDiscordSneed(uuid, discordID, username)
entry["mapped"] = true
break
}
@@ -446,64 +446,74 @@ func (c *Client) processMessage(m SneedMessage) {
}
}
}
c.addToProcessed(m.MessageID)
c.messageEditDates.Set(m.MessageID, editDate)
c.addToProcessed(uuid)
c.messageEditDates.Set(uuid, editDate)
return
}
if c.isProcessed(m.MessageID) {
if prev, exists := c.messageEditDates.Get(m.MessageID); exists {
if c.isProcessed(uuid) {
if prev, exists := c.messageEditDates.Get(uuid); exists {
if editDate > prev.(int) {
c.messageEditDates.Set(m.MessageID, editDate)
c.messageEditDates.Set(uuid, editDate)
if c.OnEdit != nil {
c.OnEdit(m.MessageID, messageText)
c.OnEdit(uuid, messageText)
}
}
}
return
}
c.addToProcessed(m.MessageID)
c.messageEditDates.Set(m.MessageID, editDate)
c.addToProcessed(uuid)
c.messageEditDates.Set(uuid, editDate)
if c.OnMessage != nil {
c.OnMessage(map[string]interface{}{
"username": username,
"content": messageText,
"message_id": m.MessageID,
"author_id": userID,
"raw": m,
"username": username,
"content": messageText,
"message_uuid": uuid,
"message_id": m.MessageID,
"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()
defer c.processedMu.Unlock()
for _, x := range c.processedMessageIDs {
if x == id {
for _, x := range c.processedUUIDs {
if x == uuid {
return true
}
}
return false
}
func (c *Client) addToProcessed(id int) {
func (c *Client) addToProcessed(uuid string) {
if uuid == "" {
return
}
c.processedMu.Lock()
defer c.processedMu.Unlock()
c.processedMessageIDs = append(c.processedMessageIDs, id)
if len(c.processedMessageIDs) > ProcessedCacheSize {
excess := len(c.processedMessageIDs) - ProcessedCacheSize
c.processedMessageIDs = c.processedMessageIDs[excess:]
c.processedUUIDs = append(c.processedUUIDs, uuid)
if len(c.processedUUIDs) > ProcessedCacheSize {
excess := len(c.processedUUIDs) - ProcessedCacheSize
c.processedUUIDs = c.processedUUIDs[excess:]
}
}
func (c *Client) removeFromProcessed(id int) {
func (c *Client) removeFromProcessed(uuid string) {
if uuid == "" {
return
}
c.processedMu.Lock()
defer c.processedMu.Unlock()
for i, x := range c.processedMessageIDs {
if x == id {
c.processedMessageIDs = append(c.processedMessageIDs[:i], c.processedMessageIDs[i+1:]...)
for i, x := range c.processedUUIDs {
if x == uuid {
c.processedUUIDs = append(c.processedUUIDs[:i], c.processedUUIDs[i+1:]...)
return
}
}
@@ -513,7 +523,7 @@ func (c *Client) SetOutboundIter(f func() []map[string]interface{}) {
c.recentOutboundIter = f
}
func (c *Client) SetMapDiscordSneed(f func(int, int, string)) {
func (c *Client) SetMapDiscordSneed(f func(string, int, string)) {
c.mapDiscordSneed = f
}
+3 -1
View File
@@ -2,8 +2,10 @@ package sneed
type SneedMessage struct {
MessageID int `json:"message_id"`
MessageUUID string `json:"message_uuid"`
Message string `json:"message"`
MessageRaw string `json:"message_raw"`
MessageDate int `json:"message_date"`
MessageEditDate int `json:"message_edit_date"`
Author map[string]interface{} `json:"author"`
Deleted bool `json:"deleted"`
@@ -13,5 +15,5 @@ type SneedMessage struct {
type SneedPayload struct {
Messages []SneedMessage `json:"messages"`
Message *SneedMessage `json:"message"`
Delete interface{} `json:"delete"`
Delete []string `json:"delete"`
}
+9 -9
View File
@@ -7,24 +7,24 @@ import (
type BoundedMap struct {
mu sync.RWMutex
data map[int]interface{}
timestamps map[int]time.Time
data map[string]interface{}
timestamps map[string]time.Time
maxSize int
maxAge time.Duration
keys []int
keys []string
}
func NewBoundedMap(maxSize int, maxAge time.Duration) *BoundedMap {
return &BoundedMap{
data: make(map[int]interface{}),
timestamps: make(map[int]time.Time),
data: make(map[string]interface{}),
timestamps: make(map[string]time.Time),
maxSize: maxSize,
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()
defer bm.mu.Unlock()
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()
defer bm.mu.RUnlock()
v, ok := bm.data[key]
return v, ok
}
func (bm *BoundedMap) Delete(key int) {
func (bm *BoundedMap) Delete(key string) {
bm.mu.Lock()
defer bm.mu.Unlock()
delete(bm.data, key)