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
codegirl007 de8560948a Merge master into mail-event-bus.
CI / test (pull_request) Successful in 6m21s
2026-08-29 11:32:13 -07:00
codegirl007 8eddbfe438 Post Discord questions as named threads (#17)
CI / test (pull_request) Successful in 6m25s
Stacks on #16. Opens a public thread named "{author} asks: {title}" and puts the post body in the first thread message.

Reviewed-on: #17
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-29 18:25:43 +00:00
codegirl007 911355ae35 Add an in-process post event bus (#13)
CI / test (push) Successful in 6m16s
Handlers emit PostCreated and PostUpdated after a successful write. Store writes do not publish, and hidden roots are skipped.

Reviewed-on: #13
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-29 18:24:13 +00:00
codegirl007 573f54afd2 Send reply mail from the post event bus.
CI / test (pull_request) Successful in 6m27s
2026-08-29 06:19:44 -07:00
codegirl007 1acc296541 Add Discord inbound replies.
CI / test (pull_request) Successful in 6m19s
2026-08-29 01:20:40 -07:00
12 changed files with 863 additions and 103 deletions
+4 -4
View File
@@ -37,14 +37,15 @@ func main() {
log.Fatalf("mail: %v", err) log.Fatalf("mail: %v", err)
} }
bus := events.New() bus := events.New()
bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus) mail.Subscribe(bus, store.NewPostgres(db), notifier)
bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus, store.NewPostgres(db))
if err != nil { if err != nil {
log.Fatalf("discord: %v", err) log.Fatalf("discord: %v", err)
} }
if bot != nil { if bot != nil {
defer bot.Close() defer bot.Close()
} }
handler := newHandler(db, sessions, uploader, notifier, bus) handler := newHandler(db, sessions, uploader, bus)
run(&http.Server{ run(&http.Server{
Addr: listenAddr(), Addr: listenAddr(),
Handler: handler, Handler: handler,
@@ -68,13 +69,12 @@ func openDB() (*sql.DB, *store.SessionStore) {
return db, sessions return db, sessions
} }
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, notifier mail.Notifier, bus events.Publisher) http.Handler { func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, bus events.Publisher) http.Handler {
srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{ srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")), AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
SecureCookie: secureCookieFromEnv(), SecureCookie: secureCookieFromEnv(),
TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")), TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")),
Blob: uploader, Blob: uploader,
Mail: notifier,
Events: bus, Events: bus,
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/"), BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/"),
}) })
+8 -4
View File
@@ -9,7 +9,7 @@ import (
// API is the Discord REST surface used by the outbound subscriber. // API is the Discord REST surface used by the outbound subscriber.
type API interface { type API interface {
SendToChannel(ctx context.Context, channelID string, msg Message) (messageID string, err error) SendToChannel(ctx context.Context, channelID string, msg Message) (messageID string, err error)
StartThread(ctx context.Context, channelID, messageID, name string) (threadID string, err error) StartThread(ctx context.Context, channelID, name string) (threadID string, err error)
SendToThread(ctx context.Context, threadID string, msg Message) (messageID string, err error) SendToThread(ctx context.Context, threadID string, msg Message) (messageID string, err error)
Edit(ctx context.Context, channelID, messageID string, msg Message) error Edit(ctx context.Context, channelID, messageID string, msg Message) error
Close() error Close() error
@@ -27,9 +27,10 @@ func (s *sessionAPI) SendToChannel(_ context.Context, channelID string, msg Mess
return sent.ID, nil return sent.ID, nil
} }
func (s *sessionAPI) StartThread(_ context.Context, channelID, messageID, name string) (string, error) { func (s *sessionAPI) StartThread(_ context.Context, channelID, name string) (string, error) {
thread, err := s.session.MessageThreadStartComplex(channelID, messageID, &discordgo.ThreadStart{ thread, err := s.session.ThreadStartComplex(channelID, &discordgo.ThreadStart{
Name: name, Name: name,
Type: discordgo.ChannelTypeGuildPublicThread,
AutoArchiveDuration: 10080, AutoArchiveDuration: 10080,
}) })
if err != nil { if err != nil {
@@ -43,10 +44,12 @@ func (s *sessionAPI) SendToThread(ctx context.Context, threadID string, msg Mess
} }
func (s *sessionAPI) Edit(_ context.Context, channelID, messageID string, msg Message) error { func (s *sessionAPI) Edit(_ context.Context, channelID, messageID string, msg Message) error {
content := messageContent(msg)
embeds := toEmbeds(msg) embeds := toEmbeds(msg)
_, err := s.session.ChannelMessageEditComplex(&discordgo.MessageEdit{ _, err := s.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
ID: messageID, ID: messageID,
Channel: channelID, Channel: channelID,
Content: &content,
Embeds: &embeds, Embeds: &embeds,
}) })
return err return err
@@ -61,6 +64,7 @@ func (s *sessionAPI) Close() error {
func toMessageSend(msg Message) *discordgo.MessageSend { func toMessageSend(msg Message) *discordgo.MessageSend {
return &discordgo.MessageSend{ return &discordgo.MessageSend{
Content: messageContent(msg),
Embeds: toEmbeds(msg), Embeds: toEmbeds(msg),
AllowedMentions: &discordgo.MessageAllowedMentions{}, AllowedMentions: &discordgo.MessageAllowedMentions{},
} }
@@ -69,7 +73,7 @@ func toMessageSend(msg Message) *discordgo.MessageSend {
func toEmbeds(msg Message) []*discordgo.MessageEmbed { func toEmbeds(msg Message) []*discordgo.MessageEmbed {
main := &discordgo.MessageEmbed{ main := &discordgo.MessageEmbed{
Title: msg.Title, Title: msg.Title,
URL: msg.URL, URL: publicURL(msg.URL),
Description: msg.Description, Description: msg.Description,
Color: embedColor, Color: embedColor,
} }
+33 -9
View File
@@ -23,6 +23,10 @@ type Bot struct {
channelID string channelID string
links store.DiscordLinkStore links store.DiscordLinkStore
api API api API
store store.Store
bus events.Publisher
admins map[string]string
botUserID string
} }
// New constructs an outbound subscriber. Tests inject a fake API. // New constructs an outbound subscriber. Tests inject a fake API.
@@ -31,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. // 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) { func FromEnv(links store.DiscordLinkStore, bus *events.Bus, st store.Store) (*Bot, error) {
token := strings.TrimSpace(os.Getenv("DISCORD_BOT_TOKEN")) token := strings.TrimSpace(os.Getenv("DISCORD_BOT_TOKEN"))
channelID := strings.TrimSpace(os.Getenv("DISCORD_CHANNEL_ID")) channelID := strings.TrimSpace(os.Getenv("DISCORD_CHANNEL_ID"))
if token == "" && channelID == "" { if token == "" && channelID == "" {
@@ -50,11 +54,23 @@ func FromEnv(links store.DiscordLinkStore, bus *events.Bus) (*Bot, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent
bot := New(channelID, links, &sessionAPI{session: session}) bot := New(channelID, links, &sessionAPI{session: session})
bot.store = st
bot.bus = bus
bot.admins = parseAdminMap(os.Getenv("DISCORD_ADMIN_MAP"))
session.AddHandler(bot.onMessageCreate)
if bus != nil { if bus != nil {
bus.Subscribe(bot.Handle) bus.Subscribe(bot.Handle)
} }
log.Printf("discord: outbound subscriber enabled") if err := session.Open(); err != nil {
_ = session.Close()
return nil, fmt.Errorf("discord gateway: %w", err)
}
if session.State != nil && session.State.User != nil {
bot.botUserID = session.State.User.ID
}
log.Printf("discord: subscriber enabled")
return bot, nil return bot, nil
} }
@@ -82,6 +98,14 @@ func (b *Bot) Handle(_ context.Context, ev any) {
} }
func (b *Bot) onCreated(ctx context.Context, ev events.PostEvent) { 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) { if isRoot(ev) {
b.createRoot(ctx, ev) b.createRoot(ctx, ev)
return return
@@ -113,16 +137,16 @@ func (b *Bot) onUpdated(ctx context.Context, ev events.PostEvent) {
func (b *Bot) createRoot(ctx context.Context, ev events.PostEvent) { func (b *Bot) createRoot(ctx context.Context, ev events.PostEvent) {
msg := formatMessage(ev) msg := formatMessage(ev)
messageID, err := b.api.SendToChannel(ctx, b.channelID, msg) threadID, err := b.api.StartThread(ctx, b.channelID, msg.ThreadName)
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 { if err != nil {
log.Printf("discord: start thread %s: %v", ev.PostID, err) log.Printf("discord: start thread %s: %v", ev.PostID, err)
return return
} }
messageID, err := b.api.SendToThread(ctx, threadID, msg)
if err != nil {
log.Printf("discord: send root %s: %v", ev.PostID, err)
return
}
if err := b.links.Upsert(ctx, store.DiscordLink{ if err := b.links.Upsert(ctx, store.DiscordLink{
PostID: ev.PostID, PostID: ev.PostID,
MessageID: messageID, MessageID: messageID,
@@ -165,7 +189,7 @@ func (b *Bot) createReply(ctx context.Context, ev events.PostEvent) {
func (b *Bot) editChannel(ctx context.Context, ev events.PostEvent, link *store.DiscordLink) (string, error) { func (b *Bot) editChannel(ctx context.Context, ev events.PostEvent, link *store.DiscordLink) (string, error) {
if strings.TrimSpace(link.ThreadID) != "" { if strings.TrimSpace(link.ThreadID) != "" {
return b.channelID, nil return link.ThreadID, nil
} }
root, err := b.links.GetByPostID(ctx, ev.RootID) root, err := b.links.GetByPostID(ctx, ev.RootID)
if err != nil { if err != nil {
+61 -17
View File
@@ -2,11 +2,11 @@ package discord
import ( import (
"context" "context"
"strconv"
"strings"
"sync" "sync"
"testing" "testing"
"strconv"
"plumber/internal/events" "plumber/internal/events"
"plumber/internal/store" "plumber/internal/store"
) )
@@ -30,17 +30,18 @@ func (f *fakeAPI) SendToChannel(_ context.Context, channelID string, msg Message
return f.record("channel", channelID, "", msg) return f.record("channel", channelID, "", msg)
} }
func (f *fakeAPI) StartThread(_ context.Context, channelID, messageID, name string) (string, error) { func (f *fakeAPI) StartThread(_ context.Context, channelID, name string) (string, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
f.next++ f.next++
id := "thread-" + strconv.Itoa(f.next)
f.sends = append(f.sends, recordedSend{ f.sends = append(f.sends, recordedSend{
Kind: "thread", Kind: "thread",
ChannelID: channelID, ChannelID: channelID,
Name: name, Name: name,
Msg: Message{ThreadName: name, URL: messageID}, Msg: Message{ThreadName: name},
}) })
return "thread-" + messageID, nil return id, nil
} }
func (f *fakeAPI) SendToThread(_ context.Context, threadID string, msg Message) (string, error) { func (f *fakeAPI) SendToThread(_ context.Context, threadID string, msg Message) (string, error) {
@@ -93,17 +94,21 @@ func TestOutboundRootReplyAndEdit(t *testing.T) {
} }
bot.Handle(ctx, events.PostCreated{PostEvent: root}) bot.Handle(ctx, events.PostCreated{PostEvent: root})
if len(api.sends) != 2 || api.sends[0].Kind != "channel" || api.sends[1].Kind != "thread" { if len(api.sends) != 2 ||
api.sends[0].Kind != "thread" ||
api.sends[1].Kind != "thread-msg" {
t.Fatalf("root sends = %+v", api.sends) t.Fatalf("root sends = %+v", api.sends)
} }
if api.sends[0].ChannelID != "channel-1" || api.sends[1].Name != "Leaky sink" { if api.sends[0].ChannelID != "channel-1" ||
api.sends[0].Name != "sam asks: Leaky sink" ||
api.sends[1].ChannelID != "thread-1" {
t.Fatalf("root routing = %+v", api.sends) t.Fatalf("root routing = %+v", api.sends)
} }
if got := api.sends[0].Msg.ImageURLs; len(got) != 2 || got[0] != "https://cdn.example/a.jpg" { if got := api.sends[1].Msg.ImageURLs; len(got) != 2 || got[0] != "https://cdn.example/a.jpg" {
t.Fatalf("root images = %v", got) t.Fatalf("root images = %v", got)
} }
link, err := links.GetByPostID(ctx, "root-1") link, err := links.GetByPostID(ctx, "root-1")
if err != nil || link.MessageID != "msg-1" || link.ThreadID != "thread-msg-1" { if err != nil || link.MessageID != "msg-2" || link.ThreadID != "thread-1" {
t.Fatalf("root link = %+v, %v", link, err) t.Fatalf("root link = %+v, %v", link, err)
} }
@@ -116,7 +121,7 @@ func TestOutboundRootReplyAndEdit(t *testing.T) {
Permalink: "https://www.askaplumberfirst.com/questions/root-1#post-reply-1", Permalink: "https://www.askaplumberfirst.com/questions/root-1#post-reply-1",
} }
bot.Handle(ctx, events.PostCreated{PostEvent: reply}) bot.Handle(ctx, events.PostCreated{PostEvent: reply})
if len(api.sends) != 3 || api.sends[2].Kind != "thread-msg" || api.sends[2].ChannelID != "thread-msg-1" { if len(api.sends) != 3 || api.sends[2].Kind != "thread-msg" || api.sends[2].ChannelID != "thread-1" {
t.Fatalf("reply sends = %+v", api.sends) t.Fatalf("reply sends = %+v", api.sends)
} }
replyLink, err := links.GetByPostID(ctx, "reply-1") replyLink, err := links.GetByPostID(ctx, "reply-1")
@@ -126,7 +131,7 @@ func TestOutboundRootReplyAndEdit(t *testing.T) {
root.Body = "Updated leak." root.Body = "Updated leak."
bot.Handle(ctx, events.PostUpdated{PostEvent: root}) bot.Handle(ctx, events.PostUpdated{PostEvent: root})
if len(api.edits) != 1 || api.edits[0].ChannelID != "channel-1" || api.edits[0].Name != "msg-1" { if len(api.edits) != 1 || api.edits[0].ChannelID != "thread-1" || api.edits[0].Name != "msg-2" {
t.Fatalf("root edit = %+v", api.edits) t.Fatalf("root edit = %+v", api.edits)
} }
if api.edits[0].Msg.Description != "Updated leak." { if api.edits[0].Msg.Description != "Updated leak." {
@@ -135,7 +140,7 @@ func TestOutboundRootReplyAndEdit(t *testing.T) {
reply.Body = "Use a ceramic cartridge." reply.Body = "Use a ceramic cartridge."
bot.Handle(ctx, events.PostUpdated{PostEvent: reply}) bot.Handle(ctx, events.PostUpdated{PostEvent: reply})
if len(api.edits) != 2 || api.edits[1].ChannelID != "thread-msg-1" || api.edits[1].Name != "msg-3" { if len(api.edits) != 2 || api.edits[1].ChannelID != "thread-1" || api.edits[1].Name != "msg-3" {
t.Fatalf("reply edit = %+v", api.edits) t.Fatalf("reply edit = %+v", api.edits)
} }
} }
@@ -156,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) { func TestOutboundUpdateWithoutLinkCreates(t *testing.T) {
t.Parallel() t.Parallel()
@@ -190,21 +225,30 @@ func TestFormatMessage(t *testing.T) {
got.City != "Oakland" || got.City != "Oakland" ||
got.Author != "sam" || got.Author != "sam" ||
got.URL != "https://example.com/q" || got.URL != "https://example.com/q" ||
got.ThreadName != "Leaky sink" || got.ThreadName != "sam asks: Leaky sink" ||
len(got.ImageURLs) != 1 { len(got.ImageURLs) != 1 {
t.Fatalf("format = %+v", got) t.Fatalf("format = %+v", got)
} }
reply := formatMessage(events.PostEvent{Body: "Thanks", AuthorName: ""}) reply := formatMessage(events.PostEvent{Body: "Thanks", AuthorName: ""})
if reply.Title != "Reply" || reply.Author != "Someone" || reply.ThreadName != "Question" { if reply.Title != "Reply" || reply.Author != "Someone" || reply.ThreadName != "Someone asks: Question" {
t.Fatalf("reply format = %+v", reply) t.Fatalf("reply format = %+v", reply)
} }
content := messageContent(got)
if strings.Contains(content, "Leaky sink") ||
!strings.Contains(content, "It drips.") ||
!strings.Contains(content, "Oakland") {
t.Fatalf("content = %q", content)
}
if publicURL("/questions/x") != "" || publicURL("http://localhost:8080/q") != "" {
t.Fatal("localhost or relative permalink should not be an embed URL")
}
} }
func TestFromEnvDisabled(t *testing.T) { func TestFromEnvDisabled(t *testing.T) {
t.Setenv("DISCORD_BOT_TOKEN", "") t.Setenv("DISCORD_BOT_TOKEN", "")
t.Setenv("DISCORD_CHANNEL_ID", "") t.Setenv("DISCORD_CHANNEL_ID", "")
bot, err := FromEnv(newMemoryLinks(), nil) bot, err := FromEnv(newMemoryLinks(), nil, nil)
if err != nil || bot != nil { if err != nil || bot != nil {
t.Fatalf("disabled FromEnv = (%v, %v)", bot, err) t.Fatalf("disabled FromEnv = (%v, %v)", bot, err)
} }
@@ -213,12 +257,12 @@ func TestFromEnvDisabled(t *testing.T) {
func TestFromEnvRequiresBoth(t *testing.T) { func TestFromEnvRequiresBoth(t *testing.T) {
t.Setenv("DISCORD_BOT_TOKEN", "token") t.Setenv("DISCORD_BOT_TOKEN", "token")
t.Setenv("DISCORD_CHANNEL_ID", "") t.Setenv("DISCORD_CHANNEL_ID", "")
if _, err := FromEnv(newMemoryLinks(), nil); err == nil { if _, err := FromEnv(newMemoryLinks(), nil, nil); err == nil {
t.Fatal("expected error when channel is missing") t.Fatal("expected error when channel is missing")
} }
t.Setenv("DISCORD_BOT_TOKEN", "") t.Setenv("DISCORD_BOT_TOKEN", "")
t.Setenv("DISCORD_CHANNEL_ID", "channel") t.Setenv("DISCORD_CHANNEL_ID", "channel")
if _, err := FromEnv(newMemoryLinks(), nil); err == nil { if _, err := FromEnv(newMemoryLinks(), nil, nil); err == nil {
t.Fatal("expected error when token is missing") t.Fatal("expected error when token is missing")
} }
} }
+40 -4
View File
@@ -39,7 +39,7 @@ func formatMessage(ev events.PostEvent) Message {
Description: truncateRunes(strings.TrimSpace(ev.Body), embedDescriptionLimit), Description: truncateRunes(strings.TrimSpace(ev.Body), embedDescriptionLimit),
City: strings.TrimSpace(ev.City), City: strings.TrimSpace(ev.City),
Author: author, Author: author,
ThreadName: threadName(ev.Title), ThreadName: threadName(author, ev.Title),
} }
for _, img := range ev.Images { for _, img := range ev.Images {
url := strings.TrimSpace(img.URL) url := strings.TrimSpace(img.URL)
@@ -51,12 +51,16 @@ func formatMessage(ev events.PostEvent) Message {
return msg return msg
} }
func threadName(title string) string { func threadName(author, title string) string {
author = strings.TrimSpace(author)
if author == "" {
author = "Someone"
}
title = strings.TrimSpace(title) title = strings.TrimSpace(title)
if title == "" { if title == "" {
return "Question" title = "Question"
} }
return truncateRunes(title, threadNameLimit) return truncateRunes(author+" asks: "+title, threadNameLimit)
} }
func truncateRunes(s string, max int) string { func truncateRunes(s string, max int) string {
@@ -73,3 +77,35 @@ func truncateRunes(s string, max int) string {
func isRoot(ev events.PostEvent) bool { func isRoot(ev events.PostEvent) bool {
return strings.TrimSpace(ev.ParentID) == "" return strings.TrimSpace(ev.ParentID) == ""
} }
func messageContent(msg Message) string {
var parts []string
if body := strings.TrimSpace(msg.Description); body != "" {
parts = append(parts, body)
}
var meta []string
if msg.City != "" {
meta = append(meta, msg.City)
}
if msg.Author != "" {
meta = append(meta, msg.Author)
}
if len(meta) > 0 {
parts = append(parts, strings.Join(meta, " · "))
}
if u := publicURL(msg.URL); u != "" {
parts = append(parts, u)
}
return truncateRunes(strings.Join(parts, "\n"), 2000)
}
func publicURL(raw string) string {
raw = strings.TrimSpace(raw)
if !strings.HasPrefix(raw, "https://") {
return ""
}
if strings.Contains(raw, "localhost") || strings.Contains(raw, "127.0.0.1") {
return ""
}
return raw
}
+203
View File
@@ -0,0 +1,203 @@
package discord
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"strings"
"github.com/bwmarrin/discordgo"
"plumber/internal/events"
"plumber/internal/store"
)
const inboundBodyLimit = 12000
type inboundMessage struct {
ID string
ChannelID string
GuildID string
AuthorID string
Content string
ReferencedMessageID string
Bot bool
Attachments int
}
func parseAdminMap(raw string) map[string]string {
out := map[string]string{}
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, username, ok := strings.Cut(part, ":")
id = strings.TrimSpace(id)
username = store.NormalizeUsername(username)
if !ok || id == "" || username == "" {
log.Printf("discord: skip invalid DISCORD_ADMIN_MAP entry %q", part)
continue
}
out[id] = username
}
return out
}
func (b *Bot) onMessageCreate(_ *discordgo.Session, m *discordgo.MessageCreate) {
if b == nil || m == nil || m.Author == nil {
return
}
in := inboundMessage{
ID: m.ID,
ChannelID: m.ChannelID,
GuildID: m.GuildID,
AuthorID: m.Author.ID,
Content: m.Content,
Bot: m.Author.Bot,
Attachments: len(m.Attachments),
}
if m.MessageReference != nil {
in.ReferencedMessageID = m.MessageReference.MessageID
}
b.handleInbound(in)
}
func (b *Bot) handleInbound(in inboundMessage) {
if b == nil || b.store == nil {
return
}
if in.Bot || strings.TrimSpace(in.GuildID) == "" {
return
}
if b.botUserID != "" && in.AuthorID == b.botUserID {
return
}
body := strings.TrimSpace(in.Content)
if in.Attachments > 0 {
log.Printf("discord: ignoring %d attachment(s) on %s", in.Attachments, in.ID)
}
if body == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), discordTimeout)
defer cancel()
if !b.knownChannel(ctx, in.ChannelID) {
return
}
username := b.admins[in.AuthorID]
if username == "" {
return
}
author, err := b.store.UserByUsername(ctx, username)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("discord: inbound author %s: %v", username, err)
}
return
}
if !author.Admin() {
log.Printf("discord: inbound %s is not an admin", username)
return
}
parent, root, err := b.inboundParent(ctx, in)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("discord: inbound parent %s: %v", in.ID, err)
}
return
}
if root.PostState == store.PostStateHidden {
return
}
reply := &store.Post{
AuthorID: author.ID,
Body: truncateRunes(body, inboundBodyLimit),
ParentID: &parent.ID,
}
if err := b.store.CreatePost(ctx, reply); err != nil {
log.Printf("discord: create inbound %s: %v", in.ID, err)
return
}
if err := b.links.Upsert(ctx, store.DiscordLink{
PostID: reply.ID,
MessageID: in.ID,
}); err != nil {
log.Printf("discord: save inbound link %s: %v", reply.ID, err)
return
}
b.publishInbound(reply, root, author)
log.Printf("discord: inbound reply %s -> post %s", in.ID, reply.ID)
}
func (b *Bot) knownChannel(ctx context.Context, channelID string) bool {
if strings.TrimSpace(channelID) == "" {
return false
}
if channelID == b.channelID {
return true
}
_, err := b.links.GetRootByThreadID(ctx, channelID)
return err == nil
}
func (b *Bot) inboundParent(ctx context.Context, in inboundMessage) (*store.Post, *store.Post, error) {
if ref := strings.TrimSpace(in.ReferencedMessageID); ref != "" {
link, err := b.links.GetByMessageID(ctx, ref)
if err == nil {
return b.postAndRoot(ctx, link.PostID)
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, nil, err
}
}
link, err := b.links.GetRootByThreadID(ctx, in.ChannelID)
if err != nil {
return nil, nil, err
}
return b.postAndRoot(ctx, link.PostID)
}
func (b *Bot) postAndRoot(ctx context.Context, postID string) (*store.Post, *store.Post, error) {
postID = strings.TrimSpace(postID)
if postID == "" {
return nil, nil, sql.ErrNoRows
}
post, err := b.store.GetPost(ctx, postID)
if err != nil {
return nil, nil, err
}
current := post
seen := map[string]bool{}
for current.ParentID != nil {
if seen[current.ID] {
return nil, nil, fmt.Errorf("post ancestry cycle at %s", current.ID)
}
seen[current.ID] = true
current, err = b.store.GetPost(ctx, *current.ParentID)
if err != nil {
return nil, nil, err
}
}
return post, current, 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
}
ev := events.PostEvent{
PostID: reply.ID,
RootID: root.ID,
Body: reply.Body,
AuthorID: author.ID,
AuthorName: author.Name,
AuthorRole: string(author.Role),
}
if reply.ParentID != nil {
ev.ParentID = *reply.ParentID
}
b.bus.Publish(context.Background(), events.PostCreated{PostEvent: ev})
}
+219
View File
@@ -0,0 +1,219 @@
package discord
import (
"context"
"testing"
"time"
"plumber/internal/events"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestParseAdminMap(t *testing.T) {
t.Parallel()
got := parseAdminMap(" 123:Plumber ,456:other,bad, :empty,789: ")
if got["123"] != "plumber" || got["456"] != "other" || len(got) != 2 {
t.Fatalf("parseAdminMap = %#v", got)
}
}
func TestInboundCreatesSiteReply(t *testing.T) {
t.Parallel()
mem, homeowner, admin := seedInboundUsers(t)
links := newMemoryLinks()
api := &fakeAPI{}
mailer := &mail.Recording{}
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")
bot.handleInbound(inboundMessage{
ID: "d-reply-1",
ChannelID: "thread-1",
GuildID: "guild-1",
AuthorID: "snow-admin",
Content: "Replace the cartridge.",
})
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 1 {
t.Fatalf("thread = %+v, %v", thread, err)
}
reply := thread.Replies[0]
if reply.AuthorID != admin.ID || reply.Body != "Replace the cartridge." || reply.ParentID == nil || *reply.ParentID != root.ID {
t.Fatalf("reply = %+v", reply)
}
link, err := links.GetByPostID(context.Background(), reply.ID)
if err != nil || link.MessageID != "d-reply-1" || link.ThreadID != "" {
t.Fatalf("inbound link = %+v, %v", link, err)
}
time.Sleep(20 * time.Millisecond)
if len(api.sends) != 0 || len(api.edits) != 0 {
t.Fatalf("inbound echoed to Discord: sends=%+v edits=%+v", api.sends, api.edits)
}
msgs := waitForMail(t, mailer, 1)
if msgs[0].ToEmail != homeowner.Email || msgs[0].ReplyID != reply.ID || msgs[0].RootID != root.ID {
t.Fatalf("mail = %+v", msgs[0])
}
}
func TestInboundParentsFromReference(t *testing.T) {
t.Parallel()
mem, homeowner, admin := seedInboundUsers(t)
links := newMemoryLinks()
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 {
t.Fatal(err)
}
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: plumberReply.ID,
MessageID: "d-plumber-1",
}); err != nil {
t.Fatal(err)
}
bot.handleInbound(inboundMessage{
ID: "d-nested",
ChannelID: "thread-1",
GuildID: "guild-1",
AuthorID: "snow-admin",
Content: "More detail.",
ReferencedMessageID: "d-plumber-1",
})
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 1 || len(thread.Replies[0].Replies) != 1 {
t.Fatalf("thread = %+v, %v", thread, err)
}
nested := thread.Replies[0].Replies[0]
if nested.ParentID == nil || *nested.ParentID != plumberReply.ID {
t.Fatalf("nested parent = %+v", nested)
}
}
func TestInboundIgnoresAllowlistHiddenAndEchoSources(t *testing.T) {
t.Parallel()
mem, homeowner, admin := seedInboundUsers(t)
links := newMemoryLinks()
api := &fakeAPI{}
bot := inboundTestBot(mem, links, api, admin.Username)
root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1")
hidden := &store.Post{
AuthorID: homeowner.ID,
Title: "Hidden",
Body: "No.",
PostDate: pacific.Today(),
PostState: store.PostStateHidden,
}
if err := mem.CreatePost(context.Background(), hidden); err != nil {
t.Fatal(err)
}
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: hidden.ID,
MessageID: "d-hidden",
ThreadID: "thread-hidden",
}); err != nil {
t.Fatal(err)
}
cases := []inboundMessage{
{ID: "bot", ChannelID: "thread-1", GuildID: "g", AuthorID: "snow-admin", Content: "x", Bot: true},
{ID: "dm", ChannelID: "thread-1", AuthorID: "snow-admin", Content: "x"},
{ID: "self", ChannelID: "thread-1", GuildID: "g", AuthorID: "bot-1", Content: "x"},
{ID: "stranger", ChannelID: "thread-1", GuildID: "g", AuthorID: "snow-other", Content: "x"},
{ID: "elsewhere", ChannelID: "other-thread", GuildID: "g", AuthorID: "snow-admin", Content: "x"},
{ID: "empty", ChannelID: "thread-1", GuildID: "g", AuthorID: "snow-admin", Content: " ", Attachments: 1},
{ID: "hidden", ChannelID: "thread-hidden", GuildID: "g", AuthorID: "snow-admin", Content: "x"},
{ID: "channel-root", ChannelID: "channel-1", GuildID: "g", AuthorID: "snow-admin", Content: "new question"},
}
for _, in := range cases {
bot.handleInbound(in)
}
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 0 {
t.Fatalf("unexpected replies: %+v, %v", thread, err)
}
if len(api.sends) != 0 {
t.Fatalf("unexpected discord sends %+v", api.sends)
}
}
func inboundTestBot(mem *store.Memory, links *memoryLinks, api *fakeAPI, adminUsername string) *Bot {
bot := New("channel-1", links, api)
bot.store = mem
bot.admins = map[string]string{"snow-admin": adminUsername}
bot.botUserID = "bot-1"
return bot
}
func seedInboundUsers(t *testing.T) (*store.Memory, *store.User, *store.User) {
t.Helper()
mem := store.NewMemory()
homeowner := &store.User{
Username: "homeowner",
Name: "Sam",
Email: "sam@example.com",
PasswordHash: "x",
Role: store.RoleUser,
}
if err := mem.CreateUser(context.Background(), homeowner); err != nil {
t.Fatal(err)
}
admin := &store.User{
Username: "plumber",
Name: "Pat",
Email: "pat@example.com",
PasswordHash: "x",
Role: store.RoleAdmin,
}
if err := mem.CreateUser(context.Background(), admin); err != nil {
t.Fatal(err)
}
return mem, homeowner, admin
}
func seedLinkedRoot(t *testing.T, mem *store.Memory, links *memoryLinks, authorID, threadID string) *store.Post {
t.Helper()
root := &store.Post{
AuthorID: authorID,
Title: "Leaky sink",
Body: "It drips.",
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
}
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: root.ID,
MessageID: "d-root",
ThreadID: threadID,
}); err != nil {
t.Fatal(err)
}
return root
}
func waitForMail(t *testing.T, recording *mail.Recording, want int) []mail.PostReply {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if recording.Len() >= want {
return recording.Snapshot()
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("recorded %d notifications, want %d", recording.Len(), want)
return nil
}
+89
View File
@@ -0,0 +1,89 @@
package mail
import (
"context"
"log"
"strings"
"time"
"plumber/internal/events"
"plumber/internal/store"
)
// Subscribe sends reply emails from PostCreated events. Nop or nil is a no-op.
func Subscribe(bus *events.Bus, st store.Store, n Notifier) {
if bus == nil || st == nil || n == nil {
return
}
if _, disabled := n.(Nop); disabled {
return
}
s := subscriber{store: st, mail: n}
bus.Subscribe(s.handle)
}
type subscriber struct {
store store.Store
mail Notifier
}
func (s subscriber) handle(_ context.Context, ev any) {
created, ok := ev.(events.PostCreated)
if !ok {
return
}
if strings.TrimSpace(created.ParentID) == "" {
return
}
go s.notifyReply(created.PostEvent)
}
func (s subscriber) notifyReply(ev events.PostEvent) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
parent, err := s.store.GetPost(ctx, ev.ParentID)
if err != nil {
log.Printf("notify reply %s: load parent: %v", ev.PostID, err)
return
}
root, err := s.store.GetPost(ctx, ev.RootID)
if err != nil {
log.Printf("notify reply %s: load root: %v", ev.PostID, err)
return
}
author, err := s.store.UserByID(ctx, ev.AuthorID)
if err != nil {
log.Printf("notify reply %s: load author: %v", ev.PostID, err)
return
}
recipientID := parent.AuthorID
if author.Admin() {
recipientID = root.AuthorID
}
if recipientID == author.ID {
return
}
msg := PostReply{
RootID: root.ID,
RootTitle: root.Title,
ReplyID: ev.PostID,
ReplyBody: ev.Body,
ReplyAuthorName: author.Name,
}
recipient, err := s.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 := s.mail.NotifyPostReply(ctx, msg); err != nil {
log.Printf("notify reply %s: %v", msg.ReplyID, err)
return
}
log.Printf("notify reply %s: accepted", msg.ReplyID)
}
+200
View File
@@ -0,0 +1,200 @@
package mail
import (
"context"
"testing"
"time"
"plumber/internal/events"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestSubscribeReplyNotifications(t *testing.T) {
t.Parallel()
mem := store.NewMemory()
homeowner := seedMailUser(t, mem, "homeowner", store.RoleUser, "sam@example.com")
admin := seedMailUser(t, mem, "plumber", store.RoleAdmin, "pat@example.com")
root := seedMailRoot(t, mem, homeowner.ID, "Leaky sink", "It drips.")
adminReply := seedMailReply(t, mem, admin.ID, root.ID)
homeownerReply := seedMailReply(t, mem, homeowner.ID, adminReply.ID)
bus := events.New()
defer bus.Close()
recording := &Recording{}
Subscribe(bus, mem, recording)
ctx := context.Background()
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: root.ID,
RootID: root.ID,
Title: root.Title,
Body: root.Body,
AuthorID: homeowner.ID,
}})
bus.Publish(ctx, events.PostUpdated{PostEvent: events.PostEvent{
PostID: adminReply.ID,
RootID: root.ID,
ParentID: root.ID,
Body: "Edited",
AuthorID: admin.ID,
}})
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: adminReply.ID,
RootID: root.ID,
ParentID: root.ID,
Body: adminReply.Body,
AuthorID: admin.ID,
}})
msgs := waitForMail(t, recording, 1)
if msg := msgs[0]; msg.ToEmail != homeowner.Email ||
msg.RootID != root.ID ||
msg.RootTitle != root.Title ||
msg.ReplyID != adminReply.ID ||
msg.ReplyBody != adminReply.Body ||
msg.ReplyAuthorName != admin.Name {
t.Fatalf("admin reply = %+v", msg)
}
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: homeownerReply.ID,
RootID: root.ID,
ParentID: adminReply.ID,
Body: homeownerReply.Body,
AuthorID: homeowner.ID,
}})
msgs = waitForMail(t, recording, 2)
if msg := msgs[1]; msg.ToEmail != admin.Email ||
msg.RootID != root.ID ||
msg.ReplyID != homeownerReply.ID ||
msg.ReplyAuthorName != homeowner.Name {
t.Fatalf("homeowner reply = %+v", msg)
}
nestedAdmin := seedMailReply(t, mem, admin.ID, adminReply.ID)
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: nestedAdmin.ID,
RootID: root.ID,
ParentID: adminReply.ID,
Body: nestedAdmin.Body,
AuthorID: admin.ID,
}})
msgs = waitForMail(t, recording, 3)
if msg := msgs[2]; msg.ToEmail != homeowner.Email ||
msg.RootID != root.ID ||
msg.ReplyBody != nestedAdmin.Body ||
msg.ReplyAuthorName != admin.Name {
t.Fatalf("nested admin reply = %+v", msg)
}
self := seedMailReply(t, mem, homeowner.ID, root.ID)
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: self.ID,
RootID: root.ID,
ParentID: root.ID,
Body: self.Body,
AuthorID: homeowner.ID,
}})
noEmail := seedMailUser(t, mem, "quiet", store.RoleUser, "")
quietRoot := seedMailRoot(t, mem, noEmail.ID, "Quiet thread", "No email.")
quietReply := seedMailReply(t, mem, admin.ID, quietRoot.ID)
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: quietReply.ID,
RootID: quietRoot.ID,
ParentID: quietRoot.ID,
Body: quietReply.Body,
AuthorID: admin.ID,
}})
time.Sleep(50 * time.Millisecond)
if recording.Len() != 3 {
t.Fatalf("self, root, edit, or no-email sent mail: %+v", recording.Snapshot())
}
}
func TestSubscribeNopIgnoresReplies(t *testing.T) {
t.Parallel()
mem := store.NewMemory()
homeowner := seedMailUser(t, mem, "homeowner", store.RoleUser, "sam@example.com")
admin := seedMailUser(t, mem, "plumber", store.RoleAdmin, "pat@example.com")
root := seedMailRoot(t, mem, homeowner.ID, "Leaky sink", "It drips.")
reply := seedMailReply(t, mem, admin.ID, root.ID)
bus := events.New()
defer bus.Close()
recording := &Recording{}
Subscribe(bus, mem, Nop{})
Subscribe(nil, mem, recording)
Subscribe(bus, mem, nil)
bus.Publish(context.Background(), events.PostCreated{PostEvent: events.PostEvent{
PostID: reply.ID,
RootID: root.ID,
ParentID: root.ID,
Body: reply.Body,
AuthorID: admin.ID,
}})
time.Sleep(50 * time.Millisecond)
if recording.Len() != 0 {
t.Fatalf("Nop or nil subscribe sent mail: %+v", recording.Snapshot())
}
}
func seedMailUser(t *testing.T, mem *store.Memory, username string, role store.Role, email string) *store.User {
t.Helper()
u := &store.User{
Username: username,
Name: username,
Email: email,
PasswordHash: "x",
Role: role,
}
if err := mem.CreateUser(context.Background(), u); err != nil {
t.Fatal(err)
}
return u
}
func seedMailRoot(t *testing.T, mem *store.Memory, authorID, title, body string) *store.Post {
t.Helper()
root := &store.Post{
AuthorID: authorID,
Title: title,
Body: body,
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
}
return root
}
func seedMailReply(t *testing.T, mem *store.Memory, authorID, parentID string) *store.Post {
t.Helper()
reply := &store.Post{
ParentID: &parentID,
AuthorID: authorID,
Body: "Reply from " + authorID,
}
if err := mem.CreatePost(context.Background(), reply); err != nil {
t.Fatal(err)
}
return reply
}
func waitForMail(t *testing.T, recording *Recording, want int) []PostReply {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if recording.Len() >= want {
return recording.Snapshot()
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("recorded %d notifications, want %d", recording.Len(), want)
return nil
}
-59
View File
@@ -5,15 +5,12 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"plumber/internal/mail"
"plumber/internal/store" "plumber/internal/store"
) )
@@ -82,9 +79,6 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
if root == nil { if root == nil {
root = post root = post
} }
if parent != nil {
s.notifyPostReply(parent, root, post, user)
}
s.publishPostCreated(post, root, user) s.publishPostCreated(post, root, user)
http.Redirect( http.Redirect(
w, w,
@@ -94,59 +88,6 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
) )
} }
// notifyPostReply emails the root homeowner for admin replies and the direct
// parent author for homeowner replies.
func (s *Server) notifyPostReply(
parent *store.Post,
root *store.Post,
reply *store.Post,
replyAuthor *store.User,
) {
if parent == nil ||
root == nil ||
reply == nil ||
replyAuthor == nil ||
s.cfg.Mail == nil {
return
}
if _, disabled := s.cfg.Mail.(mail.Nop); disabled {
return
}
recipientID := parent.AuthorID
if replyAuthor.Admin() {
recipientID = root.AuthorID
}
if recipientID == replyAuthor.ID {
return
}
msg := mail.PostReply{
RootID: root.ID,
RootTitle: root.Title,
ReplyID: reply.ID,
ReplyBody: reply.Body,
ReplyAuthorName: replyAuthor.Name,
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
recipient, err := s.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 := s.cfg.Mail.NotifyPostReply(ctx, msg); err != nil {
log.Printf("notify reply %s: %v", msg.ReplyID, err)
return
}
log.Printf("notify reply %s: accepted", msg.ReplyID)
}()
}
// handleEditPost updates only a post's body after verifying that the current // handleEditPost updates only a post's body after verifying that the current
// homeowner owns it or that an admin is editing an admin-authored post. // homeowner owns it or that an admin is editing an admin-authored post.
func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
+6 -1
View File
@@ -9,6 +9,7 @@ import (
"testing" "testing"
"time" "time"
"plumber/internal/events"
"plumber/internal/mail" "plumber/internal/mail"
"plumber/internal/pacific" "plumber/internal/pacific"
"plumber/internal/store" "plumber/internal/store"
@@ -263,8 +264,12 @@ func TestEditPostRoutePermissions(t *testing.T) {
func TestPostReplyNotifications(t *testing.T) { func TestPostReplyNotifications(t *testing.T) {
t.Parallel() t.Parallel()
mem := store.NewMemory()
bus := events.New()
defer bus.Close()
recording := &mail.Recording{} recording := &mail.Recording{}
srv, mem := newTestServer(t, Config{Mail: recording}) mail.Subscribe(bus, mem, recording)
srv := newTestServerStore(t, mem, Config{Events: bus})
handler := srv.Handler() handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser) homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin) admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
-5
View File
@@ -22,7 +22,6 @@ import (
"plumber/internal/blob" "plumber/internal/blob"
"plumber/internal/events" "plumber/internal/events"
"plumber/internal/geo" "plumber/internal/geo"
"plumber/internal/mail"
"plumber/internal/pacific" "plumber/internal/pacific"
"plumber/internal/store" "plumber/internal/store"
) )
@@ -35,7 +34,6 @@ type Config struct {
// TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer). // TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer).
TrustedProxies []*net.IPNet TrustedProxies []*net.IPNet
Blob blob.Uploader Blob blob.Uploader
Mail mail.Notifier
Events events.Publisher Events events.Publisher
BaseURL string BaseURL string
} }
@@ -110,9 +108,6 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
if cfg.Blob == nil { if cfg.Blob == nil {
cfg.Blob = blob.Disabled{} cfg.Blob = blob.Disabled{}
} }
if cfg.Mail == nil {
cfg.Mail = mail.Nop{}
}
if cfg.Events == nil { if cfg.Events == nil {
cfg.Events = events.Nop{} cfg.Events = events.Nop{}
} }