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