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:
125
store/store.go
Normal file
125
store/store.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Package store remembers which post each output produced for a given Discord
|
||||
// message, so a later delete or reply on Discord can be mirrored.
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"tweetdistributor/output"
|
||||
)
|
||||
|
||||
// maxEntries bounds how far back a delete or reply can reach; the oldest
|
||||
// message is forgotten once the limit is passed.
|
||||
const maxEntries = 1000
|
||||
|
||||
// Refs maps an output name to the post that output published.
|
||||
type Refs map[string]output.Ref
|
||||
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
refs map[string]Refs
|
||||
order []string
|
||||
}
|
||||
|
||||
// New returns a store backed by the JSON file at path, loading it if it
|
||||
// already exists. An empty path keeps everything in memory only, in which
|
||||
// case the mapping is lost on restart.
|
||||
func New(path string) (*Store, error) {
|
||||
s := &Store{
|
||||
path: path,
|
||||
refs: map[string]Refs{},
|
||||
}
|
||||
if path == "" {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
var saved struct {
|
||||
Order []string `json:"order"`
|
||||
Refs map[string]Refs `json:"refs"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &saved); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
if saved.Refs != nil {
|
||||
s.refs = saved.Refs
|
||||
}
|
||||
s.order = saved.Order
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Put(messageID string, refs Refs) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.refs[messageID]; !exists {
|
||||
s.order = append(s.order, messageID)
|
||||
}
|
||||
s.refs[messageID] = refs
|
||||
|
||||
for len(s.order) > maxEntries {
|
||||
delete(s.refs, s.order[0])
|
||||
s.order = s.order[1:]
|
||||
}
|
||||
return s.save()
|
||||
}
|
||||
|
||||
func (s *Store) Get(messageID string) (Refs, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
refs, ok := s.refs[messageID]
|
||||
return refs, ok
|
||||
}
|
||||
|
||||
func (s *Store) Delete(messageID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.refs[messageID]; !ok {
|
||||
return nil
|
||||
}
|
||||
delete(s.refs, messageID)
|
||||
for i, id := range s.order {
|
||||
if id == messageID {
|
||||
s.order = append(s.order[:i], s.order[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// save writes the whole store out; the caller must hold the lock.
|
||||
func (s *Store) save() error {
|
||||
if s.path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := json.Marshal(struct {
|
||||
Order []string `json:"order"`
|
||||
Refs map[string]Refs `json:"refs"`
|
||||
}{Order: s.order, Refs: s.refs})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("writing %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
return fmt.Errorf("replacing %s: %w", s.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
77
store/store_test.go
Normal file
77
store/store_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"tweetdistributor/output"
|
||||
)
|
||||
|
||||
func TestStoreRoundTripThroughFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "posts.json")
|
||||
|
||||
s, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs := Refs{
|
||||
"twitter": {ID: "123"},
|
||||
"bluesky": {URI: "at://did:plc:xyz/app.bsky.feed.post/abc", CID: "bafy"},
|
||||
}
|
||||
if err := s.Put("discord-1", refs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reopened, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := reopened.Get("discord-1")
|
||||
if !ok {
|
||||
t.Fatal("stored message was not found after reopening")
|
||||
}
|
||||
if got["twitter"] != (output.Ref{ID: "123"}) {
|
||||
t.Errorf("twitter ref = %+v, want ID 123", got["twitter"])
|
||||
}
|
||||
if got["bluesky"].URI != refs["bluesky"].URI {
|
||||
t.Errorf("bluesky URI = %q, want %q", got["bluesky"].URI, refs["bluesky"].URI)
|
||||
}
|
||||
|
||||
if err := reopened.Delete("discord-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := reopened.Get("discord-1"); ok {
|
||||
t.Error("message is still present after Delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMissingMessage(t *testing.T) {
|
||||
s, err := New("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s.Get("nope"); ok {
|
||||
t.Error("Get returned a ref for a message that was never stored")
|
||||
}
|
||||
if err := s.Delete("nope"); err != nil {
|
||||
t.Errorf("deleting an unknown message returned %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreEvictsOldest(t *testing.T) {
|
||||
s, err := New("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < maxEntries+10; i++ {
|
||||
if err := s.Put(string(rune('a'+i%26))+string(rune(i)), Refs{"stdout": {ID: "x"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(s.refs) > maxEntries {
|
||||
t.Errorf("store holds %d entries, want at most %d", len(s.refs), maxEntries)
|
||||
}
|
||||
if len(s.order) != len(s.refs) {
|
||||
t.Errorf("order has %d entries but refs has %d", len(s.order), len(s.refs))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user