mirror Discord deletions to X and bluesky

Deleting a message on the watched channel now deletes the posts it
produced. Making that possible reshapes how a message reaches an output:

  - OutputInterface takes an output.Post and returns an output.Ref, the
    identifier the platform gave the new post (tweet ID for X, URI and
    CID for bluesky). It also gained Delete(Ref).
  - The discord package no longer forwards raw MessageCreate values but
    a discord.Event carrying the kind of change, so the MessageDelete
    handler can use the same channel. Deleted messages arrive without an
    author, so only the channel ID can be filtered on.
  - The new store package remembers Discord message ID -> per output Ref.
    Set POST_STORE to a path to keep that mapping across restarts;
    without it the mapping is memory only and a restart makes older
    messages undeletable. It holds the most recent 1000 messages.

main.go grows a distributor type to hold the outputs and the store
rather than threading them through the event loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 11:54:29 +09:00
parent 4bd51a31a8
commit 067c9252c9
11 changed files with 753 additions and 62 deletions

View File

@@ -3,6 +3,7 @@ package output
import (
"bytes"
"context"
"errors"
"fmt"
"os"
@@ -44,26 +45,39 @@ func (to *twitteroutput) newOAuth1Client(accessToken, accessSecret string) error
return err
}
func (to *twitteroutput) Write(str string, images []Image) error {
mediaIDs := make([]string, 0, len(images))
for _, img := range images {
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 fmt.Errorf("uploading %s: %w", img.Filename, err)
return Ref{}, fmt.Errorf("uploading %s: %w", img.Filename, err)
}
mediaIDs = append(mediaIDs, mediaID)
}
p := &types.CreateInput{
Text: gotwi.String(str),
Text: gotwi.String(post.Text),
}
if len(mediaIDs) > 0 {
p.Media = &types.CreateInputMedia{
MediaIDs: mediaIDs,
}
}
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
}
_, err := managetweet.Create(context.Background(), to.client, p)
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
}