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,7 +3,9 @@ package output
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/bluesky-social/indigo/api/atproto"
@@ -13,6 +15,8 @@ import (
"github.com/bluesky-social/indigo/xrpc"
)
const blueskyCollection = "app.bsky.feed.post"
type blueskyoutput struct {
identifier string
password string
@@ -27,7 +31,8 @@ func BlueskyOutput(identifier string, password string) *blueskyoutput {
return blueskyoutput
}
func (bo *blueskyoutput) Write(str string, images []Image) error {
// session logs in and returns a client authenticated as the configured account.
func (bo *blueskyoutput) session() (*xrpc.Client, error) {
cli := &xrpc.Client{
Host: "https://bsky.social",
}
@@ -39,7 +44,7 @@ func (bo *blueskyoutput) Write(str string, images []Image) error {
output, err := atproto.ServerCreateSession(context.TODO(), cli, input)
if err != nil {
return err
return nil, err
}
cli.Auth = &xrpc.AuthInfo{
AccessJwt: output.AccessJwt,
@@ -47,26 +52,34 @@ func (bo *blueskyoutput) Write(str string, images []Image) error {
Handle: output.Handle,
Did: output.Did,
}
return cli, nil
}
post := &bsky.FeedPost{
Text: str,
func (bo *blueskyoutput) Write(post Post) (Ref, error) {
cli, err := bo.session()
if err != nil {
return Ref{}, err
}
feedpost := &bsky.FeedPost{
Text: post.Text,
CreatedAt: time.Now().Format(util.ISO8601),
Langs: []string{"ja"},
}
if len(images) > 0 {
embedimages := make([]*bsky.EmbedImages_Image, 0, len(images))
for _, img := range images {
if len(post.Images) > 0 {
embedimages := make([]*bsky.EmbedImages_Image, 0, len(post.Images))
for _, img := range post.Images {
blob, err := atproto.RepoUploadBlob(context.TODO(), cli, bytes.NewReader(img.Data))
if err != nil {
return fmt.Errorf("uploading %s: %w", img.Filename, err)
return Ref{}, fmt.Errorf("uploading %s: %w", img.Filename, err)
}
embedimages = append(embedimages, &bsky.EmbedImages_Image{
Alt: img.Filename,
Image: blob.Blob,
})
}
post.Embed = &bsky.FeedPost_Embed{
feedpost.Embed = &bsky.FeedPost_Embed{
EmbedImages: &bsky.EmbedImages{
Images: embedimages,
},
@@ -74,13 +87,47 @@ func (bo *blueskyoutput) Write(str string, images []Image) error {
}
Recordinput := &atproto.RepoCreateRecord_Input{
Collection: "app.bsky.feed.post",
Collection: blueskyCollection,
Repo: cli.Auth.Did, // "matope.bsky.social" のDID
Record: &lexutil.LexiconTypeDecoder{Val: post},
Record: &lexutil.LexiconTypeDecoder{Val: feedpost},
}
record, recerr := atproto.RepoCreateRecord(context.TODO(), cli, Recordinput)
if recerr != nil {
return Ref{}, recerr
}
_, recerr := atproto.RepoCreateRecord(context.TODO(), cli, Recordinput)
return recerr
return Ref{URI: record.Uri, CID: record.Cid}, nil
}
func (bo *blueskyoutput) Delete(ref Ref) error {
rkey, err := recordKey(ref.URI)
if err != nil {
return err
}
cli, err := bo.session()
if err != nil {
return err
}
_, err = atproto.RepoDeleteRecord(context.TODO(), cli, &atproto.RepoDeleteRecord_Input{
Collection: blueskyCollection,
Repo: cli.Auth.Did,
Rkey: rkey,
})
return err
}
// recordKey pulls the rkey out of an at://did/collection/rkey URI.
func recordKey(uri string) (string, error) {
if uri == "" {
return "", errors.New("no record URI to delete")
}
idx := strings.LastIndex(uri, "/")
if idx < 0 || idx == len(uri)-1 {
return "", fmt.Errorf("malformed record URI %q", uri)
}
return uri[idx+1:], nil
}
func (bo *blueskyoutput) GetName() string {

19
output/bluesky_test.go Normal file
View File

@@ -0,0 +1,19 @@
package output
import "testing"
func TestRecordKey(t *testing.T) {
rkey, err := recordKey("at://did:plc:abc123/app.bsky.feed.post/3kqz7xyz")
if err != nil {
t.Fatal(err)
}
if rkey != "3kqz7xyz" {
t.Errorf("recordKey = %q, want 3kqz7xyz", rkey)
}
for _, uri := range []string{"", "at://did:plc:abc123/app.bsky.feed.post/", "nonsense"} {
if _, err := recordKey(uri); err == nil {
t.Errorf("recordKey(%q) returned no error", uri)
}
}
}

View File

@@ -6,7 +6,23 @@ type Image struct {
Filename string
}
// 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.
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
}
// Post is a single message to distribute.
type Post struct {
Text string
Images []Image
}
type OutputInterface interface {
Write(string, []Image) error
Write(Post) (Ref, error)
Delete(Ref) error
GetName() string
}

View File

@@ -11,11 +11,16 @@ func StdOutput() *stdoutput {
return &stdoutput{}
}
func (so *stdoutput) Write(str string, images []Image) error {
fmt.Println(str)
for _, img := range images {
func (so *stdoutput) Write(post Post) (Ref, error) {
fmt.Println(post.Text)
for _, img := range post.Images {
fmt.Printf("[image: %s (%s, %d bytes)]\n", img.Filename, img.ContentType, len(img.Data))
}
return Ref{ID: post.Text}, nil
}
func (so *stdoutput) Delete(ref Ref) error {
fmt.Printf("[deleted: %s]\n", ref.ID)
return nil
}

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
}