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