Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adba77b3e5 | ||
|
|
a749e33737 | ||
|
|
85de6d175f | ||
|
|
4b455eb58e |
19
Dockerfile
Normal file
19
Dockerfile
Normal 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"]
|
||||||
11
README.md
11
README.md
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
17
docker-compose.yml
Normal 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
main.go
6
main.go
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
111
sneed/client.go
111
sneed/client.go
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user