| 1 | import Foundation |
| 2 | |
| 3 | // π€ Native slash-command routing, mirroring the web (apps.js runCommand + |
| 4 | // apps/chat.js). The native feed can't run the mini-app JS or render the bot's |
| 5 | // action chips, so the client-answerable commands are handled DIRECTLY here β |
| 6 | // `/me` broadcasts an action line, `/theme`/`/themes` drive the native theme, |
| 7 | // and anything else is sent to the thread as-is so a bot answers it (the same |
| 8 | // fallthrough the web uses for bot-owned commands). |
| 9 | public enum SlashCommand: Equatable { |
| 10 | case me(String) // broadcast "<name> <action>" as an action line |
| 11 | case setTheme(String) // apply a native theme id |
| 12 | case openThemes // open the theme picker (unknown/blank /theme) |
| 13 | case help // list available commands (private notice) |
| 14 | case integrity // tamper-evidence status (private notice, #573) |
| 15 | case sendAsMessage // not a client command β send the text as-is |
| 16 | |
| 17 | /// Classify composer text. `meName` is the sender's screen name (for /me). |
| 18 | public static func parse(_ text: String, meName: String) -> SlashCommand { |
| 19 | let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| 20 | guard trimmed.first == "/" else { return .sendAsMessage } |
| 21 | |
| 22 | // /me waves β an action line (mirrors parseMeCommand). A bare /me sends |
| 23 | // nothing meaningful; treat it as a normal (empty-ish) message. |
| 24 | if let action = parseMe(trimmed) { |
| 25 | return action.isEmpty ? .sendAsMessage : .me("\(meName) \(action)") |
| 26 | } |
| 27 | |
| 28 | // First token β command name. |
| 29 | let name = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init) ?? trimmed |
| 30 | let args = trimmed.dropFirst(name.count).trimmingCharacters(in: .whitespaces) |
| 31 | |
| 32 | switch name.lowercased() { |
| 33 | case "/theme": |
| 34 | let id = themeID(for: args) |
| 35 | return id.map(SlashCommand.setTheme) ?? .openThemes |
| 36 | case "/themes": |
| 37 | return .openThemes |
| 38 | case "/help": |
| 39 | return .help |
| 40 | case "/integrity": |
| 41 | return .integrity |
| 42 | default: |
| 43 | // Unknown or bot-owned (/dark, /light, /profile, β¦) β send it; a bot |
| 44 | // answers where present, exactly like the web's bot-command path. |
| 45 | return .sendAsMessage |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | /// "/me waves" β "waves" (or "" for a bare /me); nil when it isn't a /me line. |
| 50 | static func parseMe(_ text: String) -> String? { |
| 51 | let t = text.trimmingCharacters(in: .whitespaces) |
| 52 | guard t.lowercased() == "/me" || t.lowercased().hasPrefix("/me ") else { return nil } |
| 53 | return String(t.dropFirst(3)).trimmingCharacters(in: .whitespaces) |
| 54 | } |
| 55 | |
| 56 | /// Map a /theme argument to a native theme id (ChatTheme.presets), tolerating |
| 57 | /// "lit room" / "Lit Room" β "litroom". nil = unknown β open the picker. |
| 58 | static func themeID(for arg: String) -> String? { |
| 59 | let a = arg.lowercased().replacingOccurrences(of: " ", with: "") |
| 60 | guard !a.isEmpty else { return nil } |
| 61 | return ChatTheme.presets.first { $0.id == a }?.id |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // π€ The historical command-host whitelist (/emoji, /gif) β now only the |
| 66 | // FALLBACK the router below uses before a thread's commandItems() rows have |
| 67 | // landed (a booting host, or an instance still serving a pre-bridge deploy). |
| 68 | // With live rows, every framework-owned command forwards (docs/native-apps.md |
| 69 | // phase 3) β the needKey/needAppThread bridge lifted the "can't route to |
| 70 | // another thread" constraint that used to cap this list at two. |
| 71 | public enum WebPanelCommands { |
| 72 | public static let catalog: [(name: String, desc: String)] = [ |
| 73 | ("/emoji", "browse emoji β tap one to add it to your message"), |
| 74 | ("/gif", "find a GIF β tap one to send it here"), |
| 75 | ] |
| 76 | |
| 77 | /// Whether composer text names a command the command host answers β |
| 78 | /// matched on the first token, case-insensitively, like SlashCommand.parse. |
| 79 | public static func owns(_ text: String) -> Bool { |
| 80 | let t = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| 81 | guard t.first == "/" else { return false } |
| 82 | let name = t.split(separator: " ", maxSplits: 1).first.map(String.init) ?? t |
| 83 | return catalog.contains { $0.name == name.lowercased() } |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // π€ The forwarding decision for a typed slash command that ISN'T the native |
| 88 | // floor (/me, /theme, /themes, /integrity β SlashCommand.parse answers those) |
| 89 | // and isn't the current app-thread's own default-command verb (routed |
| 90 | // natively by AppDefaultCommand): consult the thread's commandItems() rows. |
| 91 | // Bot-answered rows ride to the conversation as ordinary messages (the bot |
| 92 | // replies there, matching the web); framework rows β and unknown commands, |
| 93 | // which the web engine answers with an honest "Unknown command β try /help" |
| 94 | // private notice instead of posting the literal β forward to the command |
| 95 | // host. Before rows land, the historical /emoji//gif whitelist stands. |
| 96 | public enum CommandRouting { |
| 97 | public enum Decision: Equatable { |
| 98 | case host // run through the command host (the web app framework) |
| 99 | case message // send as a chat message (bot-answered, or pre-rows floor) |
| 100 | } |
| 101 | |
| 102 | public static func route(_ text: String, rows: [CommandRow]) -> Decision { |
| 103 | let t = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| 104 | guard t.first == "/", t.count > 1 else { return .message } |
| 105 | let name = (t.split(separator: " ", maxSplits: 1).first.map(String.init) ?? t).lowercased() |
| 106 | if rows.isEmpty { |
| 107 | return WebPanelCommands.owns(t) ? .host : .message |
| 108 | } |
| 109 | if let row = rows.first(where: { $0.name.lowercased() == name }) { |
| 110 | return (row.bot ?? false) ? .message : .host |
| 111 | } |
| 112 | return .host |
| 113 | } |
| 114 | } |
| 115 | |