| 1 | import 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). |
| 16 | public 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 | |
| 60 | public struct CardChip: Equatable, Sendable { |
| 61 | public let label: String |
| 62 | public let url: String |
| 63 | } |
| 64 | |
| 65 | public struct CardKVRow: Equatable, Sendable { |
| 66 | public let key: String |
| 67 | public let value: String |
| 68 | } |
| 69 | |
| 70 | public 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 | |
| 81 | public 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 | |
| 204 | extension 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 | |