diff --git a/discord/discordread.go b/discord/discordread.go index 24be855..ee2462c 100644 --- a/discord/discordread.go +++ b/discord/discordread.go @@ -20,6 +20,9 @@ type Event struct { 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 { @@ -49,12 +52,16 @@ func (d *Client) read(eventchannel chan<- Event) { if m.ChannelID != d.ChannelID || m.Author.ID == s.State.User.ID { return } - eventchannel <- Event{ + 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 diff --git a/main.go b/main.go index 811be58..92d5a90 100644 --- a/main.go +++ b/main.go @@ -114,7 +114,8 @@ func (dist *distributor) reportf(format string, args ...any) { 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) { if length := utf8.RuneCountInString(event.Content); 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 } + // 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 { - ref, err := out.Write(output.Post{ + post := output.Post{ Text: event.Content, Images: images, - }) + } + 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 diff --git a/output/bluesky.go b/output/bluesky.go index 176cb5c..c133479 100644 --- a/output/bluesky.go +++ b/output/bluesky.go @@ -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 { diff --git a/output/output.go b/output/output.go index 714f3ec..ec063de 100644 --- a/output/output.go +++ b/output/output.go @@ -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 { diff --git a/output/stdout.go b/output/stdout.go index 0a9e4c4..0bbf5d8 100644 --- a/output/stdout.go +++ b/output/stdout.go @@ -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)) diff --git a/output/twitter.go b/output/twitter.go index 4759a5a..30a3aab 100644 --- a/output/twitter.go +++ b/output/twitter.go @@ -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