Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60ed8c3ca0 | ||
|
|
f954771c0c | ||
|
|
93e875ffb7 | ||
|
|
4b2e7c0784 | ||
|
|
c602492b8c |
@@ -7,6 +7,8 @@ DISCORD_PING_USER_ID=your_discord_user_id_here
|
|||||||
DISCORD_WEBHOOK_URL=your_discord_webhook_url_here
|
DISCORD_WEBHOOK_URL=your_discord_webhook_url_here
|
||||||
# Interval between reconnect attempts if connection is lost
|
# Interval between reconnect attempts if connection is lost
|
||||||
RECONNECT_INTERVAL=5
|
RECONNECT_INTERVAL=5
|
||||||
|
# Media upload backend (currently only litterbox is supported)
|
||||||
|
MEDIA_UPLOAD_SERVICE=litterbox
|
||||||
# Which room will be bridged, append integer at the end of room name. Current options: general.1, gunt.8, keno-kasino.15, fishtank.16, beauty-parlor.18, sports.19,
|
# Which room will be bridged, append integer at the end of room name. Current options: general.1, gunt.8, keno-kasino.15, fishtank.16, beauty-parlor.18, sports.19,
|
||||||
SNEEDCHAT_ROOM_ID=1
|
SNEEDCHAT_ROOM_ID=1
|
||||||
# Enable logging to bridge.log file for debugging purposes(true/false, default: false)
|
# Enable logging to bridge.log file for debugging purposes(true/false, default: false)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ A high-performance bridge written in Go that synchronizes messages between Kiwi
|
|||||||
- ✅ Bidirectional message sync (Sneedchat ↔ Discord)
|
- ✅ Bidirectional message sync (Sneedchat ↔ Discord)
|
||||||
- ✅ Edit and delete synchronization
|
- ✅ Edit and delete synchronization
|
||||||
- ✅ Attachment uploads and BBcode formating via Litterbox
|
- ✅ Attachment uploads and BBcode formating via Litterbox
|
||||||
|
- ✅ Pluggable media upload services (Litterbox by default)
|
||||||
- ✅ BBCode → Markdown parsing
|
- ✅ BBCode → Markdown parsing
|
||||||
- ✅ Message queueing during outages
|
- ✅ Message queueing during outages
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ A high-performance bridge written in Go that synchronizes messages between Kiwi
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- **Go 1.19 or higher**
|
- **Go 1.23 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
|
||||||
@@ -34,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.19 or higher
|
go version # Should show 1.23 or higher
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Clone and Build
|
### 2. Clone and Build
|
||||||
@@ -135,6 +136,7 @@ Create separate systemd services with unique names
|
|||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
- Replace `BRIDGE_USERNAME` with your **Kiwi Farms username** (not email)
|
- Replace `BRIDGE_USERNAME` with your **Kiwi Farms username** (not email)
|
||||||
- `SNEEDCHAT_ROOM_ID=1` is the default Sneedchat room
|
- `SNEEDCHAT_ROOM_ID=1` is the default Sneedchat room
|
||||||
|
- `MEDIA_UPLOAD_SERVICE` selects the attachment backend (currently only `litterbox`)
|
||||||
- Keep quotes out of values
|
- Keep quotes out of values
|
||||||
- Don't share your `.env` file!
|
- Don't share your `.env` file!
|
||||||
|
|
||||||
@@ -162,6 +164,9 @@ BRIDGE_USER_ID=12345
|
|||||||
# Your Discord user ID (right-click yourself → Copy User ID)
|
# Your Discord user ID (right-click yourself → Copy User ID)
|
||||||
DISCORD_PING_USER_ID=1234567890123456789
|
DISCORD_PING_USER_ID=1234567890123456789
|
||||||
|
|
||||||
|
# Media upload backend (defaults to litterbox if unset)
|
||||||
|
MEDIA_UPLOAD_SERVICE=litterbox
|
||||||
|
|
||||||
# Optional: Enable file logging
|
# Optional: Enable file logging
|
||||||
ENABLE_FILE_LOGGING=false
|
ENABLE_FILE_LOGGING=false
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Config struct {
|
|||||||
DiscordGuildID string
|
DiscordGuildID string
|
||||||
DiscordWebhookURL string
|
DiscordWebhookURL string
|
||||||
SneedchatRoomID int
|
SneedchatRoomID int
|
||||||
|
MediaUploadService string
|
||||||
BridgeUsername string
|
BridgeUsername string
|
||||||
BridgePassword string
|
BridgePassword string
|
||||||
BridgeUserID int
|
BridgeUserID int
|
||||||
@@ -31,10 +32,14 @@ func Load(envFile string) (*Config, error) {
|
|||||||
DiscordChannelID: os.Getenv("DISCORD_CHANNEL_ID"),
|
DiscordChannelID: os.Getenv("DISCORD_CHANNEL_ID"),
|
||||||
DiscordGuildID: os.Getenv("DISCORD_GUILD_ID"),
|
DiscordGuildID: os.Getenv("DISCORD_GUILD_ID"),
|
||||||
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
|
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
|
||||||
|
MediaUploadService: os.Getenv("MEDIA_UPLOAD_SERVICE"),
|
||||||
BridgeUsername: os.Getenv("BRIDGE_USERNAME"),
|
BridgeUsername: os.Getenv("BRIDGE_USERNAME"),
|
||||||
BridgePassword: os.Getenv("BRIDGE_PASSWORD"),
|
BridgePassword: os.Getenv("BRIDGE_PASSWORD"),
|
||||||
DiscordPingUserID: os.Getenv("DISCORD_PING_USER_ID"),
|
DiscordPingUserID: os.Getenv("DISCORD_PING_USER_ID"),
|
||||||
}
|
}
|
||||||
|
if cfg.MediaUploadService == "" {
|
||||||
|
cfg.MediaUploadService = "litterbox"
|
||||||
|
}
|
||||||
roomID, err := strconv.Atoi(os.Getenv("SNEEDCHAT_ROOM_ID"))
|
roomID, err := strconv.Atoi(os.Getenv("SNEEDCHAT_ROOM_ID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid SNEEDCHAT_ROOM_ID: %w", err)
|
return nil, fmt.Errorf("invalid SNEEDCHAT_ROOM_ID: %w", err)
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
package discord
|
package discord
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"local/sneedchatbridge/config"
|
"local/sneedchatbridge/config"
|
||||||
|
"local/sneedchatbridge/media"
|
||||||
"local/sneedchatbridge/sneed"
|
"local/sneedchatbridge/sneed"
|
||||||
"local/sneedchatbridge/utils"
|
"local/sneedchatbridge/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MaxAttachments = 4
|
MaxAttachments = 4
|
||||||
LitterboxTTL = "72h"
|
|
||||||
ProcessedCacheSize = 250
|
ProcessedCacheSize = 250
|
||||||
MappingCacheSize = 1000
|
MappingCacheSize = 1000
|
||||||
MappingMaxAge = 1 * time.Hour
|
MappingMaxAge = 1 * time.Hour
|
||||||
@@ -29,6 +28,14 @@ const (
|
|||||||
QueuedMessageTTL = 90 * time.Second
|
QueuedMessageTTL = 90 * time.Second
|
||||||
OutageUpdateInterval = 10 * time.Second
|
OutageUpdateInterval = 10 * time.Second
|
||||||
OutboundMatchWindow = 60 * time.Second
|
OutboundMatchWindow = 60 * time.Second
|
||||||
|
OutageEmbedColorActive = 0xF1C40F
|
||||||
|
OutageEmbedColorFixed = 0x2ECC71
|
||||||
|
UploadStatusColorPending = 0x3498DB
|
||||||
|
UploadStatusColorSuccess = 0x2ECC71
|
||||||
|
UploadStatusColorFailed = 0xE74C3C
|
||||||
|
UploadDeliveryTimeout = 2 * time.Minute
|
||||||
|
UploadStatusCleanupDelay = 15 * time.Second
|
||||||
|
UploadStatusFailureLifetime = 60 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type OutboundEntry struct {
|
type OutboundEntry struct {
|
||||||
@@ -49,7 +56,7 @@ type Bridge struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
session *discordgo.Session
|
session *discordgo.Session
|
||||||
sneed *sneed.Client
|
sneed *sneed.Client
|
||||||
httpClient *http.Client
|
mediaSvc media.Service
|
||||||
|
|
||||||
sneedToDiscord *utils.BoundedMap
|
sneedToDiscord *utils.BoundedMap
|
||||||
discordToSneed *utils.BoundedMap
|
discordToSneed *utils.BoundedMap
|
||||||
@@ -61,7 +68,8 @@ type Bridge struct {
|
|||||||
queuedOutbound []QueuedMessage
|
queuedOutbound []QueuedMessage
|
||||||
queuedOutboundMu sync.Mutex
|
queuedOutboundMu sync.Mutex
|
||||||
|
|
||||||
outageMessages []*discordgo.Message
|
outageNotices []*discordgo.Message
|
||||||
|
outageActiveMessage *discordgo.Message
|
||||||
outageMessagesMu sync.Mutex
|
outageMessagesMu sync.Mutex
|
||||||
outageStart time.Time
|
outageStart time.Time
|
||||||
|
|
||||||
@@ -74,17 +82,21 @@ func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
mediaSvc, err := media.NewService(cfg.MediaUploadService, &http.Client{Timeout: 60 * time.Second})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
b := &Bridge{
|
b := &Bridge{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
session: s,
|
session: s,
|
||||||
sneed: sneedClient,
|
sneed: sneedClient,
|
||||||
httpClient: &http.Client{Timeout: 60 * time.Second},
|
mediaSvc: mediaSvc,
|
||||||
sneedToDiscord: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
sneedToDiscord: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
||||||
discordToSneed: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
discordToSneed: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
||||||
sneedUsernames: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
sneedUsernames: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
||||||
recentOutbound: make([]OutboundEntry, 0, ProcessedCacheSize),
|
recentOutbound: make([]OutboundEntry, 0, ProcessedCacheSize),
|
||||||
queuedOutbound: make([]QueuedMessage, 0),
|
queuedOutbound: make([]QueuedMessage, 0),
|
||||||
outageMessages: make([]*discordgo.Message, 0),
|
outageNotices: make([]*discordgo.Message, 0),
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,20 +194,50 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
|
|||||||
}
|
}
|
||||||
|
|
||||||
var attachmentsBB []string
|
var attachmentsBB []string
|
||||||
|
var statusMsg *discordgo.Message
|
||||||
|
var statusErr error
|
||||||
|
if len(m.Attachments) > 0 {
|
||||||
|
mention := fmt.Sprintf("<@%s>", m.Author.ID)
|
||||||
|
statusMsg, statusErr = b.sendUploadStatusMessage(m.ChannelID, mention, len(m.Attachments))
|
||||||
|
if statusErr != nil {
|
||||||
|
log.Printf("⚠️ Failed to send upload status message: %v", statusErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(m.Attachments) > MaxAttachments {
|
if len(m.Attachments) > MaxAttachments {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, att := range m.Attachments {
|
ctx := context.Background()
|
||||||
url, err := b.uploadToLitterbox(att.URL, att.Filename)
|
for idx, att := range m.Attachments {
|
||||||
|
if statusMsg != nil {
|
||||||
|
desc := fmt.Sprintf("Uploading attachment %d/%d: `%s`", idx+1, len(m.Attachments), att.Filename)
|
||||||
|
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle(""), desc, UploadStatusColorPending)
|
||||||
|
}
|
||||||
|
url, statusCode, err := b.mediaSvc.Upload(ctx, att)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if statusMsg != nil {
|
||||||
|
failureDesc := fmt.Sprintf("Failed to upload `%s`: %v", att.Filename, err)
|
||||||
|
if statusCode > 0 {
|
||||||
|
failureDesc = fmt.Sprintf("%s\n%s response: HTTP %d", failureDesc, b.mediaServiceDisplayName(), statusCode)
|
||||||
|
}
|
||||||
|
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle("failed"), failureDesc, UploadStatusColorFailed)
|
||||||
|
b.scheduleUploadStatusDeletion(statusMsg.ChannelID, statusMsg.ID, UploadStatusFailureLifetime)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if statusMsg != nil {
|
||||||
|
desc := fmt.Sprintf("Uploaded %d/%d: `%s` (HTTP %d)", idx+1, len(m.Attachments), att.Filename, statusCode)
|
||||||
|
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle(""), desc, UploadStatusColorPending)
|
||||||
|
}
|
||||||
|
|
||||||
ct := strings.ToLower(att.ContentType)
|
ct := strings.ToLower(att.ContentType)
|
||||||
if strings.HasPrefix(ct, "video") || strings.HasSuffix(strings.ToLower(att.Filename), ".mp4") ||
|
|
||||||
strings.HasSuffix(strings.ToLower(att.Filename), ".webm") {
|
// Images → wrap inside clickable URL with an image preview
|
||||||
attachmentsBB = append(attachmentsBB, fmt.Sprintf("[url=%s][video]%s[/video][/url]", url, url))
|
if strings.HasPrefix(ct, "image/") {
|
||||||
|
attachmentsBB = append(attachmentsBB,
|
||||||
|
fmt.Sprintf("[url=%s][img]%s[/img][/url]", url, url))
|
||||||
} else {
|
} else {
|
||||||
attachmentsBB = append(attachmentsBB, fmt.Sprintf("[url=%s][img]%s[/img][/url]", url, url))
|
// Everything else → plain URL (videos, archives, PDFs, audio, etc)
|
||||||
|
attachmentsBB = append(attachmentsBB, url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
combined := contentText
|
combined := contentText
|
||||||
@@ -206,10 +248,11 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
|
|||||||
combined += strings.Join(attachmentsBB, "\n")
|
combined += strings.Join(attachmentsBB, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
discordMsgID := parseMessageID(m.ID)
|
||||||
if b.sneed.Send(combined) {
|
if b.sneed.Send(combined) {
|
||||||
b.recentOutboundMu.Lock()
|
b.recentOutboundMu.Lock()
|
||||||
b.recentOutbound = append(b.recentOutbound, OutboundEntry{
|
b.recentOutbound = append(b.recentOutbound, OutboundEntry{
|
||||||
DiscordID: parseMessageID(m.ID),
|
DiscordID: discordMsgID,
|
||||||
Content: combined,
|
Content: combined,
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Mapped: false,
|
Mapped: false,
|
||||||
@@ -218,15 +261,23 @@ func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.Messa
|
|||||||
b.recentOutbound = b.recentOutbound[1:]
|
b.recentOutbound = b.recentOutbound[1:]
|
||||||
}
|
}
|
||||||
b.recentOutboundMu.Unlock()
|
b.recentOutboundMu.Unlock()
|
||||||
|
if statusMsg != nil {
|
||||||
|
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle(""), "Uploads complete, awaiting Sneedchat confirmation…", UploadStatusColorPending)
|
||||||
|
go b.awaitSneedConfirmation(discordMsgID, statusMsg.ChannelID, statusMsg.ID)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
b.queuedOutboundMu.Lock()
|
b.queuedOutboundMu.Lock()
|
||||||
b.queuedOutbound = append(b.queuedOutbound, QueuedMessage{
|
b.queuedOutbound = append(b.queuedOutbound, QueuedMessage{
|
||||||
Content: combined,
|
Content: combined,
|
||||||
ChannelID: m.ChannelID,
|
ChannelID: m.ChannelID,
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
DiscordID: parseMessageID(m.ID),
|
DiscordID: discordMsgID,
|
||||||
})
|
})
|
||||||
b.queuedOutboundMu.Unlock()
|
b.queuedOutboundMu.Unlock()
|
||||||
|
if statusMsg != nil {
|
||||||
|
b.editUploadStatusMessage(statusMsg.ChannelID, statusMsg.ID, b.uploadStatusTitle("queued"), "Uploads succeeded but Sneedchat is unavailable. Message queued for delivery once the bridge reconnects.", UploadStatusColorPending)
|
||||||
|
b.scheduleUploadStatusDeletion(statusMsg.ChannelID, statusMsg.ID, UploadStatusFailureLifetime)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,12 +395,15 @@ func (b *Bridge) handleSneedDelete(sneedID int) {
|
|||||||
func (b *Bridge) onSneedConnect() {
|
func (b *Bridge) onSneedConnect() {
|
||||||
log.Println("🟢 Sneedchat connected")
|
log.Println("🟢 Sneedchat connected")
|
||||||
b.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "online"})
|
b.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "online"})
|
||||||
|
queued := b.queuedMessageCount()
|
||||||
|
b.finishOutageNotification(queued)
|
||||||
go b.flushQueuedMessages()
|
go b.flushQueuedMessages()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) onSneedDisconnect() {
|
func (b *Bridge) onSneedDisconnect() {
|
||||||
log.Println("🔴 Sneedchat disconnected")
|
log.Println("🔴 Sneedchat disconnected")
|
||||||
b.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "idle"})
|
b.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "idle"})
|
||||||
|
b.startOutageNotificationLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) flushQueuedMessages() {
|
func (b *Bridge) flushQueuedMessages() {
|
||||||
@@ -387,6 +441,125 @@ func (b *Bridge) flushQueuedMessages() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) queuedMessageCount() int {
|
||||||
|
b.queuedOutboundMu.Lock()
|
||||||
|
defer b.queuedOutboundMu.Unlock()
|
||||||
|
return len(b.queuedOutbound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) startOutageNotificationLoop() {
|
||||||
|
b.outageMessagesMu.Lock()
|
||||||
|
alreadyRunning := !b.outageStart.IsZero()
|
||||||
|
if !alreadyRunning {
|
||||||
|
b.outageStart = time.Now()
|
||||||
|
go b.outageNotificationLoop()
|
||||||
|
}
|
||||||
|
b.outageMessagesMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) outageNotificationLoop() {
|
||||||
|
if !b.updateOutageMessage() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(OutageUpdateInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if !b.updateOutageMessage() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-b.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) updateOutageMessage() bool {
|
||||||
|
b.outageMessagesMu.Lock()
|
||||||
|
start := b.outageStart
|
||||||
|
existing := b.outageActiveMessage
|
||||||
|
b.outageMessagesMu.Unlock()
|
||||||
|
|
||||||
|
if start.IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := formatDuration(time.Since(start))
|
||||||
|
queued := b.queuedMessageCount()
|
||||||
|
desc := fmt.Sprintf("Connection lost **%s** ago.\n%d queued Discord message(s) will be sent automatically once Sneedchat returns.", duration, queued)
|
||||||
|
|
||||||
|
if existing == nil {
|
||||||
|
msg, err := b.sendOutageEmbed("Sneedchat outage", desc, OutageEmbedColorActive)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Failed to send outage notification: %v", err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
b.outageMessagesMu.Lock()
|
||||||
|
b.outageActiveMessage = msg
|
||||||
|
toDelete := b.swapOutageNoticesLocked(msg)
|
||||||
|
b.outageMessagesMu.Unlock()
|
||||||
|
b.deleteOutageMessages(toDelete)
|
||||||
|
} else {
|
||||||
|
if err := b.editOutageEmbed(existing.ID, "Sneedchat outage", desc, OutageEmbedColorActive); err != nil {
|
||||||
|
log.Printf("❌ Failed to update outage notification: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) finishOutageNotification(queued int) {
|
||||||
|
b.outageMessagesMu.Lock()
|
||||||
|
start := b.outageStart
|
||||||
|
b.outageStart = time.Time{}
|
||||||
|
existing := b.outageActiveMessage
|
||||||
|
b.outageActiveMessage = nil
|
||||||
|
b.outageMessagesMu.Unlock()
|
||||||
|
|
||||||
|
if start.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := formatDuration(time.Since(start))
|
||||||
|
desc := fmt.Sprintf("Connection restored after **%s**.\n%d queued Discord message(s) are now being delivered.", duration, queued)
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
if err := b.editOutageEmbed(existing.ID, "Sneedchat outage resolved", desc, OutageEmbedColorFixed); err != nil {
|
||||||
|
log.Printf("❌ Failed to update outage resolution message: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := b.sendOutageEmbed("Sneedchat outage resolved", desc, OutageEmbedColorFixed)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Failed to send outage resolution message: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.outageMessagesMu.Lock()
|
||||||
|
toDelete := b.swapOutageNoticesLocked(msg)
|
||||||
|
b.outageMessagesMu.Unlock()
|
||||||
|
b.deleteOutageMessages(toDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) deleteOutageMessages(messages []*discordgo.Message) {
|
||||||
|
for _, msg := range messages {
|
||||||
|
if msg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := b.deleteWebhookMessage(msg.ID); err != nil {
|
||||||
|
log.Printf("❌ Failed to prune outage notice %s: %v", msg.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) swapOutageNoticesLocked(newMsg *discordgo.Message) []*discordgo.Message {
|
||||||
|
toDelete := make([]*discordgo.Message, len(b.outageNotices))
|
||||||
|
copy(toDelete, b.outageNotices)
|
||||||
|
b.outageNotices = []*discordgo.Message{newMsg}
|
||||||
|
return toDelete
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bridge) recentOutboundIter() []map[string]interface{} {
|
func (b *Bridge) recentOutboundIter() []map[string]interface{} {
|
||||||
b.recentOutboundMu.Lock()
|
b.recentOutboundMu.Lock()
|
||||||
defer b.recentOutboundMu.Unlock()
|
defer b.recentOutboundMu.Unlock()
|
||||||
@@ -409,40 +582,129 @@ func (b *Bridge) mapDiscordSneed(discordID, sneedID int, username string) {
|
|||||||
log.Printf("Mapped sneed_id=%d <-> discord_id=%d (username='%s')", sneedID, discordID, username)
|
log.Printf("Mapped sneed_id=%d <-> discord_id=%d (username='%s')", sneedID, discordID, username)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) uploadToLitterbox(fileURL, filename string) (string, error) {
|
func (b *Bridge) sendUploadStatusMessage(channelID, mention string, attachmentCount int) (*discordgo.Message, error) {
|
||||||
resp, err := b.httpClient.Get(fileURL)
|
desc := fmt.Sprintf("Uploading %d attachment(s) from %s…", attachmentCount, mention)
|
||||||
if err != nil {
|
msg := &discordgo.MessageSend{
|
||||||
return "", err
|
Embeds: []*discordgo.MessageEmbed{buildStatusEmbed(b.uploadStatusTitle(""), desc, UploadStatusColorPending)},
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
return b.session.ChannelMessageSendComplex(channelID, msg)
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
func (b *Bridge) uploadStatusTitle(suffix string) string {
|
||||||
return "", err
|
base := fmt.Sprintf("%s upload", b.mediaServiceDisplayName())
|
||||||
|
if suffix == "" {
|
||||||
|
return base
|
||||||
}
|
}
|
||||||
body := &bytes.Buffer{}
|
return fmt.Sprintf("%s %s", base, suffix)
|
||||||
w := multipart.NewWriter(body)
|
|
||||||
_ = w.WriteField("reqtype", "fileupload")
|
|
||||||
_ = w.WriteField("time", LitterboxTTL)
|
|
||||||
part, _ := w.CreateFormFile("fileToUpload", filename)
|
|
||||||
_, _ = part.Write(data)
|
|
||||||
_ = w.Close()
|
|
||||||
req, _ := http.NewRequest("POST", "https://litterbox.catbox.moe/resources/internals/api.php", body)
|
|
||||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
|
||||||
uResp, err := b.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
defer uResp.Body.Close()
|
|
||||||
if uResp.StatusCode != 200 {
|
func (b *Bridge) mediaServiceDisplayName() string {
|
||||||
return "", fmt.Errorf("Litterbox returned HTTP %d", uResp.StatusCode)
|
if b.mediaSvc == nil {
|
||||||
|
return "Media"
|
||||||
}
|
}
|
||||||
out, _ := io.ReadAll(uResp.Body)
|
name := capitalizeWord(b.mediaSvc.Name())
|
||||||
url := strings.TrimSpace(string(out))
|
if name == "" {
|
||||||
log.Printf("SUCCESS: Uploaded '%s' to Litterbox: %s", filename, url)
|
return "Media"
|
||||||
return url, nil
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) editUploadStatusMessage(channelID, messageID, title, description string, color int) {
|
||||||
|
embed := buildStatusEmbed(title, description, color)
|
||||||
|
edit := &discordgo.MessageEdit{
|
||||||
|
ID: messageID,
|
||||||
|
Channel: channelID,
|
||||||
|
Embeds: []*discordgo.MessageEmbed{embed},
|
||||||
|
}
|
||||||
|
if _, err := b.session.ChannelMessageEditComplex(edit); err != nil {
|
||||||
|
log.Printf("⚠️ Failed to edit upload status message %s: %v", messageID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) awaitSneedConfirmation(discordID int, channelID, statusMessageID string) {
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
timer := time.NewTimer(UploadDeliveryTimeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if _, ok := b.discordToSneed.Get(discordID); ok {
|
||||||
|
desc := "Delivered to Sneedchat."
|
||||||
|
b.editUploadStatusMessage(channelID, statusMessageID, b.uploadStatusTitle("complete"), desc, UploadStatusColorSuccess)
|
||||||
|
b.scheduleUploadStatusDeletion(channelID, statusMessageID, UploadStatusCleanupDelay)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-timer.C:
|
||||||
|
desc := "Uploads finished but no Sneedchat confirmation was observed. Message may have been dropped."
|
||||||
|
b.editUploadStatusMessage(channelID, statusMessageID, b.uploadStatusTitle("warning"), desc, UploadStatusColorFailed)
|
||||||
|
b.scheduleUploadStatusDeletion(channelID, statusMessageID, UploadStatusFailureLifetime)
|
||||||
|
return
|
||||||
|
case <-b.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) scheduleUploadStatusDeletion(channelID, messageID string, delay time.Duration) {
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-time.After(delay):
|
||||||
|
if err := b.session.ChannelMessageDelete(channelID, messageID); err != nil {
|
||||||
|
log.Printf("⚠️ Failed to delete upload status message %s: %v", messageID, err)
|
||||||
|
}
|
||||||
|
case <-b.stopCh:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStatusEmbed(title, description string, color int) *discordgo.MessageEmbed {
|
||||||
|
return &discordgo.MessageEmbed{
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
Color: color,
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func capitalizeWord(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(s)
|
||||||
|
runes[0] = unicode.ToUpper(runes[0])
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) sendOutageEmbed(title, description string, color int) (*discordgo.Message, error) {
|
||||||
|
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
|
||||||
|
embed := buildOutageEmbed(title, description, color)
|
||||||
|
params := &discordgo.WebhookParams{Embeds: []*discordgo.MessageEmbed{embed}}
|
||||||
|
return b.session.WebhookExecute(webhookID, webhookToken, true, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) editOutageEmbed(messageID, title, description string, color int) error {
|
||||||
|
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
|
||||||
|
embed := buildOutageEmbed(title, description, color)
|
||||||
|
embeds := []*discordgo.MessageEmbed{embed}
|
||||||
|
edit := &discordgo.WebhookEdit{Embeds: &embeds}
|
||||||
|
_, err := b.session.WebhookMessageEdit(webhookID, webhookToken, messageID, edit)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOutageEmbed(title, description string, color int) *discordgo.MessageEmbed {
|
||||||
|
return &discordgo.MessageEmbed{
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
Color: color,
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) deleteWebhookMessage(messageID string) error {
|
||||||
|
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
|
||||||
|
return b.session.WebhookMessageDelete(webhookID, webhookToken, messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseWebhookURL(webhookURL string) (string, string) {
|
func parseWebhookURL(webhookURL string) (string, string) {
|
||||||
@@ -457,3 +719,15 @@ func parseMessageID(id string) int {
|
|||||||
parsed, _ := strconv.ParseInt(id, 10, 64)
|
parsed, _ := strconv.ParseInt(id, 10, 64)
|
||||||
return int(parsed)
|
return int(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
if d < time.Minute {
|
||||||
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||||
|
}
|
||||||
|
if d < time.Hour {
|
||||||
|
return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||||
|
}
|
||||||
|
hours := int(d.Hours())
|
||||||
|
minutes := int(d.Minutes()) % 60
|
||||||
|
return fmt.Sprintf("%dh%dm", hours, minutes)
|
||||||
|
}
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module local/sneedchatbridge
|
module local/sneedchatbridge
|
||||||
|
|
||||||
go 1.19
|
go 1.23
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bwmarrin/discordgo v0.27.1
|
github.com/bwmarrin/discordgo v0.27.1
|
||||||
|
|||||||
84
media/litterbox.go
Normal file
84
media/litterbox.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
litterboxTTL = "72h"
|
||||||
|
litterboxEndpoint = "https://litterbox.catbox.moe/resources/internals/api.php"
|
||||||
|
defaultHTTPTimeout = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type LitterboxService struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LitterboxService) Name() string { return DefaultService }
|
||||||
|
|
||||||
|
func (s *LitterboxService) Upload(ctx context.Context, attachment *discordgo.MessageAttachment) (string, int, error) {
|
||||||
|
client := s.client
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: defaultHTTPTimeout}
|
||||||
|
}
|
||||||
|
if client.Timeout == 0 {
|
||||||
|
client.Timeout = defaultHTTPTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, attachment.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", resp.StatusCode, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
_ = writer.WriteField("reqtype", "fileupload")
|
||||||
|
_ = writer.WriteField("time", litterboxTTL)
|
||||||
|
part, _ := writer.CreateFormFile("fileToUpload", attachment.Filename)
|
||||||
|
_, _ = part.Write(data)
|
||||||
|
_ = writer.Close()
|
||||||
|
|
||||||
|
uploadReq, err := http.NewRequestWithContext(ctx, http.MethodPost, litterboxEndpoint, body)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
uploadResp, err := client.Do(uploadReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
defer uploadResp.Body.Close()
|
||||||
|
if uploadResp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(uploadResp.Body)
|
||||||
|
reason := strings.TrimSpace(string(bodyBytes))
|
||||||
|
if reason != "" {
|
||||||
|
return "", uploadResp.StatusCode, fmt.Errorf("Litterbox returned HTTP %d: %s", uploadResp.StatusCode, reason)
|
||||||
|
}
|
||||||
|
return "", uploadResp.StatusCode, fmt.Errorf("Litterbox returned HTTP %d", uploadResp.StatusCode)
|
||||||
|
}
|
||||||
|
out, _ := io.ReadAll(uploadResp.Body)
|
||||||
|
url := strings.TrimSpace(string(out))
|
||||||
|
return url, uploadResp.StatusCode, nil
|
||||||
|
}
|
||||||
36
media/service.go
Normal file
36
media/service.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultService = "litterbox"
|
||||||
|
|
||||||
|
// Service defines a pluggable uploader for Discord attachments.
|
||||||
|
type Service interface {
|
||||||
|
Name() string
|
||||||
|
Upload(ctx context.Context, attachment *discordgo.MessageAttachment) (url string, statusCode int, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService returns the configured media upload backend. Currently only
|
||||||
|
// Litterbox is supported but the hook point allows future expansion.
|
||||||
|
func NewService(name string, httpClient *http.Client) (Service, error) {
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = &http.Client{}
|
||||||
|
}
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if normalized == "" {
|
||||||
|
normalized = DefaultService
|
||||||
|
}
|
||||||
|
switch normalized {
|
||||||
|
case DefaultService:
|
||||||
|
return &LitterboxService{client: httpClient}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown media upload service: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ const (
|
|||||||
MappingCleanupInterval = 5 * time.Minute
|
MappingCleanupInterval = 5 * time.Minute
|
||||||
MappingMaxAge = 1 * time.Hour
|
MappingMaxAge = 1 * time.Hour
|
||||||
OutboundMatchWindow = 60 * time.Second
|
OutboundMatchWindow = 60 * time.Second
|
||||||
|
PingIdleThreshold = 60 * time.Second
|
||||||
|
StaleRejoinThreshold = 90 * time.Second
|
||||||
|
StaleReconnectThreshold = 3 * time.Minute
|
||||||
|
RejoinCooldown = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -34,6 +38,7 @@ type Client struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
lastMessage time.Time
|
lastMessage time.Time
|
||||||
|
lastJoinAttempt time.Time
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
|
||||||
@@ -107,7 +112,7 @@ func (c *Client) Connect() error {
|
|||||||
c.wg.Add(1)
|
c.wg.Add(1)
|
||||||
go c.readLoop()
|
go c.readLoop()
|
||||||
|
|
||||||
c.Send(fmt.Sprintf("/join %d", c.roomID))
|
c.joinRoom()
|
||||||
log.Printf("✅ Successfully connected to Sneedchat room %d", c.roomID)
|
log.Printf("✅ Successfully connected to Sneedchat room %d", c.roomID)
|
||||||
if c.OnConnect != nil {
|
if c.OnConnect != nil {
|
||||||
c.OnConnect()
|
c.OnConnect()
|
||||||
@@ -115,8 +120,14 @@ func (c *Client) Connect() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) joinRoom() {
|
func (c *Client) joinRoom() bool {
|
||||||
c.Send(fmt.Sprintf("/join %d", c.roomID))
|
sent := c.Send(fmt.Sprintf("/join %d", c.roomID))
|
||||||
|
if sent {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lastJoinAttempt = time.Now()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
return sent
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) readLoop() {
|
func (c *Client) readLoop() {
|
||||||
@@ -148,7 +159,7 @@ func (c *Client) readLoop() {
|
|||||||
|
|
||||||
func (c *Client) heartbeatLoop() {
|
func (c *Client) heartbeatLoop() {
|
||||||
defer c.wg.Done()
|
defer c.wg.Done()
|
||||||
t := time.NewTicker(30 * time.Second)
|
t := time.NewTicker(15 * time.Second)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -157,15 +168,46 @@ func (c *Client) heartbeatLoop() {
|
|||||||
connected := c.connected
|
connected := c.connected
|
||||||
conn := c.conn
|
conn := c.conn
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
if connected && time.Since(c.lastMessage) > 60*time.Second && conn != nil {
|
if !connected || conn == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
silence := time.Since(c.lastMessage)
|
||||||
|
if silence > PingIdleThreshold {
|
||||||
_ = conn.WriteMessage(websocket.TextMessage, []byte("/ping"))
|
_ = conn.WriteMessage(websocket.TextMessage, []byte("/ping"))
|
||||||
}
|
}
|
||||||
|
c.handleStaleState(silence)
|
||||||
case <-c.stopCh:
|
case <-c.stopCh:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleStaleState(silence time.Duration) {
|
||||||
|
if silence < StaleRejoinThreshold {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if silence >= StaleReconnectThreshold {
|
||||||
|
log.Printf("⚠️ No Sneedchat messages for %s; recycling websocket", silence.Round(time.Second))
|
||||||
|
c.handleDisconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.RLock()
|
||||||
|
lastJoin := c.lastJoinAttempt
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if time.Since(lastJoin) < RejoinCooldown {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.joinRoom() {
|
||||||
|
log.Printf("⚠️ Sneedchat feed silent for %s, reasserted /join %d", silence.Round(time.Second), c.roomID)
|
||||||
|
} else {
|
||||||
|
log.Printf("⚠️ Sneedchat feed silent for %s but websocket not writable; waiting", silence.Round(time.Second))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) cleanupLoop() {
|
func (c *Client) cleanupLoop() {
|
||||||
defer c.wg.Done()
|
defer c.wg.Done()
|
||||||
t := time.NewTicker(MappingCleanupInterval)
|
t := time.NewTicker(MappingCleanupInterval)
|
||||||
@@ -199,6 +241,13 @@ func (c *Client) Send(s string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) handleDisconnect() {
|
func (c *Client) handleDisconnect() {
|
||||||
|
c.mu.RLock()
|
||||||
|
alreadyDisconnected := !c.connected
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if alreadyDisconnected {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-c.stopCh:
|
case <-c.stopCh:
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user