diff --git a/Dockerfile b/Dockerfile index 7ff1743..9ce1d7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR tweetdistributor RUN mkdir -p /build RUN go mod tidy -RUN go build -a -tags "netgo" -tags timetzdata -installsuffix netgo -ldflags="-s -w -extldflags \"-static\"" -o=/build/tweetdistributor main.go +RUN go build -a -tags "netgo" -tags timetzdata -installsuffix netgo -ldflags="-s -w -extldflags \"-static\"" -o=/build/tweetdistributor . FROM alpine:3.22 diff --git a/main.go b/main.go index 92d5a90..e3c97e2 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,9 @@ import ( "net/http" "os" "path" + "regexp" "strings" + "time" "tweetdistributor/discord" "tweetdistributor/output" "tweetdistributor/store" @@ -28,6 +30,17 @@ const maxImagesPerPost = 4 // Bluesky rejects blobs over 2,000,000 bytes; Twitter allows up to 5MB. const maxImageBytes = 2_000_000 +// urlPattern picks candidate links out of message text; each one is parsed +// properly before it is judged to be a YouTube link. +var urlPattern = regexp.MustCompile(`https?://[^\s<>"']+`) + +// trimURL drops the punctuation that ends the sentence rather than the URL. +func trimURL(match string) string { + return strings.TrimRight(match, ".,!?、。)]}>") +} + +var httpClient = &http.Client{Timeout: 30 * time.Second} + // shrinkImage re-encodes (and if necessary downscales) an image until it // fits within maxImageBytes. Images already small enough pass through // untouched. @@ -69,29 +82,44 @@ func shrinkImage(img output.Image) (output.Image, error) { return output.Image{}, fmt.Errorf("%s could not be shrunk below %d bytes", img.Filename, maxImageBytes) } +// fetch GETs url and returns its body. +func fetch(url string) ([]byte, error) { + resp, err := httpClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %s", resp.Status) + } + return data, nil +} + +// downloadImage fetches an image and shrinks it to a postable size. +func downloadImage(url, filename, contentType string) (output.Image, error) { + data, err := fetch(url) + if err != nil { + return output.Image{}, fmt.Errorf("downloading %s: %w", filename, err) + } + return shrinkImage(output.Image{ + Data: data, + ContentType: contentType, + Filename: filename, + }) +} + func downloadImages(attachments []*discordgo.MessageAttachment) ([]output.Image, error) { var images []output.Image for _, attachment := range attachments { if !strings.HasPrefix(attachment.ContentType, "image/") { continue } - resp, err := http.Get(attachment.URL) - if err != nil { - return nil, fmt.Errorf("downloading %s: %w", attachment.Filename, err) - } - data, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return nil, fmt.Errorf("downloading %s: %w", attachment.Filename, err) - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("downloading %s: status %s", attachment.Filename, resp.Status) - } - img, err := shrinkImage(output.Image{ - Data: data, - ContentType: attachment.ContentType, - Filename: attachment.Filename, - }) + img, err := downloadImage(attachment.URL, attachment.Filename, attachment.ContentType) if err != nil { return nil, err } @@ -132,6 +160,15 @@ func (dist *distributor) created(event discord.Event) { return } + var preview *output.Preview + if videoURL := findYouTubeURL(event.Content); videoURL != "" { + preview, err = youtubePreview(videoURL) + if err != nil { + // The post is still worth making without its card. + fmt.Fprintln(os.Stderr, err) + } + } + // A reply to a message we never distributed becomes a top level post. var parents store.Refs if event.ReplyToID != "" { @@ -141,8 +178,9 @@ func (dist *distributor) created(event discord.Event) { refs := store.Refs{} for _, out := range dist.outputs { post := output.Post{ - Text: event.Content, - Images: images, + Text: event.Content, + Images: images, + Preview: preview, } if parent, ok := parents[out.GetName()]; ok && !parent.IsZero() { post.ReplyTo = &parent diff --git a/output/bluesky.go b/output/bluesky.go index c133479..cefa18f 100644 --- a/output/bluesky.go +++ b/output/bluesky.go @@ -89,7 +89,9 @@ func (bo *blueskyoutput) Write(post Post) (Ref, error) { root = Ref{RootURI: rootref.Uri, RootCID: rootref.Cid} } - if len(post.Images) > 0 { + // Bluesky allows a single embed, so attached images win over a link card. + switch { + case len(post.Images) > 0: embedimages := make([]*bsky.EmbedImages_Image, 0, len(post.Images)) for _, img := range post.Images { blob, err := atproto.RepoUploadBlob(context.TODO(), cli, bytes.NewReader(img.Data)) @@ -106,6 +108,24 @@ func (bo *blueskyoutput) Write(post Post) (Ref, error) { Images: embedimages, }, } + case post.Preview != nil: + external := &bsky.EmbedExternal_External{ + Uri: post.Preview.URL, + Title: post.Preview.Title, + Description: post.Preview.Description, + } + if thumb := post.Preview.Thumb; thumb != nil { + blob, err := atproto.RepoUploadBlob(context.TODO(), cli, bytes.NewReader(thumb.Data)) + if err != nil { + return Ref{}, fmt.Errorf("uploading %s: %w", thumb.Filename, err) + } + external.Thumb = blob.Blob + } + feedpost.Embed = &bsky.FeedPost_Embed{ + EmbedExternal: &bsky.EmbedExternal{ + External: external, + }, + } } Recordinput := &atproto.RepoCreateRecord_Input{ diff --git a/output/output.go b/output/output.go index ec063de..b4b4c6e 100644 --- a/output/output.go +++ b/output/output.go @@ -6,6 +6,14 @@ type Image struct { Filename string } +// Preview is a link preview card ("プレビュー窓") to attach to a post. +type Preview struct { + URL string + Title string + Description string + Thumb *Image +} + // Ref identifies a post an output has already published so it can later be // replied to or deleted. The fields are output specific; only the ones the // publishing output filled in are meaningful to it. @@ -24,8 +32,9 @@ func (r Ref) IsZero() bool { // Post is a single message to distribute. type Post struct { - Text string - Images []Image + Text string + Images []Image + Preview *Preview // ReplyTo is the Ref this same output returned for the post being // replied to, or nil for a top level post. ReplyTo *Ref diff --git a/output/stdout.go b/output/stdout.go index 0bbf5d8..917bd4c 100644 --- a/output/stdout.go +++ b/output/stdout.go @@ -19,6 +19,9 @@ func (so *stdoutput) Write(post Post) (Ref, error) { for _, img := range post.Images { fmt.Printf("[image: %s (%s, %d bytes)]\n", img.Filename, img.ContentType, len(img.Data)) } + if post.Preview != nil { + fmt.Printf("[preview: %s - %s (%s)]\n", post.Preview.Title, post.Preview.Description, post.Preview.URL) + } return Ref{ID: post.Text}, nil } diff --git a/output/twitter.go b/output/twitter.go index 30a3aab..9581524 100644 --- a/output/twitter.go +++ b/output/twitter.go @@ -63,6 +63,8 @@ func (to *twitteroutput) Write(post Post) (Ref, error) { MediaIDs: mediaIDs, } } + // A preview needs no special handling here: Twitter builds its own card + // from the URL that is already in the text. if post.ReplyTo != nil && post.ReplyTo.ID != "" { p.Reply = &types.CreateInputReply{ InReplyToTweetID: post.ReplyTo.ID, diff --git a/preview.go b/preview.go new file mode 100644 index 0000000..713d7a6 --- /dev/null +++ b/preview.go @@ -0,0 +1,74 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + "tweetdistributor/output" +) + +// findYouTubeURL returns the first YouTube video link in content, or "" if +// there is none. +func findYouTubeURL(content string) string { + for _, match := range urlPattern.FindAllString(content, -1) { + raw := trimURL(match) + u, err := url.Parse(raw) + if err != nil { + continue + } + switch strings.ToLower(u.Hostname()) { + case "youtu.be": + if strings.Trim(u.Path, "/") != "" { + return raw + } + case "youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com": + if u.Path == "/watch" && u.Query().Get("v") != "" { + return raw + } + if strings.HasPrefix(u.Path, "/shorts/") || strings.HasPrefix(u.Path, "/live/") { + return raw + } + } + } + return "" +} + +// oEmbedResponse is the part of YouTube's oEmbed document we care about. +type oEmbedResponse struct { + Title string `json:"title"` + AuthorName string `json:"author_name"` + ThumbnailURL string `json:"thumbnail_url"` +} + +// youtubePreview builds the preview card for a YouTube link by asking +// YouTube's oEmbed endpoint for the title, channel and thumbnail. +func youtubePreview(videoURL string) (*output.Preview, error) { + endpoint := "https://www.youtube.com/oembed?format=json&url=" + url.QueryEscape(videoURL) + body, err := fetch(endpoint) + if err != nil { + return nil, fmt.Errorf("fetching preview for %s: %w", videoURL, err) + } + + var oembed oEmbedResponse + if err := json.Unmarshal(body, &oembed); err != nil { + return nil, fmt.Errorf("parsing preview for %s: %w", videoURL, err) + } + + preview := &output.Preview{ + URL: videoURL, + Title: oembed.Title, + Description: oembed.AuthorName, + } + + if oembed.ThumbnailURL != "" { + thumb, err := downloadImage(oembed.ThumbnailURL, "thumbnail.jpg", "image/jpeg") + if err != nil { + // A card without its thumbnail is still worth posting. + return preview, nil + } + preview.Thumb = &thumb + } + + return preview, nil +} diff --git a/preview_test.go b/preview_test.go new file mode 100644 index 0000000..852b9bb --- /dev/null +++ b/preview_test.go @@ -0,0 +1,33 @@ +package main + +import "testing" + +func TestFindYouTubeURL(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + {"watch", "みてみて https://www.youtube.com/watch?v=dQw4w9WgXcQ おもしろい", "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}, + {"short host", "https://youtu.be/dQw4w9WgXcQ?t=42", "https://youtu.be/dQw4w9WgXcQ?t=42"}, + {"shorts", "https://www.youtube.com/shorts/abc_123", "https://www.youtube.com/shorts/abc_123"}, + {"live", "https://youtube.com/live/abc-123", "https://youtube.com/live/abc-123"}, + {"mobile", "https://m.youtube.com/watch?v=abc&feature=share", "https://m.youtube.com/watch?v=abc&feature=share"}, + {"trailing punctuation", "これ→https://youtu.be/abc123。", "https://youtu.be/abc123"}, + {"first of several", "https://youtu.be/one https://youtu.be/two", "https://youtu.be/one"}, + {"skips other links", "https://example.com/watch?v=x https://youtu.be/abc", "https://youtu.be/abc"}, + {"no url", "ただのつぶやき", ""}, + {"other site", "https://example.com/", ""}, + {"youtube without video", "https://www.youtube.com/", ""}, + {"watch without v", "https://www.youtube.com/watch?list=PL123", ""}, + {"lookalike host", "https://youtube.com.evil.example/watch?v=abc", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := findYouTubeURL(tt.content); got != tt.want { + t.Errorf("findYouTubeURL(%q) = %q, want %q", tt.content, got, tt.want) + } + }) + } +}