eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package client
2
3// 🤖 App-event access: the mini-app layer's events are ordinary encrypted
4// messages whose decrypted payload carries a `type` (web/static/apps.js) — the
5// chat surfaces skip them, and app folds consume them. Events() lets a CLI
6// fold an app's full per-thread log (the Vault, docs/locked-vaults.md);
7// SendEvent() posts one event exactly the way the web transport does
8// (shell.js setEmitTransport: payload + injected name/ts/uid), so no fold can
9// tell where an event was emitted.
10
11import (
12 "encoding/json"
13 "fmt"
14 "net/url"
15 "strings"
16 "time"
17
18 "lchat/internal/e2ee"
19)
20
21// eventPageLimit matches the server's /api/messages cap.
22const eventPageLimit = 200
23
24// eventMaxPages bounds a full-history walk. Folding a PARTIAL log would
25// silently drop early events (a vault.lock, an add), so past this we refuse
26// loudly instead of returning something subtly wrong.
27const eventMaxPages = 200
28
29// Events returns every decrypted payload in a thread whose type has the given
30// prefix (e.g. "vault."), oldest first, walking the thread's whole history.
31// Undecryptable blobs (from before this member held the key) are skipped.
32func (c *Client) Events(threadID, typePrefix string) ([]map[string]any, error) {
33 key, err := c.threadKey(threadID)
34 if err != nil {
35 return nil, err
36 }
37 type rawMsg struct {
38 ID int64 `json:"id"`
39 Blob string `json:"blob"`
40 }
41 var pages [][]rawMsg
42 before := int64(1) << 62
43 for page := 0; ; page++ {
44 if page >= eventMaxPages {
45 return nil, fmt.Errorf("client: thread %s exceeds %d messages — refusing to fold a partial history", threadID, eventPageLimit*eventMaxPages)
46 }
47 var ml struct {
48 Messages []rawMsg `json:"messages"`
49 }
50 path := fmt.Sprintf("/api/messages?thread=%s&limit=%d&before=%d", url.QueryEscape(threadID), eventPageLimit, before)
51 if err := c.get(path, &ml); err != nil {
52 return nil, err
53 }
54 if len(ml.Messages) == 0 {
55 break
56 }
57 pages = append(pages, ml.Messages)
58 before = ml.Messages[0].ID // each page is ascending; its head is the oldest seen
59 if len(ml.Messages) < eventPageLimit {
60 break
61 }
62 }
63 var out []map[string]any
64 for i := len(pages) - 1; i >= 0; i-- { // pages were collected newest-first
65 for _, m := range pages[i] {
66 var p map[string]any
67 if e2ee.DecryptJSON(key, m.Blob, &p) != nil {
68 continue
69 }
70 if t, _ := p["type"].(string); typePrefix == "" || strings.HasPrefix(t, typePrefix) {
71 out = append(out, p)
72 }
73 }
74 }
75 return out, nil
76}
77
78// SendEvent encrypts one typed app event under the thread's key and posts it.
79// The transport-owned fields — name, ts, uid — are injected here, overwriting
80// anything the event carried, exactly like the web's emit transport (which is
81// why app payloads keep their own strings in fields like `label`).
82func (c *Client) SendEvent(threadID string, event map[string]any) error {
83 key, err := c.threadKey(threadID)
84 if err != nil {
85 return err
86 }
87 payload := make(map[string]any, len(event)+3)
88 for k, v := range event {
89 payload[k] = v
90 }
91 payload["name"] = c.Session.Name
92 payload["ts"] = time.Now().UnixMilli()
93 if uid, err := c.Me(); err == nil && uid != "" {
94 payload["uid"] = uid
95 }
96 blob, err := e2ee.EncryptJSON(key, payload)
97 if err != nil {
98 return err
99 }
100 body, err := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob})
101 if err != nil {
102 return err
103 }
104 return c.postJSON("/api/messages?token="+url.QueryEscape(c.Session.Token), body, nil)
105}
106