package bot import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "strings" "sync" "sync/atomic" "time" "github.com/coder/websocket" "lchat/internal/e2ee" ) // Runtime is the Go port of agent/runtime.mjs's persona-agnostic core: one bot // identity's live connection to an instance. It dials the WebSocket, unwraps // thread keys, decrypts what arrives, and hands anything that triggers the bot // (a DM, or an @mention in a group) to Handler — the seam where a brain plugs // in. Today that's the deterministic command floor (CommandFloor); the recipe // engine and the model layer slot into the same seam next (docs/bots.md). // // One process hosts many bots: each Runtime is self-contained (own session, // socket, keys, cursors), so `lchat bot run` starts one goroutine per identity. // Like every bot, it is a pure outbound client — the private key in Session // never leaves this process, and the server only ever sees ciphertext. type Runtime struct { Base string Session *Session // Handler decides the response to a message. The ThreadCtx gives it the // thread's services (history, typing, the model brain); return ok=false to // stay silent. Handler func(*ThreadCtx, Incoming) (Reply, bool) // AIKey is the ANTHROPIC_API_KEY. Set, the runtime self-declares kind "ai" // and the model brain (brain.go) is live; empty, the bot is deterministic // and badges "bot". AIKey string // Ready, if set, is called once each time the runtime finishes connecting — // right before it starts reading frames, so a supervisor (or a test) knows // the bot is live and will receive broadcasts. Ready func() Logf func(format string, args ...any) // Charters marks this identity as the CHARTER HOST: the one runtime in a // sidecar that runs member bots (docs/member-bots.md) — it arms their // schedules in its self-thread, executes due tasks, and offers the // create_bot tool. Exactly one identity per instance must host, or every // charter would run once per host (botcmd elects it). Charters bool client *http.Client ai *anthropicClient meter *usageMeter // model-spend accounting, flushed to the instance (#857, meter.go) // pendingUsage is the one usage report in flight — kept verbatim across // failed flushes so the ledger can dedupe a replay whose first ack was // lost. Touched only on the meterLoop goroutine. pendingUsage *usageReport // budgetNotified arms the once-per-exhaustion notice (meter.go). budgetNotified atomic.Bool // budgetState is the space's AI budget as of the last usage flush ("ok", // "unmetered", …). Inert today — phase 3 (docs/ai-provisioning.md) gates // the model path on it. budgetState atomic.Value connect *connectConfig // connected-services (docs/connected-services.md) keys map[string]threadEntry cursors map[string]int64 // threadId -> last message id we've processed agents map[string]bool // roster labels of ALL agents — never reply to one mention *regexp.Regexp bare *regexp.Regexp // bare-name mention, for listen mode "name" // Console-managed config (loadConfig, re-fetched each connect). persona string model string docs string listen string // The instance's sandboxed-app authoring guide (GET /api/member-app-guide, // fetched each connect). Non-empty enables the create_app tool — the bot // builds against the instance's own rules, so the two can never disagree. appGuide string wmu sync.Mutex // coder/websocket allows one concurrent writer conn *websocket.Conn // building guards against duplicate concurrent app builds: a member who // re-asks during the ~minute a build takes (thinking it hung) would otherwise // get the app built twice. Keyed by thread + requester uid (appbuild.go). buildMu sync.Mutex building map[string]bool // The decrypted member-app list behind memberAppsNote (appbuild.go), // cached briefly so every model reply doesn't refetch + re-decrypt every // deployed blob. Guarded by appsMu; invalidated after this sidecar's own // deploys, refreshed after appsNoteTTL otherwise. appsMu sync.Mutex apps []deployedApp appsAt time.Time // The charter host's self-thread (id + key), snapshotted at connect so the // builder goroutine and the reconcile ticker can read it without touching // r.keys (which stays single-goroutine, owned by the frame loop). selfMu sync.Mutex selfID string selfKey []byte // muted holds threads where a client-run scripted flow has the floor // (flow.state events — the one-Eleven welcome): the brain stays quiet // there until the script ends or the entry expires — the fail-safe for a // client that dies mid-flow. Frame goroutine only. muted map[string]time.Time // charterRuns records when each charter last ran — the duplicate-tick // guard for the connect-time catch-up arm (see charterTick). Frame // goroutine only, like muted. charterRuns map[string]time.Time // charterFails counts consecutive failed runs per charter — the backoff // dial (charterRunFailed, chartersched.go). Unlike charterRuns it has its // own lock: ticks bump it on the frame goroutine, but a successful build // or a removal clears it from the builder goroutine. failMu sync.Mutex charterFails map[string]int // charterHomes maps each member bot's dedicated thread to (bot id, owner) // — what makes the bot's own chat conversational: the owner talking there // is always talking to the host, no @mention needed, and the brain gets // the charter as context (charterHomeNote, brain.go). Rebuilt whole on // every listCharters, so builds, edits, and removals all refresh it. Own // lock: the frame goroutine reads per message; reconcile + builder // goroutines write. homeMu sync.Mutex charterHomes map[string]charterHome } // charterHome is one entry of charterHomes: which charter a thread belongs to // and whose bot it is. type charterHome struct { BotID string Owner string } // ThreadCtx hands a Handler the thread's services: decrypted history, the // typing indicator, and the model brain — everything a reply path needs // beyond the triggering message itself. type ThreadCtx struct { rt *Runtime ID string Kind string // "dm" or "group" key []byte // Who sent the triggering message — the app-builder records the requester // (SenderUID gates later updates to their app; see appbuild.go). Sender string SenderUID string } // HistoryMsg is one decrypted message from the thread's recent history. type HistoryMsg struct { ID int64 Name string Text string Mine bool // authored by this bot (by payload name) ActionCount int // chips the message carried (history truthfulness — brain.go) } // History returns the thread's latest messages, oldest first, decrypted. // Undecryptable or non-chat payloads are skipped. func (tc *ThreadCtx) History(limit int) ([]HistoryMsg, error) { var ml struct { Messages []struct { ID int64 `json:"id"` Blob string `json:"blob"` TombstoneOf int64 `json:"tombstone_of"` } `json:"messages"` } // Over-fetch: delete tombstones (#581), reactions and app events ride the // window too, and each would otherwise shrink the model's context — a // thread with a burst of unsends could leave the brain near-blind (#765). // The server caps limit at 200; trim back to `limit` real lines below. fetch := limit * 2 if fetch > 200 { fetch = 200 } path := fmt.Sprintf("/api/messages?thread=%s&limit=%d", url.QueryEscape(tc.ID), fetch) if err := tc.rt.get(path, &ml); err != nil { return nil, err } var out []HistoryMsg for _, m := range ml.Messages { if m.TombstoneOf != 0 { continue // an unsent message's marker, not a line anyone said } var p struct { Type string `json:"type"` Name string `json:"name"` Text string `json:"text"` Actions []map[string]any `json:"actions"` } if e2ee.DecryptJSON(tc.key, m.Blob, &p) != nil || p.Type != "" { continue } out = append(out, HistoryMsg{ ID: m.ID, Name: p.Name, Text: p.Text, Mine: p.Name == tc.rt.Session.Name, ActionCount: len(p.Actions), }) } if len(out) > limit { out = out[len(out)-limit:] // newest `limit` — oldest-first order kept } return out, nil } // Typing sends a best-effort "…is typing" beat (expiry seconds; 0 clears it // now). Same relay-only ephemeral chat.typing shape the live chat engine uses. func (tc *ThreadCtx) Typing(expiry int) { blob, err := e2ee.EncryptJSON(tc.key, map[string]any{"type": "chat.typing", "name": tc.rt.Session.Name, "expiry": expiry}) if err != nil { return } tc.rt.send(map[string]any{"type": "msg", "thread_id": tc.ID, "ephemeral": true, "blob": blob}) } // ModelReply asks the model brain for a conversational reply (recipes reach it // via the "model" action). ok=false when there's no API key, the call fails, // or the reply is empty — the caller stays silent or falls through. func (tc *ThreadCtx) ModelReply() (Reply, bool) { return tc.rt.modelReply(tc) } // actionLine starts a /me-style status line in this thread that the caller // updates in place — a handy way for any handler to report ongoing work without // stacking messages (see the actionLine type in runtime.go). func (tc *ThreadCtx) actionLine(text string) *actionLine { return tc.rt.newActionLine(tc.ID, tc.key, text) } // Incoming is one decrypted chat message. The Runtime hands the Handler every // person-authored message it can read (that's what lets watcher recipes exist); // Triggered says whether the bot was actually addressed — a DM, or a group // @mention — and mention-gated handlers must check it before acting. type Incoming struct { ThreadID string Kind string // "dm" or "group" Sender string Text string // raw text, @mention included (see mentionStripper) Triggered bool // a DM, or an explicit @mention in a group } // Reply is what a Handler posts back: text plus optional one-tap action chips // (the same client-whitelisted `actions` payload the Node sidecar ships). type Reply struct { Text string Actions []map[string]any } type threadEntry struct { key []byte kind string } // Run connects and serves until ctx is cancelled, reconnecting with backoff on // any drop — the same loop shape as the Node runtime's ws.on("close"). func (r *Runtime) Run(ctx context.Context) error { // markTransport stamps every REST call with SidecarHeader (sidecarmark.go) // so a hibernating instance's router can exclude bot traffic from its idle // clock; a non-hibernating instance ignores it. r.client = &http.Client{Timeout: 30 * time.Second, Transport: markTransport{}} r.keys = map[string]threadEntry{} r.cursors = map[string]int64{} r.agents = map[string]bool{} r.muted = map[string]time.Time{} r.charterRuns = map[string]time.Time{} r.charterFails = map[string]int{} r.charterHomes = map[string]charterHome{} r.mention = regexp.MustCompile(`(?i)(^|[^a-z0-9])@` + regexp.QuoteMeta(r.Session.Name) + `\b`) r.bare = regexp.MustCompile(`(?i)(^|[^a-z0-9])` + regexp.QuoteMeta(r.Session.Name) + `\b`) r.meter = newUsageMeter() if r.AIKey != "" { r.ai = newAnthropicClient(r.AIKey, r.recordUsage) } r.connect = loadConnectConfig() // The usage meter flushes on its own clock, independent of connection // state — a failed flush restores the counters and the next tick retries. go r.meterLoop(ctx) if r.Charters { // The slow reconcile loop: heals lost ticks, drops alarms for deleted // charters, and arms charters that appeared while we were connected. // It never touches r.keys — only the self-thread snapshot + REST. go func() { t := time.NewTicker(5 * time.Minute) defer t.Stop() for { select { case <-t.C: r.charterReconcile(false) case <-ctx.Done(): return } } }() } backoff := time.Second for { start := time.Now() err := r.connectOnce(ctx) if ctx.Err() != nil { return ctx.Err() } // A connection that held for a while earned a fresh backoff. if time.Since(start) > time.Minute { backoff = time.Second } r.logf("disconnected (%v); reconnecting in %s", err, backoff) select { case <-time.After(backoff): case <-ctx.Done(): return ctx.Err() } backoff = min(backoff*2, 30*time.Second) } } func (r *Runtime) connectOnce(ctx context.Context) error { wsBase := strings.Replace(r.Base, "http", "ws", 1) // The WebSocket carries the sidecar marker too — it's the long-lived // connection the activity exclusion exists for (sidecarmark.go). conn, _, err := websocket.Dial(ctx, wsBase+"/ws?token="+url.QueryEscape(r.Session.Token), &websocket.DialOptions{ HTTPHeader: http.Header{SidecarHeader: []string{"1"}}, }) if err != nil { return err } defer conn.CloseNow() // The server caps blobs at 64K; the library's 32K default would kill the // socket on the first big message. conn.SetReadLimit(256 * 1024) r.conn = conn defer func() { r.conn = nil }() // Same setup as the Node runtime's ws.on("open"): pull console config, // learn the roster's bots, unwrap thread keys, declare what we are, then // backfill anything missed. Order matters only in that declareKind is a // visible "connected and caught up to here" signal; the cursor gate makes // catchUp vs live frames safe in any interleaving. r.loadConfig() r.loadAgents() r.loadThreads() r.declareKind() r.catchUp() // The charter host reconciles member-bot alarms on every connect (and // keeps reconciling on a slow tick — see Run) so a tick lost to a crash // between delivery and re-arm heals itself. if r.Charters { r.charterReconcile(true) } r.logf("connected to %s", r.Base) // Keepalive: the server closes idle sockets and ignores "ping" frames. pingCtx, cancel := context.WithCancel(ctx) defer cancel() go func() { t := time.NewTicker(30 * time.Second) defer t.Stop() for { select { case <-t.C: r.send(map[string]any{"type": "ping"}) case <-pingCtx.Done(): return } } }() if r.Ready != nil { r.Ready() } for { _, data, err := conn.Read(ctx) if err != nil { return err } r.onFrame(data) } } type wsFrame struct { Type string `json:"type"` ThreadID string `json:"thread_id"` ID int64 `json:"id"` Blob string `json:"blob"` Ephemeral bool `json:"ephemeral"` } func (r *Runtime) onFrame(data []byte) { var f wsFrame if json.Unmarshal(data, &f) != nil { return } // Ignore reactions/edits/deletes/typing, and — crucially — ephemeral frames: // relay-only presence traffic (typing beats, live cursors) arrives as // type:"msg" with no id, so the type check alone would let it through and a // DM bot would answer every beat. if f.Type != "msg" || f.ThreadID == "" || f.Ephemeral { return } if _, ok := r.keys[f.ThreadID]; !ok { r.loadThreads() // a thread we were just added to // loadThreads baselines a new thread's cursor at its latest message — // which is the very frame that made us load it (the server stores // before it broadcasts), so the gate below would swallow it. Rewind so // the triggering message gets an answer; anything older stays history. // (The Node runtime drops this first message — a quirk, not mirrored.) if f.ID != 0 && r.cursors[f.ThreadID] >= f.ID { r.cursors[f.ThreadID] = f.ID - 1 } } r.processMsg(f.ThreadID, f.ID, f.Blob) } // chatPayload is the decrypted message shape the runtime cares about. Typed // payloads (connect.*, mini-app events) are protocol traffic, not chat lines. type chatPayload struct { Type string `json:"type"` Name string `json:"name"` Text string `json:"text"` UID string `json:"uid"` } // processMsg decrypts one message, advances the thread cursor, and replies if // it's for us. Frames and catch-up pages are processed on one goroutine per // bot, so the cursor read+advance can't race — a message delivered twice (a // reconnect's catch-up overlapping a live frame) is dropped here instead of // answered twice. func (r *Runtime) processMsg(threadID string, id int64, blob string) { entry, ok := r.keys[threadID] if !ok { return } if id != 0 { if id <= r.cursors[threadID] { return } r.cursors[threadID] = id } var p chatPayload if e2ee.DecryptJSON(entry.key, blob, &p) != nil { return // e.g. a message from before we held the key } // Typed payloads are protocol traffic, not chat lines — route them ahead of // the chat-path drops. A scheduled bot.tick is authored by this bot by // design (the own-echo skip below would swallow it), and capability ops // must work whoever posted them. The anti-ping-pong property survives // because handleTyped never produces chat: typed traffic is answered with // typed traffic or not at all. if p.Type != "" { r.handleTyped(threadID, entry, p.Type, blob) return } if p.Name == r.Session.Name { return // our own message, echoed back } if r.agents[p.Name] { return // bots never talk to bots (see loadAgents) } if r.Handler == nil { return } // A scripted flow has the floor here (flow.state): the client is walking // the member through a conversation AS this bot, and their typed answers // are the script's, not the brain's. One voice at a time. if exp, ok := r.muted[threadID]; ok { if time.Now().Before(exp) { return } delete(r.muted, threadID) } tc := &ThreadCtx{rt: r, ID: threadID, Kind: entry.kind, key: entry.key, Sender: p.Name, SenderUID: p.UID} rep, ok := r.Handler(tc, Incoming{ ThreadID: threadID, Kind: entry.kind, Sender: p.Name, Text: p.Text, Triggered: r.triggered(tc, p.Text, p.UID), }) if !ok { return } r.postReply(threadID, entry.key, rep) } // handleTyped routes one typed (non-chat) payload. Unknown types — mini-app // events, our own echoed connect.result — are silently ignored, exactly as // the old `p.Type != "" → return` chokepoint did. func (r *Runtime) handleTyped(threadID string, entry threadEntry, typ, blob string) { switch typ { case "bot.tick": // A charter alarm (chartersched.go). Only honoured from this bot's own // self-thread: nobody else is a member there, so nobody else can ring // the bot's alarms early or repeatedly. if !r.Charters || entry.kind != "self" { return } var tp tickPayload if e2ee.DecryptJSON(entry.key, blob, &tp) != nil { return } r.charterTick(threadID, entry.key, &tp) case "connect.research": // A tapped research chip: sealed read grants + one question // (research.go). Validation + ack run here; the loop takes its own // goroutine so a minute of research never blocks this pump. var rp researchPayload if e2ee.DecryptJSON(entry.key, blob, &rp) != nil { return } r.handleResearch(threadID, entry.key, &rp) case "connect.op", "connect.oauth": // A direct capability invocation (a sealed connected-services grant) — // handled whether or not the bot has a recipe/brain (connect.go). var cp connectPayload if e2ee.DecryptJSON(entry.key, blob, &cp) != nil { return } if cp.Type == "connect.op" { r.handleConnect(threadID, entry.key, &cp) } else { r.handleOAuthExchange(threadID, entry.key, &cp) } case "flow.state": // A client-run scripted flow (the one-Eleven welcome, running in this // bot's own DM) taking or ceding the floor. DMs only, with a hard // expiry so a client that dies mid-flow can't mute the bot forever. if entry.kind != "dm" { return } var fs struct { Active bool `json:"active"` } if e2ee.DecryptJSON(entry.key, blob, &fs) != nil { return } if fs.Active { r.muted[threadID] = time.Now().Add(15 * time.Minute) } else { delete(r.muted, threadID) } } } // triggered decides whether the bot was addressed. DMs always; groups on an // explicit @Name — except a member bot's home thread, where the OWNER talking // is always talking to the host (the thread holds no one else, and "Hello?" // going unanswered there reads as the bot ignoring them). The uid gate // matters: the bot's own editions post under the persona name, not ours, so // without it the host would answer its own bot every occurrence. Listen mode // "name" (console config) also counts a bare-name mention — but a bare name // is ambiguous (people talk *about* a bot as often as *to* it), so it must // additionally pass the model classifier, which means the mode does nothing // without an API key. func (r *Runtime) triggered(tc *ThreadCtx, text, uid string) bool { if tc.Kind == "dm" { return true } if r.mention.MatchString(text) { return true } if r.ai != nil && uid != "" { if h, ok := r.charterHomeFor(tc.ID); ok && h.Owner == uid { return true } } if r.listen == "name" && r.ai != nil && r.bare.MatchString(text) { return r.isAddressed(tc) } return false } // charterHomeFor looks a thread up in the charter-home map (any goroutine). func (r *Runtime) charterHomeFor(threadID string) (charterHome, bool) { r.homeMu.Lock() defer r.homeMu.Unlock() h, ok := r.charterHomes[threadID] return h, ok } // setCharterHomes replaces the charter-home map with the store's current // truth — called from listCharters, so every path that reads charters // (connect + slow reconcile, ticks, builds, removals) keeps it fresh. func (r *Runtime) setCharterHomes(entries []charterEntry) { homes := make(map[string]charterHome, len(entries)) for _, e := range entries { if e.Charter.Thread != "" { homes[e.Charter.Thread] = charterHome{BotID: e.ID, Owner: e.Charter.Owner} } } r.homeMu.Lock() r.charterHomes = homes r.homeMu.Unlock() } // postReply encrypts and sends one chat reply payload — the same wire shape the // Node sidecar posts, so clients can't tell the implementations apart. func (r *Runtime) postReply(threadID string, key []byte, rep Reply) { if rep.Text == "" && len(rep.Actions) > 0 { rep.Text = "Tap to apply:" } if rep.Text == "" { return } payload := map[string]any{"name": r.Session.Name, "text": rep.Text, "ts": time.Now().UnixMilli()} if len(rep.Actions) > 0 { payload["actions"] = rep.Actions } if err := r.sendPayload(threadID, key, payload); err != nil { r.logf("send reply: %v", err) } } // sendPayload encrypts an arbitrary payload under the thread key and sends it as // a msg frame. Shared by chat replies (postReply) and the non-rendering // connected-services replies (connect.result / connect.token). func (r *Runtime) sendPayload(threadID string, key []byte, payload map[string]any) error { blob, err := e2ee.EncryptJSON(key, payload) if err != nil { return err } return r.send(map[string]any{"type": "msg", "thread_id": threadID, "blob": blob}) } // postText posts a chat message as this bot and returns the stored message id, // so the caller can edit it in place afterwards — postReply is fire-and-forget // over the socket and yields no id. It goes through the REST endpoint, which // returns the stored message. The payload is a normal chat payload; set // "action": true for a subtle /me-style line rather than a bubble. func (r *Runtime) postText(threadID string, key []byte, payload map[string]any) (int64, error) { blob, err := e2ee.EncryptJSON(key, payload) if err != nil { return 0, err } body, err := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob}) if err != nil { return 0, err } var m struct { ID int64 `json:"id"` } if err := r.postJSON("/api/messages?token="+url.QueryEscape(r.Session.Token), body, &m); err != nil { return 0, err } return m.ID, nil } // editText edits a message this bot posted, in place — the server verifies the // author and the editWindow. The whole new payload rides as the blob (a reload // folds the stored blob normally), so it must be complete. func (r *Runtime) editText(threadID string, key []byte, id int64, payload map[string]any) error { blob, err := e2ee.EncryptJSON(key, payload) if err != nil { return err } return r.send(map[string]any{"type": "edit", "thread_id": threadID, "message_id": id, "blob": blob}) } // actionLine is a bot's handle to a single chat message it updates in place: // posted once, then edited as work progresses — so any long or multi-step task // (a build, a slow fetch, a background job) reports live status as ONE moving // line instead of a stack of messages. It renders as a subtle /me-style action // line (action:true), which the web keeps across edits (web/static/apps/chat.js), // and reuses one timestamp so the line never jumps. Every method is best-effort: // a dropped post or edit costs the user a little visibility, never correctness. // // Start one with (*Runtime).newActionLine or (*ThreadCtx).actionLine, then call // set() to advance the text. Reusable by any bot flow — nothing here is // build-specific. type actionLine struct { r *Runtime thread string key []byte ts int64 id int64 posted bool } // newActionLine posts the initial status line and returns a handle to update it. func (r *Runtime) newActionLine(threadID string, key []byte, text string) *actionLine { a := &actionLine{r: r, thread: threadID, key: key, ts: time.Now().UnixMilli()} a.set(text) return a } func (a *actionLine) payload(text string) map[string]any { // action:true → a centered /me-style line, not a chat bubble; the flag // persists across edits, so the line stays subtle as its text changes. return map[string]any{"name": a.r.Session.Name, "text": text, "ts": a.ts, "action": true} } // set shows text: it posts the line the first time and edits it in place after. // A failed first post is retried on the next call; a failed edit is dropped (the // line simply doesn't advance). Not safe for concurrent use — drive one line // from a single goroutine. func (a *actionLine) set(text string) { if !a.posted { id, err := a.r.postText(a.thread, a.key, a.payload(text)) if err != nil { a.r.logf("action line post: %v", err) return } a.id, a.posted = id, true return } if err := a.r.editText(a.thread, a.key, a.id, a.payload(text)); err != nil { a.r.logf("action line edit: %v", err) } } func (r *Runtime) send(v any) error { r.wmu.Lock() defer r.wmu.Unlock() conn := r.conn if conn == nil { return fmt.Errorf("not connected") } data, err := json.Marshal(v) if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return conn.Write(ctx, websocket.MessageText, data) } // loadAgents refreshes the never-reply-to set: a bot's mention trigger matches // its bare name, and bot-authored text naturally names other bots — without // this, two bots in one group ping-pong forever. func (r *Runtime) loadAgents() { var rl struct { Roster []struct { Label string `json:"label"` IsAgent bool `json:"is_agent"` } `json:"roster"` } if err := r.get("/api/roster", &rl); err != nil { r.logf("loadAgents: %v", err) return } agents := map[string]bool{} for _, e := range rl.Roster { if e.IsAgent { agents[e.Label] = true } } r.agents = agents } // loadThreads caches the (unwrapped) key for any thread we don't hold yet. A // newly-seen thread's cursor starts at its latest message, so we never reply // to history — only to messages that arrive after we become aware of it. func (r *Runtime) loadThreads() { var tl struct { Threads []struct { ID string `json:"id"` Kind string `json:"kind"` Wrapped string `json:"wrapped"` } `json:"threads"` } if err := r.get("/api/threads", &tl); err != nil { r.logf("loadThreads: %v", err) return } for _, t := range tl.Threads { if t.Wrapped == "" { continue } if _, ok := r.keys[t.ID]; ok { continue } keyB64, err := e2ee.UnwrapThreadKey(t.Wrapped, r.Session.UserPriv) if err != nil { r.logf("load thread %s: %v", t.ID, err) continue } key, err := e2ee.ImportKey(keyB64) if err != nil { r.logf("load thread %s: %v", t.ID, err) continue } r.keys[t.ID] = threadEntry{key: key, kind: t.Kind} var ml struct { Messages []struct { ID int64 `json:"id"` } `json:"messages"` } if err := r.get("/api/messages?thread="+url.QueryEscape(t.ID)+"&limit=1", &ml); err == nil { for _, m := range ml.Messages { if m.ID > r.cursors[t.ID] { r.cursors[t.ID] = m.ID } } } } } // catchUp processes anything that arrived while disconnected, so a message // sent during a reconnect blip still gets a reply. Cursors start at each // thread's latest on first sight, so the initial connect backfills nothing. func (r *Runtime) catchUp() { for threadID := range r.keys { var ml struct { Messages []struct { ID int64 `json:"id"` Blob string `json:"blob"` } `json:"messages"` } path := fmt.Sprintf("/api/messages?thread=%s&after=%d", url.QueryEscape(threadID), r.cursors[threadID]) if err := r.get(path, &ml); err != nil { r.logf("catchUp %s: %v", threadID, err) continue } for _, m := range ml.Messages { r.processMsg(threadID, m.ID, m.Blob) } } } // declareKind self-reports what we are so the UI badges us honestly: "ai" // when the model brain is available, else "bot" (deterministic). Re-declared // on every connect, so toggling the key flips the badge with no admin action. func (r *Runtime) declareKind() { kind := "bot" if r.ai != nil { kind = "ai" } body, _ := json.Marshal(map[string]string{"token": r.Session.Token, "kind": kind}) resp, err := r.client.Post(r.Base+"/api/bot-config", "application/json", strings.NewReader(string(body))) if err != nil { r.logf("declareKind: %v", err) return } resp.Body.Close() } // ---- member-endpoint helpers (schedules, threads) --------------------------- // // Everything below rides the same ordinary member endpoints a browser uses — // the server has no bot-specific surface, and only ever sees ciphertext. // ScheduleEntry is one pending server-side schedule ({id, deliver_at, blob}). type ScheduleEntry struct { ID string `json:"id"` DeliverAt int64 `json:"deliver_at"` Blob string `json:"blob"` } // schedule encrypts payload under the thread key NOW and asks the server to // deliver it into the thread at deliverAt — the mini-app "future message" // primitive (miniapps.go). The server learns only (thread, time). func (r *Runtime) schedule(threadID string, key []byte, payload map[string]any, deliverAt time.Time) (string, error) { blob, err := e2ee.EncryptJSON(key, payload) if err != nil { return "", err } var out struct { ID string `json:"id"` } err = r.post("/api/schedule", map[string]any{ "token": r.Session.Token, "thread_id": threadID, "deliver_at": deliverAt.Unix(), "blob": blob, }, &out) return out.ID, err } // unschedule cancels a pending schedule. Cancelling one that already fired // succeeds quietly (races are by design, matching the server's semantics). func (r *Runtime) unschedule(id string) error { return r.del("/api/schedule/" + url.PathEscape(id) + "?token=" + url.QueryEscape(r.Session.Token)) } // listSchedules returns a thread's pending schedules. func (r *Runtime) listSchedules(threadID string) ([]ScheduleEntry, error) { var out struct { Schedules []ScheduleEntry `json:"schedules"` } err := r.get("/api/schedule?thread="+url.QueryEscape(threadID), &out) return out.Schedules, err } // ensureSelfThread finds — or creates — the bot's private workspace: a self // thread only this bot is a member of. Charter alarms live here, so no human // thread carries tick traffic and nobody else can post into it. Frame // goroutine only (it touches r.keys); everyone else reads the selfThread // snapshot it leaves behind. func (r *Runtime) ensureSelfThread() (string, []byte, error) { snapshot := func(id string, key []byte) (string, []byte, error) { r.selfMu.Lock() r.selfID, r.selfKey = id, key r.selfMu.Unlock() return id, key, nil } for id, e := range r.keys { if e.kind == "self" { return snapshot(id, e.key) } } var me struct { UserID string `json:"user_id"` } if err := r.get("/api/me", &me); err != nil { return "", nil, err } key, err := e2ee.GenerateKey() if err != nil { return "", nil, err } wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(key), r.Session.UserPub) if err != nil { return "", nil, err } var out struct { ThreadID string `json:"thread_id"` } err = r.post("/api/threads", map[string]any{ "token": r.Session.Token, "kind": "self", "members": []map[string]string{{"user_id": me.UserID, "wrapped": wrapped}}, }, &out) if err != nil { return "", nil, err } r.keys[out.ThreadID] = threadEntry{key: key, kind: "self"} return snapshot(out.ThreadID, key) } // selfThread reads the self-thread snapshot ensureSelfThread left — safe from // any goroutine; ok=false until the first charter-host connect completes. func (r *Runtime) selfThread() (string, []byte, bool) { r.selfMu.Lock() defer r.selfMu.Unlock() return r.selfID, r.selfKey, r.selfID != "" } // rosterPubKey returns a member's public key (for wrapping a new thread key // to them) and their display label. func (r *Runtime) rosterPubKey(uid string) (pub, label string, err error) { var rl struct { Roster []struct { UserID string `json:"user_id"` Label string `json:"label"` PublicKey string `json:"public_key"` } `json:"roster"` } if err := r.get("/api/roster", &rl); err != nil { return "", "", err } for _, e := range rl.Roster { if e.UserID == uid { if e.PublicKey == "" { return "", "", fmt.Errorf("member %s has no public key", uid) } return e.PublicKey, e.Label, nil } } return "", "", fmt.Errorf("member %s not in roster", uid) } // createCharterThread creates a member bot's dedicated thread: a titled group // of exactly (owner, host), its key wrapped to both, its name encrypted under // the thread key (the same name_blob shape the web client writes). Returns // the thread id + key. Deliberately does NOT touch r.keys — the frame // goroutine learns the thread via loadThreads when its first frame arrives. func (r *Runtime) createCharterThread(ownerUID, title string) (string, []byte, error) { ownerPub, _, err := r.rosterPubKey(ownerUID) if err != nil { return "", nil, err } var me struct { UserID string `json:"user_id"` } if err := r.get("/api/me", &me); err != nil { return "", nil, err } key, err := e2ee.GenerateKey() if err != nil { return "", nil, err } keyB64 := e2ee.B64uEncode(key) mine, err := e2ee.WrapThreadKey(keyB64, r.Session.UserPub) if err != nil { return "", nil, err } theirs, err := e2ee.WrapThreadKey(keyB64, ownerPub) if err != nil { return "", nil, err } nameBlob, err := e2ee.EncryptJSON(key, map[string]any{"name": title}) if err != nil { return "", nil, err } var out struct { ThreadID string `json:"thread_id"` } err = r.post("/api/threads", map[string]any{ "token": r.Session.Token, "kind": "group", "name_blob": nameBlob, "members": []map[string]string{ {"user_id": me.UserID, "wrapped": mine}, {"user_id": ownerUID, "wrapped": theirs}, }, }, &out) if err != nil { return "", nil, err } return out.ThreadID, key, nil } // renameCharterThread re-titles a member bot's dedicated thread — a renamed // bot renames its chat, since the thread was titled after the bot at birth // and members read the bot's identity off that name. Same name_blob shape the // web client's "Edit group" writes, encrypted under the thread key; the key // comes off the threads endpoint (wrappedKeyFor) so the builder goroutine // stays off r.keys. Best-effort: the charter (the truth) is already stored, // so a failed rename costs a stale label, never a wrong bot. func (r *Runtime) renameCharterThread(threadID, title string) { key, err := r.wrappedKeyFor(threadID) if err != nil { r.logf("bot rename %s: %v", threadID, err) return } nameBlob, err := e2ee.EncryptJSON(key, map[string]any{"name": title}) if err != nil { r.logf("bot rename %s: %v", threadID, err) return } if err := r.post("/api/threads/"+url.PathEscape(threadID)+"/name", map[string]any{ "token": r.Session.Token, "name_blob": nameBlob, }, nil); err != nil { r.logf("bot rename %s: %v", threadID, err) } } func (r *Runtime) post(path string, body any, out any) error { data, err := json.Marshal(body) if err != nil { return err } resp, err := r.client.Post(r.Base+path, "application/json", strings.NewReader(string(data))) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("%s: server %d", path, resp.StatusCode) } if out == nil { return nil } return json.NewDecoder(resp.Body).Decode(out) } // put sends a JSON PUT (the member-bots deploy endpoint). func (r *Runtime) put(path string, body any) error { data, err := json.Marshal(body) if err != nil { return err } req, err := http.NewRequest(http.MethodPut, r.Base+path, strings.NewReader(string(data))) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := r.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("%s: server %d", path, resp.StatusCode) } return nil } // del sends a DELETE (the path carries its own ?token=… auth). func (r *Runtime) del(path string) error { req, err := http.NewRequest(http.MethodDelete, r.Base+path, nil) if err != nil { return err } resp, err := r.client.Do(req) if err != nil { return err } resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("%s: server %d", path, resp.StatusCode) } return nil } func (r *Runtime) get(path string, out any) error { sep := "?" if strings.Contains(path, "?") { sep = "&" } resp, err := r.client.Get(r.Base + path + sep + "token=" + url.QueryEscape(r.Session.Token)) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("%s: server %d", path, resp.StatusCode) } return json.NewDecoder(resp.Body).Decode(out) } // postJSON POSTs a JSON body to an instance endpoint and decodes the response // into out (nil to ignore it). The path must carry its own auth (?token=…). func (r *Runtime) postJSON(path string, body []byte, out any) error { resp, err := r.client.Post(r.Base+path, "application/json", strings.NewReader(string(body))) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("%s: server %d", path, resp.StatusCode) } if out == nil { return nil } return json.NewDecoder(resp.Body).Decode(out) } // getText fetches a plain-text (ungated) endpoint — the authoring guide. func (r *Runtime) getText(path string) (string, error) { resp, err := r.client.Get(r.Base + path) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("%s: server %d", path, resp.StatusCode) } b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", err } return string(b), nil } func (r *Runtime) logf(format string, args ...any) { if r.Logf != nil { r.Logf("["+r.Session.Name+"] "+format, args...) } }