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

@@ -67,6 +67,28 @@ func (bo *blueskyoutput) Write(post Post) (Ref, error) {
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 {
embedimages := make([]*bsky.EmbedImages_Image, 0, len(post.Images))
for _, img := range post.Images {
@@ -96,7 +118,12 @@ func (bo *blueskyoutput) Write(post Post) (Ref, error) {
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 {

View File

@@ -7,18 +7,28 @@ type Image struct {
}
// 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
// output filled in are meaningful to it.
// 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
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
// 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 {

View File

@@ -12,6 +12,9 @@ func StdOutput() *stdoutput {
}
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)
for _, img := range post.Images {
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,
}
}
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