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
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
13import (
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).
22type 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.
34func (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).
55func (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.
82func (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