179 lines
4.7 KiB
Go
179 lines
4.7 KiB
Go
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
|
|
}
|