package client import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "sync" "time" "github.com/coder/websocket" "lchat/internal/e2ee" ) // Client is one member identity's live connection to an instance: it dials the // WebSocket, unwraps thread keys, decrypts what arrives, and hands every chat // message to OnMessage. It is the transport half of internal/bot's Runtime with // the bot policy left out — a client shows its person everything (including // other bots' messages, which a bot deliberately never sees), and decides // nothing on their behalf. // // Unlike the bot runtime, methods here may be called from other goroutines // than the read loop (a CLI sends from stdin while frames arrive), so the // key/cursor state is mutex-guarded. type Client struct { Base string Session *Session // OnMessage receives every decrypted chat message, live or via catch-up — // including this member's own (Mine), which arrive echoed back; the caller // decides how to render each. OnMessage func(Message) // OnReady, if set, is called once each time the client finishes connecting. OnReady func() // OnEvent, if set, receives every decrypted TYPED payload (mini-app // events), live or via catch-up — traffic OnMessage deliberately never // sees. Unset, typed payloads are consumed silently, as before. OnEvent func(Event) // OnEphemeral, if set, receives relay-only frames (typing beats, live // tails — id-less, never stored). Unset, they're dropped, as before. OnEphemeral func(Event) Logf func(format string, args ...any) hc *http.Client uid string // this member's user id (lazily fetched from /api/me) mu sync.Mutex // guards keys + cursors keys map[string]keyEntry cursors map[string]int64 // threadId -> last message id delivered wmu sync.Mutex // coder/websocket allows one concurrent writer conn *websocket.Conn sentMu sync.Mutex // guards sentIDs sentIDs map[int64]bool // sendMu is held for a Send's whole store round trip. The server // broadcasts as soon as it stores, so our own echo can race the POST // response that carries the stored id — delivery of own-name messages // takes this lock first, waiting out the in-flight Send so the echo is // never misread as another device's message. sendMu sync.Mutex } type keyEntry struct { key []byte kind string } // Message is one decrypted chat message. type Message struct { ThreadID string ID int64 Name string // sender's payload name Text string Ts int64 // sender-stamped, ms epoch (0 when the payload carries none) Mine bool // authored by this member (by payload name — any of their devices) Echo bool // sent by THIS process, echoed back (already on the caller's screen) Action bool // a /me-style action line, not a bubble Actions int // action chips the message carries (tappable only in the app) } // Thread is one chat as a person sees it in the sidebar: display name // decrypted, latest line previewed. type Thread struct { ID string Kind string // self | dm | group Name string // decrypted display name ("" when no key or no name yet) App string // a self app-thread's app id (name_blob.app — e.g. "vault"), "" otherwise LastName string // sender of the latest message ("" when none/undecryptable) LastText string LastAt int64 // unix seconds of the latest message (0 when none) } func (c *Client) init() { if c.hc == nil { c.hc = &http.Client{Timeout: 30 * time.Second} } c.mu.Lock() if c.keys == nil { c.keys = map[string]keyEntry{} c.cursors = map[string]int64{} } c.mu.Unlock() } // Run connects and serves until ctx is cancelled, reconnecting with backoff on // any drop. func (c *Client) Run(ctx context.Context) error { c.init() backoff := time.Second for { start := time.Now() err := c.connectOnce(ctx) if ctx.Err() != nil { return ctx.Err() } if time.Since(start) > time.Minute { backoff = time.Second } c.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 (c *Client) connectOnce(ctx context.Context) error { wsBase := strings.Replace(c.Base, "http", "ws", 1) conn, _, err := websocket.Dial(ctx, wsBase+"/ws?token="+url.QueryEscape(c.Session.Token), nil) 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) c.wmu.Lock() c.conn = conn c.wmu.Unlock() defer func() { c.wmu.Lock() c.conn = nil c.wmu.Unlock() }() if err := c.loadThreads(); err != nil { return err } c.catchUp() // 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: c.send(map[string]any{"type": "ping"}) case <-pingCtx.Done(): return } } }() if c.OnReady != nil { c.OnReady() } for { _, data, err := conn.Read(ctx) if err != nil { return err } c.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 (c *Client) onFrame(data []byte) { var f wsFrame if json.Unmarshal(data, &f) != nil { return } // Chat renders new messages only: reactions/edits/deletes are skipped. // Ephemeral presence traffic (id-less type:"msg" frames) goes to the // OnEphemeral hook when a caller wants it, and nowhere else. if f.Type != "msg" || f.ThreadID == "" { return } if f.Ephemeral { c.deliverEphemeral(f.ThreadID, f.Blob) return } c.mu.Lock() _, known := c.keys[f.ThreadID] c.mu.Unlock() if !known { c.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 this // first message is delivered; anything older stays history. c.mu.Lock() if f.ID != 0 && c.cursors[f.ThreadID] >= f.ID { c.cursors[f.ThreadID] = f.ID - 1 } c.mu.Unlock() } c.deliver(f.ThreadID, f.ID, f.Blob) } // chatPayload is the decrypted message shape a person's client renders. 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"` Ts int64 `json:"ts"` Action bool `json:"action"` Actions []map[string]any `json:"actions"` } // deliver decrypts one message, advances the thread cursor, and hands it to // OnMessage. The cursor gate drops duplicates (a reconnect's catch-up // overlapping a live frame) so nothing prints twice. func (c *Client) deliver(threadID string, id int64, blob string) { c.mu.Lock() entry, ok := c.keys[threadID] if ok && id != 0 { if id <= c.cursors[threadID] { c.mu.Unlock() return } c.cursors[threadID] = id } c.mu.Unlock() if !ok { return } var p chatPayload if e2ee.DecryptJSON(entry.key, blob, &p) != nil { return // e.g. a message from before we held the key } if p.Type != "" { // A typed payload (mini-app event, connect traffic), not chat text — // delivered through the event hook, never OnMessage. c.deliverEvent(threadID, id, entry.key, blob) return } if c.OnMessage != nil { if p.Name == c.Session.Name { // Our own message: let any in-flight Send finish recording its // stored id before deciding whether this is its echo. c.sendMu.Lock() //lint:ignore SA2001 the lock is a barrier, not a critical section c.sendMu.Unlock() } c.sentMu.Lock() echo := c.sentIDs[id] c.sentMu.Unlock() c.OnMessage(Message{ ThreadID: threadID, ID: id, Name: p.Name, Text: p.Text, Ts: p.Ts, Mine: p.Name == c.Session.Name, Echo: echo, Action: p.Action, Actions: len(p.Actions), }) } } // serverThread is the /api/threads wire entry (db.go Thread). type serverThread struct { ID string `json:"id"` Kind string `json:"kind"` NameBlob string `json:"name_blob"` Wrapped string `json:"wrapped"` Members []string `json:"members"` LastBlob string `json:"last_blob"` LastAt int64 `json:"last_at"` Hidden bool `json:"hidden"` } func (c *Client) fetchThreads() ([]serverThread, error) { c.init() var tl struct { Threads []serverThread `json:"threads"` } if err := c.get("/api/threads", &tl); err != nil { return nil, err } // Cache any key we don't hold yet; a newly-seen thread's cursor starts at // its latest message, so live delivery never replays history. for _, t := range tl.Threads { if t.Wrapped == "" { continue } c.mu.Lock() _, held := c.keys[t.ID] c.mu.Unlock() if held { continue } keyB64, err := e2ee.UnwrapThreadKey(t.Wrapped, c.Session.UserPriv) if err != nil { c.logf("thread %s: %v", t.ID, err) continue } key, err := e2ee.ImportKey(keyB64) if err != nil { c.logf("thread %s: %v", t.ID, err) continue } var ml struct { Messages []struct { ID int64 `json:"id"` } `json:"messages"` } latest := int64(0) if err := c.get("/api/messages?thread="+url.QueryEscape(t.ID)+"&limit=1", &ml); err == nil { for _, m := range ml.Messages { latest = max(latest, m.ID) } } c.mu.Lock() if _, held := c.keys[t.ID]; !held { c.keys[t.ID] = keyEntry{key: key, kind: t.Kind} c.cursors[t.ID] = max(c.cursors[t.ID], latest) } c.mu.Unlock() } return tl.Threads, nil } func (c *Client) loadThreads() error { _, err := c.fetchThreads() return err } // catchUp delivers anything that arrived while disconnected. Cursors start at // each thread's latest on first sight, so the initial connect backfills // nothing. func (c *Client) catchUp() { c.mu.Lock() after := make(map[string]int64, len(c.cursors)) for id := range c.keys { after[id] = c.cursors[id] } c.mu.Unlock() for threadID, cursor := range after { 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), cursor) if err := c.get(path, &ml); err != nil { c.logf("catchUp %s: %v", threadID, err) continue } for _, m := range ml.Messages { c.deliver(threadID, m.ID, m.Blob) } } } // Threads lists this member's chats, sidebar-ready: server order (pinned, then // most recent), names decrypted — a group's from its name_blob, a DM's from // the other member's roster label, a self app-thread's from its app title — // and the latest line previewed. Hidden (closed) chats are skipped. func (c *Client) Threads() ([]Thread, error) { sts, err := c.fetchThreads() if err != nil { return nil, err } labels := c.rosterLabels() me, _ := c.Me() out := []Thread{} for _, st := range sts { if st.Hidden { continue } t := Thread{ID: st.ID, Kind: st.Kind, LastAt: st.LastAt} c.mu.Lock() entry, held := c.keys[st.ID] c.mu.Unlock() if held && st.NameBlob != "" { // A group's explicit name, or a self app-thread's app id + title // (shell.js threadDisplayName). var n struct { Name string `json:"name"` Title string `json:"title"` App string `json:"app"` } if e2ee.DecryptJSON(entry.key, st.NameBlob, &n) == nil { t.Name = n.Name t.App = n.App if t.Name == "" { t.Name = n.Title } } } if t.Name == "" && st.Kind == "dm" { for _, uid := range st.Members { if uid != me && labels[uid] != "" { t.Name = labels[uid] break } } } if held && st.LastBlob != "" { var p chatPayload if e2ee.DecryptJSON(entry.key, st.LastBlob, &p) == nil && p.Type == "" { t.LastName, t.LastText = p.Name, p.Text } } out = append(out, t) } return out, nil } // rosterLabels maps user id -> display name for everyone this member can see. // Bots carry a plaintext label; people publish theirs encrypted under the // instance key, so those decrypt only once another member's device has granted // us that key — best-effort until then. func (c *Client) rosterLabels() map[string]string { labels := map[string]string{} var rl struct { Roster []struct { UserID string `json:"user_id"` Label string `json:"label"` LabelBlob string `json:"label_blob"` } `json:"roster"` } if err := c.get("/api/roster", &rl); err != nil { c.logf("roster: %v", err) return labels } instKey, _ := c.InstanceKey() for _, e := range rl.Roster { name := e.Label if name == "" && e.LabelBlob != "" && instKey != nil { var n string if e2ee.DecryptJSON(instKey, e.LabelBlob, &n) == nil { name = n } } if name != "" { labels[e.UserID] = name } } return labels } // RosterMember is one member of the space, with the fields the vault's `give` // needs: who they are (UserID), their resolved display name, their roster // PUBLIC key (to seal a handoff to), and whether they're a bot (IsAgent) — a // locked vault must never hand its key to a machine member. type RosterMember struct { UserID string Name string PublicKey string IsAgent bool } // Roster returns the caller's visible roster with public keys — the CLI end of // the web's roster read (same /api/roster, same name resolution: a bot's // plaintext label, else the instance-key-encrypted label_blob). Used by // `eleven vault give` to seal a handoff to a member's roster key. func (c *Client) Roster() ([]RosterMember, error) { c.init() var rl struct { Roster []struct { UserID string `json:"user_id"` Label string `json:"label"` LabelBlob string `json:"label_blob"` PublicKey string `json:"public_key"` IsAgent bool `json:"is_agent"` } `json:"roster"` } if err := c.get("/api/roster", &rl); err != nil { return nil, err } instKey, _ := c.InstanceKey() out := make([]RosterMember, 0, len(rl.Roster)) for _, e := range rl.Roster { name := e.Label if name == "" && e.LabelBlob != "" && instKey != nil { var n string if e2ee.DecryptJSON(instKey, e.LabelBlob, &n) == nil { name = n } } out = append(out, RosterMember{UserID: e.UserID, Name: name, PublicKey: e.PublicKey, IsAgent: e.IsAgent}) } return out, nil } // Me returns this member's user id (cached after the first call). func (c *Client) Me() (string, error) { if c.uid != "" { return c.uid, nil } c.init() var me struct { UserID string `json:"user_id"` } if err := c.get("/api/me", &me); err != nil { return "", err } c.uid = me.UserID return c.uid, nil } // History returns a thread's latest messages, oldest first, decrypted. // Undecryptable or non-chat payloads are skipped. func (c *Client) History(threadID string, limit int) ([]Message, error) { key, err := c.threadKey(threadID) if err != nil { return nil, err } var ml struct { Messages []struct { ID int64 `json:"id"` Blob string `json:"blob"` } `json:"messages"` } path := fmt.Sprintf("/api/messages?thread=%s&limit=%d", url.QueryEscape(threadID), limit) if err := c.get(path, &ml); err != nil { return nil, err } var out []Message for _, m := range ml.Messages { var p chatPayload if e2ee.DecryptJSON(key, m.Blob, &p) != nil || p.Type != "" { continue } out = append(out, Message{ ThreadID: threadID, ID: m.ID, Name: p.Name, Text: p.Text, Ts: p.Ts, Mine: p.Name == c.Session.Name, Action: p.Action, Actions: len(p.Actions), }) } return out, nil } // Send encrypts text under the thread's key and posts it — the same wire shape // the web composer sends, so no client can tell where a message was typed. // Usable without Run (a one-shot `eleven send`). func (c *Client) Send(threadID, text string) error { key, err := c.threadKey(threadID) if err != nil { return err } // Held until the stored id is recorded — see sendMu. c.sendMu.Lock() defer c.sendMu.Unlock() blob, err := e2ee.EncryptJSON(key, map[string]any{ "name": c.Session.Name, "text": text, "ts": time.Now().UnixMilli(), }) if err != nil { return err } body, err := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob}) if err != nil { return err } var stored struct { ID int64 `json:"id"` } if err := c.postJSON("/api/messages?token="+url.QueryEscape(c.Session.Token), body, &stored); err != nil { return err } // Remember what this process sent, so its own echo is distinguishable from // the same member's sends on another device (Message.Echo vs Mine). if stored.ID != 0 { c.sentMu.Lock() if c.sentIDs == nil { c.sentIDs = map[int64]bool{} } c.sentIDs[stored.ID] = true c.sentMu.Unlock() } return nil } // threadKey returns the unwrapped key for a thread, loading threads once if it // isn't cached yet. func (c *Client) threadKey(threadID string) ([]byte, error) { c.init() c.mu.Lock() entry, ok := c.keys[threadID] c.mu.Unlock() if !ok { if err := c.loadThreads(); err != nil { return nil, err } c.mu.Lock() entry, ok = c.keys[threadID] c.mu.Unlock() } if !ok { return nil, fmt.Errorf("client: no key for thread %s (not a member, or the key hasn't been granted yet)", threadID) } return entry.key, nil } // InstanceKey unwraps this member's copy of the instance key — nil error only // once another member's device has granted it (grants are how keys reach a // fresh member; there may be nothing to unwrap in the first minutes). func (c *Client) InstanceKey() ([]byte, error) { c.init() var ik struct { Wrapped string `json:"wrapped"` } if err := c.get("/api/instance-key", &ik); err != nil { return nil, err } if ik.Wrapped == "" { return nil, fmt.Errorf("client: no instance key granted yet") } keyB64, err := e2ee.UnwrapThreadKey(ik.Wrapped, c.Session.UserPriv) if err != nil { return nil, err } return e2ee.ImportKey(keyB64) } // PublishLabel publishes this member's display name to the roster, encrypted // under the instance key (what the web does after joining — the server never // sees the plaintext name). Fails until the instance key has been granted. func (c *Client) PublishLabel() error { instKey, err := c.InstanceKey() if err != nil { return err } blob, err := e2ee.EncryptJSON(instKey, c.Session.Name) if err != nil { return err } body, err := json.Marshal(map[string]string{"token": c.Session.Token, "label_blob": blob}) if err != nil { return err } return c.postJSON("/api/label", body, nil) } func (c *Client) send(v any) error { c.wmu.Lock() defer c.wmu.Unlock() conn := c.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) } func (c *Client) get(path string, out any) error { c.init() sep := "?" if strings.Contains(path, "?") { sep = "&" } resp, err := c.hc.Get(c.Base + path + sep + "token=" + url.QueryEscape(c.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) } func (c *Client) postJSON(path string, body []byte, out any) error { c.init() resp, err := c.hc.Post(c.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) } func (c *Client) logf(format string, args ...any) { if c.Logf != nil { c.Logf(format, args...) } }