Skip to main content

vector_core/
bot_interface.rs

1//! Bot Interface — manifests + slash commands (Phase 1 of the bot-UI layer).
2//!
3//! Transport-agnostic by design: everything here is CONTENT-level (structured
4//! tags on ordinary chat rumors, plus one plain replaceable discovery event),
5//! so the same commands work in NIP-17 DMs and Concord v1/v2 channels — the
6//! envelope is whatever the conversation already uses.
7//!
8//! Two pieces:
9//!
10//! 1. **Manifest** ([`BotManifest`], kind [`KIND_BOT_MANIFEST`]): a replaceable
11//!    event signed by the bot's key describing every command with typed args.
12//!    Clients fetch it by pubkey to render a `/` picker with argument hints and
13//!    validate input before anything hits the wire.
14//! 2. **Invocation** ([`parse_command_text`]): a command is a NORMAL chat
15//!    message whose `content` IS the invocation ("/price btc") — no extra
16//!    structure on the wire. The manifest's ordering rules (required args
17//!    first, a greedy String only in trailing position) make the text
18//!    deterministically parseable, so the bot recovers exact typed arguments
19//!    from content alone, and EVERY existing client is already a fully
20//!    capable command sender.
21//!
22//! The kind number is provisional pending upstream registry coordination.
23
24use std::collections::HashMap;
25
26use nostr_sdk::prelude::{Event, EventBuilder, Keys, Kind, Tag};
27use serde::{Deserialize, Serialize};
28
29/// Replaceable bot-interface manifest (outside any wrap): one authoritative
30/// command catalog per bot pubkey, the same shape as a profile or relay list.
31pub const KIND_BOT_MANIFEST: u16 = 10304;
32
33/// Optional recipient tag a picker client attaches to an invocation:
34/// `["bot", <bot pubkey hex>]`. Addressing is the ONE piece of a command not
35/// derivable from content — two bots can share a command name — so it rides a
36/// tag while the invocation stays plain text. Semantics: tagged → only the
37/// named bot(s) execute (others skip even on a manifest match); untagged →
38/// broadcast, any matching bot may answer (the legacy-client path — bots never
39/// REQUIRE the tag). Deliberately NOT `p`: chat rumors already carry `p` for
40/// DM recipients and reply parents, and a skip-unless-me rule keyed on `p`
41/// would silently swallow a command sent as a reply to a human. The tag is
42/// routing, not authority — bots authorize by SENDER, never by tag.
43pub const TAG_BOT: &str = "bot";
44
45/// Recipient tags honored per message (routing metadata must stay cheap).
46pub const MAX_BOT_TAGS: usize = 8;
47
48/// Build the recipient tag a picker attaches: `["bot", <hex>]`.
49pub fn bot_tag(bot: &nostr_sdk::prelude::PublicKey) -> Tag {
50    Tag::custom(nostr_sdk::prelude::TagKind::Custom(TAG_BOT.into()), [bot.to_hex()])
51}
52
53/// Extract the addressed bots from a rumor's tags as npubs (deduped, capped,
54/// invalid values skipped). Empty = untagged/broadcast.
55pub fn addressed_bots<'a, I: IntoIterator<Item = &'a Tag>>(tags: I) -> Vec<String> {
56    use nostr_sdk::prelude::ToBech32;
57    let mut out: Vec<String> = Vec::new();
58    for t in tags {
59        let s = t.as_slice();
60        if s.first().map(|k| k.as_str()) != Some(TAG_BOT) {
61            continue;
62        }
63        let Some(v) = s.get(1) else { continue };
64        let Ok(pk) = nostr_sdk::prelude::PublicKey::from_hex(v) else { continue };
65        let Ok(npub) = pk.to_bech32() else { continue };
66        if !out.contains(&npub) {
67            out.push(npub);
68        }
69        if out.len() >= MAX_BOT_TAGS {
70            break;
71        }
72    }
73    out
74}
75
76/// Bounds (validated on BOTH build and parse — a foreign manifest is untrusted
77/// input and must never cost unbounded memory or render work).
78pub const MAX_COMMANDS: usize = 64;
79pub const MAX_ARGS: usize = 8;
80pub const MAX_CHOICES: usize = 32;
81pub const MAX_NAME_LEN: usize = 32;
82pub const MAX_DESCRIPTION_LEN: usize = 200;
83pub const MAX_MANIFEST_BYTES: usize = 32_768;
84/// A single argument value on the wire (tag or text) is clamped before typing.
85pub const MAX_ARG_VALUE_LEN: usize = 1_024;
86
87// ── Manifest ─────────────────────────────────────────────────────────────────
88
89/// The typed shape of one command argument.
90#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
91#[serde(rename_all = "lowercase")]
92pub enum ArgType {
93    /// Free text. As the LAST argument it greedily swallows the remainder of a
94    /// plain-text invocation ("/say hello there" → one value "hello there").
95    String,
96    /// Signed integer (i64).
97    Int,
98    /// Float (f64).
99    Number,
100    /// "true"/"false" (also accepts "yes"/"no"/"1"/"0" from text).
101    Bool,
102    /// A user reference — an `npub1…` string on the wire.
103    User,
104    /// One of a fixed set of strings (renders as a picker).
105    Choice,
106}
107
108/// One declared argument of a command.
109#[derive(Serialize, Deserialize, Clone, Debug)]
110pub struct ArgSpec {
111    pub name: String,
112    #[serde(rename = "type")]
113    pub arg_type: ArgType,
114    #[serde(default)]
115    pub description: String,
116    #[serde(default)]
117    pub required: bool,
118    /// Populated only for [`ArgType::Choice`].
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub choices: Vec<String>,
121}
122
123/// One command a bot answers.
124#[derive(Serialize, Deserialize, Clone, Debug)]
125pub struct CommandSpec {
126    pub name: String,
127    pub description: String,
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub args: Vec<ArgSpec>,
130}
131
132/// The bot's full published interface. Unknown fields are ignored on read
133/// (forward compatibility); `v` gates breaking schema changes.
134#[derive(Serialize, Deserialize, Clone, Debug)]
135pub struct BotManifest {
136    pub v: u32,
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub commands: Vec<CommandSpec>,
139}
140
141/// Command/arg names are lowercase slugs so every client renders and matches
142/// them identically (the cross-client contract).
143fn valid_name(s: &str) -> bool {
144    !s.is_empty()
145        && s.len() <= MAX_NAME_LEN
146        && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
147}
148
149impl BotManifest {
150    /// Fail-closed structural validation, applied to our own manifests before
151    /// publish AND to fetched foreign ones before use.
152    pub fn validate(&self) -> Result<(), String> {
153        if self.v != 1 {
154            return Err(format!("unsupported manifest version {}", self.v));
155        }
156        if self.commands.len() > MAX_COMMANDS {
157            return Err(format!("too many commands ({} > {MAX_COMMANDS})", self.commands.len()));
158        }
159        let mut seen = std::collections::HashSet::new();
160        for c in &self.commands {
161            if !valid_name(&c.name) {
162                return Err(format!("bad command name {:?}", c.name));
163            }
164            if !seen.insert(c.name.as_str()) {
165                return Err(format!("duplicate command {:?}", c.name));
166            }
167            if c.description.len() > MAX_DESCRIPTION_LEN {
168                return Err(format!("description too long on /{}", c.name));
169            }
170            if c.args.len() > MAX_ARGS {
171                return Err(format!("too many args on /{}", c.name));
172            }
173            let mut arg_seen = std::collections::HashSet::new();
174            let mut optional_seen = false;
175            for a in &c.args {
176                if !valid_name(&a.name) {
177                    return Err(format!("bad arg name {:?} on /{}", a.name, c.name));
178                }
179                if !arg_seen.insert(a.name.as_str()) {
180                    return Err(format!("duplicate arg {:?} on /{}", a.name, c.name));
181                }
182                if a.description.len() > MAX_DESCRIPTION_LEN {
183                    return Err(format!("arg description too long on /{}", c.name));
184                }
185                // Positional text fallback needs required args to come first —
186                // an optional hole would make "/cmd a b" ambiguous.
187                if a.required && optional_seen {
188                    return Err(format!("required arg {:?} after an optional one on /{}", a.name, c.name));
189                }
190                optional_seen |= !a.required;
191                match a.arg_type {
192                    ArgType::Choice => {
193                        if a.choices.is_empty() || a.choices.len() > MAX_CHOICES {
194                            return Err(format!("choice arg {:?} needs 1..={MAX_CHOICES} choices", a.name));
195                        }
196                        if a.choices.iter().any(|ch| ch.is_empty() || ch.len() > MAX_NAME_LEN) {
197                            return Err(format!("bad choice value on {:?}", a.name));
198                        }
199                    }
200                    _ if !a.choices.is_empty() => {
201                        return Err(format!("choices on non-choice arg {:?}", a.name));
202                    }
203                    _ => {}
204                }
205            }
206        }
207        let bytes = serde_json::to_string(self).map_err(|e| e.to_string())?.len();
208        if bytes > MAX_MANIFEST_BYTES {
209            return Err(format!("manifest too large ({bytes} > {MAX_MANIFEST_BYTES} bytes)"));
210        }
211        Ok(())
212    }
213
214    /// Look up a command by name.
215    pub fn command(&self, name: &str) -> Option<&CommandSpec> {
216        self.commands.iter().find(|c| c.name == name)
217    }
218
219    /// Parse + validate a manifest from an event's content. The event must be
220    /// the manifest kind and is otherwise treated as untrusted input.
221    pub fn from_event(event: &Event) -> Result<Self, String> {
222        if event.kind != Kind::Custom(KIND_BOT_MANIFEST) {
223            return Err(format!("not a bot manifest (kind {})", event.kind));
224        }
225        if event.content.len() > MAX_MANIFEST_BYTES {
226            return Err("manifest content over the size cap".into());
227        }
228        let m: BotManifest = serde_json::from_str(&event.content).map_err(|e| format!("manifest parse: {e}"))?;
229        m.validate()?;
230        Ok(m)
231    }
232
233    /// Build the signed replaceable manifest event (one manifest per bot
234    /// identity, keyed by `(kind, pubkey)` with no `d` tag).
235    pub fn to_event(&self, keys: &Keys) -> Result<Event, String> {
236        self.validate()?;
237        let content = serde_json::to_string(self).map_err(|e| e.to_string())?;
238        EventBuilder::new(Kind::Custom(KIND_BOT_MANIFEST), content)
239            .sign_with_keys(keys)
240            .map_err(|e| e.to_string())
241    }
242}
243
244// ── Invocation ───────────────────────────────────────────────────────────────
245
246/// A command invocation recovered from a message's content. Values are raw
247/// strings in manifest order; type them via [`typed_args`].
248#[derive(Clone, Debug, PartialEq)]
249pub struct ParsedCommand {
250    pub name: String,
251    /// Named argument values in manifest order.
252    pub args: Vec<(String, String)>,
253}
254
255/// The canonical content for an invocation a picker client builds — exactly
256/// what a human would have typed ("/name value…"). Values containing spaces
257/// or quotes are quoted with `\"` escapes so the text re-parses to the same
258/// arguments.
259pub fn command_text(name: &str, args: &[(String, String)]) -> String {
260    let mut out = format!("/{name}");
261    for (_, v) in args {
262        out.push(' ');
263        if v.is_empty() || v.contains(char::is_whitespace) || v.contains('"') {
264            out.push('"');
265            out.push_str(&v.replace('\\', "\\\\").replace('"', "\\\""));
266            out.push('"');
267        } else {
268            out.push_str(v);
269        }
270    }
271    out
272}
273
274/// One shell-style token: either a bare word or a `"quoted span"` (which may
275/// contain spaces; `\"` is a literal quote, `\\` a literal backslash). Returns
276/// (value, byte offset just past the token).
277fn next_token(s: &str, mut i: usize) -> Option<(String, usize)> {
278    let b = s.as_bytes();
279    while i < b.len() && b[i].is_ascii_whitespace() {
280        i += 1;
281    }
282    if i >= b.len() {
283        return None;
284    }
285    let mut out = String::new();
286    if b[i] == b'"' {
287        i += 1;
288        while i < b.len() {
289            match b[i] {
290                b'\\' if i + 1 < b.len() && (b[i + 1] == b'"' || b[i + 1] == b'\\') => {
291                    out.push(b[i + 1] as char);
292                    i += 2;
293                }
294                b'"' => return Some((out, i + 1)),
295                _ => {
296                    // Multi-byte chars pass through verbatim.
297                    let ch = s[i..].chars().next()?;
298                    out.push(ch);
299                    i += ch.len_utf8();
300                }
301            }
302        }
303        None // unterminated quote — malformed, not a command
304    } else {
305        let start = i;
306        while i < b.len() && !b[i].is_ascii_whitespace() {
307            i += 1;
308        }
309        out.push_str(&s[start..i]);
310        Some((out, i))
311    }
312}
313
314/// Parse "/name arg arg…" content against the manifest's arg order — THE
315/// invocation parse. Returns `None` when the text isn't a known command (it is
316/// then ordinary chat).
317///
318/// Multi-word values: a `"quoted value"` groups words in ANY position (`\"`
319/// escapes a literal quote), and an UNQUOTED trailing String arg swallows the
320/// raw remainder of the line — so the common forms ("/price btc",
321/// "/say hello there") need no syntax at all, and two multi-word strings are
322/// still expressible: `/announce "Big news" "Meeting at 5pm"`.
323pub fn parse_command_text(manifest: &BotManifest, content: &str) -> Option<ParsedCommand> {
324    let content = content.trim();
325    let rest = content.strip_prefix('/')?;
326    // The grammar is `"/" name`: a quoted first token is ordinary chat, not a
327    // command word. Test the RAW input — `next_token` hands back a quoted span
328    // with its quotes already stripped, so inspecting the token can never see one.
329    if rest.starts_with('"') {
330        return None;
331    }
332    let (raw_name, mut cursor) = next_token(rest, 0)?;
333    if raw_name.is_empty() {
334        return None;
335    }
336    // Manifest names are lowercase slugs, so fold the invocation's command word:
337    // `/Help` and `/HELP` resolve like `/help`. Argument VALUES keep their case.
338    let name = raw_name.to_ascii_lowercase();
339    let spec = manifest.command(&name)?;
340    let mut args: Vec<(String, String)> = Vec::new();
341    for (i, a) in spec.args.iter().enumerate() {
342        let remainder = rest.get(cursor..).unwrap_or("").trim_start();
343        if remainder.is_empty() {
344            break;
345        }
346        let is_last_declared = i + 1 == spec.args.len();
347        let value = if is_last_declared && matches!(a.arg_type, ArgType::String) && !remainder.starts_with('"') {
348            // Greedy tail: take the raw remainder verbatim (spacing preserved).
349            cursor = rest.len();
350            remainder.trim_end().to_string()
351        } else {
352            let (tok, next) = next_token(rest, cursor)?;
353            cursor = next;
354            tok
355        };
356        if value.len() > MAX_ARG_VALUE_LEN {
357            return None;
358        }
359        args.push((a.name.clone(), value));
360    }
361    Some(ParsedCommand { name, args })
362}
363
364// ── Typing + validation against the manifest ─────────────────────────────────
365
366/// One argument value, typed per its spec.
367#[derive(Clone, Debug, PartialEq)]
368pub enum ArgValue {
369    String(String),
370    Int(i64),
371    Number(f64),
372    Bool(bool),
373    /// An `npub1…` user reference (kept as the bech32 string).
374    User(String),
375    Choice(String),
376}
377
378impl ArgValue {
379    pub fn as_str(&self) -> &str {
380        match self {
381            ArgValue::String(s) | ArgValue::User(s) | ArgValue::Choice(s) => s,
382            _ => "",
383        }
384    }
385    pub fn as_int(&self) -> Option<i64> {
386        match self {
387            ArgValue::Int(i) => Some(*i),
388            _ => None,
389        }
390    }
391    pub fn as_number(&self) -> Option<f64> {
392        match self {
393            ArgValue::Number(n) => Some(*n),
394            ArgValue::Int(i) => Some(*i as f64),
395            _ => None,
396        }
397    }
398    pub fn as_bool(&self) -> Option<bool> {
399        match self {
400            ArgValue::Bool(b) => Some(*b),
401            _ => None,
402        }
403    }
404}
405
406/// Type-check a parsed invocation against the manifest: every provided value
407/// parses as its declared type, choices are members, required args are present.
408/// Unknown arg names are DROPPED (a newer client may know newer args; the bot's
409/// manifest is authoritative for what it consumes).
410///
411/// Errors are CANONICAL and machine-parsable — always `{arg}: {reason}`, so any
412/// implementation (in any language) emits byte-identical text and a client can
413/// split on the first `": "`. Reasons: `not an integer`, `not a number`,
414/// `not a boolean`, `not an npub`, `not one of a, b, c`, `required`.
415pub fn typed_args(spec: &CommandSpec, parsed: &ParsedCommand) -> Result<HashMap<String, ArgValue>, String> {
416    let mut out = HashMap::new();
417    for (k, v) in &parsed.args {
418        let Some(a) = spec.args.iter().find(|a| &a.name == k) else {
419            continue;
420        };
421        let typed = match a.arg_type {
422            ArgType::String => ArgValue::String(v.clone()),
423            ArgType::Int => ArgValue::Int(v.parse::<i64>().map_err(|_| format!("{k}: not an integer"))?),
424            ArgType::Number => ArgValue::Number(v.parse::<f64>().map_err(|_| format!("{k}: not a number"))?),
425            ArgType::Bool => match v.to_ascii_lowercase().as_str() {
426                "true" | "yes" | "1" => ArgValue::Bool(true),
427                "false" | "no" | "0" => ArgValue::Bool(false),
428                _ => return Err(format!("{k}: not a boolean")),
429            },
430            ArgType::User => {
431                // Canonical wire form is the bare npub, but clients commonly insert a
432                // mention as the NIP-21 `nostr:npub1…` URI — accept it and normalize
433                // back to the bare npub. Parsing also rejects a bad bech32 checksum.
434                let raw = v.strip_prefix("nostr:").unwrap_or(v);
435                if !raw.starts_with("npub1") {
436                    return Err(format!("{k}: not an npub"));
437                }
438                let pk = nostr_sdk::prelude::PublicKey::parse(raw).map_err(|_| format!("{k}: not an npub"))?;
439                let npub = nostr_sdk::prelude::ToBech32::to_bech32(&pk).map_err(|_| format!("{k}: not an npub"))?;
440                ArgValue::User(npub)
441            }
442            ArgType::Choice => {
443                if !a.choices.iter().any(|c| c == v) {
444                    return Err(format!("{k}: not one of {}", a.choices.join(", ")));
445                }
446                ArgValue::Choice(v.clone())
447            }
448        };
449        out.insert(k.clone(), typed);
450    }
451    for a in &spec.args {
452        if a.required && !out.contains_key(&a.name) {
453            return Err(format!("{}: required", a.name));
454        }
455    }
456    Ok(out)
457}
458
459// ── Picker surface: batch discovery + per-chat cache ─────────────────────────
460
461/// One bot's published commands, shaped for a client's `/` picker. The client
462/// resolves the bot's display name/avatar from its own profile cache.
463#[derive(Serialize, Clone, Debug)]
464pub struct ChatBotCommands {
465    /// The bot's npub.
466    pub bot: String,
467    pub commands: Vec<CommandSpec>,
468}
469
470/// Public relays that index replaceable events network-wide — the reliable
471/// discovery path for bot manifests. Read side: always queried ALONGSIDE a
472/// chat's own relays, so a manifest resolves even when a community relay is
473/// unreachable or drops stranger events (Ditto does). Write side: the SDK
474/// publishes every bot's manifest here for the same reason.
475pub const DISCOVERY_RELAYS: &[&str] =
476    &["wss://purplepag.es", "wss://relay.nostr.band", "wss://relay.damus.io", "wss://nos.lol"];
477
478/// What the composer's `/` picker renders, instantly answerable from local
479/// state. `fresh: false` means a background refetch was spawned — a
480/// `chat_commands_updated` event follows with the converged list.
481#[derive(Serialize, Clone, Debug)]
482pub struct ChatCommandsSnapshot {
483    /// How many bot-flagged members this chat has (spinner copy: "Loading N bots").
484    pub bots: usize,
485    /// Last-known command sets, one entry per bot with a stored manifest,
486    /// commands in MANIFEST order (bots arrange their own list).
487    pub commands: Vec<ChatBotCommands>,
488    /// `true` = served from the fresh-TTL cache; nothing further will arrive.
489    pub fresh: bool,
490}
491
492/// Batch-fetch validated manifests for a set of authors over specific relays —
493/// the picker's ONE REQ (all bots of a room in a single query). Transport-
494/// generic so community relays are queried directly and tests run offline.
495/// Per author: the NEWEST event wins, then must validate (a bot that breaks
496/// its own manifest has no usable interface — parity with [`fetch_manifest`]);
497/// authors the relay volunteers beyond the asked set are dropped, as is
498/// anything failing signature verification. Returns each manifest with its
499/// event timestamp (the store's newest-wins key).
500pub async fn fetch_manifests<T: crate::community::transport::Transport + ?Sized>(
501    transport: &T,
502    authors: &[nostr_sdk::prelude::PublicKey],
503    relays: &[String],
504) -> Result<Vec<(nostr_sdk::prelude::PublicKey, BotManifest, u64)>, String> {
505    use crate::community::transport::Query;
506    if authors.is_empty() {
507        return Ok(Vec::new());
508    }
509    let query = Query {
510        kinds: vec![KIND_BOT_MANIFEST],
511        authors: authors.iter().map(|p| p.to_hex()).collect(),
512        ..Default::default()
513    };
514    let events = transport.fetch(&query, relays).await?;
515    let mut best: HashMap<nostr_sdk::prelude::PublicKey, &Event> = HashMap::new();
516    for ev in &events {
517        if !authors.contains(&ev.pubkey) || ev.verify().is_err() {
518            continue;
519        }
520        match best.get(&ev.pubkey) {
521            Some(b) if b.created_at >= ev.created_at => {}
522            _ => {
523                best.insert(ev.pubkey, ev);
524            }
525        }
526    }
527    let mut out: Vec<(nostr_sdk::prelude::PublicKey, BotManifest, u64)> = best
528        .into_iter()
529        .filter_map(|(pk, ev)| BotManifest::from_event(ev).ok().map(|m| (pk, m, ev.created_at.as_secs())))
530        .collect();
531    out.sort_by_key(|(pk, _, _)| pk.to_hex());
532    Ok(out)
533}
534
535/// Assemble picker entries from the persistent manifest store for a set of bot
536/// pubkeys (hex, pre-sorted order preserved). Bots with no stored manifest are
537/// absent; a stored row that no longer parses/validates is skipped.
538pub fn assemble_from_store(bot_hexes: &[String]) -> Vec<ChatBotCommands> {
539    use nostr_sdk::prelude::ToBech32;
540    let rows = crate::db::bots::get_bot_manifests(bot_hexes).unwrap_or_default();
541    let by_pk: HashMap<&str, &str> = rows.iter().map(|(pk, m)| (pk.as_str(), m.as_str())).collect();
542    bot_hexes
543        .iter()
544        .filter_map(|hex| {
545            let json = by_pk.get(hex.as_str())?;
546            let manifest: BotManifest = serde_json::from_str(json).ok()?;
547            manifest.validate().ok()?;
548            let npub = nostr_sdk::prelude::PublicKey::from_hex(hex).ok()?.to_bech32().ok()?;
549            Some(ChatBotCommands { bot: npub, commands: manifest.commands })
550        })
551        .collect()
552}
553
554/// Freshness memory per chat: (session generation, refreshed-at, the bot set
555/// the refresh covered). One REQ per chat per minute; a CHANGED bot set (a bot
556/// joined/left) counts as stale immediately. The generation tag makes an
557/// account swap a natural invalidation.
558const COMMANDS_TTL: std::time::Duration = std::time::Duration::from_secs(60);
559static COMMANDS_FRESH: std::sync::LazyLock<
560    std::sync::Mutex<HashMap<String, (u64, std::time::Instant, Vec<String>)>>,
561> = std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
562/// Chats with a refresh REQ in flight (stampede guard for `/` keystrokes).
563static REFRESH_INFLIGHT: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
564    std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
565
566/// `true` while the last completed refresh for this chat is within TTL AND
567/// covered exactly `bot_hexes`.
568pub fn commands_fresh(chat_id: &str, bot_hexes: &[String]) -> bool {
569    let generation = crate::state::SessionGuard::capture().generation();
570    let map = match COMMANDS_FRESH.lock() {
571        Ok(m) => m,
572        Err(_) => return false,
573    };
574    match map.get(chat_id) {
575        Some((g, at, bots)) => *g == generation && at.elapsed() < COMMANDS_TTL && bots == bot_hexes,
576        None => false,
577    }
578}
579
580fn mark_commands_fresh(chat_id: &str, generation: u64, bot_hexes: &[String]) {
581    if let Ok(mut map) = COMMANDS_FRESH.lock() {
582        if map.len() > 256 {
583            map.clear();
584        }
585        map.insert(chat_id.to_string(), (generation, std::time::Instant::now(), bot_hexes.to_vec()));
586    }
587}
588
589/// Background half of the picker flow: ONE REQ for every bot's manifest (5s
590/// unification window), persist newer editions, mark the chat fresh, and tell
591/// the UI to swap in the converged list. Deduped per chat; session-guarded
592/// before every write.
593pub fn spawn_commands_refresh(chat_id: String, bots: Vec<nostr_sdk::prelude::PublicKey>, relays: Vec<String>) {
594    {
595        let Ok(mut inflight) = REFRESH_INFLIGHT.lock() else { return };
596        if !inflight.insert(chat_id.clone()) {
597            return; // already fetching for this chat
598        }
599    }
600    let session = crate::state::SessionGuard::capture();
601    tokio::spawn(async move {
602        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(5));
603        let fetched = fetch_manifests(&transport, &bots, &relays).await;
604        if let Ok(mut inflight) = REFRESH_INFLIGHT.lock() {
605            inflight.remove(&chat_id);
606        }
607        let Ok(found) = fetched else { return }; // transient failure: stay stale, next `/` retries
608        if !session.is_valid() {
609            return;
610        }
611        for (pk, manifest, created_at) in &found {
612            if let Ok(json) = serde_json::to_string(manifest) {
613                let _ = crate::db::bots::upsert_bot_manifest(&pk.to_hex(), &json, *created_at);
614            }
615        }
616        let bot_hexes: Vec<String> = bots.iter().map(|p| p.to_hex()).collect();
617        let commands = assemble_from_store(&bot_hexes);
618        if !session.is_valid() {
619            return;
620        }
621        mark_commands_fresh(&chat_id, session.generation(), &bot_hexes);
622        crate::traits::emit_event(
623            "chat_commands_updated",
624            &serde_json::json!({ "chat_id": chat_id, "bots": bots.len(), "commands": commands }),
625        );
626    });
627}
628
629// ── Network: publish + fetch ─────────────────────────────────────────────────
630
631/// Publish `manifest` as the signed replaceable event over the given relays
632/// (targeted send — the caller decides the reach: login relays, communities,
633/// indexers). Returns how many relays accepted it.
634pub async fn publish_manifest(manifest: &BotManifest, keys: &Keys, relays: &[String]) -> Result<usize, String> {
635    let event = manifest.to_event(keys)?;
636    let client = crate::state::nostr_client().ok_or("no client connected")?;
637    for r in relays {
638        let _ = client.add_relay(r.as_str()).await;
639    }
640    client.connect().await;
641    let out = client
642        .send_event_to(relays.to_vec(), &event)
643        .await
644        .map_err(|e| e.to_string())?;
645    Ok(out.success.len())
646}
647
648/// Fetch + validate a bot's manifest by pubkey from the given relays. Returns
649/// the newest valid one, or `None` when the bot has published no interface.
650pub async fn fetch_manifest(bot: &nostr_sdk::prelude::PublicKey, relays: &[String]) -> Option<BotManifest> {
651    let client = crate::state::nostr_client()?;
652    let filter = nostr_sdk::prelude::Filter::new()
653        .kind(Kind::Custom(KIND_BOT_MANIFEST))
654        .author(*bot)
655        .limit(1);
656    let events = if relays.is_empty() {
657        client.fetch_events(filter, std::time::Duration::from_secs(8)).await.ok()?
658    } else {
659        client
660            .fetch_events_from(relays.to_vec(), filter, std::time::Duration::from_secs(8))
661            .await
662            .ok()?
663    };
664    events
665        .into_iter()
666        .max_by_key(|e| e.created_at)
667        .and_then(|e| BotManifest::from_event(&e).ok())
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use nostr_sdk::prelude::Keys;
674
675    fn price_manifest() -> BotManifest {
676        BotManifest {
677            v: 1,
678            commands: vec![
679                CommandSpec {
680                    name: "price".into(),
681                    description: "Get a coin price".into(),
682                    args: vec![ArgSpec {
683                        name: "asset".into(),
684                        arg_type: ArgType::Choice,
685                        description: "Which coin".into(),
686                        required: true,
687                        choices: vec!["btc".into(), "xmr".into(), "pivx".into()],
688                    }],
689                },
690                CommandSpec {
691                    name: "say".into(),
692                    description: "Echo".into(),
693                    args: vec![
694                        ArgSpec {
695                            name: "count".into(),
696                            arg_type: ArgType::Int,
697                            description: String::new(),
698                            required: true,
699                            choices: vec![],
700                        },
701                        ArgSpec {
702                            name: "text".into(),
703                            arg_type: ArgType::String,
704                            description: String::new(),
705                            required: false,
706                            choices: vec![],
707                        },
708                    ],
709                },
710            ],
711        }
712    }
713
714    #[test]
715    fn manifest_round_trips_through_its_event() {
716        let keys = Keys::generate();
717        let m = price_manifest();
718        let ev = m.to_event(&keys).unwrap();
719        assert_eq!(ev.kind, Kind::Custom(KIND_BOT_MANIFEST));
720        let back = BotManifest::from_event(&ev).unwrap();
721        assert_eq!(back.commands.len(), 2);
722        assert_eq!(back.command("price").unwrap().args[0].choices.len(), 3);
723    }
724
725    #[test]
726    fn a_quoted_command_word_is_ordinary_chat() {
727        // The grammar is `"/" name`, so a quoted first token is not a command.
728        // The guard has to read the RAW input: `next_token` returns a quoted span
729        // with its quotes stripped, so a check on the token can never fire — and
730        // this parsed as a valid `/price` until it did. A message another client
731        // sends as chat must not execute here.
732        let m = price_manifest();
733        assert!(parse_command_text(&m, r#"/"price" btc"#).is_none());
734        assert!(parse_command_text(&m, r#"/"" btc"#).is_none());
735
736        // The unquoted form still parses, so the guard is narrow.
737        assert!(parse_command_text(&m, "/price btc").is_some());
738    }
739
740    #[test]
741    fn the_command_word_is_case_folded() {
742        // Manifest names are lowercase slugs, so `/PRICE` and `/Say` still resolve.
743        let m = price_manifest();
744        let p = parse_command_text(&m, "/PRICE btc").expect("an uppercase command resolves");
745        assert_eq!(p.name, "price");
746        assert_eq!(p.args, vec![("asset".to_string(), "btc".to_string())]);
747
748        // Only the command WORD folds — argument values keep their case.
749        let p = parse_command_text(&m, "/Say 2 Hello There").expect("a mixed-case command resolves");
750        assert_eq!(p.name, "say");
751        assert_eq!(p.args[1], ("text".to_string(), "Hello There".to_string()));
752    }
753
754    #[test]
755    fn user_args_accept_the_nip21_uri_and_normalize_to_a_bare_npub() {
756        use nostr_sdk::prelude::ToBech32;
757        let npub = Keys::generate().public_key().to_bech32().unwrap();
758        let spec = CommandSpec {
759            name: "greet".into(),
760            description: String::new(),
761            args: vec![ArgSpec {
762                name: "who".into(),
763                arg_type: ArgType::User,
764                description: String::new(),
765                required: true,
766                choices: vec![],
767            }],
768        };
769        let m = BotManifest { v: 1, commands: vec![spec.clone()] };
770
771        // The bare npub — what a compliant picker emits.
772        let p = parse_command_text(&m, &format!("/greet {npub}")).unwrap();
773        assert_eq!(typed_args(&spec, &p).unwrap()["who"], ArgValue::User(npub.clone()));
774
775        // A NIP-21 `nostr:` mention URI: accepted, normalized back to the bare npub.
776        let p = parse_command_text(&m, &format!("/greet nostr:{npub}")).unwrap();
777        assert_eq!(typed_args(&spec, &p).unwrap()["who"], ArgValue::User(npub.clone()));
778
779        // A malformed npub is still refused (the bech32 checksum is verified).
780        let p = parse_command_text(&m, "/greet npub1nope").unwrap();
781        assert!(typed_args(&spec, &p).is_err());
782    }
783
784    #[test]
785    fn typing_errors_are_canonical_and_parsable() {
786        // Every error is `{arg}: {reason}` — byte-identical in any implementation,
787        // so a client splits on the first ": " to recover which arg failed and why.
788        let m = price_manifest();
789        let price = m.command("price").unwrap().clone();
790        let say = m.command("say").unwrap().clone();
791
792        let p = parse_command_text(&m, "/price doge").unwrap();
793        assert_eq!(typed_args(&price, &p).unwrap_err(), "asset: not one of btc, xmr, pivx");
794
795        let p = parse_command_text(&m, "/price").unwrap();
796        assert_eq!(typed_args(&price, &p).unwrap_err(), "asset: required");
797
798        let p = parse_command_text(&m, "/say notanint hi").unwrap();
799        assert_eq!(typed_args(&say, &p).unwrap_err(), "count: not an integer");
800    }
801
802    #[test]
803    fn validation_rejects_the_sharp_edges() {
804        let mut m = price_manifest();
805        m.commands[0].name = "Bad Name".into();
806        assert!(m.validate().is_err());
807
808        let mut m = price_manifest();
809        m.commands.push(m.commands[0].clone());
810        assert!(m.validate().is_err(), "duplicate command name");
811
812        let mut m = price_manifest();
813        m.commands[0].args[0].choices.clear();
814        assert!(m.validate().is_err(), "choice without choices");
815
816        // Required after optional breaks positional text parsing.
817        let mut m = price_manifest();
818        m.commands[1].args[0].required = false;
819        m.commands[1].args[1].required = true;
820        assert!(m.validate().is_err());
821    }
822
823    /// A command with TWO multi-word strings — expressible only via quoting.
824    fn announce_manifest() -> BotManifest {
825        BotManifest {
826            v: 1,
827            commands: vec![CommandSpec {
828                name: "announce".into(),
829                description: "Post an announcement".into(),
830                args: vec![
831                    ArgSpec { name: "title".into(), arg_type: ArgType::String, description: String::new(), required: true, choices: vec![] },
832                    ArgSpec { name: "body".into(), arg_type: ArgType::String, description: String::new(), required: true, choices: vec![] },
833                ],
834            }],
835        }
836    }
837
838    #[test]
839    fn command_text_and_parse_are_inverses() {
840        let m = price_manifest();
841        let args = vec![("asset".to_string(), "btc".to_string())];
842        let content = command_text("price", &args);
843        assert_eq!(content, "/price btc");
844        let p = parse_command_text(&m, &content).unwrap();
845        assert_eq!(p.name, "price");
846        assert_eq!(p.args, args);
847
848        // Multi-word + embedded-quote values round-trip via quoting.
849        let m = announce_manifest();
850        let args = vec![
851            ("title".to_string(), "Big \"news\" day".to_string()),
852            ("body".to_string(), "Meeting at 5pm".to_string()),
853        ];
854        let content = command_text("announce", &args);
855        let p = parse_command_text(&m, &content).unwrap();
856        assert_eq!(p.args, args);
857
858        // Adversarial values: no value can escape its quoted slot — every
859        // combination of quotes/backslashes/newlines re-parses to itself.
860        for nasty in [
861            "\"",                        // a lone quote
862            "\\",                        // a lone backslash
863            "ends with backslash \\",    // trailing backslash before the closing quote
864            "\\\" fake close",           // escaped-quote prefix
865            "line one\nline two",        // literal newline inside a value
866            "\" \\\" \\\\ \"\"",         // quote/escape soup
867            " leading and trailing ",    // spaces preserved verbatim
868        ] {
869            let args = vec![
870                ("title".to_string(), nasty.to_string()),
871                ("body".to_string(), format!("after {nasty} end")),
872            ];
873            let content = command_text("announce", &args);
874            let p = parse_command_text(&m, &content)
875                .unwrap_or_else(|| panic!("adversarial value failed to re-parse: {nasty:?}"));
876            assert_eq!(p.args, args, "value must survive the wire byte-exact: {nasty:?}");
877        }
878    }
879
880    #[test]
881    fn text_parses_positionally_and_greedily() {
882        let m = price_manifest();
883        let p = parse_command_text(&m, "/price btc").unwrap();
884        assert_eq!(p.args, vec![("asset".to_string(), "btc".to_string())]);
885
886        // Unquoted trailing String arg swallows the RAW remainder (spacing kept).
887        let p = parse_command_text(&m, "/say 3 hello  there world").unwrap();
888        assert_eq!(p.args[0], ("count".to_string(), "3".to_string()));
889        assert_eq!(p.args[1], ("text".to_string(), "hello  there world".to_string()));
890
891        assert!(parse_command_text(&m, "/unknown x").is_none());
892        assert!(parse_command_text(&m, "not a command").is_none());
893        assert!(parse_command_text(&m, "/").is_none());
894    }
895
896    #[test]
897    fn bot_recipient_tags_extract_dedup_and_cap() {
898        use nostr_sdk::prelude::ToBech32;
899        let a = Keys::generate().public_key();
900        let b = Keys::generate().public_key();
901        let tags: Vec<Tag> = vec![
902            bot_tag(&a),
903            bot_tag(&a), // dup
904            bot_tag(&b),
905            Tag::custom(nostr_sdk::prelude::TagKind::Custom(TAG_BOT.into()), ["nothex"]),
906            Tag::custom(nostr_sdk::prelude::TagKind::Custom("p".into()), [a.to_hex()]), // not ours
907        ];
908        let out = addressed_bots(tags.iter());
909        assert_eq!(out, vec![a.to_bech32().unwrap(), b.to_bech32().unwrap()]);
910
911        // Cap: 20 distinct tags → MAX_BOT_TAGS honored.
912        let many: Vec<Tag> = (0..20).map(|_| bot_tag(&Keys::generate().public_key())).collect();
913        assert_eq!(addressed_bots(many.iter()).len(), MAX_BOT_TAGS);
914
915        // Untagged → empty (broadcast).
916        assert!(addressed_bots([].iter()).is_empty());
917    }
918
919    #[test]
920    fn quoting_terminates_multi_word_values() {
921        let m = announce_manifest();
922        // Both strings quoted — unambiguous.
923        let p = parse_command_text(&m, r#"/announce "Hello everyone" "Meeting at 5pm""#).unwrap();
924        assert_eq!(p.args[0].1, "Hello everyone");
925        assert_eq!(p.args[1].1, "Meeting at 5pm");
926
927        // First quoted, trailing unquoted → greedy tail.
928        let p = parse_command_text(&m, r#"/announce "Hello everyone" Meeting at 5pm"#).unwrap();
929        assert_eq!(p.args[0].1, "Hello everyone");
930        assert_eq!(p.args[1].1, "Meeting at 5pm");
931
932        // Unquoted first string takes ONE word (position rules unchanged).
933        let p = parse_command_text(&m, "/announce Hello Meeting at 5pm").unwrap();
934        assert_eq!(p.args[0].1, "Hello");
935        assert_eq!(p.args[1].1, "Meeting at 5pm");
936
937        // Escapes inside quotes.
938        let p = parse_command_text(&m, r#"/announce "say \"hi\" \\ ok" done"#).unwrap();
939        assert_eq!(p.args[0].1, r#"say "hi" \ ok"#);
940
941        // Unterminated quote → not a command (ordinary chat).
942        assert!(parse_command_text(&m, r#"/announce "dangling"#).is_none());
943    }
944
945    #[tokio::test]
946    async fn batch_fetch_returns_newest_valid_per_author_and_ignores_strangers() {
947        use crate::community::transport::{memory::MemoryRelay, Transport};
948        use nostr_sdk::prelude::Timestamp;
949        let relay = MemoryRelay::new();
950        let relays = vec!["r1".to_string()];
951        let bot_a = Keys::generate();
952        let bot_b = Keys::generate();
953        let stranger = Keys::generate();
954
955        let manifest_event = |m: &BotManifest, keys: &Keys, at: u64| {
956            EventBuilder::new(Kind::Custom(KIND_BOT_MANIFEST), serde_json::to_string(m).unwrap())
957                .custom_created_at(Timestamp::from_secs(at))
958                .sign_with_keys(keys)
959                .unwrap()
960        };
961        // A: an old manifest, then a newer edition with a different command set.
962        let old_ev = manifest_event(&price_manifest(), &bot_a, 100);
963        let newer = BotManifest {
964            v: 1,
965            commands: vec![CommandSpec { name: "newer".into(), description: "n".into(), args: vec![] }],
966        };
967        let new_ev = manifest_event(&newer, &bot_a, 200);
968        // B: newest is garbage — B has no usable interface (no fallback to older).
969        let b_garbage = EventBuilder::new(Kind::Custom(KIND_BOT_MANIFEST), "not json")
970            .custom_created_at(Timestamp::from_secs(300))
971            .sign_with_keys(&bot_b)
972            .unwrap();
973        // Stranger: a VALID manifest outside the asked author set.
974        let s_ev = price_manifest().to_event(&stranger).unwrap();
975        for ev in [&old_ev, &new_ev, &b_garbage, &s_ev] {
976            relay.publish(ev, &relays).await.unwrap();
977        }
978
979        let found = fetch_manifests(&relay, &[bot_a.public_key(), bot_b.public_key()], &relays)
980            .await
981            .unwrap();
982        assert_eq!(found.len(), 1, "only A has a usable newest manifest: {found:?}");
983        assert_eq!(found[0].0, bot_a.public_key());
984        assert!(found[0].1.command("newer").is_some(), "the newest edition won");
985        assert!(found[0].1.command("price").is_none(), "the older edition lost");
986        assert_eq!(found[0].2, 200, "the winning edition's timestamp rides along");
987
988        let none = fetch_manifests(&relay, &[], &relays).await.unwrap();
989        assert!(none.is_empty(), "empty author set short-circuits");
990    }
991
992    #[test]
993    fn command_freshness_is_generation_ttl_and_botset_scoped() {
994        // Serialize with bed tests — they bump the session generation mid-test.
995        let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner());
996        let generation = crate::state::SessionGuard::capture().generation();
997        let bots = vec!["aa".to_string(), "bb".to_string()];
998
999        assert!(!commands_fresh("cmd-fresh-a", &bots), "unseen chat is stale");
1000        mark_commands_fresh("cmd-fresh-a", generation, &bots);
1001        assert!(commands_fresh("cmd-fresh-a", &bots));
1002
1003        // A CHANGED bot set is immediately stale (a bot joined/left the room).
1004        let grown = vec!["aa".to_string(), "bb".to_string(), "cc".to_string()];
1005        assert!(!commands_fresh("cmd-fresh-a", &grown));
1006
1007        // Another generation's entry is invisible — the account-swap invalidation.
1008        mark_commands_fresh("cmd-fresh-b", generation.wrapping_add(1), &bots);
1009        assert!(!commands_fresh("cmd-fresh-b", &bots));
1010    }
1011
1012    #[test]
1013    fn typing_enforces_the_manifest() {
1014        let m = price_manifest();
1015        let spec = m.command("price").unwrap();
1016
1017        let ok = ParsedCommand {
1018            name: "price".into(),
1019            args: vec![("asset".into(), "btc".into())],
1020        };
1021        let t = typed_args(spec, &ok).unwrap();
1022        assert_eq!(t["asset"], ArgValue::Choice("btc".into()));
1023
1024        let bad_choice = ParsedCommand {
1025            name: "price".into(),
1026            args: vec![("asset".into(), "doge".into())],
1027        };
1028        assert!(typed_args(spec, &bad_choice).is_err());
1029
1030        let missing = ParsedCommand { name: "price".into(), args: vec![] };
1031        assert!(typed_args(spec, &missing).is_err());
1032
1033        // Unknown arg names are dropped, not fatal (newer-manifest tolerance).
1034        let extra = ParsedCommand {
1035            name: "price".into(),
1036            args: vec![("asset".into(), "btc".into()), ("future".into(), "1".into())],
1037        };
1038        let t = typed_args(spec, &extra).unwrap();
1039        assert!(!t.contains_key("future"));
1040
1041        let spec = m.command("say").unwrap();
1042        let typed = typed_args(
1043            spec,
1044            &ParsedCommand {
1045                name: "say".into(),
1046                args: vec![("count".into(), "5".into()), ("text".into(), "hi".into())],
1047            },
1048        )
1049        .unwrap();
1050        assert_eq!(typed["count"].as_int(), Some(5));
1051        assert_eq!(typed["text"].as_str(), "hi");
1052
1053        let not_int = ParsedCommand {
1054            name: "say".into(),
1055            args: vec![("count".into(), "many".into())],
1056        };
1057        assert!(typed_args(spec, &not_int).is_err());
1058    }
1059}