expand a preview card for YouTube links

A message containing a YouTube link now carries a preview card. Discord
and X build their own card from the URL in the text, so the work is on
the bluesky side: the new preview.go asks YouTube's oEmbed endpoint for
the title, channel and thumbnail and posts them as an
app.bsky.embed.external.

Links are found by pulling candidates out of the text and parsing them
with net/url rather than by matching a URL shaped regexp, so a host like
youtube.com.example.invalid is not mistaken for the real thing.

Bluesky allows one embed per post, so a message with both attachments
and a link keeps the attachments. A failed oEmbed lookup only costs the
card, not the post.

Downloading the thumbnail wants the attachment download path, so it is
split into fetch and downloadImage, and both now go through a client
with a timeout: the event loop is single threaded and a hung request
would stall every later message.

The Dockerfile built main.go by name, which stops working now that
package main spans two files; it builds the package instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 11:56:49 +09:00
parent 1c2ab29404
commit fd14c07ae0
8 changed files with 202 additions and 23 deletions

View File

@@ -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{

View File

@@ -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

View File

@@ -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
}

View File

@@ -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,