package client // 🤖 App-event access: the mini-app layer's events are ordinary encrypted // messages whose decrypted payload carries a `type` (web/static/apps.js) — the // chat surfaces skip them, and app folds consume them. Events() lets a CLI // fold an app's full per-thread log (the Vault, docs/locked-vaults.md); // SendEvent() posts one event exactly the way the web transport does // (shell.js setEmitTransport: payload + injected name/ts/uid), so no fold can // tell where an event was emitted. import ( "encoding/json" "fmt" "net/url" "strings" "time" "lchat/internal/e2ee" ) // eventPageLimit matches the server's /api/messages cap. const eventPageLimit = 200 // eventMaxPages bounds a full-history walk. Folding a PARTIAL log would // silently drop early events (a vault.lock, an add), so past this we refuse // loudly instead of returning something subtly wrong. const eventMaxPages = 200 // Events returns every decrypted payload in a thread whose type has the given // prefix (e.g. "vault."), oldest first, walking the thread's whole history. // Undecryptable blobs (from before this member held the key) are skipped. func (c *Client) Events(threadID, typePrefix string) ([]map[string]any, error) { key, err := c.threadKey(threadID) if err != nil { return nil, err } type rawMsg struct { ID int64 `json:"id"` Blob string `json:"blob"` } var pages [][]rawMsg before := int64(1) << 62 for page := 0; ; page++ { if page >= eventMaxPages { return nil, fmt.Errorf("client: thread %s exceeds %d messages — refusing to fold a partial history", threadID, eventPageLimit*eventMaxPages) } var ml struct { Messages []rawMsg `json:"messages"` } path := fmt.Sprintf("/api/messages?thread=%s&limit=%d&before=%d", url.QueryEscape(threadID), eventPageLimit, before) if err := c.get(path, &ml); err != nil { return nil, err } if len(ml.Messages) == 0 { break } pages = append(pages, ml.Messages) before = ml.Messages[0].ID // each page is ascending; its head is the oldest seen if len(ml.Messages) < eventPageLimit { break } } var out []map[string]any for i := len(pages) - 1; i >= 0; i-- { // pages were collected newest-first for _, m := range pages[i] { var p map[string]any if e2ee.DecryptJSON(key, m.Blob, &p) != nil { continue } if t, _ := p["type"].(string); typePrefix == "" || strings.HasPrefix(t, typePrefix) { out = append(out, p) } } } return out, nil } // SendEvent encrypts one typed app event under the thread's key and posts it. // The transport-owned fields — name, ts, uid — are injected here, overwriting // anything the event carried, exactly like the web's emit transport (which is // why app payloads keep their own strings in fields like `label`). func (c *Client) SendEvent(threadID string, event map[string]any) error { key, err := c.threadKey(threadID) if err != nil { return err } payload := make(map[string]any, len(event)+3) for k, v := range event { payload[k] = v } payload["name"] = c.Session.Name payload["ts"] = time.Now().UnixMilli() if uid, err := c.Me(); err == nil && uid != "" { payload["uid"] = uid } blob, err := e2ee.EncryptJSON(key, payload) if err != nil { return err } body, err := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob}) if err != nil { return err } return c.postJSON("/api/messages?token="+url.QueryEscape(c.Session.Token), body, nil) }