package client // 🤖 Live typed-event + ephemeral transport: the client-side plumbing the // agents bridge (docs/superpowers/specs/2026-08-05-eleven-claude-agents-design.md) // needs and Events()/SendEvent() don't cover — DELIVERY of typed payloads as // they arrive (OnEvent), receipt of relay-only presence frames (OnEphemeral), // and the ephemeral send (SendEphemeral, the Go end of the web's // ctx.emitEphemeral). The hooks are opt-in: a client that sets neither behaves // exactly as before (typed payloads consumed silently, ephemeral frames // dropped — the right default for a person's chat surface, and the bot // runtime's deliberate policy for a DM bot that must not answer typing beats). import ( "time" "lchat/internal/e2ee" ) // Event is one decrypted typed payload (a mini-app event — web/static/apps.js // routes these by their `type` string). ID is 0 for an ephemeral frame, which // the server relays without storing (hub.go: no insert, no id). type Event struct { ThreadID string ID int64 Type string Payload map[string]any // the full decrypted payload, `type` included } // deliverEvent decrypts a typed payload and hands it to OnEvent. The chat // path (deliver) has already advanced the cursor and decided this blob is // typed traffic; this decrypts again into the generic shape — a second // AES-GCM pass over ≤64KB, cheap and simpler than threading a raw decode // through the chat path. func (c *Client) deliverEvent(threadID string, id int64, key []byte, blob string) { if c.OnEvent == nil { return } var p map[string]any if e2ee.DecryptJSON(key, blob, &p) != nil { return } t, _ := p["type"].(string) if t == "" { return } c.OnEvent(Event{ThreadID: threadID, ID: id, Type: t, Payload: p}) } // deliverEphemeral handles a relay-only frame: decrypt, require a type (the // web's ephemeral transport only ever carries typed app events), no cursor — // ephemeral frames are id-less by design so they can never be confused with // stored messages. Only threads whose key is already cached are considered: // ephemeral chatter must not trigger a thread reload (any thread worth // steering has durable traffic that loads it the normal way). func (c *Client) deliverEphemeral(threadID, blob string) { if c.OnEphemeral == nil || threadID == "" { return } c.mu.Lock() entry, ok := c.keys[threadID] c.mu.Unlock() if !ok { return } var p map[string]any if e2ee.DecryptJSON(entry.key, blob, &p) != nil { return } t, _ := p["type"].(string) if t == "" { return } c.OnEphemeral(Event{ThreadID: threadID, Type: t, Payload: p}) } // SendEphemeral relays one typed event to the thread's connected members and // nothing else — never stored, never pushed, never folded (the Go end of the // web's ctx.emitEphemeral; hub.go re-broadcasts and forgets). The transport // fields — name, ts, uid — are injected exactly as SendEvent injects them, so // receivers attribute both kinds the same way. Requires a live Run connection: // there is no REST fallback because an ephemeral frame has no store to land in. func (c *Client) SendEphemeral(threadID string, event map[string]any) error { key, err := c.threadKey(threadID) if err != nil { return err } payload := make(map[string]any, len(event)+3) for k, v := range event { payload[k] = v } payload["name"] = c.Session.Name payload["ts"] = time.Now().UnixMilli() if uid, err := c.Me(); err == nil && uid != "" { payload["uid"] = uid } blob, err := e2ee.EncryptJSON(key, payload) if err != nil { return err } return c.send(map[string]any{"type": "msg", "thread_id": threadID, "blob": blob, "ephemeral": true}) }