package discord import ( "context" "database/sql" "errors" "fmt" "log" "strings" "time" "github.com/bwmarrin/discordgo" "plumber/internal/mail" "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) } b.notifyInboundReply(parent, root, reply, 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) notifyInboundReply(parent, root, reply *store.Post, author *store.User) { if parent == nil || root == nil || reply == nil || author == nil || b.mail == 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{ 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) }() }