Fixing what broke during making it modular
This commit is contained in:
@@ -35,13 +35,11 @@ func Load(envFile string) (*Config, error) {
|
|||||||
BridgePassword: os.Getenv("BRIDGE_PASSWORD"),
|
BridgePassword: os.Getenv("BRIDGE_PASSWORD"),
|
||||||
DiscordPingUserID: os.Getenv("DISCORD_PING_USER_ID"),
|
DiscordPingUserID: os.Getenv("DISCORD_PING_USER_ID"),
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
cfg.SneedchatRoomID = roomID
|
cfg.SneedchatRoomID = roomID
|
||||||
|
|
||||||
if v := os.Getenv("BRIDGE_USER_ID"); v != "" {
|
if v := os.Getenv("BRIDGE_USER_ID"); v != "" {
|
||||||
cfg.BridgeUserID, _ = strconv.Atoi(v)
|
cfg.BridgeUserID, _ = strconv.Atoi(v)
|
||||||
}
|
}
|
||||||
@@ -49,4 +47,4 @@ func Load(envFile string) (*Config, error) {
|
|||||||
cfg.Debug = true
|
cfg.Debug = true
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ type RefreshService struct {
|
|||||||
func NewRefreshService(username, password, domain string) *RefreshService {
|
func NewRefreshService(username, password, domain string) *RefreshService {
|
||||||
jar, _ := cookiejar.New(nil)
|
jar, _ := cookiejar.New(nil)
|
||||||
tr := &http.Transport{
|
tr := &http.Transport{
|
||||||
// Force HTTP/1.1 (avoid ALPN h2 differences)
|
// Force HTTP/1.1 to avoid Cloudflare/ALPN issues
|
||||||
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
|
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
|
||||||
}
|
}
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
@@ -77,7 +77,6 @@ func (r *RefreshService) GetCurrentCookie() string {
|
|||||||
|
|
||||||
func (r *RefreshService) loop() {
|
func (r *RefreshService) loop() {
|
||||||
defer r.wg.Done()
|
defer r.wg.Done()
|
||||||
|
|
||||||
log.Println("🔑 Fetching initial cookie...")
|
log.Println("🔑 Fetching initial cookie...")
|
||||||
c, err := r.FetchFreshCookie()
|
c, err := r.FetchFreshCookie()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -196,11 +195,11 @@ func (r *RefreshService) attemptFetchCookie() (string, error) {
|
|||||||
"remember": {"1"},
|
"remember": {"1"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensure GET cookies are kept
|
|
||||||
cookieURL, _ := url.Parse(fmt.Sprintf("https://%s/", r.domain))
|
cookieURL, _ := url.Parse(fmt.Sprintf("https://%s/", r.domain))
|
||||||
if resp.Cookies() != nil {
|
for _, c := range r.client.Jar.Cookies(cookieURL) {
|
||||||
r.client.Jar.SetCookies(cookieURL, resp.Cookies())
|
c.Domain = strings.TrimPrefix(c.Domain, ".")
|
||||||
}
|
}
|
||||||
|
r.client.Jar.SetCookies(cookieURL, r.client.Jar.Cookies(cookieURL))
|
||||||
|
|
||||||
postReq, _ := http.NewRequest("POST", postURL, strings.NewReader(form.Encode()))
|
postReq, _ := http.NewRequest("POST", postURL, strings.NewReader(form.Encode()))
|
||||||
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -208,9 +207,10 @@ func (r *RefreshService) attemptFetchCookie() (string, error) {
|
|||||||
postReq.Header.Set("Referer", loginURL)
|
postReq.Header.Set("Referer", loginURL)
|
||||||
postReq.Header.Set("Origin", fmt.Sprintf("https://%s", r.domain))
|
postReq.Header.Set("Origin", fmt.Sprintf("https://%s", r.domain))
|
||||||
postReq.Header.Set("X-XF-Token", csrf)
|
postReq.Header.Set("X-XF-Token", csrf)
|
||||||
|
postReq.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||||
postReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
postReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
postReq.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
postReq.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||||
postReq.Header.Set("Accept-Encoding", "gzip, deflate") // avoid br
|
postReq.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||||
|
|
||||||
loginResp, err := r.client.Do(postReq)
|
loginResp, err := r.client.Do(postReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -219,7 +219,7 @@ func (r *RefreshService) attemptFetchCookie() (string, error) {
|
|||||||
defer loginResp.Body.Close()
|
defer loginResp.Body.Close()
|
||||||
log.Printf("Login response status: %d", loginResp.StatusCode)
|
log.Printf("Login response status: %d", loginResp.StatusCode)
|
||||||
|
|
||||||
// Follow a single redirect (XenForo usually sets xf_user on redirect target)
|
// Follow redirect if present
|
||||||
if loginResp.StatusCode >= 300 && loginResp.StatusCode < 400 {
|
if loginResp.StatusCode >= 300 && loginResp.StatusCode < 400 {
|
||||||
if loc := loginResp.Header.Get("Location"); loc != "" {
|
if loc := loginResp.Header.Get("Location"); loc != "" {
|
||||||
log.Printf("Following redirect to %s to check for xf_user...", loc)
|
log.Printf("Following redirect to %s to check for xf_user...", loc)
|
||||||
@@ -255,10 +255,15 @@ func (r *RefreshService) attemptFetchCookie() (string, error) {
|
|||||||
return r.retryWithFreshCSRF()
|
return r.retryWithFreshCSRF()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize cookie domains and compose cookie string
|
// Wait before extracting cookies
|
||||||
|
log.Println("⏳ Waiting 2 seconds for XenForo to issue cookies...")
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// Normalize and show cookies
|
||||||
cookies := r.client.Jar.Cookies(cookieURL)
|
cookies := r.client.Jar.Cookies(cookieURL)
|
||||||
for _, c := range cookies {
|
for _, c := range cookies {
|
||||||
c.Domain = strings.TrimPrefix(c.Domain, ".")
|
c.Domain = strings.TrimPrefix(c.Domain, ".")
|
||||||
|
log.Printf("Cookie after login: %s=%s", c.Name, c.Value)
|
||||||
}
|
}
|
||||||
r.client.Jar.SetCookies(cookieURL, cookies)
|
r.client.Jar.SetCookies(cookieURL, cookies)
|
||||||
|
|
||||||
|
|||||||
@@ -61,16 +61,12 @@ func (r *RefreshService) retryWithFreshCSRF() (string, error) {
|
|||||||
} else {
|
} else {
|
||||||
reader = io.NopCloser(resp2.Body)
|
reader = io.NopCloser(resp2.Body)
|
||||||
}
|
}
|
||||||
b, _ := io.ReadAll(reader)
|
_, _ = io.ReadAll(reader)
|
||||||
if strings.Contains(string(b), `data-logged-in="true"`) {
|
|
||||||
log.Println("✅ Retry indicates logged in successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
cookieURL, _ := url.Parse(fmt.Sprintf("https://%s/", r.domain))
|
cookieURL, _ := url.Parse(fmt.Sprintf("https://%s/", r.domain))
|
||||||
for _, c := range r.client.Jar.Cookies(cookieURL) {
|
for _, c := range r.client.Jar.Cookies(cookieURL) {
|
||||||
if c.Name == "xf_user" {
|
if c.Name == "xf_user" {
|
||||||
log.Printf("✅ Successfully fetched fresh cookie with xf_user: %.12s...", c.Value)
|
log.Printf("✅ Successfully fetched fresh cookie with xf_user: %.12s...", c.Value)
|
||||||
// Rebuild cookie header with known-good set (reuse attemptFetchCookie’s logic if you want)
|
|
||||||
return "xf_user=" + c.Value, nil
|
return "xf_user=" + c.Value, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ func (r *RefreshService) getClearanceToken() (string, error) {
|
|||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
// Detect challenge (several patterns)
|
|
||||||
patterns := []*regexp.Regexp{
|
patterns := []*regexp.Regexp{
|
||||||
regexp.MustCompile(`<html[^>]*id=["']sssg["'][^>]*data-sssg-challenge=["']([^"']+)["'][^>]*data-sssg-difficulty=["'](\d+)["']`),
|
regexp.MustCompile(`<html[^>]*id=["']sssg["'][^>]*data-sssg-challenge=["']([^"']+)["'][^>]*data-sssg-difficulty=["'](\d+)["']`),
|
||||||
regexp.MustCompile(`<html[^>]*id=["']sssg["'][^>]*data-sssg-difficulty=["'](\d+)["'][^>]*data-sssg-challenge=["']([^"']+)["']`),
|
regexp.MustCompile(`<html[^>]*id=["']sssg["'][^>]*data-sssg-difficulty=["'](\d+)["'][^>]*data-sssg-challenge=["']([^"']+)["']`),
|
||||||
@@ -81,7 +80,6 @@ func (r *RefreshService) getClearanceToken() (string, error) {
|
|||||||
}
|
}
|
||||||
defer resp2.Body.Close()
|
defer resp2.Body.Close()
|
||||||
|
|
||||||
// Some deployments return JSON like {"auth":"..."}
|
|
||||||
var result map[string]any
|
var result map[string]any
|
||||||
_ = json.NewDecoder(resp2.Body).Decode(&result)
|
_ = json.NewDecoder(resp2.Body).Decode(&result)
|
||||||
|
|
||||||
@@ -95,7 +93,6 @@ func (r *RefreshService) getClearanceToken() (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v, ok := result["auth"].(string); ok && v != "" {
|
if v, ok := result["auth"].(string); ok && v != "" {
|
||||||
// Fallback: manually add
|
|
||||||
r.client.Jar.SetCookies(cookieURL, []*http.Cookie{{
|
r.client.Jar.SetCookies(cookieURL, []*http.Cookie{{
|
||||||
Name: "sssg_clearance",
|
Name: "sssg_clearance",
|
||||||
Value: v,
|
Value: v,
|
||||||
|
|||||||
@@ -1,18 +1,72 @@
|
|||||||
package discord
|
package discord
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"local/sneedchatbridge/config"
|
"local/sneedchatbridge/config"
|
||||||
"local/sneedchatbridge/sneed"
|
"local/sneedchatbridge/sneed"
|
||||||
|
"local/sneedchatbridge/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxAttachments = 4
|
||||||
|
LitterboxTTL = "72h"
|
||||||
|
ProcessedCacheSize = 250
|
||||||
|
MappingCacheSize = 1000
|
||||||
|
MappingMaxAge = 1 * time.Hour
|
||||||
|
MappingCleanupInterval = 5 * time.Minute
|
||||||
|
QueuedMessageTTL = 90 * time.Second
|
||||||
|
OutageUpdateInterval = 10 * time.Second
|
||||||
|
OutboundMatchWindow = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type OutboundEntry struct {
|
||||||
|
DiscordID int
|
||||||
|
Content string
|
||||||
|
Timestamp time.Time
|
||||||
|
Mapped bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueuedMessage struct {
|
||||||
|
Content string
|
||||||
|
ChannelID string
|
||||||
|
Timestamp time.Time
|
||||||
|
DiscordID int
|
||||||
|
}
|
||||||
|
|
||||||
type Bridge struct {
|
type Bridge struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
session *discordgo.Session
|
session *discordgo.Session
|
||||||
sneed *sneed.Client
|
sneed *sneed.Client
|
||||||
|
httpClient *http.Client
|
||||||
|
|
||||||
|
sneedToDiscord *utils.BoundedMap
|
||||||
|
discordToSneed *utils.BoundedMap
|
||||||
|
sneedUsernames *utils.BoundedMap
|
||||||
|
|
||||||
|
recentOutbound []OutboundEntry
|
||||||
|
recentOutboundMu sync.Mutex
|
||||||
|
|
||||||
|
queuedOutbound []QueuedMessage
|
||||||
|
queuedOutboundMu sync.Mutex
|
||||||
|
|
||||||
|
outageMessages []*discordgo.Message
|
||||||
|
outageMessagesMu sync.Mutex
|
||||||
|
outageStart time.Time
|
||||||
|
|
||||||
|
stopCh chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
|
func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
|
||||||
@@ -20,9 +74,36 @@ func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
b := &Bridge{cfg: cfg, session: s, sneed: sneedClient}
|
b := &Bridge{
|
||||||
s.AddHandler(b.onReady)
|
cfg: cfg,
|
||||||
s.AddHandler(b.onMessage)
|
session: s,
|
||||||
|
sneed: sneedClient,
|
||||||
|
httpClient: &http.Client{Timeout: 60 * time.Second},
|
||||||
|
sneedToDiscord: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
||||||
|
discordToSneed: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
||||||
|
sneedUsernames: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
||||||
|
recentOutbound: make([]OutboundEntry, 0, ProcessedCacheSize),
|
||||||
|
queuedOutbound: make([]QueuedMessage, 0),
|
||||||
|
outageMessages: make([]*discordgo.Message, 0),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// hook Sneed client callbacks
|
||||||
|
sneedClient.OnMessage = b.onSneedMessage
|
||||||
|
sneedClient.OnEdit = b.handleSneedEdit
|
||||||
|
sneedClient.OnDelete = b.handleSneedDelete
|
||||||
|
sneedClient.OnConnect = b.onSneedConnect
|
||||||
|
sneedClient.OnDisconnect = b.onSneedDisconnect
|
||||||
|
sneedClient.SetOutboundIter(b.recentOutboundIter)
|
||||||
|
sneedClient.SetMapDiscordSneed(b.mapDiscordSneed)
|
||||||
|
sneedClient.SetBridgeIdentity(cfg.BridgeUserID, cfg.BridgeUsername)
|
||||||
|
|
||||||
|
// Discord event handlers
|
||||||
|
s.AddHandler(b.onDiscordReady)
|
||||||
|
s.AddHandler(b.onDiscordMessageCreate)
|
||||||
|
s.AddHandler(b.onDiscordMessageEdit)
|
||||||
|
s.AddHandler(b.onDiscordMessageDelete)
|
||||||
|
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,30 +112,348 @@ 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)
|
||||||
|
go b.cleanupLoop()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) Stop() {
|
func (b *Bridge) Stop() {
|
||||||
|
close(b.stopCh)
|
||||||
b.session.Close()
|
b.session.Close()
|
||||||
|
b.wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) onReady(s *discordgo.Session, r *discordgo.Ready) {
|
func (b *Bridge) cleanupLoop() {
|
||||||
|
defer b.wg.Done()
|
||||||
|
t := time.NewTicker(MappingCleanupInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
removed := 0
|
||||||
|
removed += b.sneedToDiscord.CleanupOldEntries()
|
||||||
|
removed += b.discordToSneed.CleanupOldEntries()
|
||||||
|
removed += b.sneedUsernames.CleanupOldEntries()
|
||||||
|
if removed > 0 {
|
||||||
|
log.Printf("🧹 Cleaned up %d old message mappings", removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup expired queued messages
|
||||||
|
b.queuedOutboundMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
filtered := make([]QueuedMessage, 0)
|
||||||
|
for _, msg := range b.queuedOutbound {
|
||||||
|
if now.Sub(msg.Timestamp) <= QueuedMessageTTL {
|
||||||
|
filtered = append(filtered, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.queuedOutbound = filtered
|
||||||
|
b.queuedOutboundMu.Unlock()
|
||||||
|
|
||||||
|
case <-b.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) onDiscordReady(s *discordgo.Session, r *discordgo.Ready) {
|
||||||
log.Printf("🤖 Discord bot ready: %s (%s)", r.User.Username, r.User.ID)
|
log.Printf("🤖 Discord bot ready: %s (%s)", r.User.Username, r.User.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
|
func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||||||
if m.Author == nil || m.Author.Bot {
|
if m.Author == nil || m.Author.Bot {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if m.ChannelID != b.cfg.DiscordChannelID {
|
if m.ChannelID != b.cfg.DiscordChannelID {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Simple pass-through to Sneedchat (extend with attachments, mapping, etc., as in your original)
|
|
||||||
if ok := b.sneed.Send(m.Content); !ok {
|
log.Printf("📤 Discord → Sneedchat: %s: %s", m.Author.Username, m.Content)
|
||||||
s.ChannelMessageSend(m.ChannelID, "⚠️ Sneedchat appears offline. Message not sent.")
|
|
||||||
|
contentText := strings.TrimSpace(m.Content)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
contentText = fmt.Sprintf("@%s, %s", uname.(string), contentText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachmentsBB []string
|
||||||
|
if len(m.Attachments) > MaxAttachments {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("📤 Discord → Sneedchat: %s: %s", m.Author.Username, m.Content)
|
for _, att := range m.Attachments {
|
||||||
// Basic echo confirmation
|
url, err := b.uploadToLitterbox(att.URL, att.Filename)
|
||||||
_, _ = s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("✅ Sent to Sneedchat: %s", m.Content))
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ct := strings.ToLower(att.ContentType)
|
||||||
|
if strings.HasPrefix(ct, "video") || strings.HasSuffix(strings.ToLower(att.Filename), ".mp4") ||
|
||||||
|
strings.HasSuffix(strings.ToLower(att.Filename), ".webm") {
|
||||||
|
attachmentsBB = append(attachmentsBB, fmt.Sprintf("[url=%s][video]%s[/video][/url]", url, url))
|
||||||
|
} else {
|
||||||
|
attachmentsBB = append(attachmentsBB, fmt.Sprintf("[url=%s][img]%s[/img][/url]", url, url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
combined := contentText
|
||||||
|
if len(attachmentsBB) > 0 {
|
||||||
|
if combined != "" {
|
||||||
|
combined += "\n"
|
||||||
|
}
|
||||||
|
combined += strings.Join(attachmentsBB, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.sneed.Send(combined) {
|
||||||
|
b.recentOutboundMu.Lock()
|
||||||
|
b.recentOutbound = append(b.recentOutbound, OutboundEntry{
|
||||||
|
DiscordID: parseMessageID(m.ID),
|
||||||
|
Content: combined,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Mapped: false,
|
||||||
|
})
|
||||||
|
if len(b.recentOutbound) > ProcessedCacheSize {
|
||||||
|
b.recentOutbound = b.recentOutbound[1:]
|
||||||
|
}
|
||||||
|
b.recentOutboundMu.Unlock()
|
||||||
|
} else {
|
||||||
|
b.queuedOutboundMu.Lock()
|
||||||
|
b.queuedOutbound = append(b.queuedOutbound, QueuedMessage{
|
||||||
|
Content: combined,
|
||||||
|
ChannelID: m.ChannelID,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
DiscordID: parseMessageID(m.ID),
|
||||||
|
})
|
||||||
|
b.queuedOutboundMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) onDiscordMessageEdit(s *discordgo.Session, m *discordgo.MessageUpdate) {
|
||||||
|
if m.Author == nil || m.Author.Bot {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.ChannelID != b.cfg.DiscordChannelID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
discordID := parseMessageID(m.ID)
|
||||||
|
sneedIDInt, ok := b.discordToSneed.Get(discordID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sneedID := sneedIDInt.(int)
|
||||||
|
payload := map[string]interface{}{"id": sneedID, "message": strings.TrimSpace(m.Content)}
|
||||||
|
data, _ := json.Marshal(payload)
|
||||||
|
log.Printf("↩️ Discord edit -> Sneedchat (sneed_id=%d)", sneedID)
|
||||||
|
b.sneed.Send(fmt.Sprintf("/edit %s", string(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) onDiscordMessageDelete(s *discordgo.Session, m *discordgo.MessageDelete) {
|
||||||
|
if m.ChannelID != b.cfg.DiscordChannelID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
discordID := parseMessageID(m.ID)
|
||||||
|
sneedIDInt, ok := b.discordToSneed.Get(discordID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("↩️ Discord delete -> Sneedchat (sneed_id=%d)", sneedIDInt.(int))
|
||||||
|
b.sneed.Send(fmt.Sprintf("/delete %d", sneedIDInt.(int)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) onSneedMessage(msg map[string]interface{}) {
|
||||||
|
username, _ := msg["username"].(string)
|
||||||
|
rawContent, _ := msg["content"].(string)
|
||||||
|
content := utils.BBCodeToMarkdown(rawContent)
|
||||||
|
|
||||||
|
log.Printf("📄 New Sneed message from %s", username)
|
||||||
|
|
||||||
|
content = sneed.ReplaceBridgeMention(content, b.cfg.BridgeUsername, b.cfg.DiscordPingUserID)
|
||||||
|
|
||||||
|
var avatarURL string
|
||||||
|
if raw, ok := msg["raw"].(sneed.SneedMessage); ok {
|
||||||
|
if a, ok2 := raw.Author["avatar_url"].(string); ok2 {
|
||||||
|
if strings.HasPrefix(a, "/") {
|
||||||
|
avatarURL = "https://kiwifarms.st" + a
|
||||||
|
} else {
|
||||||
|
avatarURL = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
|
||||||
|
params := &discordgo.WebhookParams{
|
||||||
|
Content: content,
|
||||||
|
Username: username,
|
||||||
|
AvatarURL: avatarURL,
|
||||||
|
}
|
||||||
|
sent, err := b.session.WebhookExecute(webhookID, webhookToken, true, params)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Failed to send Sneed → Discord webhook message: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ Sent Sneedchat → Discord: %s", username)
|
||||||
|
|
||||||
|
if sent != nil {
|
||||||
|
if mid, ok := msg["message_id"].(int); ok && mid > 0 {
|
||||||
|
discordMsgID := parseMessageID(sent.ID)
|
||||||
|
b.sneedToDiscord.Set(mid, discordMsgID)
|
||||||
|
b.discordToSneed.Set(discordMsgID, mid)
|
||||||
|
b.sneedUsernames.Set(mid, username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) handleSneedEdit(sneedID int, newContent string) {
|
||||||
|
discordIDInt, ok := b.sneedToDiscord.Get(sneedID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
discordID := discordIDInt.(int)
|
||||||
|
parsed := utils.BBCodeToMarkdown(newContent)
|
||||||
|
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
|
||||||
|
edit := &discordgo.WebhookEdit{Content: &parsed}
|
||||||
|
_, err := b.session.WebhookMessageEdit(webhookID, webhookToken, fmt.Sprintf("%d", discordID), edit)
|
||||||
|
if err != nil {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) handleSneedDelete(sneedID int) {
|
||||||
|
discordIDInt, ok := b.sneedToDiscord.Get(sneedID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
discordID := discordIDInt.(int)
|
||||||
|
webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL)
|
||||||
|
err := b.session.WebhookMessageDelete(webhookID, webhookToken, fmt.Sprintf("%d", discordID))
|
||||||
|
if err != nil {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) onSneedConnect() {
|
||||||
|
log.Println("🟢 Sneedchat connected")
|
||||||
|
b.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "online"})
|
||||||
|
go b.flushQueuedMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) onSneedDisconnect() {
|
||||||
|
log.Println("🔴 Sneedchat disconnected")
|
||||||
|
b.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "idle"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) flushQueuedMessages() {
|
||||||
|
b.queuedOutboundMu.Lock()
|
||||||
|
queued := make([]QueuedMessage, len(b.queuedOutbound))
|
||||||
|
copy(queued, b.queuedOutbound)
|
||||||
|
b.queuedOutbound = b.queuedOutbound[:0]
|
||||||
|
b.queuedOutboundMu.Unlock()
|
||||||
|
|
||||||
|
if len(queued) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Flushing %d queued messages to Sneedchat", len(queued))
|
||||||
|
|
||||||
|
for _, msg := range queued {
|
||||||
|
age := time.Since(msg.Timestamp)
|
||||||
|
if age > QueuedMessageTTL {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.sneed.Send(msg.Content) {
|
||||||
|
b.recentOutboundMu.Lock()
|
||||||
|
b.recentOutbound = append(b.recentOutbound, OutboundEntry{
|
||||||
|
DiscordID: msg.DiscordID,
|
||||||
|
Content: msg.Content,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Mapped: false,
|
||||||
|
})
|
||||||
|
if len(b.recentOutbound) > ProcessedCacheSize {
|
||||||
|
b.recentOutbound = b.recentOutbound[1:]
|
||||||
|
}
|
||||||
|
b.recentOutboundMu.Unlock()
|
||||||
|
log.Printf("✅ Queued message delivered to Sneedchat after reconnect.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) recentOutboundIter() []map[string]interface{} {
|
||||||
|
b.recentOutboundMu.Lock()
|
||||||
|
defer b.recentOutboundMu.Unlock()
|
||||||
|
res := make([]map[string]interface{}, len(b.recentOutbound))
|
||||||
|
for i, e := range b.recentOutbound {
|
||||||
|
res[i] = map[string]interface{}{
|
||||||
|
"discord_id": e.DiscordID,
|
||||||
|
"content": e.Content,
|
||||||
|
"ts": e.Timestamp,
|
||||||
|
"mapped": e.Mapped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) uploadToLitterbox(fileURL, filename string) (string, error) {
|
||||||
|
resp, err := b.httpClient.Get(fileURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
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 {
|
||||||
|
return "", fmt.Errorf("Litterbox returned HTTP %d", uResp.StatusCode)
|
||||||
|
}
|
||||||
|
out, _ := io.ReadAll(uResp.Body)
|
||||||
|
url := strings.TrimSpace(string(out))
|
||||||
|
log.Printf("SUCCESS: Uploaded '%s' to Litterbox: %s", filename, url)
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWebhookURL(webhookURL string) (string, string) {
|
||||||
|
parts := strings.Split(webhookURL, "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return parts[len(parts)-2], parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMessageID(id string) int {
|
||||||
|
parsed, _ := strconv.ParseInt(id, 10, 64)
|
||||||
|
return int(parsed)
|
||||||
}
|
}
|
||||||
|
|||||||
11
main.go
11
main.go
@@ -15,7 +15,6 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
envFile := ".env"
|
envFile := ".env"
|
||||||
// allow: ./bin --env /path/to/.env
|
|
||||||
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]
|
||||||
@@ -30,21 +29,19 @@ func main() {
|
|||||||
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)
|
||||||
|
|
||||||
// Cookie service
|
// Cookie service (HTTP/1.1, KF PoW, CSRF retry)
|
||||||
cookieSvc := cookie.NewRefreshService(cfg.BridgeUsername, cfg.BridgePassword, "kiwifarms.st")
|
cookieSvc := cookie.NewRefreshService(cfg.BridgeUsername, cfg.BridgePassword, "kiwifarms.st")
|
||||||
cookieSvc.Start()
|
cookieSvc.Start()
|
||||||
log.Println("⏳ Waiting for initial cookie...")
|
log.Println("⏳ Waiting for initial cookie...")
|
||||||
cookieSvc.WaitForCookie()
|
cookieSvc.WaitForCookie()
|
||||||
|
if cookieSvc.GetCurrentCookie() == "" {
|
||||||
initialCookie := cookieSvc.GetCurrentCookie()
|
|
||||||
if initialCookie == "" {
|
|
||||||
log.Fatal("❌ Failed to obtain initial cookie, cannot start bridge")
|
log.Fatal("❌ Failed to obtain initial cookie, cannot start bridge")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sneedchat client
|
// Sneedchat client
|
||||||
sneedClient := sneed.NewClient(cfg.SneedchatRoomID, cookieSvc)
|
sneedClient := sneed.NewClient(cfg.SneedchatRoomID, cookieSvc)
|
||||||
|
|
||||||
// Discord bridge
|
// Discord bridge (full parity features)
|
||||||
bridge, err := discord.NewBridge(cfg, sneedClient)
|
bridge, err := discord.NewBridge(cfg, sneedClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create Discord bridge: %v", err)
|
log.Fatalf("Failed to create Discord bridge: %v", err)
|
||||||
@@ -54,7 +51,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Println("🌉 Discord-Sneedchat Bridge started successfully")
|
log.Println("🌉 Discord-Sneedchat Bridge started successfully")
|
||||||
|
|
||||||
// Periodic cookie refresh
|
// Auto cookie refresh every 4h
|
||||||
go func() {
|
go func() {
|
||||||
t := time.NewTicker(4 * time.Hour)
|
t := time.NewTicker(4 * time.Hour)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
|
|||||||
235
sneed/client.go
235
sneed/client.go
@@ -1,14 +1,27 @@
|
|||||||
package sneed
|
package sneed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"local/sneedchatbridge/cookie"
|
"local/sneedchatbridge/cookie"
|
||||||
|
"local/sneedchatbridge/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProcessedCacheSize = 250
|
||||||
|
ReconnectInterval = 7 * time.Second
|
||||||
|
MappingCacheSize = 1000
|
||||||
|
MappingCleanupInterval = 5 * time.Minute
|
||||||
|
MappingMaxAge = 1 * time.Hour
|
||||||
|
OutboundMatchWindow = 60 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -23,17 +36,45 @@ type Client struct {
|
|||||||
lastMessage time.Time
|
lastMessage time.Time
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
|
||||||
|
// processed
|
||||||
|
processedMu sync.Mutex
|
||||||
|
processedMessageIDs []int
|
||||||
|
|
||||||
|
messageEditDates *utils.BoundedMap
|
||||||
|
|
||||||
|
// event callbacks
|
||||||
|
OnMessage func(map[string]interface{})
|
||||||
|
OnEdit func(int, string)
|
||||||
|
OnDelete func(int)
|
||||||
|
OnConnect func()
|
||||||
|
OnDisconnect func()
|
||||||
|
|
||||||
|
// outbound correlation for echo suppression / mapping
|
||||||
|
recentOutboundIter func() []map[string]interface{}
|
||||||
|
mapDiscordSneed func(int, int, string)
|
||||||
|
|
||||||
|
bridgeUserID int
|
||||||
|
bridgeUsername string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(roomID int, cookieSvc *cookie.RefreshService) *Client {
|
func NewClient(roomID int, cookieSvc *cookie.RefreshService) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
wsURL: "wss://kiwifarms.st:9443/chat.ws",
|
wsURL: "wss://kiwifarms.st:9443/chat.ws",
|
||||||
roomID: roomID,
|
roomID: roomID,
|
||||||
cookies: cookieSvc,
|
cookies: cookieSvc,
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
|
processedMessageIDs: make([]int, 0, ProcessedCacheSize),
|
||||||
|
messageEditDates: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
|
||||||
|
lastMessage: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetBridgeIdentity(userID int, username string) {
|
||||||
|
c.bridgeUserID = userID
|
||||||
|
c.bridgeUsername = username
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) Connect() error {
|
func (c *Client) Connect() error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if c.connected {
|
if c.connected {
|
||||||
@@ -43,7 +84,9 @@ func (c *Client) Connect() error {
|
|||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
headers := http.Header{}
|
headers := http.Header{}
|
||||||
headers.Add("Cookie", c.cookies.GetCurrentCookie())
|
if ck := c.cookies.GetCurrentCookie(); ck != "" {
|
||||||
|
headers.Add("Cookie", ck)
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("Connecting to Sneedchat room %d", c.roomID)
|
log.Printf("Connecting to Sneedchat room %d", c.roomID)
|
||||||
conn, _, err := websocket.DefaultDialer.Dial(c.wsURL, headers)
|
conn, _, err := websocket.DefaultDialer.Dial(c.wsURL, headers)
|
||||||
@@ -63,6 +106,9 @@ func (c *Client) Connect() error {
|
|||||||
go c.joinRoom()
|
go 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 {
|
||||||
|
c.OnConnect()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +141,7 @@ func (c *Client) readLoop() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.lastMessage = time.Now()
|
c.lastMessage = time.Now()
|
||||||
_ = message // plug in your existing JSON handling if needed
|
c.handleIncoming(string(message))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +193,11 @@ func (c *Client) handleDisconnect() {
|
|||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
log.Println("🔴 Sneedchat disconnected")
|
log.Println("🔴 Sneedchat disconnected")
|
||||||
time.Sleep(7 * time.Second)
|
if c.OnDisconnect != nil {
|
||||||
_ = c.Connect() // try once; your original had a loop — add if desired
|
c.OnDisconnect()
|
||||||
|
}
|
||||||
|
time.Sleep(ReconnectInterval)
|
||||||
|
_ = c.Connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Disconnect() {
|
func (c *Client) Disconnect() {
|
||||||
@@ -161,3 +210,173 @@ func (c *Client) Disconnect() {
|
|||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
c.wg.Wait()
|
c.wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleIncoming(raw string) {
|
||||||
|
var payload SneedPayload
|
||||||
|
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// top-level deletes
|
||||||
|
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 _, id := range ids {
|
||||||
|
c.messageEditDates.Delete(id)
|
||||||
|
c.removeFromProcessed(id)
|
||||||
|
if c.OnDelete != nil {
|
||||||
|
c.OnDelete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// messages list or single
|
||||||
|
var messages []SneedMessage
|
||||||
|
if len(payload.Messages) > 0 {
|
||||||
|
messages = payload.Messages
|
||||||
|
} else if payload.Message != nil {
|
||||||
|
messages = []SneedMessage{*payload.Message}
|
||||||
|
}
|
||||||
|
for _, m := range messages {
|
||||||
|
c.processMessage(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) processMessage(m SneedMessage) {
|
||||||
|
username := "Unknown"
|
||||||
|
var userID int
|
||||||
|
if a, ok := m.Author["username"].(string); ok {
|
||||||
|
username = a
|
||||||
|
}
|
||||||
|
if id, ok := m.Author["id"].(float64); ok {
|
||||||
|
userID = int(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
messageText := m.MessageRaw
|
||||||
|
if messageText == "" {
|
||||||
|
messageText = m.Message
|
||||||
|
}
|
||||||
|
messageText = html.UnescapeString(messageText)
|
||||||
|
|
||||||
|
editDate := m.MessageEditDate
|
||||||
|
deleted := m.Deleted || m.IsDeleted
|
||||||
|
if deleted {
|
||||||
|
c.messageEditDates.Delete(m.MessageID)
|
||||||
|
c.removeFromProcessed(m.MessageID)
|
||||||
|
if c.OnDelete != nil {
|
||||||
|
c.OnDelete(m.MessageID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// suppress bridge echoes
|
||||||
|
if (c.bridgeUserID > 0 && userID == c.bridgeUserID) ||
|
||||||
|
(c.bridgeUsername != "" && username == c.bridgeUsername) {
|
||||||
|
// correlate outbound -> map IDs
|
||||||
|
if m.MessageID > 0 && c.recentOutboundIter != nil && c.mapDiscordSneed != nil {
|
||||||
|
now := time.Now()
|
||||||
|
for _, entry := range c.recentOutboundIter() {
|
||||||
|
if mapped, ok := entry["mapped"].(bool); ok && mapped {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, _ := entry["content"].(string)
|
||||||
|
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)
|
||||||
|
entry["mapped"] = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.addToProcessed(m.MessageID)
|
||||||
|
c.messageEditDates.Set(m.MessageID, editDate)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// de-dup / edits
|
||||||
|
if c.isProcessed(m.MessageID) {
|
||||||
|
if prev, exists := c.messageEditDates.Get(m.MessageID); exists {
|
||||||
|
if editDate > prev.(int) {
|
||||||
|
c.messageEditDates.Set(m.MessageID, editDate)
|
||||||
|
if c.OnEdit != nil {
|
||||||
|
c.OnEdit(m.MessageID, messageText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// new message
|
||||||
|
c.addToProcessed(m.MessageID)
|
||||||
|
c.messageEditDates.Set(m.MessageID, editDate)
|
||||||
|
|
||||||
|
if c.OnMessage != nil {
|
||||||
|
c.OnMessage(map[string]interface{}{
|
||||||
|
"username": username,
|
||||||
|
"content": messageText,
|
||||||
|
"message_id": m.MessageID,
|
||||||
|
"author_id": userID,
|
||||||
|
"raw": m,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) isProcessed(id int) bool {
|
||||||
|
c.processedMu.Lock()
|
||||||
|
defer c.processedMu.Unlock()
|
||||||
|
for _, x := range c.processedMessageIDs {
|
||||||
|
if x == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) addToProcessed(id int) {
|
||||||
|
c.processedMu.Lock()
|
||||||
|
defer c.processedMu.Unlock()
|
||||||
|
c.processedMessageIDs = append(c.processedMessageIDs, id)
|
||||||
|
if len(c.processedMessageIDs) > ProcessedCacheSize {
|
||||||
|
c.processedMessageIDs = c.processedMessageIDs[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) removeFromProcessed(id int) {
|
||||||
|
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:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpers for mapping in bridge
|
||||||
|
func (c *Client) SetOutboundIter(f func() []map[string]interface{}) {
|
||||||
|
c.recentOutboundIter = f
|
||||||
|
}
|
||||||
|
func (c *Client) SetMapDiscordSneed(f func(int, int, string)) {
|
||||||
|
c.mapDiscordSneed = f
|
||||||
|
}
|
||||||
|
|
||||||
|
// expose helper for mention replacement
|
||||||
|
func ReplaceBridgeMention(content, bridgeUsername, pingID string) string {
|
||||||
|
if bridgeUsername == "" || pingID == "" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
pat := regexp.MustCompile(fmt.Sprintf(`(?i)@%s(?:\W|$)`, regexp.QuoteMeta(bridgeUsername)))
|
||||||
|
return pat.ReplaceAllString(content, fmt.Sprintf("<@%s>", pingID))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user