Compare commits

..
Author SHA1 Message Date
codegirl007 2b3aac413d Publish Discord inbound replies on the post event bus.
CI / test (pull_request) Successful in 6m21s
2026-08-29 11:38:11 -07:00
codegirl007 a1b0351048 Merge pull request 'Add Discord posting, inbound replies, and reply mail' (#18) from mail-event-bus into master
CI / test (push) Successful in 6m21s
Reviewed-on: #18
2026-08-29 18:32:46 +00:00
5 changed files with 66 additions and 55 deletions
+1 -1
View File
@@ -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)
}
+11 -7
View File
@@ -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
+33 -3
View File
@@ -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")
}
}
+14 -38
View File
@@ -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
}
recipientID := parent.AuthorID
if author.Admin() {
recipientID = root.AuthorID
}
if recipientID == author.ID {
return
}
msg := mail.PostReply{
ev := events.PostEvent{
PostID: reply.ID,
RootID: root.ID,
RootTitle: root.Title,
ReplyID: reply.ID,
ReplyBody: reply.Body,
ReplyAuthorName: author.Name,
Body: reply.Body,
AuthorID: author.ID,
AuthorName: author.Name,
AuthorRole: string(author.Role),
}
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 reply.ParentID != nil {
ev.ParentID = *reply.ParentID
}
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})
}
+6 -5
View File
@@ -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