eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3// 🤖 Cards — the native third of the card contract (web/static/cards.js ·
4// internal/bot/card.go). A card is a small, bounded declarative document a bot
5// attaches to a chat message ({ …payload, card }); this file parses and
6// normalizes it, CardView.swift renders it in SwiftUI. The contract is
7// test/event-vectors/cards.json (bundled as Fixtures/cards-vector.json, byte-
8// checked against the canonical copy by test/cards.test.mjs): anything
9// unknown or malformed FAILS CLOSED — normalize returns nil and the client
10// renders the message's plain text instead, which is why `text` is a
11// mandatory fallback on any card-carrying message.
12
13/// A decoded JSON tree — MessagePayload.card arrives as arbitrary JSON that
14/// must survive Codable round-trips without a fixed schema (the whole point
15/// of fail-closed evolution is that we can't know future shapes).
16public enum JSONValue: Codable, Equatable, Sendable {
17 case string(String)
18 case number(Double)
19 case bool(Bool)
20 case null
21 case array([JSONValue])
22 case object([String: JSONValue])
23
24 public init(from decoder: Decoder) throws {
25 let c = try decoder.singleValueContainer()
26 if c.decodeNil() {
27 self = .null
28 } else if let b = try? c.decode(Bool.self) {
29 self = .bool(b)
30 } else if let n = try? c.decode(Double.self) {
31 self = .number(n)
32 } else if let s = try? c.decode(String.self) {
33 self = .string(s)
34 } else if let a = try? c.decode([JSONValue].self) {
35 self = .array(a)
36 } else if let o = try? c.decode([String: JSONValue].self) {
37 self = .object(o)
38 } else {
39 throw DecodingError.dataCorruptedError(in: c, debugDescription: "unsupported JSON value")
40 }
41 }
42
43 public func encode(to encoder: Encoder) throws {
44 var c = encoder.singleValueContainer()
45 switch self {
46 case .string(let s): try c.encode(s)
47 case .number(let n): try c.encode(n)
48 case .bool(let b): try c.encode(b)
49 case .null: try c.encodeNil()
50 case .array(let a): try c.encode(a)
51 case .object(let o): try c.encode(o)
52 }
53 }
54
55 var stringValue: String? { if case .string(let s) = self { return s }; return nil }
56 var arrayValue: [JSONValue]? { if case .array(let a) = self { return a }; return nil }
57 var objectValue: [String: JSONValue]? { if case .object(let o) = self { return o }; return nil }
58}
59
60public struct CardChip: Equatable, Sendable {
61 public let label: String
62 public let url: String
63}
64
65public struct CardKVRow: Equatable, Sendable {
66 public let key: String
67 public let value: String
68}
69
70public indirect enum CardBlock: Equatable, Sendable {
71 case heading(text: String, sub: String?)
72 case text(String)
73 case item(text: String, source: String?, url: String?)
74 case list([String])
75 case kv([CardKVRow])
76 case divider
77 case section(title: String?, blocks: [CardBlock])
78 case chips([CardChip])
79}
80
81public struct Card: Equatable, Sendable {
82 public static let schema = 1
83
84 // Bounds — part of the contract; the web and Go validators enforce the
85 // same numbers. (Length caps count UTF-16 code units, matching JS
86 // string.length; the vectors don't pin multibyte boundary cases.)
87 static let maxBlocks = 32
88 static let maxListItems = 32
89 static let maxKVRows = 16
90 static let maxChips = 4
91 static let maxText = 4000
92 static let maxLabel = 200
93 static let maxURL = 2048
94
95 public let blocks: [CardBlock]
96
97 /// Validates a payload's card into its canonical form, or nil — the
98 /// fail-closed signal to show the plain-text fallback.
99 public static func normalize(_ v: JSONValue?) -> Card? {
100 guard let obj = v?.objectValue else { return nil }
101 guard case .number(let s)? = obj["schema"], s == Double(schema) else { return nil }
102 guard let blocks = normalizeBlocks(obj["blocks"], allowSection: true), !blocks.isEmpty else { return nil }
103 return Card(blocks: blocks)
104 }
105
106 private static func str(_ v: JSONValue?, _ max: Int) -> String? {
107 guard let s = v?.stringValue, !s.isEmpty, s.utf16.count <= max else { return nil }
108 return s
109 }
110
111 /// Distinguishes "absent" (ok → nil) from "present but wrong" (fail →
112 /// .some(nil) is unrepresentable, so this returns (value, ok)).
113 private static func optStr(_ obj: [String: JSONValue], _ key: String, _ max: Int) -> (String?, Bool) {
114 guard let v = obj[key], v != .null else { return (nil, true) }
115 guard let s = v.stringValue, s.utf16.count <= max else { return (nil, false) }
116 return (s.isEmpty ? nil : s, true)
117 }
118
119 private static func httpsURL(_ v: JSONValue?) -> String? {
120 guard let s = str(v, maxURL) else { return nil }
121 let lower = s.lowercased()
122 guard lower.hasPrefix("https://") || lower.hasPrefix("http://") else { return nil }
123 return s
124 }
125
126 private static func normalizeBlocks(_ v: JSONValue?, allowSection: Bool) -> [CardBlock]? {
127 guard let list = v?.arrayValue, !list.isEmpty, list.count <= maxBlocks else { return nil }
128 var out: [CardBlock] = []
129 for b in list {
130 guard let n = normalizeBlock(b, allowSection: allowSection) else { return nil }
131 out.append(n)
132 }
133 return out
134 }
135
136 private static func normalizeBlock(_ v: JSONValue, allowSection: Bool) -> CardBlock? {
137 guard let m = v.objectValue, let kind = m["b"]?.stringValue else { return nil }
138 switch kind {
139 case "heading":
140 guard let text = str(m["text"], maxLabel) else { return nil }
141 let (sub, ok) = optStr(m, "sub", maxLabel)
142 guard ok else { return nil }
143 return .heading(text: text, sub: sub)
144 case "text":
145 guard let text = str(m["text"], maxText) else { return nil }
146 return .text(text)
147 case "item":
148 guard let text = str(m["text"], maxText) else { return nil }
149 let (source, ok) = optStr(m, "source", maxLabel)
150 guard ok else { return nil }
151 var url: String?
152 if let u = m["url"], u != .null {
153 guard let valid = httpsURL(u) else { return nil }
154 url = valid
155 }
156 return .item(text: text, source: source, url: url)
157 case "list":
158 guard let items = m["items"]?.arrayValue, !items.isEmpty, items.count <= maxListItems else { return nil }
159 var out: [String] = []
160 for it in items {
161 guard let s = str(it, maxText) else { return nil }
162 out.append(s)
163 }
164 return .list(out)
165 case "kv":
166 guard let rows = m["rows"]?.arrayValue, !rows.isEmpty, rows.count <= maxKVRows else { return nil }
167 var out: [CardKVRow] = []
168 for r in rows {
169 guard let pair = r.arrayValue, pair.count == 2,
170 let k = str(pair[0], maxLabel), let val = str(pair[1], maxText) else { return nil }
171 out.append(CardKVRow(key: k, value: val))
172 }
173 return .kv(out)
174 case "divider":
175 return .divider
176 case "section":
177 guard allowSection else { return nil } // one level of nesting, no more
178 let (title, ok) = optStr(m, "title", maxLabel)
179 guard ok, let blocks = normalizeBlocks(m["blocks"], allowSection: false) else { return nil }
180 return .section(title: title, blocks: blocks)
181 case "chips":
182 guard let chips = m["chips"]?.arrayValue, !chips.isEmpty, chips.count <= maxChips else { return nil }
183 var out: [CardChip] = []
184 for c in chips {
185 guard let cm = c.objectValue,
186 let label = str(cm["label"], maxLabel),
187 let url = httpsURL(cm["url"]) else { return nil } // v1 chips are links only
188 out.append(CardChip(label: label, url: url))
189 }
190 return .chips(out)
191 default:
192 return nil // unknown block type → the whole card fails closed
193 }
194 }
195
196 /// The canonical JSON form — what the vectors' `want` holds. Used by the
197 /// contract tests to compare across implementations.
198 public func canonical() -> JSONValue {
199 .object(["schema": .number(Double(Self.schema)),
200 "blocks": .array(blocks.map { $0.canonical() })])
201 }
202}
203
204extension CardBlock {
205 func canonical() -> JSONValue {
206 switch self {
207 case .heading(let text, let sub):
208 var o: [String: JSONValue] = ["b": .string("heading"), "text": .string(text)]
209 if let sub { o["sub"] = .string(sub) }
210 return .object(o)
211 case .text(let text):
212 return .object(["b": .string("text"), "text": .string(text)])
213 case .item(let text, let source, let url):
214 var o: [String: JSONValue] = ["b": .string("item"), "text": .string(text)]
215 if let source { o["source"] = .string(source) }
216 if let url { o["url"] = .string(url) }
217 return .object(o)
218 case .list(let items):
219 return .object(["b": .string("list"), "items": .array(items.map(JSONValue.string))])
220 case .kv(let rows):
221 return .object(["b": .string("kv"),
222 "rows": .array(rows.map { .array([.string($0.key), .string($0.value)]) })])
223 case .divider:
224 return .object(["b": .string("divider")])
225 case .section(let title, let blocks):
226 var o: [String: JSONValue] = ["b": .string("section"),
227 "blocks": .array(blocks.map { $0.canonical() })]
228 if let title { o["title"] = .string(title) }
229 return .object(o)
230 case .chips(let chips):
231 return .object(["b": .string("chips"),
232 "chips": .array(chips.map { .object(["label": .string($0.label), "url": .string($0.url)]) })])
233 }
234 }
235}
236