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:
@@ -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
18
main.go
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user