Files
tweetdistributor/store/store.go
sirrow 067c9252c9 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>
2026-07-26 12:00:22 +09:00

126 lines
2.6 KiB
Go

// 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
}