| 1 | package client |
| 2 | |
| 3 | // π€ Live typed-event + ephemeral transport: the client-side plumbing the |
| 4 | // agents bridge (docs/superpowers/specs/2026-08-05-eleven-claude-agents-design.md) |
| 5 | // needs and Events()/SendEvent() don't cover β DELIVERY of typed payloads as |
| 6 | // they arrive (OnEvent), receipt of relay-only presence frames (OnEphemeral), |
| 7 | // and the ephemeral send (SendEphemeral, the Go end of the web's |
| 8 | // ctx.emitEphemeral). The hooks are opt-in: a client that sets neither behaves |
| 9 | // exactly as before (typed payloads consumed silently, ephemeral frames |
| 10 | // dropped β the right default for a person's chat surface, and the bot |
| 11 | // runtime's deliberate policy for a DM bot that must not answer typing beats). |
| 12 | |
| 13 | import ( |
| 14 | "time" |
| 15 | |
| 16 | "lchat/internal/e2ee" |
| 17 | ) |
| 18 | |
| 19 | // Event is one decrypted typed payload (a mini-app event β web/static/apps.js |
| 20 | // routes these by their `type` string). ID is 0 for an ephemeral frame, which |
| 21 | // the server relays without storing (hub.go: no insert, no id). |
| 22 | type Event struct { |
| 23 | ThreadID string |
| 24 | ID int64 |
| 25 | Type string |
| 26 | Payload map[string]any // the full decrypted payload, `type` included |
| 27 | } |
| 28 | |
| 29 | // deliverEvent decrypts a typed payload and hands it to OnEvent. The chat |
| 30 | // path (deliver) has already advanced the cursor and decided this blob is |
| 31 | // typed traffic; this decrypts again into the generic shape β a second |
| 32 | // AES-GCM pass over β€64KB, cheap and simpler than threading a raw decode |
| 33 | // through the chat path. |
| 34 | func (c *Client) deliverEvent(threadID string, id int64, key []byte, blob string) { |
| 35 | if c.OnEvent == nil { |
| 36 | return |
| 37 | } |
| 38 | var p map[string]any |
| 39 | if e2ee.DecryptJSON(key, blob, &p) != nil { |
| 40 | return |
| 41 | } |
| 42 | t, _ := p["type"].(string) |
| 43 | if t == "" { |
| 44 | return |
| 45 | } |
| 46 | c.OnEvent(Event{ThreadID: threadID, ID: id, Type: t, Payload: p}) |
| 47 | } |
| 48 | |
| 49 | // deliverEphemeral handles a relay-only frame: decrypt, require a type (the |
| 50 | // web's ephemeral transport only ever carries typed app events), no cursor β |
| 51 | // ephemeral frames are id-less by design so they can never be confused with |
| 52 | // stored messages. Only threads whose key is already cached are considered: |
| 53 | // ephemeral chatter must not trigger a thread reload (any thread worth |
| 54 | // steering has durable traffic that loads it the normal way). |
| 55 | func (c *Client) deliverEphemeral(threadID, blob string) { |
| 56 | if c.OnEphemeral == nil || threadID == "" { |
| 57 | return |
| 58 | } |
| 59 | c.mu.Lock() |
| 60 | entry, ok := c.keys[threadID] |
| 61 | c.mu.Unlock() |
| 62 | if !ok { |
| 63 | return |
| 64 | } |
| 65 | var p map[string]any |
| 66 | if e2ee.DecryptJSON(entry.key, blob, &p) != nil { |
| 67 | return |
| 68 | } |
| 69 | t, _ := p["type"].(string) |
| 70 | if t == "" { |
| 71 | return |
| 72 | } |
| 73 | c.OnEphemeral(Event{ThreadID: threadID, Type: t, Payload: p}) |
| 74 | } |
| 75 | |
| 76 | // SendEphemeral relays one typed event to the thread's connected members and |
| 77 | // nothing else β never stored, never pushed, never folded (the Go end of the |
| 78 | // web's ctx.emitEphemeral; hub.go re-broadcasts and forgets). The transport |
| 79 | // fields β name, ts, uid β are injected exactly as SendEvent injects them, so |
| 80 | // receivers attribute both kinds the same way. Requires a live Run connection: |
| 81 | // there is no REST fallback because an ephemeral frame has no store to land in. |
| 82 | func (c *Client) SendEphemeral(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 | return c.send(map[string]any{"type": "msg", "thread_id": threadID, "blob": blob, "ephemeral": true}) |
| 101 | } |
| 102 | |