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

120
main.go
View File

@@ -14,6 +14,7 @@ import (
"strings"
"tweetdistributor/discord"
"tweetdistributor/output"
"tweetdistributor/store"
"unicode/utf8"
"github.com/bwmarrin/discordgo"
@@ -99,11 +100,91 @@ func downloadImages(attachments []*discordgo.MessageAttachment) ([]output.Image,
return images, nil
}
// distributor mirrors what happens on the Discord channel to every output.
type distributor struct {
d *discord.Client
outputs []output.OutputInterface
store *store.Store
}
// reportf logs an error and echoes it back into the Discord channel.
func (dist *distributor) reportf(format string, args ...any) {
errstr := fmt.Sprintf(format, args...)
fmt.Fprintln(os.Stderr, errstr)
dist.d.Write(errstr)
}
// created posts a new Discord message to every output.
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)
return
}
images, err := downloadImages(event.Attachments)
if err != nil {
dist.reportf("Error: %s; not posted", err)
return
}
if len(images) > maxImagesPerPost {
dist.reportf("Error: %d images attached, exceeding the limit of %d; not posted", len(images), maxImagesPerPost)
return
}
refs := store.Refs{}
for _, out := range dist.outputs {
ref, err := out.Write(output.Post{
Text: event.Content,
Images: images,
})
if err != nil {
dist.reportf("%s Error: %s", out.GetName(), err)
continue
}
refs[out.GetName()] = ref
}
if len(refs) == 0 {
return
}
if err := dist.store.Put(event.MessageID, refs); err != nil {
dist.reportf("Error: could not remember the posts for this message: %s", err)
}
}
// deleted removes the posts that a now deleted Discord message produced.
func (dist *distributor) deleted(event discord.Event) {
refs, ok := dist.store.Get(event.MessageID)
if !ok {
return
}
for _, out := range dist.outputs {
ref, ok := refs[out.GetName()]
if !ok {
continue
}
if err := out.Delete(ref); err != nil {
dist.reportf("%s Error: could not delete the post: %s", out.GetName(), err)
}
}
if err := dist.store.Delete(event.MessageID); err != nil {
dist.reportf("Error: could not forget the posts for this message: %s", err)
}
}
func main() {
posts, err := store.New(os.Getenv("POST_STORE"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
d := discord.Discord(os.Getenv("DISCORD_TOKEN"), os.Getenv("DISCORD_CHANNEL"))
tweetchannel := make(chan *discordgo.MessageCreate, 1)
d.BeginRead(tweetchannel)
eventchannel := make(chan discord.Event, 1)
d.BeginRead(eventchannel)
d.Write("Tweetdistributor Started")
@@ -112,35 +193,14 @@ func main() {
outputs = append(outputs, output.TwitterOutput(os.Getenv("TW_ACCESS_TOKEN"), os.Getenv("TW_ACCESS_SECRET")))
outputs = append(outputs, output.BlueskyOutput(os.Getenv("BSKY_IDENTIFIER"), os.Getenv("BSKY_PASSWORD")))
for tweet := range tweetchannel {
if length := utf8.RuneCountInString(tweet.Content); length > maxTweetLength {
errstr := fmt.Sprintf("Error: message is %d characters, exceeding the %d character limit; not posted", length, maxTweetLength)
fmt.Fprintln(os.Stderr, errstr)
d.Write(errstr)
continue
}
dist := &distributor{d: d, outputs: outputs, store: posts}
images, err := downloadImages(tweet.Attachments)
if err != nil {
errstr := fmt.Sprintf("Error: %s; not posted", err)
fmt.Fprintln(os.Stderr, errstr)
d.Write(errstr)
continue
}
if len(images) > maxImagesPerPost {
errstr := fmt.Sprintf("Error: %d images attached, exceeding the limit of %d; not posted", len(images), maxImagesPerPost)
fmt.Fprintln(os.Stderr, errstr)
d.Write(errstr)
continue
}
for _, output := range outputs {
err := output.Write(tweet.Content, images)
if err != nil {
errstr := fmt.Sprintf("%s Error: %s", output.GetName(), err)
fmt.Fprintln(os.Stderr, errstr)
d.Write(errstr)
}
for event := range eventchannel {
switch event.Kind {
case discord.MessageCreated:
dist.created(event)
case discord.MessageDeleted:
dist.deleted(event)
}
}
}