package discord import ( "context" "database/sql" "errors" "fmt" "log" "os" "strings" "time" "github.com/bwmarrin/discordgo" "plumber/internal/events" "plumber/internal/store" ) const discordTimeout = 15 * time.Second // Bot posts site events to a Discord channel and owns post-to-message links. type Bot struct { channelID string links store.DiscordLinkStore api API } // New constructs an outbound subscriber. Tests inject a fake API. func New(channelID string, links store.DiscordLinkStore, api API) *Bot { return &Bot{channelID: strings.TrimSpace(channelID), links: links, api: api} } // FromEnv builds a bot when Discord env is set. Missing config is a no-op. func FromEnv(links store.DiscordLinkStore, bus *events.Bus) (*Bot, error) { token := strings.TrimSpace(os.Getenv("DISCORD_BOT_TOKEN")) channelID := strings.TrimSpace(os.Getenv("DISCORD_CHANNEL_ID")) if token == "" && channelID == "" { return nil, nil } if token == "" { return nil, fmt.Errorf("DISCORD_BOT_TOKEN is required when DISCORD_CHANNEL_ID is set") } if channelID == "" { return nil, fmt.Errorf("DISCORD_CHANNEL_ID is required when DISCORD_BOT_TOKEN is set") } if links == nil { return nil, fmt.Errorf("discord links store is required") } session, err := discordgo.New("Bot " + token) if err != nil { return nil, err } bot := New(channelID, links, &sessionAPI{session: session}) if bus != nil { bus.Subscribe(bot.Handle) } log.Printf("discord: outbound subscriber enabled") return bot, nil } // Close releases the Discord session. func (b *Bot) Close() error { if b == nil || b.api == nil { return nil } return b.api.Close() } // Handle processes one site event. Failures are logged and do not fail the request. func (b *Bot) Handle(_ context.Context, ev any) { if b == nil { return } ctx, cancel := context.WithTimeout(context.Background(), discordTimeout) defer cancel() switch e := ev.(type) { case events.PostCreated: b.onCreated(ctx, e.PostEvent) case events.PostUpdated: b.onUpdated(ctx, e.PostEvent) } } func (b *Bot) onCreated(ctx context.Context, ev events.PostEvent) { if isRoot(ev) { b.createRoot(ctx, ev) return } b.createReply(ctx, ev) } func (b *Bot) onUpdated(ctx context.Context, ev events.PostEvent) { link, err := b.links.GetByPostID(ctx, ev.PostID) if err != nil { if errors.Is(err, sql.ErrNoRows) { b.onCreated(ctx, ev) return } log.Printf("discord: load link %s: %v", ev.PostID, err) return } channelID, err := b.editChannel(ctx, ev, link) if err != nil { log.Printf("discord: edit channel %s: %v", ev.PostID, err) return } if err := b.api.Edit(ctx, channelID, link.MessageID, formatMessage(ev)); err != nil { log.Printf("discord: edit %s: %v", ev.PostID, err) return } log.Printf("discord: edited %s", ev.PostID) } func (b *Bot) createRoot(ctx context.Context, ev events.PostEvent) { msg := formatMessage(ev) messageID, err := b.api.SendToChannel(ctx, b.channelID, msg) if err != nil { log.Printf("discord: send root %s: %v", ev.PostID, err) return } threadID, err := b.api.StartThread(ctx, b.channelID, messageID, msg.ThreadName) if err != nil { log.Printf("discord: start thread %s: %v", ev.PostID, err) return } if err := b.links.Upsert(ctx, store.DiscordLink{ PostID: ev.PostID, MessageID: messageID, ThreadID: threadID, }); err != nil { log.Printf("discord: save root link %s: %v", ev.PostID, err) return } log.Printf("discord: posted root %s", ev.PostID) } func (b *Bot) createReply(ctx context.Context, ev events.PostEvent) { root, err := b.links.GetByPostID(ctx, ev.RootID) if err != nil { if errors.Is(err, sql.ErrNoRows) { log.Printf("discord: skip reply %s: no root thread", ev.PostID) return } log.Printf("discord: load root link %s: %v", ev.RootID, err) return } if strings.TrimSpace(root.ThreadID) == "" { log.Printf("discord: skip reply %s: no root thread", ev.PostID) return } messageID, err := b.api.SendToThread(ctx, root.ThreadID, formatMessage(ev)) if err != nil { log.Printf("discord: send reply %s: %v", ev.PostID, err) return } if err := b.links.Upsert(ctx, store.DiscordLink{ PostID: ev.PostID, MessageID: messageID, }); err != nil { log.Printf("discord: save reply link %s: %v", ev.PostID, err) return } log.Printf("discord: posted reply %s", ev.PostID) } func (b *Bot) editChannel(ctx context.Context, ev events.PostEvent, link *store.DiscordLink) (string, error) { if strings.TrimSpace(link.ThreadID) != "" { return b.channelID, nil } root, err := b.links.GetByPostID(ctx, ev.RootID) if err != nil { return "", err } if strings.TrimSpace(root.ThreadID) == "" { return "", fmt.Errorf("root %s has no thread", ev.RootID) } return root.ThreadID, nil }