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:
+28
-1
@@ -2,6 +2,7 @@ package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
@@ -63,14 +64,27 @@ func (s *sessionAPI) Close() error {
|
||||
}
|
||||
|
||||
func toMessageSend(msg Message) *discordgo.MessageSend {
|
||||
var files []*discordgo.File
|
||||
for _, f := range msg.Files {
|
||||
files = append(files, &discordgo.File{
|
||||
Name: f.Name,
|
||||
ContentType: f.ContentType,
|
||||
Reader: f.Reader,
|
||||
})
|
||||
}
|
||||
return &discordgo.MessageSend{
|
||||
Content: messageContent(msg),
|
||||
Embeds: toEmbeds(msg),
|
||||
Files: files,
|
||||
AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||
}
|
||||
}
|
||||
|
||||
func toEmbeds(msg Message) []*discordgo.MessageEmbed {
|
||||
imageURLs := embedImageURLs(msg)
|
||||
if msg.Title == "" && msg.Description == "" && msg.City == "" && msg.Author == "" && len(imageURLs) == 0 {
|
||||
return nil
|
||||
}
|
||||
main := &discordgo.MessageEmbed{
|
||||
Title: msg.Title,
|
||||
URL: publicURL(msg.URL),
|
||||
@@ -92,7 +106,7 @@ func toEmbeds(msg Message) []*discordgo.MessageEmbed {
|
||||
})
|
||||
}
|
||||
embeds := []*discordgo.MessageEmbed{main}
|
||||
for i, url := range msg.ImageURLs {
|
||||
for i, url := range imageURLs {
|
||||
if i == 0 {
|
||||
main.Image = &discordgo.MessageEmbedImage{URL: url}
|
||||
continue
|
||||
@@ -104,3 +118,16 @@ func toEmbeds(msg Message) []*discordgo.MessageEmbed {
|
||||
}
|
||||
return embeds
|
||||
}
|
||||
|
||||
func embedImageURLs(msg Message) []string {
|
||||
var attached []string
|
||||
for _, f := range msg.Files {
|
||||
if strings.HasPrefix(f.ContentType, "image/") {
|
||||
attached = append(attached, "attachment://"+f.Name)
|
||||
}
|
||||
}
|
||||
if len(attached) > 0 {
|
||||
return attached
|
||||
}
|
||||
return msg.ImageURLs
|
||||
}
|
||||
|
||||
+77
-24
@@ -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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"plumber/internal/events"
|
||||
"plumber/internal/store"
|
||||
@@ -209,6 +210,125 @@ func TestOutboundUpdateWithoutLinkCreates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundAttachesVideoFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bus := events.New()
|
||||
defer bus.Close()
|
||||
events.SubscribeRelease(bus)
|
||||
links := newMemoryLinks()
|
||||
api := &fakeAPI{}
|
||||
bot := New("channel-1", links, api)
|
||||
bot.bus = bus
|
||||
|
||||
released := make(chan struct{})
|
||||
bot.Handle(context.Background(), events.PostCreated{
|
||||
PostEvent: events.PostEvent{
|
||||
PostID: "root-1",
|
||||
RootID: "root-1",
|
||||
Title: "Valve clip",
|
||||
Body: "Watch the handle.",
|
||||
AuthorName: "sam",
|
||||
Permalink: "https://www.askaplumberfirst.com/questions/root-1#post-root-1",
|
||||
Images: []events.Image{{URL: "https://cdn.example/clip.mp4", Kind: "video"}},
|
||||
},
|
||||
Media: []events.Media{{
|
||||
Name: "clip.mp4",
|
||||
ContentType: "video/mp4",
|
||||
Kind: "video",
|
||||
Bytes: []byte("fake-mp4"),
|
||||
}},
|
||||
Release: func() { close(released) },
|
||||
})
|
||||
|
||||
if len(api.sends) != 3 ||
|
||||
api.sends[1].Kind != "thread-msg" ||
|
||||
api.sends[2].Kind != "thread-msg" {
|
||||
t.Fatalf("sends = %+v", api.sends)
|
||||
}
|
||||
if len(api.sends[1].Msg.Files) != 0 {
|
||||
t.Fatalf("text message files = %+v", api.sends[1].Msg.Files)
|
||||
}
|
||||
files := api.sends[2].Msg.Files
|
||||
if len(files) != 1 || files[0].Name != "clip.mp4" || files[0].ContentType != "video/mp4" {
|
||||
t.Fatalf("video files = %+v", files)
|
||||
}
|
||||
content := messageContent(api.sends[1].Msg)
|
||||
if strings.Contains(content, "https://cdn.example/clip.mp4") {
|
||||
t.Fatalf("content still has video URL: %q", content)
|
||||
}
|
||||
select {
|
||||
case <-released:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("PostedToDiscord did not release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundPhotosThenVideo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
api := &fakeAPI{}
|
||||
bot := New("channel-1", newMemoryLinks(), api)
|
||||
bot.Handle(context.Background(), events.PostCreated{
|
||||
PostEvent: events.PostEvent{
|
||||
PostID: "root-1",
|
||||
RootID: "root-1",
|
||||
Title: "Valve clip",
|
||||
Body: "Photo and video.",
|
||||
AuthorName: "sam",
|
||||
Images: []events.Image{
|
||||
{URL: "https://cdn.example/a.jpg"},
|
||||
{URL: "https://cdn.example/clip.mp4", Kind: "video"},
|
||||
},
|
||||
},
|
||||
Media: []events.Media{
|
||||
{Name: "still.jpg", ContentType: "image/jpeg", Kind: "image", Bytes: []byte("jpeg")},
|
||||
{Name: "clip.mp4", ContentType: "video/mp4", Kind: "video", Bytes: []byte("mp4")},
|
||||
},
|
||||
})
|
||||
if len(api.sends) != 3 ||
|
||||
api.sends[1].Kind != "thread-msg" ||
|
||||
api.sends[2].Kind != "thread-msg" {
|
||||
t.Fatalf("sends = %+v", api.sends)
|
||||
}
|
||||
photos := api.sends[1].Msg.Files
|
||||
if len(photos) != 0 {
|
||||
t.Fatalf("text message should not attach files: %+v", photos)
|
||||
}
|
||||
if got := api.sends[1].Msg.ImageURLs; len(got) != 1 || got[0] != "https://cdn.example/a.jpg" {
|
||||
t.Fatalf("text message images = %v", got)
|
||||
}
|
||||
if embeds := toEmbeds(api.sends[1].Msg); len(embeds) == 0 || embeds[0].Image == nil || embeds[0].Image.URL != "https://cdn.example/a.jpg" {
|
||||
t.Fatalf("text embeds = %+v", toEmbeds(api.sends[1].Msg))
|
||||
}
|
||||
videos := api.sends[2].Msg.Files
|
||||
if len(videos) != 1 || videos[0].Name != "clip.mp4" {
|
||||
t.Fatalf("video files = %+v", videos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundReleasesWhenAlreadyLinked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
links := newMemoryLinks()
|
||||
if err := links.Upsert(context.Background(), store.DiscordLink{
|
||||
PostID: "root-1",
|
||||
MessageID: "d-root",
|
||||
ThreadID: "thread-1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
released := false
|
||||
bot := New("channel-1", links, &fakeAPI{})
|
||||
bot.Handle(context.Background(), events.PostCreated{
|
||||
PostEvent: events.PostEvent{PostID: "root-1", RootID: "root-1", Title: "Already posted"},
|
||||
Release: func() { released = true },
|
||||
})
|
||||
if !released {
|
||||
t.Fatal("skipped send did not release held upload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -218,7 +338,10 @@ func TestFormatMessage(t *testing.T) {
|
||||
City: "Oakland",
|
||||
AuthorName: "sam",
|
||||
Permalink: "https://example.com/q",
|
||||
Images: []events.Image{{URL: "https://cdn.example/a.jpg", Description: "ignored"}},
|
||||
Images: []events.Image{
|
||||
{URL: "https://cdn.example/a.jpg", Description: "ignored"},
|
||||
{URL: "https://cdn.example/clip.mp4", Kind: "video"},
|
||||
},
|
||||
})
|
||||
if got.Title != "Leaky sink" ||
|
||||
got.Description != "It drips." ||
|
||||
@@ -226,7 +349,8 @@ func TestFormatMessage(t *testing.T) {
|
||||
got.Author != "sam" ||
|
||||
got.URL != "https://example.com/q" ||
|
||||
got.ThreadName != "sam asks: Leaky sink" ||
|
||||
len(got.ImageURLs) != 1 {
|
||||
len(got.ImageURLs) != 1 || got.ImageURLs[0] != "https://cdn.example/a.jpg" ||
|
||||
len(got.VideoURLs) != 1 || got.VideoURLs[0] != "https://cdn.example/clip.mp4" {
|
||||
t.Fatalf("format = %+v", got)
|
||||
}
|
||||
|
||||
@@ -237,7 +361,9 @@ func TestFormatMessage(t *testing.T) {
|
||||
content := messageContent(got)
|
||||
if strings.Contains(content, "Leaky sink") ||
|
||||
!strings.Contains(content, "It drips.") ||
|
||||
!strings.Contains(content, "Oakland") {
|
||||
!strings.Contains(content, "Oakland") ||
|
||||
!strings.Contains(content, "https://cdn.example/a.jpg") ||
|
||||
!strings.Contains(content, "https://cdn.example/clip.mp4") {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
if publicURL("/questions/x") != "" || publicURL("http://localhost:8080/q") != "" {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"plumber/internal/events"
|
||||
@@ -13,6 +15,14 @@ const (
|
||||
embedColor = 0xe96a26
|
||||
)
|
||||
|
||||
// Attachment is a local file Discord should upload with the message.
|
||||
type Attachment struct {
|
||||
Name string
|
||||
ContentType string
|
||||
Kind string
|
||||
Reader io.Reader
|
||||
}
|
||||
|
||||
// Message is a Discord-ready snapshot of a site post event.
|
||||
type Message struct {
|
||||
Title string
|
||||
@@ -21,36 +31,76 @@ type Message struct {
|
||||
City string
|
||||
Author string
|
||||
ImageURLs []string
|
||||
VideoURLs []string
|
||||
Files []Attachment
|
||||
ThreadName string
|
||||
}
|
||||
|
||||
func formatMessage(ev events.PostEvent) Message {
|
||||
title := strings.TrimSpace(ev.Title)
|
||||
return formatCreated(events.PostCreated{PostEvent: ev})
|
||||
}
|
||||
|
||||
func formatCreated(e events.PostCreated) Message {
|
||||
title := strings.TrimSpace(e.Title)
|
||||
if title == "" {
|
||||
title = "Reply"
|
||||
}
|
||||
author := strings.TrimSpace(ev.AuthorName)
|
||||
author := strings.TrimSpace(e.AuthorName)
|
||||
if author == "" {
|
||||
author = "Someone"
|
||||
}
|
||||
msg := Message{
|
||||
Title: truncateRunes(title, embedTitleLimit),
|
||||
URL: strings.TrimSpace(ev.Permalink),
|
||||
Description: truncateRunes(strings.TrimSpace(ev.Body), embedDescriptionLimit),
|
||||
City: strings.TrimSpace(ev.City),
|
||||
URL: strings.TrimSpace(e.Permalink),
|
||||
Description: truncateRunes(strings.TrimSpace(e.Body), embedDescriptionLimit),
|
||||
City: strings.TrimSpace(e.City),
|
||||
Author: author,
|
||||
ThreadName: threadName(author, ev.Title),
|
||||
ThreadName: threadName(author, e.Title),
|
||||
Files: filesFromMedia(e.Media),
|
||||
}
|
||||
for _, img := range ev.Images {
|
||||
for _, img := range e.Images {
|
||||
url := strings.TrimSpace(img.URL)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
if img.Kind == "video" {
|
||||
msg.VideoURLs = append(msg.VideoURLs, url)
|
||||
continue
|
||||
}
|
||||
msg.ImageURLs = append(msg.ImageURLs, url)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func filesFromMedia(media []events.Media) []Attachment {
|
||||
if len(media) == 0 {
|
||||
return nil
|
||||
}
|
||||
files := make([]Attachment, 0, len(media))
|
||||
for _, m := range media {
|
||||
var r io.Reader
|
||||
switch {
|
||||
case len(m.Bytes) > 0:
|
||||
r = bytes.NewReader(m.Bytes)
|
||||
case m.Body != nil:
|
||||
r = m.Body
|
||||
default:
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(m.Name)
|
||||
if name == "" {
|
||||
name = "upload"
|
||||
}
|
||||
files = append(files, Attachment{
|
||||
Name: name,
|
||||
ContentType: m.ContentType,
|
||||
Kind: m.Kind,
|
||||
Reader: r,
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func threadName(author, title string) string {
|
||||
author = strings.TrimSpace(author)
|
||||
if author == "" {
|
||||
@@ -96,6 +146,18 @@ func messageContent(msg Message) string {
|
||||
if u := publicURL(msg.URL); u != "" {
|
||||
parts = append(parts, u)
|
||||
}
|
||||
if len(msg.Files) == 0 {
|
||||
for _, mediaURL := range msg.ImageURLs {
|
||||
if u := publicURL(mediaURL); u != "" {
|
||||
parts = append(parts, u)
|
||||
}
|
||||
}
|
||||
for _, mediaURL := range msg.VideoURLs {
|
||||
if u := publicURL(mediaURL); u != "" {
|
||||
parts = append(parts, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
return truncateRunes(strings.Join(parts, "\n"), 2000)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user