Add video picker and Discord video attachments.
CI / test (pull_request) Successful in 6m29s

The picker accepts one MP4 or WebM, the thread lightbox zooms photos, and Discord gets the video as a file on a follow-up message so photo embeds still render.
This commit is contained in:
2026-08-31 02:00:56 -07:00
parent 84dea8ea5d
commit f96df3222d
22 changed files with 1018 additions and 144 deletions
+77 -24
View File
@@ -16,7 +16,7 @@ import (
"plumber/internal/store"
)
const discordTimeout = 15 * time.Second
const discordTimeout = 60 * time.Second
// Bot posts site events to a Discord channel and owns post-to-message links.
type Bot struct {
@@ -91,13 +91,27 @@ func (b *Bot) Handle(_ context.Context, ev any) {
defer cancel()
switch e := ev.(type) {
case events.PostCreated:
b.onCreated(ctx, e.PostEvent)
defer b.publishPosted(e)
b.onCreated(ctx, e)
case events.PostUpdated:
b.onUpdated(ctx, e.PostEvent)
}
}
func (b *Bot) onCreated(ctx context.Context, ev events.PostEvent) {
func (b *Bot) publishPosted(e events.PostCreated) {
if e.Release == nil {
return
}
done := events.PostedToDiscord{PostID: e.PostID, Release: e.Release}
if b.bus == nil {
e.Release()
return
}
b.bus.Publish(context.Background(), done)
}
func (b *Bot) onCreated(ctx context.Context, e events.PostCreated) {
ev := e.PostEvent
_, err := b.links.GetByPostID(ctx, ev.PostID)
if err == nil {
return
@@ -107,17 +121,17 @@ func (b *Bot) onCreated(ctx context.Context, ev events.PostEvent) {
return
}
if isRoot(ev) {
b.createRoot(ctx, ev)
b.createRoot(ctx, e)
return
}
b.createReply(ctx, ev)
b.createReply(ctx, e)
}
func (b *Bot) onUpdated(ctx context.Context, ev events.PostEvent) {
link, err := b.links.GetByPostID(ctx, ev.PostID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
b.onCreated(ctx, ev)
b.onCreated(ctx, events.PostCreated{PostEvent: ev})
return
}
log.Printf("discord: load link %s: %v", ev.PostID, err)
@@ -135,56 +149,95 @@ func (b *Bot) onUpdated(ctx context.Context, ev events.PostEvent) {
log.Printf("discord: edited %s", ev.PostID)
}
func (b *Bot) createRoot(ctx context.Context, ev events.PostEvent) {
msg := formatMessage(ev)
func (b *Bot) createRoot(ctx context.Context, e events.PostCreated) {
msg := formatCreated(e)
threadID, err := b.api.StartThread(ctx, b.channelID, msg.ThreadName)
if err != nil {
log.Printf("discord: start thread %s: %v", ev.PostID, err)
log.Printf("discord: start thread %s: %v", e.PostID, err)
return
}
messageID, err := b.api.SendToThread(ctx, threadID, msg)
messageID, err := b.sendCreated(ctx, threadID, msg)
if err != nil {
log.Printf("discord: send root %s: %v", ev.PostID, err)
log.Printf("discord: send root %s: %v", e.PostID, err)
return
}
if err := b.links.Upsert(ctx, store.DiscordLink{
PostID: ev.PostID,
PostID: e.PostID,
MessageID: messageID,
ThreadID: threadID,
}); err != nil {
log.Printf("discord: save root link %s: %v", ev.PostID, err)
log.Printf("discord: save root link %s: %v", e.PostID, err)
return
}
log.Printf("discord: posted root %s", ev.PostID)
log.Printf("discord: posted root %s", e.PostID)
}
func (b *Bot) createReply(ctx context.Context, ev events.PostEvent) {
root, err := b.links.GetByPostID(ctx, ev.RootID)
func (b *Bot) createReply(ctx context.Context, e events.PostCreated) {
root, err := b.links.GetByPostID(ctx, e.RootID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Printf("discord: skip reply %s: no root thread", ev.PostID)
log.Printf("discord: skip reply %s: no root thread", e.PostID)
return
}
log.Printf("discord: load root link %s: %v", ev.RootID, err)
log.Printf("discord: load root link %s: %v", e.RootID, err)
return
}
if strings.TrimSpace(root.ThreadID) == "" {
log.Printf("discord: skip reply %s: no root thread", ev.PostID)
log.Printf("discord: skip reply %s: no root thread", e.PostID)
return
}
messageID, err := b.api.SendToThread(ctx, root.ThreadID, formatMessage(ev))
messageID, err := b.sendCreated(ctx, root.ThreadID, formatCreated(e))
if err != nil {
log.Printf("discord: send reply %s: %v", ev.PostID, err)
log.Printf("discord: send reply %s: %v", e.PostID, err)
return
}
if err := b.links.Upsert(ctx, store.DiscordLink{
PostID: ev.PostID,
PostID: e.PostID,
MessageID: messageID,
}); err != nil {
log.Printf("discord: save reply link %s: %v", ev.PostID, err)
log.Printf("discord: save reply link %s: %v", e.PostID, err)
return
}
log.Printf("discord: posted reply %s", ev.PostID)
log.Printf("discord: posted reply %s", e.PostID)
}
func (b *Bot) sendCreated(ctx context.Context, threadID string, msg Message) (string, error) {
_, videos := splitAttachments(msg.Files)
msg.Files = nil
if len(videos) > 0 {
msg.VideoURLs = nil
}
messageID, err := b.send(ctx, threadID, msg)
if err != nil {
return "", err
}
for _, video := range videos {
if _, err := b.send(ctx, threadID, Message{Files: []Attachment{video}}); err != nil {
log.Printf("discord: send video %s: %v", threadID, err)
}
}
return messageID, nil
}
func splitAttachments(files []Attachment) (images, videos []Attachment) {
for _, f := range files {
if strings.HasPrefix(f.ContentType, "video/") || f.Kind == "video" {
videos = append(videos, f)
continue
}
images = append(images, f)
}
return images, videos
}
func (b *Bot) send(ctx context.Context, threadID string, msg Message) (string, error) {
messageID, err := b.api.SendToThread(ctx, threadID, msg)
if err != nil && len(msg.Files) > 0 {
log.Printf("discord: send with files %s: %v; retrying without files", threadID, err)
msg.Files = nil
return b.api.SendToThread(ctx, threadID, msg)
}
return messageID, err
}
func (b *Bot) editChannel(ctx context.Context, ev events.PostEvent, link *store.DiscordLink) (string, error) {