181 lines
5.0 KiB
Go
181 lines
5.0 KiB
Go
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 "<nest_id>-<n>" 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 "<nest_id>-<n>" 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
|
|
}
|
|
|
|
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)
|
|
}
|