228 lines
5.5 KiB
Go
228 lines
5.5 KiB
Go
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)
|
|
}()
|
|
}
|