eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import SwiftUI
3
4// πŸ€– The native host for Eleven's onboarding conversations (setup, welcome):
5// binds FlowEngine to the real thread β€” folds the persisted transcript
6// (`<app>.say` / `<app>.step` events plus the member's own chat messages,
7// wire-identical to the web's, so a conversation started on one surface
8// resumes on the other), runs the flow with native verbs (plain admin POSTs;
9// the passkey ceremony is skipped by env.native), and persists each line as
10// it's said. Everything sent is encrypted under the thread key first.
11@MainActor
12public final class ElevenConversationModel: ObservableObject {
13 public struct Row: Identifiable {
14 public let id = UUID()
15 /// A stable message-shaped identity + timestamp so the transcript can
16 /// render through the canonical MessageBubble.
17 public var seq: Int64 = 0
18 public var at: Int64 = 0
19 public var kind: Kind
20 public var chips: [FlowChip]?
21 public enum Kind {
22 case eleven(String) // Eleven's bubble
23 case action(String) // a βœ“ step line ("noted: you're Paul")
24 case mine(String) // the member's own message/answer
25 }
26 }
27
28 private var nextSeq: Int64 = 0
29 private func append(_ kind: Row.Kind, chips: [FlowChip]? = nil) {
30 nextSeq += 1
31 rows.append(Row(seq: nextSeq, at: Int64(Date().timeIntervalSince1970), kind: kind, chips: chips))
32 }
33
34 @Published public private(set) var rows: [Row] = []
35 @Published public private(set) var typing = false
36 @Published public var composerHint: String?
37 /// The composer's inset field label ("Your name:") β€” rides say extras like
38 /// the hint and follows the same reset rules.
39 @Published public var composerLabel: String?
40 @Published public private(set) var ready = false
41
42 public let instance: SavedInstance
43 public let thread: ChatThread
44 let appId: String
45 let sayType: String
46 let stepType: String
47 private let api: LChatAPI?
48 private let key: GroupKey?
49 private var socket: ChatSocket?
50 private var engine: FlowEngine?
51 private var env = FlowEnv()
52 private var said: [String: Bool] = [:]
53 private var steps: [String: (done: Bool, value: String)] = [:]
54 private var foldedIds = Set<Int64>()
55 private var answerHandler: ((FlowAnswer) -> Void)?
56 /// Native navigation for the finish chips ("Say hi to {inviter}").
57 public var openThread: ((String) -> Void)?
58 /// The finish chip's Admin hop: open (creating if needed) the Admin app
59 /// thread β€” the host owns thread creation and navigation.
60 public var openAdmin: (() -> Void)?
61 /// The welcome finish's "Browse open groups" chip β€” the host presents the
62 /// Discover surface (the twin of the web's showDiscover).
63 public var openDiscover: (() -> Void)?
64 /// The "Fill in a form instead" chip β€” the host presents the quick-form
65 /// sheet (ElevenQuickFormView), which submits back through applyQuickForm.
66 public var openQuickForm: (() -> Void)?
67 /// The key/access step's "Sign in" chip: the host opens the app's own
68 /// Eleven sign-in (Mac: Settings; iOS: the doors sheet). Its presence is
69 /// what makes bridge.canEnrol true β€” a host that can't offer a sign-in
70 /// leaves it nil and the flows record the honest "this device holds the
71 /// key" instead of pitching something that goes nowhere.
72 public var signInToEleven: (() -> Void)?
73
74 /// The welcome flow's name step just published a label β€” the host must
75 /// also persist the name into the saved instance (outgoing payloads stamp
76 /// SavedInstance.screenName; without this a flow rename fixes the roster
77 /// but new messages keep carrying the old β€” or empty β€” name snapshot).
78 public var onNameSet: ((String) -> Void)?
79
80 public init?(instance: SavedInstance, thread: ChatThread, myUserId: String) {
81 guard let appId = thread.appId, appId == "setup" || appId == "welcome",
82 !thread.threadKeyB64.isEmpty else { return nil }
83 self.instance = instance
84 self.thread = thread
85 self.appId = appId
86 self.sayType = appId + ".say"
87 self.stepType = appId + ".step"
88 self.api = instance.baseURL.map { LChatAPI(baseURL: $0, token: instance.deviceToken) }
89 self.key = try? GroupKey(base64url: thread.threadKeyB64)
90 self.myUserId = myUserId
91 }
92
93 let myUserId: String
94
95 public func start(roster: [RosterEntry], threads: [ChatThread]) {
96 guard engine == nil else { return }
97 Task { await load(roster: roster, threads: threads) }
98 }
99
100 public func stop() {
101 socket?.stop()
102 socket = nil
103 }
104
105 // MARK: history fold + engine start
106
107 private func load(roster: [RosterEntry], threads: [ChatThread]) async {
108 guard let api, let key else { return }
109 // Kept for engine re-entries (the quick form's fast-fill restarts the
110 // run the way a fresh open would β€” see restartFlow).
111 envRoster = roster
112 envThreads = threads
113 let messages = await api.messages(thread: thread.id, after: 0)
114 for m in messages { fold(id: m.id, blob: m.blob, key: key) }
115 startSocket()
116 ready = true
117 await startEngine()
118 }
119
120 private func startEngine() async {
121 guard let api, let flow = AppFlows.flow(appId) else { return }
122 let env = FlowEnv()
123 env.flags["native"] = .bool(true)
124 env.flags["suggested"] = .string("")
125 // The roster carries no role natively β€” the greeting falls back to
126 // "run by one of its members" (setup's creator IS the admin anyway).
127 env.flags["adminName"] = .string("")
128 // The inviter: the other member of the joiner's first person-DM.
129 var inviter = ""
130 var pendingThreadId: String?
131 if let dm = envThreads.first(where: { $0.kind == "dm" && $0.appId == nil }) {
132 if let other = dm.members.first(where: { $0 != myUserId }),
133 let entry = envRoster.first(where: { $0.user_id == other }) {
134 inviter = entry.label
135 pendingThreadId = dm.id
136 }
137 }
138 env.flags["inviterName"] = .string(inviter)
139 env.flags["hasPendingThread"] = .bool(pendingThreadId != nil)
140 // Open groups (welcome only): existence, never names β€” a locked
141 // level's name stays sealed by design. Joined rows don't count:
142 // default-join groups already sit in the sidebar.
143 if appId == "welcome" {
144 let open = await api.openThreads()
145 env.flags["openGroups"] = .bool(open.contains { row in
146 !(row.joined ?? false) && ((row.open_tier ?? "").isEmpty || (row.eligible ?? false))
147 })
148 }
149 let name = await api.groupName()
150 env.space = ["name": .string(name), "nameSet": .bool(!name.isEmpty)]
151 env.live["bridge.myName"] = { [instance] in .string(instance.screenName) }
152 env.live["bridge.enrolled"] = { .bool(false) }
153 env.live["bridge.fromHome"] = { .bool(false) }
154 // Is this device actually signed in to Eleven? Read LIVE, per lookup:
155 // claiming a space pushes it (key and all) into the account's vault
156 // via HomeSync.pushSoon, which returns SILENTLY when there's no
157 // session β€” so a signed-out device used to be told its key was "saved
158 // with your Eleven account" when nothing had been pushed anywhere.
159 // The flows branch on this now; both halves are honest.
160 env.live["bridge.homeSignedIn"] = { .bool(HomeSessionStore.load() != nil) }
161 // Can this host actually offer a sign-in? It can when it wired
162 // signInToEleven β€” read live, because the shell assigns the callback
163 // after the model is built, and a chip that goes nowhere is worse
164 // than an honest line about where the key lives.
165 env.live["bridge.canEnrol"] = { [weak self] in .bool(self?.signInToEleven != nil) }
166 env.live["accessBackedUp"] = { .bool(HomeSessionStore.load() != nil) }
167 let hop = pendingThreadId
168 pendingHop = hop
169 self.env = env
170 let engine = FlowEngine(flow: flow, env: env, io: self)
171 self.engine = engine
172 engine.start()
173 }
174
175 private var pendingHop: String?
176 private var envRoster: [RosterEntry] = []
177 private var envThreads: [ChatThread] = []
178
179 // Folds dedupe on CONVERSATION state (said keys, recorded steps, pending
180 // own sends), not just message ids β€” our own optimistic rows race the
181 // socket echo of the same event, and the REST response's id can lose that
182 // race (the live-duplicates bug).
183 private func fold(id: Int64, blob: String, key: GroupKey) {
184 guard !foldedIds.contains(id), let data = try? key.decryptData(blob: blob),
185 let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
186 foldedIds.insert(id)
187 let type = obj["type"] as? String
188 if type == sayType {
189 let k = obj["key"] as? String ?? (obj["text"] as? String ?? "")
190 if said[k] == true { return } // already shown (our own echo)
191 said[k] = true
192 if let text = obj["text"] as? String, !text.isEmpty {
193 append(.eleven(text))
194 }
195 } else if type == stepType {
196 guard let step = obj["step"] as? String else { return }
197 if steps[step]?.done == true { return } // already shown
198 steps[step] = (true, obj["value"] as? String ?? "")
199 if let label = obj["label"] as? String, !label.isEmpty {
200 append(.action(label))
201 }
202 } else if type == nil || type == "chat" {
203 guard let text = obj["text"] as? String, !text.isEmpty else { return }
204 if let i = pendingMine.firstIndex(of: text) {
205 pendingMine.remove(at: i) // our own send's echo
206 return
207 }
208 append(.mine(text))
209 }
210 }
211
212 // Texts we've optimistically shown, awaiting their echo.
213 private var pendingMine: [String] = []
214
215 private func startSocket() {
216 guard let base = instance.baseURL else { return }
217 let socket = ChatSocket(baseURL: base, token: instance.deviceToken)
218 socket?.onFrame = { [weak self] frame in
219 guard let self, frame.thread_id == self.thread.id, frame.type == "msg",
220 frame.ephemeral != true, let id = frame.id, let blob = frame.blob,
221 let key = self.key else { return }
222 self.fold(id: id, blob: blob, key: key)
223 }
224 socket?.start()
225 self.socket = socket
226 }
227
228 // MARK: composer
229
230 /// The member typed into the conversation: persist it as a normal chat
231 /// message (the web transcript shows the real answers) and hand it to the
232 /// flow's waiter if one is armed.
233 public func send(_ text: String) async {
234 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
235 guard !trimmed.isEmpty else { return }
236 append(.mine(trimmed))
237 pendingMine.append(trimmed)
238 await postEvent(["text": trimmed])
239 if let handler = answerHandler {
240 answerHandler = nil
241 composerHint = nil
242 composerLabel = nil
243 handler(FlowAnswer(text: trimmed))
244 }
245 }
246
247 // Stamp the transport fields and POST the encrypted payload as a message
248 // (the same wire the web's emit rides; REST so a pre-socket line lands).
249 @discardableResult
250 private func postEvent(_ fields: [String: Any]) async -> Bool {
251 guard let key, let base = instance.baseURL else { return false }
252 var obj = fields
253 obj["name"] = instance.screenName
254 obj["ts"] = Int64(Date().timeIntervalSince1970 * 1000)
255 if !myUserId.isEmpty { obj["uid"] = myUserId }
256 guard let data = try? JSONSerialization.data(withJSONObject: obj),
257 let blob = try? key.encryptData(data) else { return false }
258 var comps = URLComponents(url: base.appending(path: "api/messages"), resolvingAgainstBaseURL: false)!
259 comps.queryItems = [URLQueryItem(name: "token", value: instance.deviceToken)]
260 guard let url = comps.url,
261 let body = try? JSONSerialization.data(withJSONObject: ["thread_id": thread.id, "blob": blob]) else { return false }
262 var req = URLRequest(url: url)
263 req.httpMethod = "POST"
264 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
265 req.httpBody = body
266 guard let (respData, resp) = try? await URLSession.shared.data(for: req),
267 (resp as? HTTPURLResponse)?.statusCode == 200 else { return false }
268 // Mark the echo folded so the socket doesn't duplicate the row.
269 struct Resp: Decodable { let id: Int64? }
270 if let r = try? JSONDecoder().decode(Resp.self, from: respData), let id = r.id {
271 foldedIds.insert(id)
272 }
273 return true
274 }
275
276 // MARK: the quick form ("Fill in a form instead")
277
278 /// Which quick form the sheet draws ("setup" | "welcome") + its prefills.
279 public var quickFormIsSetup: Bool { appId == "setup" }
280 /// The current space name, only when it was explicitly set (the flow's
281 /// "Keep" affordance becomes a prefill).
282 public var quickFormSpaceName: String {
283 (env.space["nameSet"]?.truthy == true) ? (env.space["name"]?.stringValue ?? "") : ""
284 }
285
286 /// Fast-fill: run the SAME verbs and emit the SAME step records the
287 /// conversational path would (labels verbatim from the flow documents),
288 /// then re-enter the flow so it resumes over the recorded steps β€” no taps
289 /// needed. Native flows skip the access/keys sign-in and the photo step,
290 /// so no records are needed for those here. Returns a user-facing error
291 /// message (the sheet stays open), or nil on success.
292 public func applyQuickForm(_ v: QuickFormValues) async -> String? {
293 // The typed answer's clean-before-cap rule (flow.js / FlowEngine twin).
294 let name = String(cleanNameAnswer(v.name).prefix(32))
295 guard !name.isEmpty else { return "Add your name to continue." }
296 _ = await verb("setMyName", args: [.string(name)])
297 await recordIfNew(step: "name", label: "noted: you're \(name)", value: name)
298 if appId == "setup" {
299 let space = v.spaceName.trimmingCharacters(in: .whitespacesAndNewlines)
300 let r = await verb("setGroupName", args: [.string(space)])
301 if FlowEngine.resultKey(r) == "invalid" {
302 return "That space name didn't stick β€” it needs to be 1–60 characters."
303 }
304 if FlowEngine.resultKey(r) != "ok" {
305 return "Something went wrong saving the space name β€” try again in a moment."
306 }
307 await recordIfNew(step: "group", label: "named this space β€œ\(space)”", value: space)
308 let email = v.email.trimmingCharacters(in: .whitespacesAndNewlines)
309 if email.isEmpty {
310 await recordIfNew(step: "email", label: "skipped the contact email", value: "skipped")
311 } else {
312 if FlowEngine.resultKey(await verb("setContactEmail", args: [.string(email)])) != "ok" {
313 return "That doesn't look like an email address β€” fix it, or leave it empty."
314 }
315 await recordIfNew(step: "email", label: "noted a contact email", value: email)
316 }
317 _ = await verb("setUserInvites", args: [.bool(v.membersInvite)])
318 await recordIfNew(step: "invites",
319 label: v.membersInvite ? "decided members can invite people"
320 : "decided only the admin invites people",
321 value: v.membersInvite ? "members" : "admin only")
322 // The extras trio mirrors the conversation's two paths: everything
323 // on is the one-tap record (defaults are already on server-side),
324 // anything off walks the granular records, writing each gate.
325 if v.ai && v.bots && v.apps {
326 await recordIfNew(step: "extras", label: "kept all the extras on", value: "all-on")
327 } else {
328 for (key, on, noun) in [("ai", v.ai, "AI"), ("bots", v.bots, "bots"), ("apps", v.apps, "apps")] {
329 _ = await verb("setFeature", args: [.string(key), .bool(on)])
330 await recordIfNew(step: "extras-\(key)",
331 label: on ? "enabled \(noun)" : "switched \(noun) off",
332 value: on ? "on" : "off")
333 }
334 }
335 }
336 restartFlow()
337 return nil
338 }
339
340 // The flow never re-records a done step (recordIfNew discipline) β€” a form
341 // submitted after some conversational answers mustn't duplicate their
342 // action lines; its verbs still ran, so the changed values apply.
343 private func recordIfNew(step: String, label: String, value: String) async {
344 if done(step) { return }
345 await record(step: step, label: label, value: value)
346 }
347
348 /// A clean re-entry: the live engine is parked mid-step (its armed ask and
349 /// settle belong to the step the form just answered), so rather than poke
350 /// its internals, drop it and start a fresh run β€” the exact path a fresh
351 /// open takes. The new run folds the fast-filled records (the model's
352 /// `steps`/`said` maps carry them) and walks the remaining steps to the
353 /// finish on its own β€” the "advance without user taps".
354 private func restartFlow() {
355 cancelAnswer()
356 engine = nil
357 Task { @MainActor in await startEngine() }
358 }
359}
360
361/// What the quick-form sheet collects. Native flows skip the access/keys
362/// sign-in and the photo step, so: welcome = name only; setup adds the space
363/// name, contact email, invites, and the extras trio.
364public struct QuickFormValues: Sendable {
365 public var name = ""
366 public var spaceName = ""
367 public var email = ""
368 public var membersInvite = true
369 public var ai = true
370 public var bots = true
371 public var apps = true
372 public init() {}
373}
374
375// MARK: - the flow io
376
377extension ElevenConversationModel: FlowIO {
378 public func say(_ text: String, chips: [FlowChip]?, extra: FlowSayExtra) async {
379 let sayKey = extra.key ?? text
380 // The typing beat: on, held a readable moment scaled to length, off
381 // once the bubble is in the transcript (eleven.js's pacing, simplified).
382 let alreadySaid = !extra.local && said[sayKey] == true
383 if !text.isEmpty && !alreadySaid {
384 typing = true
385 let ms = min(2000.0, 500.0 + Double(text.count) * 4)
386 try? await Task.sleep(nanoseconds: UInt64(ms * 1_000_000))
387 typing = false
388 }
389 if !text.isEmpty {
390 if extra.local {
391 append(.eleven(text), chips: chips)
392 } else if !alreadySaid {
393 said[sayKey] = true
394 append(.eleven(text), chips: chips)
395 await postEvent(["type": sayType, "key": sayKey, "text": text])
396 } else if let chips {
397 // Re-run over a said line: attach the chips to a fresh row's
398 // stead β€” the last transcript row carries them.
399 attachChips(chips)
400 }
401 } else if let chips {
402 attachChips(chips)
403 }
404 // Each fresh persisted line RESETS the hint unless it carries its own β€”
405 // a question's placeholder ("Type a contact email") must not outlive
406 // the step that asked it. Local lines (resume greetings) leave it be.
407 if extra.hint != nil || extra.attachHint != nil {
408 composerHint = extra.hint ?? extra.attachHint
409 } else if !extra.local {
410 composerHint = nil
411 }
412 // The inset field label follows the same lifecycle as the hint.
413 if extra.label != nil {
414 composerLabel = extra.label
415 } else if !extra.local {
416 composerLabel = nil
417 }
418 }
419
420 private func attachChips(_ chips: [FlowChip]) {
421 if var last = rows.last {
422 last.chips = chips
423 rows[rows.count - 1] = last
424 }
425 }
426
427 public func ask(_ handler: @escaping (FlowAnswer) -> Void) {
428 answerHandler = handler
429 }
430
431 public func cancelAnswer() {
432 answerHandler = nil
433 composerHint = nil
434 composerLabel = nil
435 }
436
437 public func record(step: String, label: String, value: String) async {
438 steps[step] = (true, value)
439 if !label.isEmpty { append(.action(label)) }
440 await postEvent(["type": stepType, "step": step, "label": label, "value": value])
441 }
442
443 public func done(_ step: String) -> Bool { steps[step]?.done ?? false }
444
445 public func delay(ms: Double, _ fn: @escaping () -> Void) {
446 Task { @MainActor in
447 try? await Task.sleep(nanoseconds: UInt64(ms * 1_000_000))
448 fn()
449 }
450 }
451
452 public func verb(_ name: String, args: [FlowJSON]) async -> FlowJSON {
453 guard let api else { return .bool(false) }
454 switch name {
455 case "setMyName":
456 let raw = args.first?.stringValue ?? ""
457 let capped = String(raw.prefix(32))
458 guard !capped.isEmpty else { return .bool(false) }
459 await publishLabel(capped)
460 onNameSet?(capped)
461 return .null
462 case "setGroupName":
463 let name = (args.first?.stringValue ?? "").trimmingCharacters(in: .whitespaces)
464 guard (1...60).contains(name.count) else { return .string("invalid") }
465 let ok = await api.setGroupSettings(name: name)
466 if ok { env.space["name"] = .string(name); env.space["nameSet"] = .bool(true) }
467 return .string(ok ? "ok" : "error")
468 case "setContactEmail":
469 let email = (args.first?.stringValue ?? "").trimmingCharacters(in: .whitespaces)
470 guard email.contains("@"), email.contains(".") else { return .string("invalid") }
471 return .string(await api.setGroupSettings(adminEmail: email) ? "ok" : "error")
472 case "setUserInvites":
473 let on = args.first.map { $0.truthy } ?? true
474 _ = await api.setAdminSettings(["user_invites": on])
475 return .null
476 case "setFeature":
477 // The extras steps' gates ("ai" | "bots" | "apps" β†’ ai_enabled etc.).
478 // Whitelisted so flow data can't name an arbitrary settings key.
479 guard let key = args.first?.stringValue, ["ai", "bots", "apps"].contains(key) else { return .null }
480 let enabled = args.count > 1 ? args[1].truthy : true
481 _ = await api.setAdminSettings(["\(key)_enabled": enabled])
482 return .null
483 case "markSetupComplete":
484 _ = await api.setAdminSettings(["setup_complete": true])
485 return .null
486 case "deferBackupReminders":
487 return .null // native access is the sign-in; nothing to nag about
488 case "openPendingThread":
489 if let hop = pendingHop { openThread?(hop) }
490 return .null
491 case "savePasskey":
492 // Same verb name as the web (one vocabulary in the flow data), a
493 // different act: no ceremony here β€” the host opens the app's own
494 // Eleven sign-in, and the step's settle picks the session up when
495 // it lands.
496 signInToEleven?()
497 return .null
498 case "openAdmin":
499 openAdmin?()
500 return .null
501 case "openDiscover":
502 openDiscover?()
503 return .null
504 case "openQuickForm":
505 openQuickForm?()
506 return .null
507 default:
508 return .null // unknown/web-only verbs are quiet no-ops natively
509 }
510 }
511
512 // The member's screen name, encrypted under the instance key (the web's
513 // publishMyLabel): the server holds only the blob.
514 private func publishLabel(_ name: String) async {
515 guard let api else { return }
516 // The instance key comes wrapped to us on the roster path; SpaceClaim
517 // minted it for creators. Fetch + unwrap via the instance keypair.
518 guard let wrapped = await api.instanceKeyWrapped() else {
519 // A keyless joiner mid-welcome (#1376): the grant hasn't landed
520 // yet. Park the name β€” ThreadStore's keyless loop is already
521 // nudging holders, and the refresh that unwraps the granted key
522 // publishes the parked name. onNameSet persisted it locally, so
523 // outgoing messages carry it in the meantime.
524 PendingLabel.park(name, instanceID: instance.id)
525 await api.nudgeInstanceKey()
526 return
527 }
528 guard let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: instance.userPriv),
529 let ik = try? GroupKey(rawKey: raw),
530 let blob = try? ik.encryptJSON(name) else { return }
531 if await api.postLabel(labelBlob: blob) {
532 // A name parked in an earlier keyless moment is superseded.
533 PendingLabel.clear(instanceID: instance.id)
534 }
535 }
536}
537
538// MARK: - cache: one live conversation per thread (leaving and returning
539// resumes the same run; the flow also resumes from recorded state cold).
540
541@MainActor
542public final class ElevenConversationCache {
543 public static let shared = ElevenConversationCache()
544 private var models: [String: ElevenConversationModel] = [:]
545
546 public func model(for instance: SavedInstance, thread: ChatThread,
547 myUserId: String) -> ElevenConversationModel? {
548 let key = instance.id + "|" + thread.id
549 if let existing = models[key] { return existing }
550 guard let model = ElevenConversationModel(instance: instance, thread: thread,
551 myUserId: myUserId) else { return nil }
552 models[key] = model
553 return model
554 }
555}
556