Compare commits

..
9 Commits
Author SHA1 Message Date
sirrowandClaude Opus 5 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
sirrowandClaude Opus 5 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
sirrowandClaude Opus 5 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
sirrowandClaude Opus 5 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
sirrowandClaude Opus 5 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
sirrowandClaude Opus 5 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
sirrow 4bd51a31a8 update build-env 2026-07-23 23:16:37 +00:00
sirrow c579ae6980 attach image 2026-07-03 10:18:39 +09:00
sirrow 62cff6355b check 140 digits for twitter 2026-07-03 10:03:19 +09:00
15 changed files with 1639 additions and 41 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
FROM golang:alpine AS build-env FROM golang:1.26-alpine AS build-env
RUN apk --no-cache add git make build-base RUN apk --no-cache add git make build-base
@@ -7,7 +7,7 @@ WORKDIR tweetdistributor
RUN mkdir -p /build RUN mkdir -p /build
RUN go mod tidy 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 FROM alpine:3.22
+51 -9
View File
@@ -6,34 +6,76 @@ import (
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
type discord struct { // EventKind tells apart the things that can happen to a watched message.
type EventKind int
const (
MessageCreated EventKind = iota
MessageDeleted
)
// Event is a change on the watched channel that the distributor has to mirror.
type Event struct {
Kind EventKind
MessageID string
Content string
Attachments []*discordgo.MessageAttachment
// ReplyToID is the ID of the message this one replies to, empty when the
// message is not a reply.
ReplyToID string
}
type Client struct {
Token string Token string
ChannelID string ChannelID string
dgsession *discordgo.Session dgsession *discordgo.Session
} }
func Discord(token string, channelId string) *discord { func Discord(token string, channelId string) *Client {
return &discord{ return &Client{
Token: token, Token: token,
ChannelID: channelId, ChannelID: channelId,
} }
} }
func (d *discord) BeginRead(tweetchannel chan<- *discordgo.MessageCreate) { func (d *Client) BeginRead(eventchannel chan<- Event) {
d.read(tweetchannel) d.read(eventchannel)
} }
func (d *discord) read(tweetchannel chan<- *discordgo.MessageCreate) { func (d *Client) read(eventchannel chan<- Event) {
dgsession, err := discordgo.New("Bot " + d.Token) dgsession, err := discordgo.New("Bot " + d.Token)
if err != nil { if err != nil {
return return
} }
dgsession.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { dgsession.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.ChannelID == d.ChannelID && m.Author.ID != s.State.User.ID { if m.ChannelID != d.ChannelID || m.Author.ID == s.State.User.ID {
tweetchannel <- m return
}
event := Event{
Kind: MessageCreated,
MessageID: m.ID,
Content: m.Content,
Attachments: m.Attachments,
}
if m.MessageReference != nil {
event.ReplyToID = m.MessageReference.MessageID
}
eventchannel <- event
})
// Deleted messages arrive without an author, so the channel is the only
// thing to filter on; messages we never distributed are dropped later.
dgsession.AddHandler(func(s *discordgo.Session, m *discordgo.MessageDelete) {
if m.ChannelID != d.ChannelID {
return
}
eventchannel <- Event{
Kind: MessageDeleted,
MessageID: m.ID,
} }
}) })
d.dgsession = dgsession d.dgsession = dgsession
errOpen := dgsession.Open() errOpen := dgsession.Open()
@@ -45,6 +87,6 @@ func Read() string {
return "discord" return "discord"
} }
func (d *discord) Write(str string) { func (d *Client) Write(str string) {
d.dgsession.ChannelMessageSend(d.ChannelID, str) d.dgsession.ChannelMessageSend(d.ChannelID, str)
} }
+5 -2
View File
@@ -1,10 +1,12 @@
module tweetdistributor module tweetdistributor
go 1.24.1 go 1.25.0
require ( require (
github.com/bluesky-social/indigo v0.0.0-20250313000755-d9a74f690c90 github.com/bluesky-social/indigo v0.0.0-20250313000755-d9a74f690c90
github.com/bwmarrin/discordgo v0.28.1 github.com/bwmarrin/discordgo v0.28.1
golang.org/x/image v0.43.0
golang.org/x/net v0.23.0
) )
require ( require (
@@ -50,13 +52,14 @@ require (
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect go.uber.org/zap v1.26.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
lukechampine.com/blake3 v1.2.1 // indirect lukechampine.com/blake3 v1.2.1 // indirect
) )
require ( require (
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/michimani/gotwi v0.17.0 github.com/michimani/gotwi v0.18.2
golang.org/x/crypto v0.36.0 // indirect golang.org/x/crypto v0.36.0 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/sys v0.31.0 // indirect
) )
+232
View File
@@ -0,0 +1,232 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bluesky-social/indigo v0.0.0-20250313000755-d9a74f690c90 h1:lSo7V6ut0FrLmFyv3TfvXrqwcPNDXRC1hNvS04pLgfA=
github.com/bluesky-social/indigo v0.0.0-20250313000755-d9a74f690c90/go.mod h1:NVBwZvbBSa93kfyweAmKwOLYawdVHdwZ9s+GZtBBVLA=
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8=
github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk=
github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps=
github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ=
github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE=
github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw=
github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs=
github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs=
github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk=
github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U=
github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg=
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY=
github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI=
github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg=
github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/michimani/gotwi v0.18.2 h1:2eU7Lunc5eMyOM/XITz5ahsDNhUY9D363QaR3DddjfQ=
github.com/michimani/gotwi v0.18.2/go.mod h1:yz1cyV/30Uy/KGQyN8BVfXFPt/63Imzonykny8/SMi0=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc=
go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo=
go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4=
go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM=
go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc=
go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI=
lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
+313 -10
View File
@@ -1,19 +1,322 @@
package main package main
import ( import (
"bytes"
"fmt" "fmt"
"image"
_ "image/gif"
"image/jpeg"
_ "image/png"
"io"
"mime"
"net/http"
"net/url"
"os" "os"
"path"
"regexp"
"strings"
"time"
"tweetdistributor/discord" "tweetdistributor/discord"
"tweetdistributor/output" "tweetdistributor/output"
"tweetdistributor/store"
"unicode/utf8"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
) )
const maxTweetLength = 140
const maxImagesPerPost = 4
// Bluesky rejects blobs over 2,000,000 bytes; Twitter allows up to 5MB.
const maxImageBytes = 2_000_000
// urlLength is what a link costs against maxTweetLength: Twitter rewrites
// every URL to a t.co address of this fixed length, however long the original
// was, so counting the raw characters would reject messages it would accept.
const urlLength = 23
// 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, ".,!?、。)]}>")
}
// findLinks returns every link in content, each with the UTF-8 byte offsets
// of the text it occupies.
func findLinks(content string) []output.Link {
var links []output.Link
for _, span := range urlPattern.FindAllStringIndex(content, -1) {
raw := trimURL(content[span[0]:span[1]])
if u, err := url.Parse(raw); err != nil || u.Host == "" {
continue
}
links = append(links, output.Link{
URL: raw,
ByteStart: span[0],
ByteEnd: span[0] + len(raw),
})
}
return links
}
// tweetLength counts a message the way Twitter does, charging every link a
// fixed length instead of its actual one.
func tweetLength(content string) int {
length := utf8.RuneCountInString(content)
for _, match := range urlPattern.FindAllString(content, -1) {
// The trimmed punctuation is still ordinary text and keeps counting.
length += urlLength - utf8.RuneCountInString(trimURL(match))
}
return length
}
// maxDownloadBytes caps an image download. Discord's own attachment limit is
// well below it, so a truncated image means something else served us a body
// far larger than any picture we would want to post.
const maxDownloadBytes = 32 << 20
// userAgent names the bot to the sites whose preview tags it reads; some of
// them serve those tags only to a client that identifies itself.
const userAgent = "tweetdistributor/1.0 (link preview)"
var httpClient = &http.Client{Timeout: 30 * time.Second}
// fetched is a downloaded document together with what the response said
// about it.
type fetched struct {
body []byte
// mediaType is the Content-Type without its parameters, e.g. "text/html".
mediaType string
// contentType is the header as sent, parameters and all, which is what
// tells a decoder the character encoding.
contentType string
// url is where the body actually came from, after any redirects, and is
// what relative links in it resolve against.
url *url.URL
}
// shrinkImage re-encodes (and if necessary downscales) an image until it
// fits within maxImageBytes. Images already small enough pass through
// untouched.
func shrinkImage(img output.Image) (output.Image, error) {
if len(img.Data) <= maxImageBytes {
return img, nil
}
src, _, err := image.Decode(bytes.NewReader(img.Data))
if err != nil {
return output.Image{}, fmt.Errorf("decoding %s: %w", img.Filename, err)
}
for scale := 1.0; scale > 0.05; scale *= 0.7 {
width := int(float64(src.Bounds().Dx()) * scale)
height := int(float64(src.Bounds().Dy()) * scale)
if width < 1 || height < 1 {
break
}
scaled := image.NewRGBA(image.Rect(0, 0, width, height))
draw.CatmullRom.Scale(scaled, scaled.Bounds(), src, src.Bounds(), draw.Src, nil)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: 85}); err != nil {
return output.Image{}, fmt.Errorf("encoding %s: %w", img.Filename, err)
}
if buf.Len() <= maxImageBytes {
filename := strings.TrimSuffix(img.Filename, path.Ext(img.Filename)) + ".jpg"
return output.Image{
Data: buf.Bytes(),
ContentType: "image/jpeg",
Filename: filename,
}, nil
}
}
return output.Image{}, fmt.Errorf("%s could not be shrunk below %d bytes", img.Filename, maxImageBytes)
}
// fetch GETs rawurl, reading at most limit bytes of the body. Callers that
// only need the beginning of a document pass a small limit and treat the
// truncation as normal.
func fetch(rawurl string, limit int64) (*fetched, error) {
req, err := http.NewRequest(http.MethodGet, rawurl, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, limit))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %s", resp.Status)
}
contenttype := resp.Header.Get("Content-Type")
mediatype, _, err := mime.ParseMediaType(contenttype)
if err != nil {
mediatype = ""
}
return &fetched{
body: data,
mediaType: mediatype,
contentType: contenttype,
url: resp.Request.URL,
}, nil
}
// downloadImage fetches an image and shrinks it to a postable size.
func downloadImage(url, filename, contentType string) (output.Image, error) {
got, err := fetch(url, maxDownloadBytes)
if err != nil {
return output.Image{}, fmt.Errorf("downloading %s: %w", filename, err)
}
return shrinkImage(output.Image{
Data: got.body,
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
}
img, err := downloadImage(attachment.URL, attachment.Filename, attachment.ContentType)
if err != nil {
return nil, err
}
images = append(images, img)
}
return images, nil
}
// distributor mirrors what happens on the Discord channel to every output.
type distributor struct {
d *discord.Client
outputs []output.OutputInterface
store *store.Store
}
// reportf logs an error and echoes it back into the Discord channel.
func (dist *distributor) reportf(format string, args ...any) {
errstr := fmt.Sprintf(format, args...)
fmt.Fprintln(os.Stderr, errstr)
dist.d.Write(errstr)
}
// created posts a new Discord message to every output, as a reply when the
// Discord message itself was a reply to something we already distributed.
func (dist *distributor) created(event discord.Event) {
if length := tweetLength(event.Content); length > maxTweetLength {
dist.reportf("Error: message is %d characters counting each link as %d, exceeding the %d character limit; not posted", length, urlLength, maxTweetLength)
return
}
images, err := downloadImages(event.Attachments)
if err != nil {
dist.reportf("Error: %s; not posted", err)
return
}
if len(images) > maxImagesPerPost {
dist.reportf("Error: %d images attached, exceeding the limit of %d; not posted", len(images), maxImagesPerPost)
return
}
// The card goes to the first link, the one a reader meets first.
links := findLinks(event.Content)
var preview *output.Preview
if len(links) > 0 {
preview, err = linkPreview(links[0].URL)
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 != "" {
parents, _ = dist.store.Get(event.ReplyToID)
}
refs := store.Refs{}
for _, out := range dist.outputs {
post := output.Post{
Text: event.Content,
Images: images,
Links: links,
Preview: preview,
}
if parent, ok := parents[out.GetName()]; ok && !parent.IsZero() {
post.ReplyTo = &parent
}
ref, err := out.Write(post)
if err != nil {
dist.reportf("%s Error: %s", out.GetName(), err)
continue
}
refs[out.GetName()] = ref
}
if len(refs) == 0 {
return
}
if err := dist.store.Put(event.MessageID, refs); err != nil {
dist.reportf("Error: could not remember the posts for this message: %s", err)
}
}
// deleted removes the posts that a now deleted Discord message produced.
func (dist *distributor) deleted(event discord.Event) {
refs, ok := dist.store.Get(event.MessageID)
if !ok {
return
}
for _, out := range dist.outputs {
ref, ok := refs[out.GetName()]
if !ok {
continue
}
if err := out.Delete(ref); err != nil {
dist.reportf("%s Error: could not delete the post: %s", out.GetName(), err)
}
}
if err := dist.store.Delete(event.MessageID); err != nil {
dist.reportf("Error: could not forget the posts for this message: %s", err)
}
}
func main() { func main() {
posts, err := store.New(os.Getenv("POST_STORE"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
d := discord.Discord(os.Getenv("DISCORD_TOKEN"), os.Getenv("DISCORD_CHANNEL")) d := discord.Discord(os.Getenv("DISCORD_TOKEN"), os.Getenv("DISCORD_CHANNEL"))
tweetchannel := make(chan *discordgo.MessageCreate, 1) eventchannel := make(chan discord.Event, 1)
d.BeginRead(tweetchannel) d.BeginRead(eventchannel)
d.Write("Tweetdistributor Started") d.Write("Tweetdistributor Started")
@@ -22,14 +325,14 @@ func main() {
outputs = append(outputs, output.TwitterOutput(os.Getenv("TW_ACCESS_TOKEN"), os.Getenv("TW_ACCESS_SECRET"))) outputs = append(outputs, output.TwitterOutput(os.Getenv("TW_ACCESS_TOKEN"), os.Getenv("TW_ACCESS_SECRET")))
outputs = append(outputs, output.BlueskyOutput(os.Getenv("BSKY_IDENTIFIER"), os.Getenv("BSKY_PASSWORD"))) outputs = append(outputs, output.BlueskyOutput(os.Getenv("BSKY_IDENTIFIER"), os.Getenv("BSKY_PASSWORD")))
for tweet := range tweetchannel { dist := &distributor{d: d, outputs: outputs, store: posts}
for _, output := range outputs {
err := output.Write(tweet.Content) for event := range eventchannel {
if err != nil { switch event.Kind {
errstr := fmt.Sprintf("%s Error: %s", output.GetName(), err) case discord.MessageCreated:
fmt.Fprintln(os.Stderr, errstr) dist.created(event)
d.Write(errstr) case discord.MessageDeleted:
} dist.deleted(event)
} }
} }
} }
+152
View File
@@ -0,0 +1,152 @@
package main
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"math/rand"
"strings"
"testing"
"tweetdistributor/output"
"unicode/utf8"
)
// noiseJPEG returns a JPEG image of random noise, which compresses poorly
// and reliably exceeds maxImageBytes at large dimensions.
func noiseJPEG(t *testing.T, width, height int) []byte {
t.Helper()
rng := rand.New(rand.NewSource(1))
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{uint8(rng.Intn(256)), uint8(rng.Intn(256)), uint8(rng.Intn(256)), 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestFindLinks(t *testing.T) {
tests := []struct {
name string
content string
want []output.Link
}{
{
// The offsets are byte offsets, so the multibyte prefix counts
// for more than the three characters it looks like.
"after multibyte text",
"みてね https://example.com/article",
[]output.Link{{URL: "https://example.com/article", ByteStart: 10, ByteEnd: 37}},
},
{
"trailing punctuation is outside the link",
"これ→https://example.com/x。",
[]output.Link{{URL: "https://example.com/x", ByteStart: 9, ByteEnd: 30}},
},
{
"several",
"https://one.example https://two.example",
[]output.Link{
{URL: "https://one.example", ByteStart: 0, ByteEnd: 19},
{URL: "https://two.example", ByteStart: 20, ByteEnd: 39},
},
},
{"no link", "ただのつぶやき", nil},
{"scheme only", "http:// と書いただけ", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := findLinks(tt.content)
if len(got) != len(tt.want) {
t.Fatalf("findLinks(%q) returned %d links, want %d", tt.content, len(got), len(tt.want))
}
for i, link := range got {
if link != tt.want[i] {
t.Errorf("link %d = %+v, want %+v", i, link, tt.want[i])
}
// A facet pointing at the wrong bytes marks up the wrong text.
if slice := tt.content[link.ByteStart:link.ByteEnd]; slice != link.URL {
t.Errorf("bytes [%d:%d] are %q, want %q", link.ByteStart, link.ByteEnd, slice, link.URL)
}
}
})
}
}
func TestTweetLength(t *testing.T) {
tests := []struct {
name string
content string
want int
}{
{"no link", "こんにちは", 5},
{"long link", "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLabcdefghijklmnop", urlLength},
{"short link", "https://a.jp", urlLength},
{"link with text", "みてね " + strings.Repeat("x", 10) + " https://youtu.be/dQw4w9WgXcQ", 4 + 10 + 1 + urlLength},
{"two links", "https://a.jp https://b.jp", urlLength*2 + 1},
{"trailing punctuation still counts", "https://a.jp。", urlLength + 1},
{"multibyte path", "https://ja.wikipedia.org/wiki/日本語", urlLength},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tweetLength(tt.content); got != tt.want {
t.Errorf("tweetLength(%q) = %d, want %d", tt.content, got, tt.want)
}
})
}
}
func TestTweetLengthAcceptsMessageOnlyLinksMakeTooLong(t *testing.T) {
// A message that the raw rune count would reject but Twitter accepts.
content := strings.Repeat("あ", 100) + " https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120s"
if raw := utf8.RuneCountInString(content); raw <= maxTweetLength {
t.Fatalf("test message should be %d raw characters, got %d", maxTweetLength+1, raw)
}
if got := tweetLength(content); got > maxTweetLength {
t.Errorf("tweetLength = %d, want it to fit within %d", got, maxTweetLength)
}
}
func TestShrinkImageLarge(t *testing.T) {
data := noiseJPEG(t, 3000, 3000)
if len(data) <= maxImageBytes {
t.Fatalf("test image should exceed maxImageBytes, got %d", len(data))
}
shrunk, err := shrinkImage(output.Image{Data: data, ContentType: "image/jpeg", Filename: "DSC_7277.jpg"})
if err != nil {
t.Fatal(err)
}
if len(shrunk.Data) > maxImageBytes {
t.Errorf("shrunk image is %d bytes, want <= %d", len(shrunk.Data), maxImageBytes)
}
if shrunk.ContentType != "image/jpeg" {
t.Errorf("ContentType = %q, want image/jpeg", shrunk.ContentType)
}
if shrunk.Filename != "DSC_7277.jpg" {
t.Errorf("Filename = %q, want DSC_7277.jpg", shrunk.Filename)
}
if _, _, err := image.Decode(bytes.NewReader(shrunk.Data)); err != nil {
t.Errorf("shrunk image is not decodable: %s", err)
}
}
func TestShrinkImageSmallPassthrough(t *testing.T) {
data := noiseJPEG(t, 100, 100)
img := output.Image{Data: data, ContentType: "image/png", Filename: "small.png"}
shrunk, err := shrinkImage(img)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(shrunk.Data, img.Data) || shrunk.ContentType != img.ContentType || shrunk.Filename != img.Filename {
t.Error("small image should pass through unchanged")
}
}
+152 -11
View File
@@ -1,8 +1,11 @@
package output package output
import ( import (
"bytes"
"context" "context"
"log" "errors"
"fmt"
"strings"
"time" "time"
"github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/api/atproto"
@@ -12,6 +15,8 @@ import (
"github.com/bluesky-social/indigo/xrpc" "github.com/bluesky-social/indigo/xrpc"
) )
const blueskyCollection = "app.bsky.feed.post"
type blueskyoutput struct { type blueskyoutput struct {
identifier string identifier string
password string password string
@@ -26,7 +31,8 @@ func BlueskyOutput(identifier string, password string) *blueskyoutput {
return blueskyoutput return blueskyoutput
} }
func (bo *blueskyoutput) Write(str string) error { // session logs in and returns a client authenticated as the configured account.
func (bo *blueskyoutput) session() (*xrpc.Client, error) {
cli := &xrpc.Client{ cli := &xrpc.Client{
Host: "https://bsky.social", Host: "https://bsky.social",
} }
@@ -38,7 +44,7 @@ func (bo *blueskyoutput) Write(str string) error {
output, err := atproto.ServerCreateSession(context.TODO(), cli, input) output, err := atproto.ServerCreateSession(context.TODO(), cli, input)
if err != nil { if err != nil {
log.Fatal(err) return nil, err
} }
cli.Auth = &xrpc.AuthInfo{ cli.Auth = &xrpc.AuthInfo{
AccessJwt: output.AccessJwt, AccessJwt: output.AccessJwt,
@@ -46,19 +52,154 @@ func (bo *blueskyoutput) Write(str string) error {
Handle: output.Handle, Handle: output.Handle,
Did: output.Did, Did: output.Did,
} }
return cli, nil
}
Recordinput := &atproto.RepoCreateRecord_Input{ func (bo *blueskyoutput) Write(post Post) (Ref, error) {
Collection: "app.bsky.feed.post", cli, err := bo.session()
Repo: cli.Auth.Did, // "matope.bsky.social" のDID if err != nil {
Record: &lexutil.LexiconTypeDecoder{&bsky.FeedPost{ return Ref{}, err
Text: str, }
feedpost := &bsky.FeedPost{
Text: post.Text,
CreatedAt: time.Now().Format(util.ISO8601), CreatedAt: time.Now().Format(util.ISO8601),
Langs: []string{"ja"}, Langs: []string{"ja"},
}},
} }
_, recerr := atproto.RepoCreateRecord(context.TODO(), cli, Recordinput)
return recerr feedpost.Facets = linkFacets(post.Links)
root := Ref{}
if post.ReplyTo != nil && post.ReplyTo.URI != "" {
parent := &atproto.RepoStrongRef{
Uri: post.ReplyTo.URI,
Cid: post.ReplyTo.CID,
}
// A post that is itself a reply carries the thread root; one that is
// not is the root of its own thread.
rootref := &atproto.RepoStrongRef{
Uri: post.ReplyTo.RootURI,
Cid: post.ReplyTo.RootCID,
}
if rootref.Uri == "" {
rootref = parent
}
feedpost.Reply = &bsky.FeedPost_ReplyRef{
Parent: parent,
Root: rootref,
}
root = Ref{RootURI: rootref.Uri, RootCID: rootref.Cid}
}
// 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))
if err != nil {
return Ref{}, fmt.Errorf("uploading %s: %w", img.Filename, err)
}
embedimages = append(embedimages, &bsky.EmbedImages_Image{
Alt: img.Filename,
Image: blob.Blob,
})
}
feedpost.Embed = &bsky.FeedPost_Embed{
EmbedImages: &bsky.EmbedImages{
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{
Collection: blueskyCollection,
Repo: cli.Auth.Did, // "matope.bsky.social" のDID
Record: &lexutil.LexiconTypeDecoder{Val: feedpost},
}
record, recerr := atproto.RepoCreateRecord(context.TODO(), cli, Recordinput)
if recerr != nil {
return Ref{}, recerr
}
return Ref{
URI: record.Uri,
CID: record.Cid,
RootURI: root.RootURI,
RootCID: root.RootCID,
}, nil
}
// linkFacets marks up the links in a post's text. Bluesky linkifies nothing
// by itself: a URL stays plain text until a facet says which bytes of the
// post are a link and where they point.
func linkFacets(links []Link) []*bsky.RichtextFacet {
if len(links) == 0 {
return nil
}
facets := make([]*bsky.RichtextFacet, 0, len(links))
for _, link := range links {
facets = append(facets, &bsky.RichtextFacet{
Index: &bsky.RichtextFacet_ByteSlice{
ByteStart: int64(link.ByteStart),
ByteEnd: int64(link.ByteEnd),
},
Features: []*bsky.RichtextFacet_Features_Elem{{
RichtextFacet_Link: &bsky.RichtextFacet_Link{Uri: link.URL},
}},
})
}
return facets
}
func (bo *blueskyoutput) Delete(ref Ref) error {
rkey, err := recordKey(ref.URI)
if err != nil {
return err
}
cli, err := bo.session()
if err != nil {
return err
}
_, err = atproto.RepoDeleteRecord(context.TODO(), cli, &atproto.RepoDeleteRecord_Input{
Collection: blueskyCollection,
Repo: cli.Auth.Did,
Rkey: rkey,
})
return err
}
// recordKey pulls the rkey out of an at://did/collection/rkey URI.
func recordKey(uri string) (string, error) {
if uri == "" {
return "", errors.New("no record URI to delete")
}
idx := strings.LastIndex(uri, "/")
if idx < 0 || idx == len(uri)-1 {
return "", fmt.Errorf("malformed record URI %q", uri)
}
return uri[idx+1:], nil
} }
func (bo *blueskyoutput) GetName() string { func (bo *blueskyoutput) GetName() string {
+64
View File
@@ -0,0 +1,64 @@
package output
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/bluesky-social/indigo/api/bsky"
"github.com/bluesky-social/indigo/util"
)
func TestLinkFacets(t *testing.T) {
if facets := linkFacets(nil); facets != nil {
t.Errorf("a post with no links got %d facets, want none", len(facets))
}
text := "みてね https://example.com/x"
post := &bsky.FeedPost{
Text: text,
CreatedAt: time.Now().Format(util.ISO8601),
Facets: linkFacets([]Link{{URL: "https://example.com/x", ByteStart: 10, ByteEnd: 31}}),
}
// The feature carries its lexicon type only once marshalled, so the
// record as it goes over the wire is what has to be checked.
encoded, err := json.Marshal(post)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
`"$type":"app.bsky.richtext.facet#link"`,
`"uri":"https://example.com/x"`,
`"byteStart":10`,
`"byteEnd":31`,
} {
if !strings.Contains(string(encoded), want) {
t.Errorf("record does not contain %s\ngot: %s", want, encoded)
}
}
// A facet that points at the wrong bytes underlines the wrong text.
index := post.Facets[0].Index
if slice := text[index.ByteStart:index.ByteEnd]; slice != "https://example.com/x" {
t.Errorf("facet covers %q, want the URL", slice)
}
}
func TestRecordKey(t *testing.T) {
rkey, err := recordKey("at://did:plc:abc123/app.bsky.feed.post/3kqz7xyz")
if err != nil {
t.Fatal(err)
}
if rkey != "3kqz7xyz" {
t.Errorf("recordKey = %q, want 3kqz7xyz", rkey)
}
for _, uri := range []string{"", "at://did:plc:abc123/app.bsky.feed.post/", "nonsense"} {
if _, err := recordKey(uri); err == nil {
t.Errorf("recordKey(%q) returned no error", uri)
}
}
}
+52 -1
View File
@@ -1,6 +1,57 @@
package output package output
type Image struct {
Data []byte
ContentType string
Filename string
}
// Preview is a link preview card ("プレビュー窓") to attach to a post.
type Preview struct {
URL string
Title string
Description string
Thumb *Image
}
// Link is a URL inside a post's text, located by the UTF-8 byte offsets
// bluesky needs to mark it up: it does not linkify text on its own, so a URL
// no one points at stays unclickable.
type Link struct {
URL string
ByteStart int
ByteEnd int
}
// 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.
type Ref struct {
ID string `json:"id,omitempty"` // twitter: tweet ID
URI string `json:"uri,omitempty"` // bluesky: at:// URI of the record
CID string `json:"cid,omitempty"` // bluesky: record CID
RootURI string `json:"rooturi,omitempty"`
RootCID string `json:"rootcid,omitempty"`
}
// IsZero reports whether the ref points at nothing.
func (r Ref) IsZero() bool {
return r == Ref{}
}
// Post is a single message to distribute.
type Post struct {
Text string
Images []Image
Links []Link
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
}
type OutputInterface interface { type OutputInterface interface {
Write(string) error Write(Post) (Ref, error)
Delete(Ref) error
GetName() string GetName() string
} }
+16 -2
View File
@@ -11,8 +11,22 @@ func StdOutput() *stdoutput {
return &stdoutput{} return &stdoutput{}
} }
func (so *stdoutput) Write(str string) error { func (so *stdoutput) Write(post Post) (Ref, error) {
fmt.Println(str) if post.ReplyTo != nil {
fmt.Printf("[reply to: %s]\n", post.ReplyTo.ID)
}
fmt.Println(post.Text)
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
}
func (so *stdoutput) Delete(ref Ref) error {
fmt.Printf("[deleted: %s]\n", ref.ID)
return nil return nil
} }
+75 -4
View File
@@ -1,11 +1,15 @@
package output package output
import ( import (
"bytes"
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"github.com/michimani/gotwi" "github.com/michimani/gotwi"
"github.com/michimani/gotwi/media/upload"
uploadtypes "github.com/michimani/gotwi/media/upload/types"
"github.com/michimani/gotwi/tweet/managetweet" "github.com/michimani/gotwi/tweet/managetweet"
"github.com/michimani/gotwi/tweet/managetweet/types" "github.com/michimani/gotwi/tweet/managetweet/types"
) )
@@ -41,15 +45,82 @@ func (to *twitteroutput) newOAuth1Client(accessToken, accessSecret string) error
return err return err
} }
func (to *twitteroutput) Write(str string) error { func (to *twitteroutput) Write(post Post) (Ref, error) {
p := &types.CreateInput{ mediaIDs := make([]string, 0, len(post.Images))
Text: gotwi.String(str), for _, img := range post.Images {
mediaID, err := to.uploadImage(img)
if err != nil {
return Ref{}, fmt.Errorf("uploading %s: %w", img.Filename, err)
}
mediaIDs = append(mediaIDs, mediaID)
} }
_, err := managetweet.Create(context.Background(), to.client, p) p := &types.CreateInput{
Text: gotwi.String(post.Text),
}
if len(mediaIDs) > 0 {
p.Media = &types.CreateInputMedia{
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,
}
}
out, err := managetweet.Create(context.Background(), to.client, p)
if err != nil {
return Ref{}, err
}
if out.Data.ID == nil {
return Ref{}, errors.New("created tweet has no ID")
}
return Ref{ID: *out.Data.ID}, nil
}
func (to *twitteroutput) Delete(ref Ref) error {
if ref.ID == "" {
return errors.New("no tweet ID to delete")
}
_, err := managetweet.Delete(context.Background(), to.client, &types.DeleteInput{ID: ref.ID})
return err return err
} }
func (to *twitteroutput) uploadImage(img Image) (string, error) {
ctx := context.Background()
initialized, err := upload.Initialize(ctx, to.client, &uploadtypes.InitializeInput{
MediaCategory: uploadtypes.MediaCategoryTweetImage,
MediaType: uploadtypes.MediaType(img.ContentType),
TotalBytes: len(img.Data),
})
if err != nil {
return "", err
}
mediaID := initialized.Data.MediaID
_, err = upload.Append(ctx, to.client, &uploadtypes.AppendInput{
MediaID: mediaID,
Media: bytes.NewReader(img.Data),
SegmentIndex: 0,
})
if err != nil {
return "", err
}
_, err = upload.Finalize(ctx, to.client, &uploadtypes.FinalizeInput{
MediaID: mediaID,
})
if err != nil {
return "", err
}
return mediaID, nil
}
func (to *twitteroutput) GetName() string { func (to *twitteroutput) GetName() string {
return "twitter" return "twitter"
} }
+213
View File
@@ -0,0 +1,213 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"os"
"path"
"strings"
"tweetdistributor/output"
"golang.org/x/net/html"
"golang.org/x/net/html/charset"
)
// maxHTMLBytes caps how much of a page is read while looking for its preview
// tags. They belong in <head>, so reading further is wasted.
const maxHTMLBytes = 1 << 20
// linkPreview builds the preview card for a link. Bluesky shows no card of
// its own: whatever the record does not embed is not displayed, so every card
// has to be assembled here.
func linkPreview(link string) (*output.Preview, error) {
u, err := url.Parse(link)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", link, err)
}
if isYouTube(u) {
return youtubePreview(u)
}
return ogpPreview(u)
}
// isYouTube reports whether u addresses a YouTube video.
func isYouTube(u *url.URL) bool {
switch strings.ToLower(u.Hostname()) {
case "youtu.be":
return strings.Trim(u.Path, "/") != ""
case "youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com":
if u.Path == "/watch" && u.Query().Get("v") != "" {
return true
}
return strings.HasPrefix(u.Path, "/shorts/") || strings.HasPrefix(u.Path, "/live/")
}
return false
}
// 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 card for a YouTube link from the oEmbed endpoint,
// which answers with just the few fields a card needs instead of the megabyte
// of markup the watch page is.
func youtubePreview(video *url.URL) (*output.Preview, error) {
endpoint := "https://www.youtube.com/oembed?format=json&url=" + url.QueryEscape(video.String())
got, err := fetch(endpoint, maxHTMLBytes)
if err != nil {
return nil, fmt.Errorf("fetching preview for %s: %w", video, err)
}
var oembed oEmbedResponse
if err := json.Unmarshal(got.body, &oembed); err != nil {
return nil, fmt.Errorf("parsing preview for %s: %w", video, err)
}
preview := &output.Preview{
URL: video.String(),
Title: oembed.Title,
Description: oembed.AuthorName,
}
if oembed.ThumbnailURL != "" {
preview.Thumb = thumbnail(video, oembed.ThumbnailURL)
}
return preview, nil
}
// ogpPreview builds the card for an ordinary page from its Open Graph tags,
// falling back to the Twitter card tags and then to the plain document title.
func ogpPreview(page *url.URL) (*output.Preview, error) {
got, err := fetch(page.String(), maxHTMLBytes)
if err != nil {
return nil, fmt.Errorf("fetching preview for %s: %w", page, err)
}
switch got.mediaType {
case "", "text/html", "application/xhtml+xml":
default:
return nil, fmt.Errorf("%s is %s, which carries no preview tags", page, got.mediaType)
}
tags, err := parseMetaTags(got)
if err != nil {
return nil, fmt.Errorf("reading preview for %s: %w", page, err)
}
title := tags.first("og:title", "twitter:title", "title")
if title == "" {
return nil, fmt.Errorf("%s has no title to put on a card", page)
}
preview := &output.Preview{
// The card links to the page as it was written, not as it redirected.
URL: page.String(),
Title: title,
Description: tags.first("og:description", "twitter:description", "description"),
}
if image := tags.first("og:image", "og:image:url", "og:image:secure_url", "twitter:image", "twitter:image:src"); image != "" {
preview.Thumb = thumbnail(got.url, image)
}
return preview, nil
}
// metaTags holds a page's <meta> tags keyed by their property or name
// attribute, plus its <title> under "title".
type metaTags map[string]string
// first returns the value of the earliest of keys that the page set.
func (tags metaTags) first(keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(tags[key]); value != "" {
return value
}
}
return ""
}
// parseMetaTags reads the tags out of a page. It stops at <body>, past which
// preview tags do not belong, and treats running out of markup as the end:
// the body was cut off at maxHTMLBytes.
func parseMetaTags(page *fetched) (metaTags, error) {
// Pages are not all UTF-8; charset works out the encoding from the
// Content-Type header, a byte order mark or the meta charset tag.
decoded, err := charset.NewReader(bytes.NewReader(page.body), page.contentType)
if err != nil {
return nil, err
}
tags := metaTags{}
tokenizer := html.NewTokenizer(decoded)
for {
switch tokenizer.Next() {
case html.ErrorToken:
return tags, nil
case html.StartTagToken, html.SelfClosingTagToken:
name, hasattr := tokenizer.TagName()
switch string(name) {
case "meta":
if !hasattr {
continue
}
var key, content string
for {
attr, value, more := tokenizer.TagAttr()
switch string(attr) {
case "property", "name":
key = strings.ToLower(string(value))
case "content":
content = string(value)
}
if !more {
break
}
}
// The first tag of a name wins, as it does in every reader.
if key != "" && content != "" && tags[key] == "" {
tags[key] = content
}
case "title":
if tokenizer.Next() == html.TextToken && tags["title"] == "" {
tags["title"] = strings.TrimSpace(string(tokenizer.Text()))
}
case "body":
return tags, nil
}
}
}
}
// thumbnail fetches a card image, resolving ref against the page it was found
// on. A card without its picture is still worth posting, so a thumbnail that
// cannot be fetched is reported and dropped rather than failing the card.
func thumbnail(base *url.URL, ref string) *output.Image {
imageurl, err := base.Parse(ref)
if err != nil {
fmt.Fprintf(os.Stderr, "preview thumbnail %s: %s\n", ref, err)
return nil
}
got, err := fetch(imageurl.String(), maxDownloadBytes)
if err != nil {
fmt.Fprintf(os.Stderr, "preview thumbnail %s: %s\n", imageurl, err)
return nil
}
filename := path.Base(imageurl.Path)
if filename == "." || filename == "/" {
filename = "thumbnail"
}
img, err := shrinkImage(output.Image{
Data: got.body,
ContentType: got.mediaType,
Filename: filename,
})
if err != nil {
fmt.Fprintf(os.Stderr, "preview thumbnail %s: %s\n", imageurl, err)
return nil
}
return &img
}
+110
View File
@@ -0,0 +1,110 @@
package main
import (
"net/url"
"testing"
)
func TestIsYouTube(t *testing.T) {
tests := []struct {
raw string
want bool
}{
{"https://www.youtube.com/watch?v=dQw4w9WgXcQ", true},
{"https://youtu.be/dQw4w9WgXcQ?t=42", true},
{"https://www.youtube.com/shorts/abc_123", true},
{"https://youtube.com/live/abc-123", true},
{"https://m.youtube.com/watch?v=abc&feature=share", true},
{"https://www.youtube.com/", false},
{"https://www.youtube.com/watch?list=PL123", false},
{"https://youtube.com.evil.example/watch?v=abc", false},
{"https://example.com/", false},
}
for _, tt := range tests {
t.Run(tt.raw, func(t *testing.T) {
u, err := url.Parse(tt.raw)
if err != nil {
t.Fatal(err)
}
if got := isYouTube(u); got != tt.want {
t.Errorf("isYouTube(%q) = %v, want %v", tt.raw, got, tt.want)
}
})
}
}
func TestParseMetaTags(t *testing.T) {
page := &fetched{
contentType: "text/html; charset=utf-8",
body: []byte(`<!doctype html><html><head>
<title>plain title</title>
<meta name="description" content="plain description">
<meta property="og:title" content="OGP タイトル">
<meta property="og:description" content="OGP の説明 &amp; その続き" />
<meta content="https://example.com/card.png" property="og:image">
<meta property="og:title" content="a later duplicate">
</head><body>
<meta property="og:image" content="https://example.com/inbody.png">
</body></html>`),
}
tags, err := parseMetaTags(page)
if err != nil {
t.Fatal(err)
}
want := map[string]string{
"title": "plain title",
"description": "plain description",
"og:title": "OGP タイトル",
"og:description": "OGP の説明 & その続き",
"og:image": "https://example.com/card.png",
}
for key, value := range want {
if tags[key] != value {
t.Errorf("tags[%q] = %q, want %q", key, tags[key], value)
}
}
if got := tags.first("og:title", "twitter:title", "title"); got != "OGP タイトル" {
t.Errorf("first title = %q, want the OGP one", got)
}
if got := tags.first("twitter:title", "title"); got != "plain title" {
t.Errorf("first title = %q, want the fallback", got)
}
if got := tags.first("nothing:here"); got != "" {
t.Errorf("first of an absent key = %q, want empty", got)
}
}
func TestParseMetaTagsShiftJIS(t *testing.T) {
// "テスト" encoded as Shift_JIS, declared in the Content-Type header.
body := []byte(`<html><head><meta property="og:title" content="` +
"\x83e\x83X\x83g" + `"></head></html>`)
page := &fetched{contentType: "text/html; charset=Shift_JIS", body: body}
tags, err := parseMetaTags(page)
if err != nil {
t.Fatal(err)
}
if tags["og:title"] != "テスト" {
t.Errorf("og:title = %q, want テスト", tags["og:title"])
}
}
func TestParseMetaTagsTruncated(t *testing.T) {
// A page cut off at maxHTMLBytes ends mid markup; what was read still counts.
page := &fetched{
contentType: "text/html",
body: []byte(`<html><head><meta property="og:title" content="kept"><meta property="og:desc`),
}
tags, err := parseMetaTags(page)
if err != nil {
t.Fatal(err)
}
if tags["og:title"] != "kept" {
t.Errorf("og:title = %q, want kept", tags["og:title"])
}
}
+125
View File
@@ -0,0 +1,125 @@
// Package store remembers which post each output produced for a given Discord
// message, so a later delete or reply on Discord can be mirrored.
package store
import (
"encoding/json"
"fmt"
"os"
"sync"
"tweetdistributor/output"
)
// maxEntries bounds how far back a delete or reply can reach; the oldest
// message is forgotten once the limit is passed.
const maxEntries = 1000
// Refs maps an output name to the post that output published.
type Refs map[string]output.Ref
type Store struct {
mu sync.Mutex
path string
refs map[string]Refs
order []string
}
// New returns a store backed by the JSON file at path, loading it if it
// already exists. An empty path keeps everything in memory only, in which
// case the mapping is lost on restart.
func New(path string) (*Store, error) {
s := &Store{
path: path,
refs: map[string]Refs{},
}
if path == "" {
return s, nil
}
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return s, nil
}
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
var saved struct {
Order []string `json:"order"`
Refs map[string]Refs `json:"refs"`
}
if err := json.Unmarshal(data, &saved); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
if saved.Refs != nil {
s.refs = saved.Refs
}
s.order = saved.Order
return s, nil
}
func (s *Store) Put(messageID string, refs Refs) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.refs[messageID]; !exists {
s.order = append(s.order, messageID)
}
s.refs[messageID] = refs
for len(s.order) > maxEntries {
delete(s.refs, s.order[0])
s.order = s.order[1:]
}
return s.save()
}
func (s *Store) Get(messageID string) (Refs, bool) {
s.mu.Lock()
defer s.mu.Unlock()
refs, ok := s.refs[messageID]
return refs, ok
}
func (s *Store) Delete(messageID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.refs[messageID]; !ok {
return nil
}
delete(s.refs, messageID)
for i, id := range s.order {
if id == messageID {
s.order = append(s.order[:i], s.order[i+1:]...)
break
}
}
return s.save()
}
// save writes the whole store out; the caller must hold the lock.
func (s *Store) save() error {
if s.path == "" {
return nil
}
data, err := json.Marshal(struct {
Order []string `json:"order"`
Refs map[string]Refs `json:"refs"`
}{Order: s.order, Refs: s.refs})
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("writing %s: %w", tmp, err)
}
if err := os.Rename(tmp, s.path); err != nil {
return fmt.Errorf("replacing %s: %w", s.path, err)
}
return nil
}
+77
View File
@@ -0,0 +1,77 @@
package store
import (
"path/filepath"
"testing"
"tweetdistributor/output"
)
func TestStoreRoundTripThroughFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "posts.json")
s, err := New(path)
if err != nil {
t.Fatal(err)
}
refs := Refs{
"twitter": {ID: "123"},
"bluesky": {URI: "at://did:plc:xyz/app.bsky.feed.post/abc", CID: "bafy"},
}
if err := s.Put("discord-1", refs); err != nil {
t.Fatal(err)
}
reopened, err := New(path)
if err != nil {
t.Fatal(err)
}
got, ok := reopened.Get("discord-1")
if !ok {
t.Fatal("stored message was not found after reopening")
}
if got["twitter"] != (output.Ref{ID: "123"}) {
t.Errorf("twitter ref = %+v, want ID 123", got["twitter"])
}
if got["bluesky"].URI != refs["bluesky"].URI {
t.Errorf("bluesky URI = %q, want %q", got["bluesky"].URI, refs["bluesky"].URI)
}
if err := reopened.Delete("discord-1"); err != nil {
t.Fatal(err)
}
if _, ok := reopened.Get("discord-1"); ok {
t.Error("message is still present after Delete")
}
}
func TestStoreMissingMessage(t *testing.T) {
s, err := New("")
if err != nil {
t.Fatal(err)
}
if _, ok := s.Get("nope"); ok {
t.Error("Get returned a ref for a message that was never stored")
}
if err := s.Delete("nope"); err != nil {
t.Errorf("deleting an unknown message returned %s", err)
}
}
func TestStoreEvictsOldest(t *testing.T) {
s, err := New("")
if err != nil {
t.Fatal(err)
}
for i := 0; i < maxEntries+10; i++ {
if err := s.Put(string(rune('a'+i%26))+string(rune(i)), Refs{"stdout": {ID: "x"}}); err != nil {
t.Fatal(err)
}
}
if len(s.refs) > maxEntries {
t.Errorf("store holds %d entries, want at most %d", len(s.refs), maxEntries)
}
if len(s.order) != len(s.refs) {
t.Errorf("order has %d entries but refs has %d", len(s.order), len(s.refs))
}
}