diff --git a/cmd/server/main.go b/cmd/server/main.go index 90a3c2c..ce8efdb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -38,7 +38,7 @@ func main() { } bus := events.New() mail.Subscribe(bus, store.NewPostgres(db), notifier) - bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus, store.NewPostgres(db), notifier) + bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus, store.NewPostgres(db)) if err != nil { log.Fatalf("discord: %v", err) } diff --git a/internal/discord/bot.go b/internal/discord/bot.go index b127969..1dd7342 100644 --- a/internal/discord/bot.go +++ b/internal/discord/bot.go @@ -13,7 +13,6 @@ import ( "github.com/bwmarrin/discordgo" "plumber/internal/events" - "plumber/internal/mail" "plumber/internal/store" ) @@ -25,7 +24,7 @@ type Bot struct { links store.DiscordLinkStore api API store store.Store - mail mail.Notifier + bus events.Publisher admins map[string]string botUserID string } @@ -36,7 +35,7 @@ func New(channelID string, links store.DiscordLinkStore, api API) *Bot { } // FromEnv builds a bot when Discord env is set. Missing config is a no-op. -func FromEnv(links store.DiscordLinkStore, bus *events.Bus, st store.Store, mailer mail.Notifier) (*Bot, error) { +func FromEnv(links store.DiscordLinkStore, bus *events.Bus, st store.Store) (*Bot, error) { token := strings.TrimSpace(os.Getenv("DISCORD_BOT_TOKEN")) channelID := strings.TrimSpace(os.Getenv("DISCORD_CHANNEL_ID")) if token == "" && channelID == "" { @@ -56,12 +55,9 @@ func FromEnv(links store.DiscordLinkStore, bus *events.Bus, st store.Store, mail return nil, err } session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent - if mailer == nil { - mailer = mail.Nop{} - } bot := New(channelID, links, &sessionAPI{session: session}) bot.store = st - bot.mail = mailer + bot.bus = bus bot.admins = parseAdminMap(os.Getenv("DISCORD_ADMIN_MAP")) session.AddHandler(bot.onMessageCreate) if bus != nil { @@ -102,6 +98,14 @@ func (b *Bot) Handle(_ context.Context, ev any) { } func (b *Bot) onCreated(ctx context.Context, ev events.PostEvent) { + _, err := b.links.GetByPostID(ctx, ev.PostID) + if err == nil { + return + } + if !errors.Is(err, sql.ErrNoRows) { + log.Printf("discord: load link %s: %v", ev.PostID, err) + return + } if isRoot(ev) { b.createRoot(ctx, ev) return diff --git a/internal/discord/bot_test.go b/internal/discord/bot_test.go index fde963b..1e34a72 100644 --- a/internal/discord/bot_test.go +++ b/internal/discord/bot_test.go @@ -161,6 +161,36 @@ func TestOutboundSkipsReplyWithoutRootLink(t *testing.T) { } } +func TestOutboundSkipsAlreadyLinkedPost(t *testing.T) { + t.Parallel() + + links := newMemoryLinks() + api := &fakeAPI{} + bot := New("channel-1", links, api) + if err := links.Upsert(context.Background(), store.DiscordLink{ + PostID: "reply-1", + MessageID: "d-reply-1", + }); err != nil { + t.Fatal(err) + } + if err := links.Upsert(context.Background(), store.DiscordLink{ + PostID: "root-1", + MessageID: "d-root", + ThreadID: "thread-1", + }); err != nil { + t.Fatal(err) + } + bot.Handle(context.Background(), events.PostCreated{PostEvent: events.PostEvent{ + PostID: "reply-1", + RootID: "root-1", + ParentID: "root-1", + Body: "Already on Discord.", + }}) + if len(api.sends) != 0 { + t.Fatalf("echoed already-linked reply: %+v", api.sends) + } +} + func TestOutboundUpdateWithoutLinkCreates(t *testing.T) { t.Parallel() @@ -218,7 +248,7 @@ func TestFormatMessage(t *testing.T) { func TestFromEnvDisabled(t *testing.T) { t.Setenv("DISCORD_BOT_TOKEN", "") t.Setenv("DISCORD_CHANNEL_ID", "") - bot, err := FromEnv(newMemoryLinks(), nil, nil, nil) + bot, err := FromEnv(newMemoryLinks(), nil, nil) if err != nil || bot != nil { t.Fatalf("disabled FromEnv = (%v, %v)", bot, err) } @@ -227,12 +257,12 @@ func TestFromEnvDisabled(t *testing.T) { func TestFromEnvRequiresBoth(t *testing.T) { t.Setenv("DISCORD_BOT_TOKEN", "token") t.Setenv("DISCORD_CHANNEL_ID", "") - if _, err := FromEnv(newMemoryLinks(), nil, nil, nil); err == nil { + if _, err := FromEnv(newMemoryLinks(), nil, nil); err == nil { t.Fatal("expected error when channel is missing") } t.Setenv("DISCORD_BOT_TOKEN", "") t.Setenv("DISCORD_CHANNEL_ID", "channel") - if _, err := FromEnv(newMemoryLinks(), nil, nil, nil); err == nil { + if _, err := FromEnv(newMemoryLinks(), nil, nil); err == nil { t.Fatal("expected error when token is missing") } } diff --git a/internal/discord/inbound.go b/internal/discord/inbound.go index c7414a7..6d6ad74 100644 --- a/internal/discord/inbound.go +++ b/internal/discord/inbound.go @@ -7,11 +7,10 @@ import ( "fmt" "log" "strings" - "time" "github.com/bwmarrin/discordgo" - "plumber/internal/mail" + "plumber/internal/events" "plumber/internal/store" ) @@ -127,8 +126,9 @@ func (b *Bot) handleInbound(in inboundMessage) { MessageID: in.ID, }); err != nil { log.Printf("discord: save inbound link %s: %v", reply.ID, err) + return } - b.notifyInboundReply(parent, root, reply, author) + b.publishInbound(reply, root, author) log.Printf("discord: inbound reply %s -> post %s", in.ID, reply.ID) } @@ -184,44 +184,20 @@ func (b *Bot) postAndRoot(ctx context.Context, postID string) (*store.Post, *sto return post, current, nil } -func (b *Bot) notifyInboundReply(parent, root, reply *store.Post, author *store.User) { - if parent == nil || root == nil || reply == nil || author == nil || b.mail == nil { +func (b *Bot) publishInbound(reply, root *store.Post, author *store.User) { + if b == nil || b.bus == nil || reply == nil || root == nil || author == nil { return } - if _, disabled := b.mail.(mail.Nop); disabled { - return + ev := events.PostEvent{ + PostID: reply.ID, + RootID: root.ID, + Body: reply.Body, + AuthorID: author.ID, + AuthorName: author.Name, + AuthorRole: string(author.Role), } - recipientID := parent.AuthorID - if author.Admin() { - recipientID = root.AuthorID + if reply.ParentID != nil { + ev.ParentID = *reply.ParentID } - if recipientID == author.ID { - return - } - msg := mail.PostReply{ - RootID: root.ID, - RootTitle: root.Title, - ReplyID: reply.ID, - ReplyBody: reply.Body, - ReplyAuthorName: author.Name, - } - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - recipient, err := b.store.UserByID(ctx, recipientID) - if err != nil { - log.Printf("notify reply %s: load recipient: %v", msg.ReplyID, err) - return - } - if recipient == nil || strings.TrimSpace(recipient.Email) == "" { - return - } - msg.ToEmail = recipient.Email - msg.ToName = recipient.Name - if err := b.mail.NotifyPostReply(ctx, msg); err != nil { - log.Printf("notify reply %s: %v", msg.ReplyID, err) - return - } - log.Printf("notify reply %s: accepted", msg.ReplyID) - }() + b.bus.Publish(context.Background(), events.PostCreated{PostEvent: ev}) } diff --git a/internal/discord/inbound_test.go b/internal/discord/inbound_test.go index 53d925b..7d1ef0e 100644 --- a/internal/discord/inbound_test.go +++ b/internal/discord/inbound_test.go @@ -27,9 +27,11 @@ func TestInboundCreatesSiteReply(t *testing.T) { links := newMemoryLinks() api := &fakeAPI{} mailer := &mail.Recording{} - bot := inboundTestBot(mem, links, api, mailer, admin.Username) + bot := inboundTestBot(mem, links, api, admin.Username) bus := events.New() defer bus.Close() + bot.bus = bus + mail.Subscribe(bus, mem, mailer) bus.Subscribe(bot.Handle) root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1") @@ -68,7 +70,7 @@ func TestInboundParentsFromReference(t *testing.T) { mem, homeowner, admin := seedInboundUsers(t) links := newMemoryLinks() - bot := inboundTestBot(mem, links, &fakeAPI{}, &mail.Recording{}, admin.Username) + bot := inboundTestBot(mem, links, &fakeAPI{}, admin.Username) root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1") plumberReply := &store.Post{ParentID: &root.ID, AuthorID: admin.ID, Body: "First look."} if err := mem.CreatePost(context.Background(), plumberReply); err != nil { @@ -106,7 +108,7 @@ func TestInboundIgnoresAllowlistHiddenAndEchoSources(t *testing.T) { mem, homeowner, admin := seedInboundUsers(t) links := newMemoryLinks() api := &fakeAPI{} - bot := inboundTestBot(mem, links, api, mail.Nop{}, admin.Username) + bot := inboundTestBot(mem, links, api, admin.Username) root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1") hidden := &store.Post{ AuthorID: homeowner.ID, @@ -148,10 +150,9 @@ func TestInboundIgnoresAllowlistHiddenAndEchoSources(t *testing.T) { } } -func inboundTestBot(mem *store.Memory, links *memoryLinks, api *fakeAPI, mailer mail.Notifier, adminUsername string) *Bot { +func inboundTestBot(mem *store.Memory, links *memoryLinks, api *fakeAPI, adminUsername string) *Bot { bot := New("channel-1", links, api) bot.store = mem - bot.mail = mailer bot.admins = map[string]string{"snow-admin": adminUsername} bot.botUserID = "bot-1" return bot