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>
This commit is contained in:
2026-07-26 11:55:28 +09:00
parent 067c9252c9
commit 1c2ab29404
6 changed files with 75 additions and 10 deletions

View File

@@ -20,6 +20,9 @@ type Event struct {
MessageID string MessageID string
Content string Content string
Attachments []*discordgo.MessageAttachment 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 { type Client struct {
@@ -49,12 +52,16 @@ func (d *Client) read(eventchannel chan<- Event) {
if m.ChannelID != d.ChannelID || m.Author.ID == s.State.User.ID { if m.ChannelID != d.ChannelID || m.Author.ID == s.State.User.ID {
return return
} }
eventchannel <- Event{ event := Event{
Kind: MessageCreated, Kind: MessageCreated,
MessageID: m.ID, MessageID: m.ID,
Content: m.Content, Content: m.Content,
Attachments: m.Attachments, 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 // Deleted messages arrive without an author, so the channel is the only

18
main.go
View File

@@ -114,7 +114,8 @@ func (dist *distributor) reportf(format string, args ...any) {
dist.d.Write(errstr) dist.d.Write(errstr)
} }
// created posts a new Discord message to every output. // 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) { func (dist *distributor) created(event discord.Event) {
if length := utf8.RuneCountInString(event.Content); length > maxTweetLength { if length := utf8.RuneCountInString(event.Content); length > maxTweetLength {
dist.reportf("Error: message is %d characters, exceeding the %d character limit; not posted", length, maxTweetLength) dist.reportf("Error: message is %d characters, exceeding the %d character limit; not posted", length, maxTweetLength)
@@ -131,12 +132,23 @@ func (dist *distributor) created(event discord.Event) {
return return
} }
// 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{} refs := store.Refs{}
for _, out := range dist.outputs { for _, out := range dist.outputs {
ref, err := out.Write(output.Post{ post := output.Post{
Text: event.Content, Text: event.Content,
Images: images, Images: images,
}) }
if parent, ok := parents[out.GetName()]; ok && !parent.IsZero() {
post.ReplyTo = &parent
}
ref, err := out.Write(post)
if err != nil { if err != nil {
dist.reportf("%s Error: %s", out.GetName(), err) dist.reportf("%s Error: %s", out.GetName(), err)
continue continue

View File

@@ -67,6 +67,28 @@ func (bo *blueskyoutput) Write(post Post) (Ref, error) {
Langs: []string{"ja"}, Langs: []string{"ja"},
} }
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}
}
if len(post.Images) > 0 { if len(post.Images) > 0 {
embedimages := make([]*bsky.EmbedImages_Image, 0, len(post.Images)) embedimages := make([]*bsky.EmbedImages_Image, 0, len(post.Images))
for _, img := range post.Images { for _, img := range post.Images {
@@ -96,7 +118,12 @@ func (bo *blueskyoutput) Write(post Post) (Ref, error) {
return Ref{}, recerr return Ref{}, recerr
} }
return Ref{URI: record.Uri, CID: record.Cid}, nil return Ref{
URI: record.Uri,
CID: record.Cid,
RootURI: root.RootURI,
RootCID: root.RootCID,
}, nil
} }
func (bo *blueskyoutput) Delete(ref Ref) error { func (bo *blueskyoutput) Delete(ref Ref) error {

View File

@@ -7,18 +7,28 @@ type Image struct {
} }
// Ref identifies a post an output has already published so it can later be // Ref identifies a post an output has already published so it can later be
// deleted. The fields are output specific; only the ones the publishing // replied to or deleted. The fields are output specific; only the ones the
// output filled in are meaningful to it. // publishing output filled in are meaningful to it.
type Ref struct { type Ref struct {
ID string `json:"id,omitempty"` // twitter: tweet ID ID string `json:"id,omitempty"` // twitter: tweet ID
URI string `json:"uri,omitempty"` // bluesky: at:// URI of the record URI string `json:"uri,omitempty"` // bluesky: at:// URI of the record
CID string `json:"cid,omitempty"` // bluesky: record CID 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. // Post is a single message to distribute.
type Post struct { type Post struct {
Text string Text string
Images []Image Images []Image
// 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 {

View File

@@ -12,6 +12,9 @@ func StdOutput() *stdoutput {
} }
func (so *stdoutput) Write(post Post) (Ref, error) { func (so *stdoutput) Write(post Post) (Ref, error) {
if post.ReplyTo != nil {
fmt.Printf("[reply to: %s]\n", post.ReplyTo.ID)
}
fmt.Println(post.Text) fmt.Println(post.Text)
for _, img := range post.Images { for _, img := range post.Images {
fmt.Printf("[image: %s (%s, %d bytes)]\n", img.Filename, img.ContentType, len(img.Data)) fmt.Printf("[image: %s (%s, %d bytes)]\n", img.Filename, img.ContentType, len(img.Data))

View File

@@ -63,6 +63,12 @@ func (to *twitteroutput) Write(post Post) (Ref, error) {
MediaIDs: mediaIDs, MediaIDs: mediaIDs,
} }
} }
if post.ReplyTo != nil && post.ReplyTo.ID != "" {
p.Reply = &types.CreateInputReply{
InReplyToTweetID: post.ReplyTo.ID,
}
}
out, err := managetweet.Create(context.Background(), to.client, p) out, err := managetweet.Create(context.Background(), to.client, p)
if err != nil { if err != nil {
return Ref{}, err return Ref{}, err