eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import 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).
9public 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.
71public 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.
96public 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