Bluesky renders no link card of its own. Its PDS never fetches the page
behind a URL, so a post whose record has no app.bsky.embed.external
shows the link as bare text; every client that shows a card builds it
before posting. We only built one for YouTube, so every other link
arrived on bluesky with nothing attached.
preview.go now reads the Open Graph tags of an ordinary page and turns
them into the same card: og:title and og:description, falling back to
the twitter:* tags and then to <title> and meta description, with
og:image fetched as the thumbnail. YouTube keeps its oEmbed path, which
answers with the handful of fields a card needs rather than the megabyte
of markup the watch page is.
Only the head of a page is read, and parsing stops at <body>, since
preview tags belong above it and a truncated page still yields what was
read. Pages are decoded through x/net/html/charset rather than assumed
to be UTF-8, which Japanese pages served as Shift_JIS are not. The
thumbnail is resolved against the URL the body came from, so a relative
og:image survives a redirect. Requests now name the bot in a User-Agent,
which some sites want before serving preview tags at all.
fetch grew a byte limit and now reports the media type and final URL its
body came with, which is what the thumbnail needs to name and resolve
itself.
Checked against go.dev, Japanese Wikipedia and a YouTube video.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>