package main import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" ) // Message is one entry in a nest's conversation, shaped by the EVENTS.md // contract. IDs are assigned by the store; MsgID is "-" and is // what the contract exposes. type Message struct { NestID string `json:"nest_id"` ID int `json:"id"` Kind string `json:"kind"` Author string `json:"author"` Text string `json:"text"` To string `json:"to,omitempty"` RefersTo string `json:"refers_to,omitempty"` TaggedBy string `json:"tagged_by,omitempty"` Mode string `json:"mode,omitempty"` // solo | conspiracy | split (requests only, Epic C) ModeTagged string `json:"mode_tagged_by,omitempty"` Invited int `json:"invited,omitempty"` Timestamp string `json:"ts"` } func (m Message) MsgID() string { return fmt.Sprintf("%s-%d", m.NestID, m.ID) } // MessageStore is the append-only log of every nest's conversation, persisted // as JSON Lines (one file per nest) so a restart can replay it. Follows the // atomic-write style of NestStore.save(). type MessageStore struct { dir string mu sync.Mutex log map[string][]Message // nest_id -> messages in append order } func NewMessageStore(dir string) *MessageStore { return &MessageStore{dir: dir, log: map[string][]Message{}} } // LoadMessageStore reads existing logs from dir. A missing dir is empty, not // an error: a fresh server has no conversations yet. func LoadMessageStore(dir string) (*MessageStore, error) { s := NewMessageStore(dir) entries, err := os.ReadDir(dir) if err != nil && !os.IsNotExist(err) { return nil, err } for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { continue } nestID := strings.TrimSuffix(e.Name(), ".jsonl") data, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { return nil, fmt.Errorf("message log %s is unreadable: %w", e.Name(), err) } var msgs []Message for _, line := range strings.Split(string(data), "\n") { if strings.TrimSpace(line) == "" { continue } var m Message if err := json.Unmarshal([]byte(line), &m); err != nil { return nil, fmt.Errorf("message log %s has a bad line: %w", e.Name(), err) } msgs = append(msgs, m) } s.log[nestID] = msgs } return s, nil } // Append assigns the next id, records the message and saves its nest's log. func (s *MessageStore) Append(m Message) (Message, error) { s.mu.Lock() defer s.mu.Unlock() m.ID = len(s.log[m.NestID]) + 1 s.log[m.NestID] = append(s.log[m.NestID], m) if err := s.save(m.NestID); err != nil { log := s.log[m.NestID] s.log[m.NestID] = log[:len(log)-1] return Message{}, err } return m, nil } // After returns the messages in nest with id greater than lastID, in order. func (s *MessageStore) After(nestID string, lastID int) []Message { s.mu.Lock() defer s.mu.Unlock() var out []Message for _, m := range s.log[nestID] { if m.ID > lastID { out = append(out, m) } } return out } // All returns every message in a nest, oldest first. func (s *MessageStore) All(nestID string) []Message { return s.After(nestID, 0) } // ByMsgID finds one stored message by its "-" id. func (s *MessageStore) ByMsgID(msgID string) (Message, bool) { s.mu.Lock() defer s.mu.Unlock() for _, msgs := range s.log { for _, m := range msgs { if m.MsgID() == msgID { return m, true } } } return Message{}, false } // FirstClaim returns the author of the first claim against a message, and // whether any claim exists. func (s *MessageStore) FirstClaim(nestID, referentID string) (string, bool) { s.mu.Lock() defer s.mu.Unlock() for _, m := range s.log[nestID] { if m.Kind == "claim" && m.RefersTo == referentID { return m.Author, true } } return "", false } // EarlierDelivers returns the texts of deliver messages answering the same // referent, oldest first, excluding the one with id excludeID. func (s *MessageStore) EarlierDelivers(nestID, referentID string, excludeID int) []string { s.mu.Lock() defer s.mu.Unlock() var out []string for _, m := range s.log[nestID] { if m.Kind == "deliver" && m.RefersTo == referentID && m.ID != excludeID { out = append(out, m.Text) } } return out } // NudgeFor reports whether a nudge already references a given message. func (s *MessageStore) NudgeFor(nestID, targetMsgID string) (Message, bool) { s.mu.Lock() defer s.mu.Unlock() for _, m := range s.log[nestID] { if m.Kind == "nudge" && m.RefersTo == targetMsgID { return m, true } } return Message{}, false } // ReportsTo lists messages that answer a given referent, oldest first. func (s *MessageStore) ReportsTo(nestID, referentID string, kinds ...string) []Message { s.mu.Lock() defer s.mu.Unlock() var out []Message for _, m := range s.log[nestID] { if m.RefersTo != referentID { continue } for _, k := range kinds { if m.Kind == k { out = append(out, m) break } } } return out } func (s *MessageStore) save(nestID string) error { if err := os.MkdirAll(s.dir, 0o755); err != nil { return err } var sb strings.Builder for _, m := range s.log[nestID] { line, err := json.Marshal(m) if err != nil { return err } sb.Write(line) sb.WriteByte('\n') } path := filepath.Join(s.dir, nestID+".jsonl") tmp := path + ".tmp" if err := os.WriteFile(tmp, []byte(sb.String()), 0o644); err != nil { return err } return os.Rename(tmp, path) }