eleven / messenger Public

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

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