package buzz import ( "encoding/json" "errors" "fmt" "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-.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-.json. // A missing file is not an error; the store starts empty. Any other read or // parse failure is returned so callers can refuse to start a channel whose // replay state cannot be loaded — silently running with a broken store would // risk redelivering already-handled events. func newSeenStore(path, defaultName string) (*seenStore, error) { if path == "" { path = defaultSeenStorePath(defaultName) } s := &seenStore{ path: path, highWater: make(map[string]*channelHighWater), } if err := s.load(); err != nil { return nil, fmt.Errorf("buzz seen store load %q: %w", path, err) } return s, nil } 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) }