186 lines
5.3 KiB
Go
186 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)
|
||
|
|
}
|