23 Commits

Author SHA1 Message Date
d794f65673 make links in bluesky posts clickable
Bluesky linkifies nothing by itself. A URL in the text of a record is
plain text unless a richtext facet says which bytes of the post are a
link and where they point, so our posts carried URLs no one could press.

Each link now travels on the Post as an output.Link with the offsets of
the text it occupies, and the bluesky output turns those into
app.bsky.richtext.facet#link. The offsets are UTF-8 byte offsets, not
character counts: a facet measured in characters slides off the URL as
soon as any Japanese text precedes it, and underlines the wrong words.

Scanning moved from preview.go to findLinks in main.go, which now
reports every link with its offsets rather than just the first one; the
preview card still goes to the first, which is the one a reader meets
first. X is unaffected, as it linkifies URLs itself.

The facet feature only gains its lexicon type when marshalled, so the
test checks the encoded record rather than the struct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:35:59 +09:00
953664ff1c expand a preview card for any link, not just YouTube
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>
2026-08-03 09:32:12 +09:00
e2f8036682 count links as a fixed length against the tweet limit
Twitter rewrites every URL to a t.co address of a fixed 23 characters,
so counting the raw characters rejected messages Twitter would have
accepted: 100 characters of text plus a 50 character YouTube link
counted as 150 and was refused, where Twitter sees 123.

The punctuation trimmed off the end of a link keeps counting as ordinary
text. Only links written with a scheme are recognised; Twitter also
shortens bare hosts like example.com/foo, but detecting those without
mistaking Node.js for a link is not worth it here, and counting them in
full errs toward refusing rather than posting something too long.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:00:22 +09:00
fd14c07ae0 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>
2026-07-26 12:00:22 +09:00
1c2ab29404 mirror Discord replies as replies on X and bluesky
Replying to a message on the watched channel now posts the reply as a
reply to the posts that message produced, instead of as a standalone
post.

Discord tells us the parent through MessageReference, and the store maps
it back to the Ref each output returned; that Ref rides along on the new
Post.ReplyTo. X takes it as in_reply_to_tweet_id. Bluesky needs the
thread root as well as the parent, so a Ref now carries the root it was
posted under and a post that is not itself a reply acts as its own root.

Replying to something we never distributed, an error notice from the bot
for instance, still posts normally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:00:22 +09:00
067c9252c9 mirror Discord deletions to X and bluesky
Deleting a message on the watched channel now deletes the posts it
produced. Making that possible reshapes how a message reaches an output:

  - OutputInterface takes an output.Post and returns an output.Ref, the
    identifier the platform gave the new post (tweet ID for X, URI and
    CID for bluesky). It also gained Delete(Ref).
  - The discord package no longer forwards raw MessageCreate values but
    a discord.Event carrying the kind of change, so the MessageDelete
    handler can use the same channel. Deleted messages arrive without an
    author, so only the channel ID can be filtered on.
  - The new store package remembers Discord message ID -> per output Ref.
    Set POST_STORE to a path to keep that mapping across restarts;
    without it the mapping is memory only and a restart makes older
    messages undeletable. It holds the most recent 1000 messages.

main.go grows a distributor type to hold the outputs and the store
rather than threading them through the event loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:00:22 +09:00
4bd51a31a8 update build-env 2026-07-23 23:16:37 +00:00
c579ae6980 attach image 2026-07-03 10:18:39 +09:00
62cff6355b check 140 digits for twitter 2026-07-03 10:03:19 +09:00
68981e9eb5 add Dockerfile 2025-10-26 16:53:07 +09:00
b8152b3c6a remove redundant stdout 2025-03-20 14:49:46 +09:00
c25735f9c9 add StdOutput 2025-03-20 14:48:08 +09:00
5f9b838af5 change channel struct 2025-03-20 14:40:52 +09:00
d510488d3f remove unnecessary output to stdout at beginning 2025-03-16 19:25:10 +09:00
f9b6c39353 change error handling 2025-03-16 19:24:56 +09:00
1d6e9258f9 login to bsky for each write 2025-03-16 19:13:21 +09:00
bddc971065 add error message while posting 2025-03-16 09:24:15 +09:00
4aa04ce4d1 ignore message sent by bot self on slack channel 2025-03-16 09:08:20 +09:00
c2e8c64262 Revert "add message"
This reverts commit 6c4239f5d0.
2025-03-15 20:11:49 +09:00
6c4239f5d0 add message 2025-03-15 19:43:17 +09:00
03d847c969 add bluesky post 2025-03-15 19:35:48 +09:00
ac532ddcb5 add twitter 2025-03-15 19:07:16 +09:00
aea31da9c0 Init
Just read from discord channel
2025-03-15 17:38:20 +09:00