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:
120
main.go
120
main.go
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user