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 OverflowThreshold = 10 OverflowRecoveryThreshold = 5 OverflowBatchSize = 15 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 msgQueue chan map[string]interface{} overflow bool } 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{}), msgQueue: make(chan map[string]interface{}, 500), } // hook Sneed client callbacks sneedClient.OnMessage = b.enqueueSneedMessage 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(3) go b.cleanupLoop() go b.messageWorker() go b.messageWorker() 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 sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(refDiscordID)); ok { sneedUUID := sneedUUIDVal.(string) if uname, ok2 := b.sneedUsernames.Get(sneedUUID); ok2 { contentText = fmt.Sprintf("@%s, %s", uname.(string), contentText) } } } 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) sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(discordID)) if !ok { return } sneedUUID := sneedUUIDVal.(string) payload := map[string]interface{}{"uuid": sneedUUID, "message": strings.TrimSpace(m.Content)} data, _ := json.Marshal(payload) log.Printf("↩️ Discord edit -> Sneedchat (sneed_uuid=%s)", sneedUUID) 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) sneedUUIDVal, ok := b.discordToSneed.Get(strconv.Itoa(discordID)) if !ok { return } sneedUUID := sneedUUIDVal.(string) log.Printf("↩️ Discord delete -> Sneedchat (sneed_uuid=%s)", sneedUUID) b.sneed.Send(fmt.Sprintf("/delete %s", sneedUUID)) } func (b *Bridge) messageWorker() { defer b.wg.Done() for { select { case <-b.stopCh: return case msg, ok := <-b.msgQueue: if !ok { return } depth := len(b.msgQueue) if !b.overflow && depth >= OverflowThreshold { b.overflow = true log.Printf("⚠️ Message queue overflow (%d pending), switching to batch mode", depth) } else if b.overflow && depth <= OverflowRecoveryThreshold { b.overflow = false log.Printf("βœ… Message queue recovered (%d pending), switching to normal mode", depth) } if b.overflow { batch := []map[string]interface{}{msg} for len(batch) < OverflowBatchSize { select { case next, ok := <-b.msgQueue: if !ok { break } batch = append(batch, next) default: goto flushBatch } } flushBatch: b.sendOverflowBatch(batch) } else { b.onSneedMessage(msg) } } } } func (b *Bridge) sendOverflowBatch(batch []map[string]interface{}) { if len(batch) == 0 { return } var description strings.Builder for _, msg := range batch { username, _ := msg["username"].(string) rawContent, _ := msg["content"].(string) content := utils.BBCodeToMarkdown(rawContent) content = sneed.ReplaceBridgeMention(content, b.cfg.BridgeUsername, b.cfg.DiscordPingUserID) var ts string if raw, ok := msg["raw"].(sneed.SneedMessage); ok && raw.MessageDate > 0 { ts = time.Unix(int64(raw.MessageDate), 0).Format("3:04:05 PM") } description.WriteString(fmt.Sprintf("**%s** %s\n%s\n\n", username, ts, content)) } webhookID, webhookToken := parseWebhookURL(b.cfg.DiscordWebhookURL) embed := &discordgo.MessageEmbed{ Title: "overflow mode", Description: strings.TrimSpace(description.String()), Color: OutageEmbedColorActive, } params := &discordgo.WebhookParams{ Embeds: []*discordgo.MessageEmbed{embed}, } _, err := b.session.WebhookExecute(webhookID, webhookToken, false, params) if err != nil { log.Printf("❌ Failed to send overflow batch: %v", err) return } log.Printf("πŸ“¦ Sent overflow batch of %d messages", len(batch)) } func (b *Bridge) enqueueSneedMessage(msg map[string]interface{}) { select { case b.msgQueue <- msg: default: log.Printf("⚠️ Message queue full, dropping message from %s", msg["username"]) } } func (b *Bridge) onSneedMessage(msg map[string]interface{}) { 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 uuid, ok := msg["message_uuid"].(string); ok && uuid != "" { discordMsgID := parseMessageID(sent.ID) b.sneedToDiscord.Set(uuid, discordMsgID) b.discordToSneed.Set(strconv.Itoa(discordMsgID), uuid) b.sneedUsernames.Set(uuid, username) } } } func (b *Bridge) handleSneedEdit(sneedUUID string, newContent string) { discordIDInt, ok := b.sneedToDiscord.Get(sneedUUID) 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_uuid=%s)", discordID, sneedUUID) } func (b *Bridge) handleSneedDelete(sneedUUID string) { discordIDInt, ok := b.sneedToDiscord.Get(sneedUUID) 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_uuid=%s)", discordID, sneedUUID) b.sneedToDiscord.Delete(sneedUUID) b.discordToSneed.Delete(strconv.Itoa(discordID)) b.sneedUsernames.Delete(sneedUUID) } 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(sneedUUID string, discordID int, username string) { b.discordToSneed.Set(strconv.Itoa(discordID), sneedUUID) b.sneedToDiscord.Set(sneedUUID, discordID) b.sneedUsernames.Set(sneedUUID, username) log.Printf("Mapped sneed_uuid=%s <-> discord_id=%d (username='%s')", sneedUUID, discordID, username) } func (b *Bridge) sendUploadStatusMessage(channelID, mention string, attachmentCount int) (*discordgo.Message, error) { 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(strconv.Itoa(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) }