eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1package client
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/url"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/coder/websocket"
14
15 "lchat/internal/e2ee"
16)
17
18// Client is one member identity's live connection to an instance: it dials the
19// WebSocket, unwraps thread keys, decrypts what arrives, and hands every chat
20// message to OnMessage. It is the transport half of internal/bot's Runtime with
21// the bot policy left out — a client shows its person everything (including
22// other bots' messages, which a bot deliberately never sees), and decides
23// nothing on their behalf.
24//
25// Unlike the bot runtime, methods here may be called from other goroutines
26// than the read loop (a CLI sends from stdin while frames arrive), so the
27// key/cursor state is mutex-guarded.
28type Client struct {
29 Base string
30 Session *Session
31 // OnMessage receives every decrypted chat message, live or via catch-up —
32 // including this member's own (Mine), which arrive echoed back; the caller
33 // decides how to render each.
34 OnMessage func(Message)
35 // OnReady, if set, is called once each time the client finishes connecting.
36 OnReady func()
37 // OnEvent, if set, receives every decrypted TYPED payload (mini-app
38 // events), live or via catch-up — traffic OnMessage deliberately never
39 // sees. Unset, typed payloads are consumed silently, as before.
40 OnEvent func(Event)
41 // OnEphemeral, if set, receives relay-only frames (typing beats, live
42 // tails — id-less, never stored). Unset, they're dropped, as before.
43 OnEphemeral func(Event)
44 Logf func(format string, args ...any)
45
46 hc *http.Client
47 uid string // this member's user id (lazily fetched from /api/me)
48
49 mu sync.Mutex // guards keys + cursors
50 keys map[string]keyEntry
51 cursors map[string]int64 // threadId -> last message id delivered
52
53 wmu sync.Mutex // coder/websocket allows one concurrent writer
54 conn *websocket.Conn
55
56 sentMu sync.Mutex // guards sentIDs
57 sentIDs map[int64]bool
58 // sendMu is held for a Send's whole store round trip. The server
59 // broadcasts as soon as it stores, so our own echo can race the POST
60 // response that carries the stored id — delivery of own-name messages
61 // takes this lock first, waiting out the in-flight Send so the echo is
62 // never misread as another device's message.
63 sendMu sync.Mutex
64}
65
66type keyEntry struct {
67 key []byte
68 kind string
69}
70
71// Message is one decrypted chat message.
72type Message struct {
73 ThreadID string
74 ID int64
75 Name string // sender's payload name
76 Text string
77 Ts int64 // sender-stamped, ms epoch (0 when the payload carries none)
78 Mine bool // authored by this member (by payload name — any of their devices)
79 Echo bool // sent by THIS process, echoed back (already on the caller's screen)
80 Action bool // a /me-style action line, not a bubble
81 Actions int // action chips the message carries (tappable only in the app)
82}
83
84// Thread is one chat as a person sees it in the sidebar: display name
85// decrypted, latest line previewed.
86type Thread struct {
87 ID string
88 Kind string // self | dm | group
89 Name string // decrypted display name ("" when no key or no name yet)
90 App string // a self app-thread's app id (name_blob.app — e.g. "vault"), "" otherwise
91 LastName string // sender of the latest message ("" when none/undecryptable)
92 LastText string
93 LastAt int64 // unix seconds of the latest message (0 when none)
94}
95
96func (c *Client) init() {
97 if c.hc == nil {
98 c.hc = &http.Client{Timeout: 30 * time.Second}
99 }
100 c.mu.Lock()
101 if c.keys == nil {
102 c.keys = map[string]keyEntry{}
103 c.cursors = map[string]int64{}
104 }
105 c.mu.Unlock()
106}
107
108// Run connects and serves until ctx is cancelled, reconnecting with backoff on
109// any drop.
110func (c *Client) Run(ctx context.Context) error {
111 c.init()
112 backoff := time.Second
113 for {
114 start := time.Now()
115 err := c.connectOnce(ctx)
116 if ctx.Err() != nil {
117 return ctx.Err()
118 }
119 if time.Since(start) > time.Minute {
120 backoff = time.Second
121 }
122 c.logf("disconnected (%v); reconnecting in %s", err, backoff)
123 select {
124 case <-time.After(backoff):
125 case <-ctx.Done():
126 return ctx.Err()
127 }
128 backoff = min(backoff*2, 30*time.Second)
129 }
130}
131
132func (c *Client) connectOnce(ctx context.Context) error {
133 wsBase := strings.Replace(c.Base, "http", "ws", 1)
134 conn, _, err := websocket.Dial(ctx, wsBase+"/ws?token="+url.QueryEscape(c.Session.Token), nil)
135 if err != nil {
136 return err
137 }
138 defer conn.CloseNow()
139 // The server caps blobs at 64K; the library's 32K default would kill the
140 // socket on the first big message.
141 conn.SetReadLimit(256 * 1024)
142 c.wmu.Lock()
143 c.conn = conn
144 c.wmu.Unlock()
145 defer func() {
146 c.wmu.Lock()
147 c.conn = nil
148 c.wmu.Unlock()
149 }()
150
151 if err := c.loadThreads(); err != nil {
152 return err
153 }
154 c.catchUp()
155
156 // Keepalive: the server closes idle sockets and ignores "ping" frames.
157 pingCtx, cancel := context.WithCancel(ctx)
158 defer cancel()
159 go func() {
160 t := time.NewTicker(30 * time.Second)
161 defer t.Stop()
162 for {
163 select {
164 case <-t.C:
165 c.send(map[string]any{"type": "ping"})
166 case <-pingCtx.Done():
167 return
168 }
169 }
170 }()
171
172 if c.OnReady != nil {
173 c.OnReady()
174 }
175 for {
176 _, data, err := conn.Read(ctx)
177 if err != nil {
178 return err
179 }
180 c.onFrame(data)
181 }
182}
183
184type wsFrame struct {
185 Type string `json:"type"`
186 ThreadID string `json:"thread_id"`
187 ID int64 `json:"id"`
188 Blob string `json:"blob"`
189 Ephemeral bool `json:"ephemeral"`
190}
191
192func (c *Client) onFrame(data []byte) {
193 var f wsFrame
194 if json.Unmarshal(data, &f) != nil {
195 return
196 }
197 // Chat renders new messages only: reactions/edits/deletes are skipped.
198 // Ephemeral presence traffic (id-less type:"msg" frames) goes to the
199 // OnEphemeral hook when a caller wants it, and nowhere else.
200 if f.Type != "msg" || f.ThreadID == "" {
201 return
202 }
203 if f.Ephemeral {
204 c.deliverEphemeral(f.ThreadID, f.Blob)
205 return
206 }
207 c.mu.Lock()
208 _, known := c.keys[f.ThreadID]
209 c.mu.Unlock()
210 if !known {
211 c.loadThreads() // a thread we were just added to
212 // loadThreads baselines a new thread's cursor at its latest message —
213 // which is the very frame that made us load it (the server stores before
214 // it broadcasts), so the gate below would swallow it. Rewind so this
215 // first message is delivered; anything older stays history.
216 c.mu.Lock()
217 if f.ID != 0 && c.cursors[f.ThreadID] >= f.ID {
218 c.cursors[f.ThreadID] = f.ID - 1
219 }
220 c.mu.Unlock()
221 }
222 c.deliver(f.ThreadID, f.ID, f.Blob)
223}
224
225// chatPayload is the decrypted message shape a person's client renders. Typed
226// payloads (connect.*, mini-app events) are protocol traffic, not chat lines.
227type chatPayload struct {
228 Type string `json:"type"`
229 Name string `json:"name"`
230 Text string `json:"text"`
231 Ts int64 `json:"ts"`
232 Action bool `json:"action"`
233 Actions []map[string]any `json:"actions"`
234}
235
236// deliver decrypts one message, advances the thread cursor, and hands it to
237// OnMessage. The cursor gate drops duplicates (a reconnect's catch-up
238// overlapping a live frame) so nothing prints twice.
239func (c *Client) deliver(threadID string, id int64, blob string) {
240 c.mu.Lock()
241 entry, ok := c.keys[threadID]
242 if ok && id != 0 {
243 if id <= c.cursors[threadID] {
244 c.mu.Unlock()
245 return
246 }
247 c.cursors[threadID] = id
248 }
249 c.mu.Unlock()
250 if !ok {
251 return
252 }
253 var p chatPayload
254 if e2ee.DecryptJSON(entry.key, blob, &p) != nil {
255 return // e.g. a message from before we held the key
256 }
257 if p.Type != "" {
258 // A typed payload (mini-app event, connect traffic), not chat text —
259 // delivered through the event hook, never OnMessage.
260 c.deliverEvent(threadID, id, entry.key, blob)
261 return
262 }
263 if c.OnMessage != nil {
264 if p.Name == c.Session.Name {
265 // Our own message: let any in-flight Send finish recording its
266 // stored id before deciding whether this is its echo.
267 c.sendMu.Lock()
268 //lint:ignore SA2001 the lock is a barrier, not a critical section
269 c.sendMu.Unlock()
270 }
271 c.sentMu.Lock()
272 echo := c.sentIDs[id]
273 c.sentMu.Unlock()
274 c.OnMessage(Message{
275 ThreadID: threadID, ID: id, Name: p.Name, Text: p.Text, Ts: p.Ts,
276 Mine: p.Name == c.Session.Name, Echo: echo, Action: p.Action, Actions: len(p.Actions),
277 })
278 }
279}
280
281// serverThread is the /api/threads wire entry (db.go Thread).
282type serverThread struct {
283 ID string `json:"id"`
284 Kind string `json:"kind"`
285 NameBlob string `json:"name_blob"`
286 Wrapped string `json:"wrapped"`
287 Members []string `json:"members"`
288 LastBlob string `json:"last_blob"`
289 LastAt int64 `json:"last_at"`
290 Hidden bool `json:"hidden"`
291}
292
293func (c *Client) fetchThreads() ([]serverThread, error) {
294 c.init()
295 var tl struct {
296 Threads []serverThread `json:"threads"`
297 }
298 if err := c.get("/api/threads", &tl); err != nil {
299 return nil, err
300 }
301 // Cache any key we don't hold yet; a newly-seen thread's cursor starts at
302 // its latest message, so live delivery never replays history.
303 for _, t := range tl.Threads {
304 if t.Wrapped == "" {
305 continue
306 }
307 c.mu.Lock()
308 _, held := c.keys[t.ID]
309 c.mu.Unlock()
310 if held {
311 continue
312 }
313 keyB64, err := e2ee.UnwrapThreadKey(t.Wrapped, c.Session.UserPriv)
314 if err != nil {
315 c.logf("thread %s: %v", t.ID, err)
316 continue
317 }
318 key, err := e2ee.ImportKey(keyB64)
319 if err != nil {
320 c.logf("thread %s: %v", t.ID, err)
321 continue
322 }
323 var ml struct {
324 Messages []struct {
325 ID int64 `json:"id"`
326 } `json:"messages"`
327 }
328 latest := int64(0)
329 if err := c.get("/api/messages?thread="+url.QueryEscape(t.ID)+"&limit=1", &ml); err == nil {
330 for _, m := range ml.Messages {
331 latest = max(latest, m.ID)
332 }
333 }
334 c.mu.Lock()
335 if _, held := c.keys[t.ID]; !held {
336 c.keys[t.ID] = keyEntry{key: key, kind: t.Kind}
337 c.cursors[t.ID] = max(c.cursors[t.ID], latest)
338 }
339 c.mu.Unlock()
340 }
341 return tl.Threads, nil
342}
343
344func (c *Client) loadThreads() error {
345 _, err := c.fetchThreads()
346 return err
347}
348
349// catchUp delivers anything that arrived while disconnected. Cursors start at
350// each thread's latest on first sight, so the initial connect backfills
351// nothing.
352func (c *Client) catchUp() {
353 c.mu.Lock()
354 after := make(map[string]int64, len(c.cursors))
355 for id := range c.keys {
356 after[id] = c.cursors[id]
357 }
358 c.mu.Unlock()
359 for threadID, cursor := range after {
360 var ml struct {
361 Messages []struct {
362 ID int64 `json:"id"`
363 Blob string `json:"blob"`
364 } `json:"messages"`
365 }
366 path := fmt.Sprintf("/api/messages?thread=%s&after=%d", url.QueryEscape(threadID), cursor)
367 if err := c.get(path, &ml); err != nil {
368 c.logf("catchUp %s: %v", threadID, err)
369 continue
370 }
371 for _, m := range ml.Messages {
372 c.deliver(threadID, m.ID, m.Blob)
373 }
374 }
375}
376
377// Threads lists this member's chats, sidebar-ready: server order (pinned, then
378// most recent), names decrypted — a group's from its name_blob, a DM's from
379// the other member's roster label, a self app-thread's from its app title —
380// and the latest line previewed. Hidden (closed) chats are skipped.
381func (c *Client) Threads() ([]Thread, error) {
382 sts, err := c.fetchThreads()
383 if err != nil {
384 return nil, err
385 }
386 labels := c.rosterLabels()
387 me, _ := c.Me()
388 out := []Thread{}
389 for _, st := range sts {
390 if st.Hidden {
391 continue
392 }
393 t := Thread{ID: st.ID, Kind: st.Kind, LastAt: st.LastAt}
394 c.mu.Lock()
395 entry, held := c.keys[st.ID]
396 c.mu.Unlock()
397 if held && st.NameBlob != "" {
398 // A group's explicit name, or a self app-thread's app id + title
399 // (shell.js threadDisplayName).
400 var n struct {
401 Name string `json:"name"`
402 Title string `json:"title"`
403 App string `json:"app"`
404 }
405 if e2ee.DecryptJSON(entry.key, st.NameBlob, &n) == nil {
406 t.Name = n.Name
407 t.App = n.App
408 if t.Name == "" {
409 t.Name = n.Title
410 }
411 }
412 }
413 if t.Name == "" && st.Kind == "dm" {
414 for _, uid := range st.Members {
415 if uid != me && labels[uid] != "" {
416 t.Name = labels[uid]
417 break
418 }
419 }
420 }
421 if held && st.LastBlob != "" {
422 var p chatPayload
423 if e2ee.DecryptJSON(entry.key, st.LastBlob, &p) == nil && p.Type == "" {
424 t.LastName, t.LastText = p.Name, p.Text
425 }
426 }
427 out = append(out, t)
428 }
429 return out, nil
430}
431
432// rosterLabels maps user id -> display name for everyone this member can see.
433// Bots carry a plaintext label; people publish theirs encrypted under the
434// instance key, so those decrypt only once another member's device has granted
435// us that key — best-effort until then.
436func (c *Client) rosterLabels() map[string]string {
437 labels := map[string]string{}
438 var rl struct {
439 Roster []struct {
440 UserID string `json:"user_id"`
441 Label string `json:"label"`
442 LabelBlob string `json:"label_blob"`
443 } `json:"roster"`
444 }
445 if err := c.get("/api/roster", &rl); err != nil {
446 c.logf("roster: %v", err)
447 return labels
448 }
449 instKey, _ := c.InstanceKey()
450 for _, e := range rl.Roster {
451 name := e.Label
452 if name == "" && e.LabelBlob != "" && instKey != nil {
453 var n string
454 if e2ee.DecryptJSON(instKey, e.LabelBlob, &n) == nil {
455 name = n
456 }
457 }
458 if name != "" {
459 labels[e.UserID] = name
460 }
461 }
462 return labels
463}
464
465// RosterMember is one member of the space, with the fields the vault's `give`
466// needs: who they are (UserID), their resolved display name, their roster
467// PUBLIC key (to seal a handoff to), and whether they're a bot (IsAgent) — a
468// locked vault must never hand its key to a machine member.
469type RosterMember struct {
470 UserID string
471 Name string
472 PublicKey string
473 IsAgent bool
474}
475
476// Roster returns the caller's visible roster with public keys — the CLI end of
477// the web's roster read (same /api/roster, same name resolution: a bot's
478// plaintext label, else the instance-key-encrypted label_blob). Used by
479// `eleven vault give` to seal a handoff to a member's roster key.
480func (c *Client) Roster() ([]RosterMember, error) {
481 c.init()
482 var rl struct {
483 Roster []struct {
484 UserID string `json:"user_id"`
485 Label string `json:"label"`
486 LabelBlob string `json:"label_blob"`
487 PublicKey string `json:"public_key"`
488 IsAgent bool `json:"is_agent"`
489 } `json:"roster"`
490 }
491 if err := c.get("/api/roster", &rl); err != nil {
492 return nil, err
493 }
494 instKey, _ := c.InstanceKey()
495 out := make([]RosterMember, 0, len(rl.Roster))
496 for _, e := range rl.Roster {
497 name := e.Label
498 if name == "" && e.LabelBlob != "" && instKey != nil {
499 var n string
500 if e2ee.DecryptJSON(instKey, e.LabelBlob, &n) == nil {
501 name = n
502 }
503 }
504 out = append(out, RosterMember{UserID: e.UserID, Name: name, PublicKey: e.PublicKey, IsAgent: e.IsAgent})
505 }
506 return out, nil
507}
508
509// Me returns this member's user id (cached after the first call).
510func (c *Client) Me() (string, error) {
511 if c.uid != "" {
512 return c.uid, nil
513 }
514 c.init()
515 var me struct {
516 UserID string `json:"user_id"`
517 }
518 if err := c.get("/api/me", &me); err != nil {
519 return "", err
520 }
521 c.uid = me.UserID
522 return c.uid, nil
523}
524
525// History returns a thread's latest messages, oldest first, decrypted.
526// Undecryptable or non-chat payloads are skipped.
527func (c *Client) History(threadID string, limit int) ([]Message, error) {
528 key, err := c.threadKey(threadID)
529 if err != nil {
530 return nil, err
531 }
532 var ml struct {
533 Messages []struct {
534 ID int64 `json:"id"`
535 Blob string `json:"blob"`
536 } `json:"messages"`
537 }
538 path := fmt.Sprintf("/api/messages?thread=%s&limit=%d", url.QueryEscape(threadID), limit)
539 if err := c.get(path, &ml); err != nil {
540 return nil, err
541 }
542 var out []Message
543 for _, m := range ml.Messages {
544 var p chatPayload
545 if e2ee.DecryptJSON(key, m.Blob, &p) != nil || p.Type != "" {
546 continue
547 }
548 out = append(out, Message{
549 ThreadID: threadID, ID: m.ID, Name: p.Name, Text: p.Text, Ts: p.Ts,
550 Mine: p.Name == c.Session.Name, Action: p.Action, Actions: len(p.Actions),
551 })
552 }
553 return out, nil
554}
555
556// Send encrypts text under the thread's key and posts it — the same wire shape
557// the web composer sends, so no client can tell where a message was typed.
558// Usable without Run (a one-shot `eleven send`).
559func (c *Client) Send(threadID, text string) error {
560 key, err := c.threadKey(threadID)
561 if err != nil {
562 return err
563 }
564 // Held until the stored id is recorded — see sendMu.
565 c.sendMu.Lock()
566 defer c.sendMu.Unlock()
567 blob, err := e2ee.EncryptJSON(key, map[string]any{
568 "name": c.Session.Name, "text": text, "ts": time.Now().UnixMilli(),
569 })
570 if err != nil {
571 return err
572 }
573 body, err := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob})
574 if err != nil {
575 return err
576 }
577 var stored struct {
578 ID int64 `json:"id"`
579 }
580 if err := c.postJSON("/api/messages?token="+url.QueryEscape(c.Session.Token), body, &stored); err != nil {
581 return err
582 }
583 // Remember what this process sent, so its own echo is distinguishable from
584 // the same member's sends on another device (Message.Echo vs Mine).
585 if stored.ID != 0 {
586 c.sentMu.Lock()
587 if c.sentIDs == nil {
588 c.sentIDs = map[int64]bool{}
589 }
590 c.sentIDs[stored.ID] = true
591 c.sentMu.Unlock()
592 }
593 return nil
594}
595
596// threadKey returns the unwrapped key for a thread, loading threads once if it
597// isn't cached yet.
598func (c *Client) threadKey(threadID string) ([]byte, error) {
599 c.init()
600 c.mu.Lock()
601 entry, ok := c.keys[threadID]
602 c.mu.Unlock()
603 if !ok {
604 if err := c.loadThreads(); err != nil {
605 return nil, err
606 }
607 c.mu.Lock()
608 entry, ok = c.keys[threadID]
609 c.mu.Unlock()
610 }
611 if !ok {
612 return nil, fmt.Errorf("client: no key for thread %s (not a member, or the key hasn't been granted yet)", threadID)
613 }
614 return entry.key, nil
615}
616
617// InstanceKey unwraps this member's copy of the instance key — nil error only
618// once another member's device has granted it (grants are how keys reach a
619// fresh member; there may be nothing to unwrap in the first minutes).
620func (c *Client) InstanceKey() ([]byte, error) {
621 c.init()
622 var ik struct {
623 Wrapped string `json:"wrapped"`
624 }
625 if err := c.get("/api/instance-key", &ik); err != nil {
626 return nil, err
627 }
628 if ik.Wrapped == "" {
629 return nil, fmt.Errorf("client: no instance key granted yet")
630 }
631 keyB64, err := e2ee.UnwrapThreadKey(ik.Wrapped, c.Session.UserPriv)
632 if err != nil {
633 return nil, err
634 }
635 return e2ee.ImportKey(keyB64)
636}
637
638// PublishLabel publishes this member's display name to the roster, encrypted
639// under the instance key (what the web does after joining — the server never
640// sees the plaintext name). Fails until the instance key has been granted.
641func (c *Client) PublishLabel() error {
642 instKey, err := c.InstanceKey()
643 if err != nil {
644 return err
645 }
646 blob, err := e2ee.EncryptJSON(instKey, c.Session.Name)
647 if err != nil {
648 return err
649 }
650 body, err := json.Marshal(map[string]string{"token": c.Session.Token, "label_blob": blob})
651 if err != nil {
652 return err
653 }
654 return c.postJSON("/api/label", body, nil)
655}
656
657func (c *Client) send(v any) error {
658 c.wmu.Lock()
659 defer c.wmu.Unlock()
660 conn := c.conn
661 if conn == nil {
662 return fmt.Errorf("not connected")
663 }
664 data, err := json.Marshal(v)
665 if err != nil {
666 return err
667 }
668 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
669 defer cancel()
670 return conn.Write(ctx, websocket.MessageText, data)
671}
672
673func (c *Client) get(path string, out any) error {
674 c.init()
675 sep := "?"
676 if strings.Contains(path, "?") {
677 sep = "&"
678 }
679 resp, err := c.hc.Get(c.Base + path + sep + "token=" + url.QueryEscape(c.Session.Token))
680 if err != nil {
681 return err
682 }
683 defer resp.Body.Close()
684 if resp.StatusCode != http.StatusOK {
685 return fmt.Errorf("%s: server %d", path, resp.StatusCode)
686 }
687 return json.NewDecoder(resp.Body).Decode(out)
688}
689
690func (c *Client) postJSON(path string, body []byte, out any) error {
691 c.init()
692 resp, err := c.hc.Post(c.Base+path, "application/json", strings.NewReader(string(body)))
693 if err != nil {
694 return err
695 }
696 defer resp.Body.Close()
697 if resp.StatusCode != http.StatusOK {
698 return fmt.Errorf("%s: server %d", path, resp.StatusCode)
699 }
700 if out == nil {
701 return nil
702 }
703 return json.NewDecoder(resp.Body).Decode(out)
704}
705
706func (c *Client) logf(format string, args ...any) {
707 if c.Logf != nil {
708 c.Logf(format, args...)
709 }
710}
711