Files
Sneedchat-Discord-Bridge-Go/discord/bridge.go
Salastil f954771c0c
All checks were successful
Build & Release / build-latest (push) Successful in 9m53s
Build & Release / version-release (push) Has been skipped
Added a MediaUploadService option to the configuration loader and documented it in the README so operators can pick the attachment backend (defaulting to Litterbox) straight from .env.
Refactored the Discord bridge to build a media service during initialization, route attachment uploads through it, and dynamically label the status embeds and error diagnostics based on the selected provider while preserving the existing progress messaging flow.

Introduced a new media package that defines the uploader interface and ships a Litterbox implementation responsible for fetching Discord attachments and posting them to Catbox while reporting HTTP status codes back to the bridge.
2025-11-18 17:33:42 -05:00

734 lines
22 KiB
Go

package discord
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"unicode"
"github.com/bwmarrin/discordgo"
"local/sneedchatbridge/config"
"local/sneedchatbridge/media"
"local/sneedchatbridge/sneed"
"local/sneedchatbridge/utils"
)
const (
MaxAttachments = 4
ProcessedCacheSize = 250
MappingCacheSize = 1000
MappingMaxAge = 1 * time.Hour
MappingCleanupInterval = 5 * time.Minute
QueuedMessageTTL = 90 * time.Second
OutageUpdateInterval = 10 * 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 {
DiscordID int
Content string
Timestamp time.Time
Mapped bool
}
type QueuedMessage struct {
Content string
ChannelID string
Timestamp time.Time
DiscordID int
}
type Bridge struct {
cfg *config.Config
session *discordgo.Session
sneed *sneed.Client
mediaSvc media.Service
sneedToDiscord *utils.BoundedMap
discordToSneed *utils.BoundedMap
sneedUsernames *utils.BoundedMap
recentOutbound []OutboundEntry
recentOutboundMu sync.Mutex
queuedOutbound []QueuedMessage
queuedOutboundMu sync.Mutex
outageNotices []*discordgo.Message
outageActiveMessage *discordgo.Message
outageMessagesMu sync.Mutex
outageStart time.Time
stopCh chan struct{}
wg sync.WaitGroup
}
func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
s, err := discordgo.New("Bot " + cfg.DiscordBotToken)
if err != nil {
return nil, err
}
mediaSvc, err := media.NewService(cfg.MediaUploadService, &http.Client{Timeout: 60 * time.Second})
if err != nil {
return nil, err
}
b := &Bridge{
cfg: cfg,
session: s,
sneed: sneedClient,
mediaSvc: mediaSvc,
sneedToDiscord: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
discordToSneed: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
sneedUsernames: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge),
recentOutbound: make([]OutboundEntry, 0, ProcessedCacheSize),
queuedOutbound: make([]QueuedMessage, 0),
outageNotices: 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
}
func (b *Bridge) Start() error {
b.session.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent
if err := b.session.Open(); err != nil {
return err
}
b.wg.Add(1)
go b.cleanupLoop()
return nil
}
func (b *Bridge) Stop() {
close(b.stopCh)
b.session.Close()
b.wg.Wait()
}
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)
}
func (b *Bridge) onDiscordMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author == nil || m.Author.Bot {
return
}
if m.ChannelID != b.cfg.DiscordChannelID {
return
}
log.Printf("📤 Discord → Sneedchat: %s: %s", m.Author.Username, m.Content)
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
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 {
return
}
ctx := context.Background()
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 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
}
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)
// Images → wrap inside clickable URL with an image preview
if strings.HasPrefix(ct, "image/") {
attachmentsBB = append(attachmentsBB,
fmt.Sprintf("[url=%s][img]%s[/img][/url]", url, url))
} else {
// Everything else → plain URL (videos, archives, PDFs, audio, etc)
attachmentsBB = append(attachmentsBB, url)
}
}
combined := contentText
if len(attachmentsBB) > 0 {
if combined != "" {
combined += "\n"
}
combined += strings.Join(attachmentsBB, "\n")
}
discordMsgID := parseMessageID(m.ID)
if b.sneed.Send(combined) {
b.recentOutboundMu.Lock()
b.recentOutbound = append(b.recentOutbound, OutboundEntry{
DiscordID: discordMsgID,
Content: combined,
Timestamp: time.Now(),
Mapped: false,
})
if len(b.recentOutbound) > ProcessedCacheSize {
b.recentOutbound = b.recentOutbound[1:]
}
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 {
b.queuedOutboundMu.Lock()
b.queuedOutbound = append(b.queuedOutbound, QueuedMessage{
Content: combined,
ChannelID: m.ChannelID,
Timestamp: time.Now(),
DiscordID: discordMsgID,
})
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)
}
}
}
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"})
queued := b.queuedMessageCount()
b.finishOutageNotification(queued)
go b.flushQueuedMessages()
}
func (b *Bridge) onSneedDisconnect() {
log.Println("🔴 Sneedchat disconnected")
b.session.UpdateStatusComplex(discordgo.UpdateStatusData{Status: "idle"})
b.startOutageNotificationLoop()
}
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) 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{} {
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) sendUploadStatusMessage(channelID, mention string, attachmentCount int) (*discordgo.Message, error) {
desc := fmt.Sprintf("Uploading %d attachment(s) from %s…", attachmentCount, mention)
msg := &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{buildStatusEmbed(b.uploadStatusTitle(""), desc, UploadStatusColorPending)},
}
return b.session.ChannelMessageSendComplex(channelID, msg)
}
func (b *Bridge) uploadStatusTitle(suffix string) string {
base := fmt.Sprintf("%s upload", b.mediaServiceDisplayName())
if suffix == "" {
return base
}
return fmt.Sprintf("%s %s", base, suffix)
}
func (b *Bridge) mediaServiceDisplayName() string {
if b.mediaSvc == nil {
return "Media"
}
name := capitalizeWord(b.mediaSvc.Name())
if name == "" {
return "Media"
}
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) {
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)
}
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)
}