Files
tweetdistributor/output/twitter.go
sirrow 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

125 lines
2.9 KiB
Go

package output
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"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/types"
)
type twitteroutput struct {
accessToken string
accessSecret string
client *gotwi.Client
}
func TwitterOutput(accessToken string, accessSecret string) *twitteroutput {
twitteroutput := &twitteroutput{
accessToken: accessToken,
accessSecret: accessSecret,
}
errOAuth := twitteroutput.newOAuth1Client(accessToken, accessSecret)
if errOAuth != nil {
fmt.Fprintln(os.Stderr, errOAuth)
os.Exit(1)
}
return twitteroutput
}
func (to *twitteroutput) newOAuth1Client(accessToken, accessSecret string) error {
in := &gotwi.NewClientInput{
AuthenticationMethod: gotwi.AuthenMethodOAuth1UserContext,
OAuthToken: accessToken,
OAuthTokenSecret: accessSecret,
}
client, err := gotwi.NewClient(in)
to.client = client
return err
}
func (to *twitteroutput) Write(post Post) (Ref, error) {
mediaIDs := make([]string, 0, len(post.Images))
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)
}
p := &types.CreateInput{
Text: gotwi.String(post.Text),
}
if len(mediaIDs) > 0 {
p.Media = &types.CreateInputMedia{
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
}
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
}
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 {
return "twitter"
}