picoclaw/pkg/channels/buzz/seen_store.go

185 lines
5.3 KiB
Go

package buzz
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
"github.com/nbd-wtf/go-nostr"
"github.com/sipeed/picoclaw/pkg/config"
)
// recentIDWindow is how many recently-handled event IDs we keep per channel to
// survive the inclusive-Since boundary (NIP-01 redelivers events with
// created_at >= Since) and relays that silently ignore the Since filter.
// A few hundred is ample: it covers the burst of events at the high-water
// timestamp plus any stragglers a misbehaving relay re-sends.
const recentIDWindow = 256
// channelHighWater records the highest seen event created_at and a bounded
// ring of recently handled event IDs for a single Buzz channel ("h" tag).
type channelHighWater struct {
MaxCreatedAt int64 `json:"max_created_at"`
RecentIDs []string `json:"recent_ids,omitempty"`
}
// seenStore persists per-channel high-water state so a service restart does
// not redeliver already-handled kind:9 events to HandleInboundContext.
//
// The store tracks, per "h" tag value:
// - MaxCreatedAt: the largest event.CreatedAt we have handled, used to set a
// Since filter on resubscribe so the relay skips most stored history.
// - RecentIDs: a bounded ring of the most recently handled event IDs. This
// is the authoritative dedup guard: Since is inclusive in NIP-01, so events
// at exactly the high-water timestamp are redelivered, and some relays
// ignore Since entirely. The ring absorbs both cases without unbounded
// growth.
//
// The store is JSON-backed under ~/.picoclaw/buzz/seen-<channel-name>.json,
// matching the persistence pattern used by the wecom reqID store.
type seenStore struct {
mu sync.Mutex
path string
highWater map[string]*channelHighWater
}
// newSeenStore opens (or creates) the persistent seen-state store at path.
// If path is empty it defaults to ~/.picoclaw/buzz/seen-<defaultName>.json.
// A missing file is not an error; the store starts empty.
func newSeenStore(path, defaultName string) *seenStore {
if path == "" {
path = defaultSeenStorePath(defaultName)
}
s := &seenStore{
path: path,
highWater: make(map[string]*channelHighWater),
}
_ = s.load()
return s
}
func defaultSeenStorePath(channelName string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return filepath.Join(os.TempDir(), "picoclaw-buzz-seen-"+channelName+".json")
}
name := channelName
if name == "" {
name = config.ChannelBuzz
}
return filepath.Join(home, ".picoclaw", "buzz", "seen-"+name+".json")
}
// highWaterMark returns the maximum seen CreatedAt across all channels, or
// zero if nothing has been recorded. Callers use it to set the subscription
// Since filter so the relay skips events older than what we have handled.
func (s *seenStore) highWaterMark() nostr.Timestamp {
s.mu.Lock()
defer s.mu.Unlock()
var max int64
for _, hw := range s.highWater {
if hw.MaxCreatedAt > max {
max = hw.MaxCreatedAt
}
}
return nostr.Timestamp(max)
}
// seen reports whether an event has already been handled for chatID. Events
// older than the channel's high-water timestamp are necessarily replayed
// history. Events at the inclusive high-water boundary require an ID lookup.
func (s *seenStore) seen(chatID, eventID string, createdAt nostr.Timestamp) bool {
if eventID == "" {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
hw, ok := s.highWater[chatID]
if !ok {
return false
}
if int64(createdAt) < hw.MaxCreatedAt {
return true
}
for _, id := range hw.RecentIDs {
if id == eventID {
return true
}
}
return false
}
// markHandled records that eventID (with createdAt) has been handled for
// chatID and persists the store. It updates MaxCreatedAt when createdAt is
// newer and trims the RecentIDs ring to recentIDWindow entries.
func (s *seenStore) markHandled(chatID, eventID string, createdAt nostr.Timestamp) error {
if chatID == "" || eventID == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
hw, ok := s.highWater[chatID]
if !ok {
hw = &channelHighWater{}
s.highWater[chatID] = hw
}
ts := int64(createdAt)
if ts > hw.MaxCreatedAt {
hw.MaxCreatedAt = ts
}
// Avoid duplicate entries in the ring.
for _, id := range hw.RecentIDs {
if id == eventID {
return s.saveLocked()
}
}
hw.RecentIDs = append(hw.RecentIDs, eventID)
if len(hw.RecentIDs) > recentIDWindow {
// Drop the oldest entries, keeping only the most recent window.
drop := len(hw.RecentIDs) - recentIDWindow
hw.RecentIDs = hw.RecentIDs[drop:]
}
return s.saveLocked()
}
// load reads the persisted high-water state. A missing file is not an error.
func (s *seenStore) load() error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
var hw map[string]*channelHighWater
if err := json.Unmarshal(data, &hw); err != nil {
return err
}
s.highWater = hw
if s.highWater == nil {
s.highWater = make(map[string]*channelHighWater)
}
return nil
}
// saveLocked writes the high-water state to disk. Caller must hold s.mu.
func (s *seenStore) saveLocked() error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(s.highWater, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}