import Foundation // 🤖 Native slash-command routing, mirroring the web (apps.js runCommand + // apps/chat.js). The native feed can't run the mini-app JS or render the bot's // action chips, so the client-answerable commands are handled DIRECTLY here — // `/me` broadcasts an action line, `/theme`/`/themes` drive the native theme, // and anything else is sent to the thread as-is so a bot answers it (the same // fallthrough the web uses for bot-owned commands). public enum SlashCommand: Equatable { case me(String) // broadcast " " as an action line case setTheme(String) // apply a native theme id case openThemes // open the theme picker (unknown/blank /theme) case help // list available commands (private notice) case integrity // tamper-evidence status (private notice, #573) case sendAsMessage // not a client command — send the text as-is /// Classify composer text. `meName` is the sender's screen name (for /me). public static func parse(_ text: String, meName: String) -> SlashCommand { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.first == "/" else { return .sendAsMessage } // /me waves → an action line (mirrors parseMeCommand). A bare /me sends // nothing meaningful; treat it as a normal (empty-ish) message. if let action = parseMe(trimmed) { return action.isEmpty ? .sendAsMessage : .me("\(meName) \(action)") } // First token → command name. let name = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init) ?? trimmed let args = trimmed.dropFirst(name.count).trimmingCharacters(in: .whitespaces) switch name.lowercased() { case "/theme": let id = themeID(for: args) return id.map(SlashCommand.setTheme) ?? .openThemes case "/themes": return .openThemes case "/help": return .help case "/integrity": return .integrity default: // Unknown or bot-owned (/dark, /light, /profile, …) → send it; a bot // answers where present, exactly like the web's bot-command path. return .sendAsMessage } } /// "/me waves" → "waves" (or "" for a bare /me); nil when it isn't a /me line. static func parseMe(_ text: String) -> String? { let t = text.trimmingCharacters(in: .whitespaces) guard t.lowercased() == "/me" || t.lowercased().hasPrefix("/me ") else { return nil } return String(t.dropFirst(3)).trimmingCharacters(in: .whitespaces) } /// Map a /theme argument to a native theme id (ChatTheme.presets), tolerating /// "lit room" / "Lit Room" → "litroom". nil = unknown → open the picker. static func themeID(for arg: String) -> String? { let a = arg.lowercased().replacingOccurrences(of: " ", with: "") guard !a.isEmpty else { return nil } return ChatTheme.presets.first { $0.id == a }?.id } } // 🤖 The historical command-host whitelist (/emoji, /gif) — now only the // FALLBACK the router below uses before a thread's commandItems() rows have // landed (a booting host, or an instance still serving a pre-bridge deploy). // With live rows, every framework-owned command forwards (docs/native-apps.md // phase 3) — the needKey/needAppThread bridge lifted the "can't route to // another thread" constraint that used to cap this list at two. public enum WebPanelCommands { public static let catalog: [(name: String, desc: String)] = [ ("/emoji", "browse emoji — tap one to add it to your message"), ("/gif", "find a GIF — tap one to send it here"), ] /// Whether composer text names a command the command host answers — /// matched on the first token, case-insensitively, like SlashCommand.parse. public static func owns(_ text: String) -> Bool { let t = text.trimmingCharacters(in: .whitespacesAndNewlines) guard t.first == "/" else { return false } let name = t.split(separator: " ", maxSplits: 1).first.map(String.init) ?? t return catalog.contains { $0.name == name.lowercased() } } } // 🤖 The forwarding decision for a typed slash command that ISN'T the native // floor (/me, /theme, /themes, /integrity — SlashCommand.parse answers those) // and isn't the current app-thread's own default-command verb (routed // natively by AppDefaultCommand): consult the thread's commandItems() rows. // Bot-answered rows ride to the conversation as ordinary messages (the bot // replies there, matching the web); framework rows — and unknown commands, // which the web engine answers with an honest "Unknown command — try /help" // private notice instead of posting the literal — forward to the command // host. Before rows land, the historical /emoji//gif whitelist stands. public enum CommandRouting { public enum Decision: Equatable { case host // run through the command host (the web app framework) case message // send as a chat message (bot-answered, or pre-rows floor) } public static func route(_ text: String, rows: [CommandRow]) -> Decision { let t = text.trimmingCharacters(in: .whitespacesAndNewlines) guard t.first == "/", t.count > 1 else { return .message } let name = (t.split(separator: " ", maxSplits: 1).first.map(String.init) ?? t).lowercased() if rows.isEmpty { return WebPanelCommands.owns(t) ? .host : .message } if let row = rows.first(where: { $0.name.lowercased() == name }) { return (row.bot ?? false) ? .message : .host } return .host } }