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        // An explicitly empty token (`""`) is the positional format's hole
362        // marker: it skips an OPTIONAL arg so a later one is still reachable
363        // ("/listings \"\" 2" = no category, page 2). A required arg keeps the
364        // empty value — an empty String can be a deliberate answer, and typing
365        // judges it, not the tokenizer.
366        if value.is_empty() && !a.required {
367            continue;
368        }
369        args.push((a.name.clone(), value));
370    }
371    Some(ParsedCommand { name, args })
372}
373
374// ── Typing + validation against the manifest ─────────────────────────────────
375
376/// One argument value, typed per its spec.
377#[derive(Clone, Debug, PartialEq)]
378pub enum ArgValue {
379    String(String),
380    Int(i64),
381    Number(f64),
382    Bool(bool),
383    /// An `npub1…` user reference (kept as the bech32 string).
384    User(String),
385    Choice(String),
386}
387
388impl ArgValue {
389    pub fn as_str(&self) -> &str {
390        match self {
391            ArgValue::String(s) | ArgValue::User(s) | ArgValue::Choice(s) => s,
392            _ => "",
393        }
394    }
395    pub fn as_int(&self) -> Option<i64> {
396        match self {
397            ArgValue::Int(i) => Some(*i),
398            _ => None,
399        }
400    }
401    pub fn as_number(&self) -> Option<f64> {
402        match self {
403            ArgValue::Number(n) => Some(*n),
404            ArgValue::Int(i) => Some(*i as f64),
405            _ => None,
406        }
407    }
408    pub fn as_bool(&self) -> Option<bool> {
409        match self {
410            ArgValue::Bool(b) => Some(*b),
411            _ => None,
412        }
413    }
414}
415
416/// Type-check a parsed invocation against the manifest: every provided value
417/// parses as its declared type, choices are members, required args are present.
418/// Unknown arg names are DROPPED (a newer client may know newer args; the bot's
419/// manifest is authoritative for what it consumes).
420///
421/// Errors are CANONICAL and machine-parsable — always `{arg}: {reason}`, so any
422/// implementation (in any language) emits byte-identical text and a client can
423/// split on the first `": "`. Reasons: `not an integer`, `not a number`,
424/// `not a boolean`, `not an npub`, `not one of a, b, c`, `required`.
425pub fn typed_args(spec: &CommandSpec, parsed: &ParsedCommand) -> Result<HashMap<String, ArgValue>, String> {
426    let mut out = HashMap::new();
427    for (k, v) in &parsed.args {
428        let Some(a) = spec.args.iter().find(|a| &a.name == k) else {
429            continue;
430        };
431        let typed = match a.arg_type {
432            ArgType::String => ArgValue::String(v.clone()),
433            ArgType::Int => ArgValue::Int(v.parse::<i64>().map_err(|_| format!("{k}: not an integer"))?),
434            ArgType::Number => ArgValue::Number(v.parse::<f64>().map_err(|_| format!("{k}: not a number"))?),
435            ArgType::Bool => match v.to_ascii_lowercase().as_str() {
436                "true" | "yes" | "1" => ArgValue::Bool(true),
437                "false" | "no" | "0" => ArgValue::Bool(false),
438                _ => return Err(format!("{k}: not a boolean")),
439            },
440            ArgType::User => {
441                // Canonical wire form is the bare npub, but clients commonly insert a
442                // mention as the NIP-21 `nostr:npub1…` URI — accept it and normalize
443                // back to the bare npub. Parsing also rejects a bad bech32 checksum.
444                let raw = v.strip_prefix("nostr:").unwrap_or(v);
445                if !raw.starts_with("npub1") {
446                    return Err(format!("{k}: not an npub"));
447                }
448                let pk = nostr_sdk::prelude::PublicKey::parse(raw).map_err(|_| format!("{k}: not an npub"))?;
449                let npub = nostr_sdk::prelude::ToBech32::to_bech32(&pk).map_err(|_| format!("{k}: not an npub"))?;
450                ArgValue::User(npub)
451            }
452            ArgType::Choice => {
453                if !a.choices.iter().any(|c| c == v) {
454                    return Err(format!("{k}: not one of {}", a.choices.join(", ")));
455                }
456                ArgValue::Choice(v.clone())
457            }
458        };
459        out.insert(k.clone(), typed);
460    }
461    for a in &spec.args {
462        if a.required && !out.contains_key(&a.name) {
463            return Err(format!("{}: required", a.name));
464        }
465    }
466    Ok(out)
467}
468
469// ── Picker surface: batch discovery + per-chat cache ─────────────────────────
470
471/// One bot's published commands, shaped for a client's `/` picker. The client
472/// resolves the bot's display name/avatar from its own profile cache.
473#[derive(Serialize, Clone, Debug)]
474pub struct ChatBotCommands {
475    /// The bot's npub.
476    pub bot: String,
477    pub commands: Vec<CommandSpec>,
478}
479
480/// Public relays that index replaceable events network-wide — the reliable
481/// discovery path for bot manifests. Read side: always queried ALONGSIDE a
482/// chat's own relays, so a manifest resolves even when a community relay is
483/// unreachable or drops stranger events (Ditto does). Write side: the SDK
484/// publishes every bot's manifest here for the same reason.
485pub const DISCOVERY_RELAYS: &[&str] =
486    &["wss://purplepag.es", "wss://relay.nostr.band", "wss://nos.lol"];
487
488/// What the composer's `/` picker renders, instantly answerable from local
489/// state. `fresh: false` means a background refetch was spawned — a
490/// `chat_commands_updated` event follows with the converged list.
491#[derive(Serialize, Clone, Debug)]
492pub struct ChatCommandsSnapshot {
493    /// How many bot-flagged members this chat has (spinner copy: "Loading N bots").
494    pub bots: usize,
495    /// Last-known command sets, one entry per bot with a stored manifest,
496    /// commands in MANIFEST order (bots arrange their own list).
497    pub commands: Vec<ChatBotCommands>,
498    /// `true` = served from the fresh-TTL cache; nothing further will arrive.
499    pub fresh: bool,
500}
501
502/// Batch-fetch validated manifests for a set of authors over specific relays —
503/// the picker's ONE REQ (all bots of a room in a single query). Transport-
504/// generic so community relays are queried directly and tests run offline.
505/// Per author: the NEWEST event wins, then must validate (a bot that breaks
506/// its own manifest has no usable interface — parity with [`fetch_manifest`]);
507/// authors the relay volunteers beyond the asked set are dropped, as is
508/// anything failing signature verification. Returns each manifest with its
509/// event timestamp (the store's newest-wins key).
510pub async fn fetch_manifests<T: crate::community::transport::Transport + ?Sized>(
511    transport: &T,
512    authors: &[nostr_sdk::prelude::PublicKey],
513    relays: &[String],
514) -> Result<Vec<(nostr_sdk::prelude::PublicKey, BotManifest, u64)>, String> {
515    use crate::community::transport::Query;
516    if authors.is_empty() {
517        return Ok(Vec::new());
518    }
519    let query = Query {
520        kinds: vec![KIND_BOT_MANIFEST],
521        authors: authors.iter().map(|p| p.to_hex()).collect(),
522        ..Default::default()
523    };
524    let events = transport.fetch(&query, relays).await?;
525    let mut best: HashMap<nostr_sdk::prelude::PublicKey, &Event> = HashMap::new();
526    for ev in &events {
527        if !authors.contains(&ev.pubkey) || ev.verify().is_err() {
528            continue;
529        }
530        match best.get(&ev.pubkey) {
531            Some(b) if b.created_at >= ev.created_at => {}
532            _ => {
533                best.insert(ev.pubkey, ev);
534            }
535        }
536    }
537    let mut out: Vec<(nostr_sdk::prelude::PublicKey, BotManifest, u64)> = best
538        .into_iter()
539        .filter_map(|(pk, ev)| BotManifest::from_event(ev).ok().map(|m| (pk, m, ev.created_at.as_secs())))
540        .collect();
541    out.sort_by_key(|(pk, _, _)| pk.to_hex());
542    Ok(out)
543}
544
545/// Assemble picker entries from the persistent manifest store for a set of bot
546/// pubkeys (hex, pre-sorted order preserved). Bots with no stored manifest are
547/// absent; a stored row that no longer parses/validates is skipped.
548pub fn assemble_from_store(bot_hexes: &[String]) -> Vec<ChatBotCommands> {
549    use nostr_sdk::prelude::ToBech32;
550    let rows = crate::db::bots::get_bot_manifests(bot_hexes).unwrap_or_default();
551    let by_pk: HashMap<&str, &str> = rows.iter().map(|(pk, m)| (pk.as_str(), m.as_str())).collect();
552    bot_hexes
553        .iter()
554        .filter_map(|hex| {
555            let json = by_pk.get(hex.as_str())?;
556            let manifest: BotManifest = serde_json::from_str(json).ok()?;
557            manifest.validate().ok()?;
558            let npub = nostr_sdk::prelude::PublicKey::from_hex(hex).ok()?.to_bech32().ok()?;
559            Some(ChatBotCommands { bot: npub, commands: manifest.commands })
560        })
561        .collect()
562}
563
564/// Freshness memory per chat: (session generation, refreshed-at, the bot set
565/// the refresh covered). One REQ per chat per minute; a CHANGED bot set (a bot
566/// joined/left) counts as stale immediately. The generation tag makes an
567/// account swap a natural invalidation.
568const COMMANDS_TTL: std::time::Duration = std::time::Duration::from_secs(60);
569type CommandsFreshness = HashMap<String, (u64, web_time::Instant, Vec<String>)>;
570static COMMANDS_FRESH: std::sync::LazyLock<std::sync::Mutex<CommandsFreshness>> =
571    std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
572/// Chats with a refresh REQ in flight (stampede guard for `/` keystrokes).
573static REFRESH_INFLIGHT: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
574    std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
575
576/// `true` while the last completed refresh for this chat is within TTL AND
577/// covered exactly `bot_hexes`.
578pub fn commands_fresh(chat_id: &str, bot_hexes: &[String]) -> bool {
579    let generation = crate::db::current_session_id();
580    let map = match COMMANDS_FRESH.lock() {
581        Ok(m) => m,
582        Err(_) => return false,
583    };
584    match map.get(chat_id) {
585        Some((g, at, bots)) => *g == generation && at.elapsed() < COMMANDS_TTL && bots == bot_hexes,
586        None => false,
587    }
588}
589
590fn mark_commands_fresh(chat_id: &str, generation: u64, bot_hexes: &[String]) {
591    if let Ok(mut map) = COMMANDS_FRESH.lock() {
592        if map.len() > 256 {
593            map.clear();
594        }
595        map.insert(chat_id.to_string(), (generation, web_time::Instant::now(), bot_hexes.to_vec()));
596    }
597}
598
599/// Background half of the picker flow: ONE REQ for every bot's manifest (5s
600/// unification window), persist newer editions, mark the chat fresh, and tell
601/// the UI to swap in the converged list. Deduped per chat; session-guarded
602/// before every write.
603pub fn spawn_commands_refresh(chat_id: String, bots: Vec<nostr_sdk::prelude::PublicKey>, relays: Vec<String>) {
604    {
605        let Ok(mut inflight) = REFRESH_INFLIGHT.lock() else { return };
606        if !inflight.insert(chat_id.clone()) {
607            return; // already fetching for this chat
608        }
609    }
610    let session = crate::db::current_session();
611    crate::db::spawn_bound(async move {
612        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(5));
613        let fetched = fetch_manifests(&transport, &bots, &relays).await;
614        if let Ok(mut inflight) = REFRESH_INFLIGHT.lock() {
615            inflight.remove(&chat_id);
616        }
617        let Ok(found) = fetched else { return }; // transient failure: stay stale, next `/` retries
618        for (pk, manifest, created_at) in &found {
619            if let Ok(json) = serde_json::to_string(manifest) {
620                let _ = crate::db::bots::upsert_bot_manifest(&pk.to_hex(), &json, *created_at);
621            }
622        }
623        let bot_hexes: Vec<String> = bots.iter().map(|p| p.to_hex()).collect();
624        let commands = assemble_from_store(&bot_hexes);
625        mark_commands_fresh(&chat_id, session.id(), &bot_hexes);
626        crate::traits::emit_event(
627            "chat_commands_updated",
628            &serde_json::json!({ "chat_id": chat_id, "bots": bots.len(), "commands": commands }),
629        );
630    });
631}
632
633// ── Network: publish + fetch ─────────────────────────────────────────────────
634
635/// Publish `manifest` as the signed replaceable event over the given relays
636/// (targeted send — the caller decides the reach: login relays, communities,
637/// indexers). Returns how many relays accepted it.
638pub async fn publish_manifest(manifest: &BotManifest, keys: &Keys, relays: &[String]) -> Result<usize, String> {
639    let event = manifest.to_event(keys)?;
640    let client = crate::state::nostr_client().ok_or("no client connected")?;
641    for r in relays {
642        let _ = client.add_managed_relay(r.as_str()).await;
643    }
644    client.connect().await;
645    let out = client
646        .send_event(&event)
647        .to(relays.to_vec())
648        .await
649        .map_err(|e| e.to_string())?;
650    Ok(out.success.len())
651}
652
653/// Fetch + validate a bot's manifest by pubkey from the given relays. Returns
654/// the newest valid one, or `None` when the bot has published no interface.
655pub async fn fetch_manifest(bot: &nostr_sdk::prelude::PublicKey, relays: &[String]) -> Option<BotManifest> {
656    let client = crate::state::nostr_client()?;
657    let filter = nostr_sdk::prelude::Filter::new()
658        .kind(Kind::Custom(KIND_BOT_MANIFEST))
659        .author(*bot)
660        .limit(1);
661    let events = if relays.is_empty() {
662        client.fetch_events(filter).timeout(std::time::Duration::from_secs(8)).await.ok()?
663    } else {
664        client
665            .fetch_events(nostr_sdk::prelude::ReqTarget::manual(
666                relays.iter().cloned().map(|u| (u, vec![filter.clone()])),
667            ))
668            .timeout(std::time::Duration::from_secs(8))
669            .await
670            .ok()?
671    };
672    events
673        .into_iter()
674        .max_by_key(|e| e.created_at)
675        .and_then(|e| BotManifest::from_event(&e).ok())
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use nostr_sdk::prelude::Keys;
682
683    fn price_manifest() -> BotManifest {
684        BotManifest {
685            v: 1,
686            commands: vec![
687                CommandSpec {
688                    name: "price".into(),
689                    description: "Get a coin price".into(),
690                    args: vec![ArgSpec {
691                        name: "asset".into(),
692                        arg_type: ArgType::Choice,
693                        description: "Which coin".into(),
694                        required: true,
695                        choices: vec!["btc".into(), "xmr".into(), "pivx".into()],
696                    }],
697                },
698                CommandSpec {
699                    name: "say".into(),
700                    description: "Echo".into(),
701                    args: vec![
702                        ArgSpec {
703                            name: "count".into(),
704                            arg_type: ArgType::Int,
705                            description: String::new(),
706                            required: true,
707                            choices: vec![],
708                        },
709                        ArgSpec {
710                            name: "text".into(),
711                            arg_type: ArgType::String,
712                            description: String::new(),
713                            required: false,
714                            choices: vec![],
715                        },
716                    ],
717                },
718            ],
719        }
720    }
721
722    #[test]
723    fn manifest_round_trips_through_its_event() {
724        let keys = Keys::generate();
725        let m = price_manifest();
726        let ev = m.to_event(&keys).unwrap();
727        assert_eq!(ev.kind, Kind::Custom(KIND_BOT_MANIFEST));
728        let back = BotManifest::from_event(&ev).unwrap();
729        assert_eq!(back.commands.len(), 2);
730        assert_eq!(back.command("price").unwrap().args[0].choices.len(), 3);
731    }
732
733    /// The positional format needs a hole marker or a later arg is unreachable
734    /// past a skipped one: a composer skipping an optional first Choice used to
735    /// have no way to send the second arg at all.
736    #[test]
737    fn an_empty_token_skips_an_optional_arg_so_later_ones_are_reachable() {
738        let m = BotManifest {
739            v: 1,
740            commands: vec![CommandSpec {
741                name: "listings".into(),
742                description: String::new(),
743                args: vec![
744                    ArgSpec {
745                        name: "category".into(),
746                        arg_type: ArgType::Choice,
747                        description: String::new(),
748                        required: false,
749                        choices: vec!["general".into(), "gaming".into()],
750                    },
751                    ArgSpec {
752                        name: "page".into(),
753                        arg_type: ArgType::Int,
754                        description: String::new(),
755                        required: false,
756                        choices: vec![],
757                    },
758                ],
759            }],
760        };
761        m.validate().expect("a legal manifest — required-first forbids the reverse shape");
762        let spec = m.command("listings").unwrap();
763
764        let parsed = parse_command_text(&m, r#"/listings "" 2"#).unwrap();
765        assert_eq!(parsed.args, vec![("page".to_string(), "2".to_string())], "the hole never becomes an argument");
766        let typed = typed_args(spec, &parsed).expect("a skipped optional passes typing");
767        assert!(!typed.contains_key("category"));
768        assert_eq!(typed.get("page").and_then(|v| v.as_int()), Some(2));
769
770        // A REQUIRED arg's explicit empty is a value, not a hole — typing judges it.
771        let pm = price_manifest();
772        let parsed = parse_command_text(&pm, r#"/say "" hello"#).unwrap();
773        assert_eq!(parsed.args.len(), 2, "an empty required token still binds");
774        assert!(typed_args(pm.command("say").unwrap(), &parsed).is_err(), "and fails as a non-integer, not silently absent");
775    }
776
777    #[test]
778    fn a_quoted_command_word_is_ordinary_chat() {
779        // The grammar is `"/" name`, so a quoted first token is not a command.
780        // The guard has to read the RAW input: `next_token` returns a quoted span
781        // with its quotes stripped, so a check on the token can never fire — and
782        // this parsed as a valid `/price` until it did. A message another client
783        // sends as chat must not execute here.
784        let m = price_manifest();
785        assert!(parse_command_text(&m, r#"/"price" btc"#).is_none());
786        assert!(parse_command_text(&m, r#"/"" btc"#).is_none());
787
788        // The unquoted form still parses, so the guard is narrow.
789        assert!(parse_command_text(&m, "/price btc").is_some());
790    }
791
792    #[test]
793    fn the_command_word_is_case_folded() {
794        // Manifest names are lowercase slugs, so `/PRICE` and `/Say` still resolve.
795        let m = price_manifest();
796        let p = parse_command_text(&m, "/PRICE btc").expect("an uppercase command resolves");
797        assert_eq!(p.name, "price");
798        assert_eq!(p.args, vec![("asset".to_string(), "btc".to_string())]);
799
800        // Only the command WORD folds — argument values keep their case.
801        let p = parse_command_text(&m, "/Say 2 Hello There").expect("a mixed-case command resolves");
802        assert_eq!(p.name, "say");
803        assert_eq!(p.args[1], ("text".to_string(), "Hello There".to_string()));
804    }
805
806    #[test]
807    fn user_args_accept_the_nip21_uri_and_normalize_to_a_bare_npub() {
808        use nostr_sdk::prelude::ToBech32;
809        let npub = Keys::generate().public_key().to_bech32().unwrap();
810        let spec = CommandSpec {
811            name: "greet".into(),
812            description: String::new(),
813            args: vec![ArgSpec {
814                name: "who".into(),
815                arg_type: ArgType::User,
816                description: String::new(),
817                required: true,
818                choices: vec![],
819            }],
820        };
821        let m = BotManifest { v: 1, commands: vec![spec.clone()] };
822
823        // The bare npub — what a compliant picker emits.
824        let p = parse_command_text(&m, &format!("/greet {npub}")).unwrap();
825        assert_eq!(typed_args(&spec, &p).unwrap()["who"], ArgValue::User(npub.clone()));
826
827        // A NIP-21 `nostr:` mention URI: accepted, normalized back to the bare npub.
828        let p = parse_command_text(&m, &format!("/greet nostr:{npub}")).unwrap();
829        assert_eq!(typed_args(&spec, &p).unwrap()["who"], ArgValue::User(npub.clone()));
830
831        // A malformed npub is still refused (the bech32 checksum is verified).
832        let p = parse_command_text(&m, "/greet npub1nope").unwrap();
833        assert!(typed_args(&spec, &p).is_err());
834    }
835
836    #[test]
837    fn typing_errors_are_canonical_and_parsable() {
838        // Every error is `{arg}: {reason}` — byte-identical in any implementation,
839        // so a client splits on the first ": " to recover which arg failed and why.
840        let m = price_manifest();
841        let price = m.command("price").unwrap().clone();
842        let say = m.command("say").unwrap().clone();
843
844        let p = parse_command_text(&m, "/price doge").unwrap();
845        assert_eq!(typed_args(&price, &p).unwrap_err(), "asset: not one of btc, xmr, pivx");
846
847        let p = parse_command_text(&m, "/price").unwrap();
848        assert_eq!(typed_args(&price, &p).unwrap_err(), "asset: required");
849
850        let p = parse_command_text(&m, "/say notanint hi").unwrap();
851        assert_eq!(typed_args(&say, &p).unwrap_err(), "count: not an integer");
852    }
853
854    #[test]
855    fn validation_rejects_the_sharp_edges() {
856        let mut m = price_manifest();
857        m.commands[0].name = "Bad Name".into();
858        assert!(m.validate().is_err());
859
860        let mut m = price_manifest();
861        m.commands.push(m.commands[0].clone());
862        assert!(m.validate().is_err(), "duplicate command name");
863
864        let mut m = price_manifest();
865        m.commands[0].args[0].choices.clear();
866        assert!(m.validate().is_err(), "choice without choices");
867
868        // Required after optional breaks positional text parsing.
869        let mut m = price_manifest();
870        m.commands[1].args[0].required = false;
871        m.commands[1].args[1].required = true;
872        assert!(m.validate().is_err());
873    }
874
875    /// A command with TWO multi-word strings — expressible only via quoting.
876    fn announce_manifest() -> BotManifest {
877        BotManifest {
878            v: 1,
879            commands: vec![CommandSpec {
880                name: "announce".into(),
881                description: "Post an announcement".into(),
882                args: vec![
883                    ArgSpec { name: "title".into(), arg_type: ArgType::String, description: String::new(), required: true, choices: vec![] },
884                    ArgSpec { name: "body".into(), arg_type: ArgType::String, description: String::new(), required: true, choices: vec![] },
885                ],
886            }],
887        }
888    }
889
890    #[test]
891    fn command_text_and_parse_are_inverses() {
892        let m = price_manifest();
893        let args = vec![("asset".to_string(), "btc".to_string())];
894        let content = command_text("price", &args);
895        assert_eq!(content, "/price btc");
896        let p = parse_command_text(&m, &content).unwrap();
897        assert_eq!(p.name, "price");
898        assert_eq!(p.args, args);
899
900        // Multi-word + embedded-quote values round-trip via quoting.
901        let m = announce_manifest();
902        let args = vec![
903            ("title".to_string(), "Big \"news\" day".to_string()),
904            ("body".to_string(), "Meeting at 5pm".to_string()),
905        ];
906        let content = command_text("announce", &args);
907        let p = parse_command_text(&m, &content).unwrap();
908        assert_eq!(p.args, args);
909
910        // Adversarial values: no value can escape its quoted slot — every
911        // combination of quotes/backslashes/newlines re-parses to itself.
912        for nasty in [
913            "\"",                        // a lone quote
914            "\\",                        // a lone backslash
915            "ends with backslash \\",    // trailing backslash before the closing quote
916            "\\\" fake close",           // escaped-quote prefix
917            "line one\nline two",        // literal newline inside a value
918            "\" \\\" \\\\ \"\"",         // quote/escape soup
919            " leading and trailing ",    // spaces preserved verbatim
920        ] {
921            let args = vec![
922                ("title".to_string(), nasty.to_string()),
923                ("body".to_string(), format!("after {nasty} end")),
924            ];
925            let content = command_text("announce", &args);
926            let p = parse_command_text(&m, &content)
927                .unwrap_or_else(|| panic!("adversarial value failed to re-parse: {nasty:?}"));
928            assert_eq!(p.args, args, "value must survive the wire byte-exact: {nasty:?}");
929        }
930    }
931
932    #[test]
933    fn text_parses_positionally_and_greedily() {
934        let m = price_manifest();
935        let p = parse_command_text(&m, "/price btc").unwrap();
936        assert_eq!(p.args, vec![("asset".to_string(), "btc".to_string())]);
937
938        // Unquoted trailing String arg swallows the RAW remainder (spacing kept).
939        let p = parse_command_text(&m, "/say 3 hello  there world").unwrap();
940        assert_eq!(p.args[0], ("count".to_string(), "3".to_string()));
941        assert_eq!(p.args[1], ("text".to_string(), "hello  there world".to_string()));
942
943        assert!(parse_command_text(&m, "/unknown x").is_none());
944        assert!(parse_command_text(&m, "not a command").is_none());
945        assert!(parse_command_text(&m, "/").is_none());
946    }
947
948    #[test]
949    fn bot_recipient_tags_extract_dedup_and_cap() {
950        use nostr_sdk::prelude::ToBech32;
951        let a = Keys::generate().public_key();
952        let b = Keys::generate().public_key();
953        let tags: Vec<Tag> = vec![
954            bot_tag(&a),
955            bot_tag(&a), // dup
956            bot_tag(&b),
957            Tag::custom(TAG_BOT, ["nothex"]),
958            Tag::custom("p", [a.to_hex()]), // not ours
959        ];
960        let out = addressed_bots(tags.iter());
961        assert_eq!(out, vec![a.to_bech32().unwrap(), b.to_bech32().unwrap()]);
962
963        // Cap: 20 distinct tags → MAX_BOT_TAGS honored.
964        let many: Vec<Tag> = (0..20).map(|_| bot_tag(&Keys::generate().public_key())).collect();
965        assert_eq!(addressed_bots(many.iter()).len(), MAX_BOT_TAGS);
966
967        // Untagged → empty (broadcast).
968        assert!(addressed_bots([].iter()).is_empty());
969    }
970
971    #[test]
972    fn quoting_terminates_multi_word_values() {
973        let m = announce_manifest();
974        // Both strings quoted — unambiguous.
975        let p = parse_command_text(&m, r#"/announce "Hello everyone" "Meeting at 5pm""#).unwrap();
976        assert_eq!(p.args[0].1, "Hello everyone");
977        assert_eq!(p.args[1].1, "Meeting at 5pm");
978
979        // First quoted, trailing unquoted → greedy tail.
980        let p = parse_command_text(&m, r#"/announce "Hello everyone" Meeting at 5pm"#).unwrap();
981        assert_eq!(p.args[0].1, "Hello everyone");
982        assert_eq!(p.args[1].1, "Meeting at 5pm");
983
984        // Unquoted first string takes ONE word (position rules unchanged).
985        let p = parse_command_text(&m, "/announce Hello Meeting at 5pm").unwrap();
986        assert_eq!(p.args[0].1, "Hello");
987        assert_eq!(p.args[1].1, "Meeting at 5pm");
988
989        // Escapes inside quotes.
990        let p = parse_command_text(&m, r#"/announce "say \"hi\" \\ ok" done"#).unwrap();
991        assert_eq!(p.args[0].1, r#"say "hi" \ ok"#);
992
993        // Unterminated quote → not a command (ordinary chat).
994        assert!(parse_command_text(&m, r#"/announce "dangling"#).is_none());
995    }
996
997    #[tokio::test]
998    async fn batch_fetch_returns_newest_valid_per_author_and_ignores_strangers() {
999        use crate::community::transport::{memory::MemoryRelay, Transport};
1000        use nostr_sdk::prelude::Timestamp;
1001        let relay = MemoryRelay::new();
1002        let relays = vec!["r1".to_string()];
1003        let bot_a = Keys::generate();
1004        let bot_b = Keys::generate();
1005        let stranger = Keys::generate();
1006
1007        let manifest_event = |m: &BotManifest, keys: &Keys, at: u64| {
1008            EventBuilder::new(Kind::Custom(KIND_BOT_MANIFEST), serde_json::to_string(m).unwrap())
1009                .custom_created_at(Timestamp::from_secs(at))
1010                .finalize(keys)
1011                .unwrap()
1012        };
1013        // A: an old manifest, then a newer edition with a different command set.
1014        let old_ev = manifest_event(&price_manifest(), &bot_a, 100);
1015        let newer = BotManifest {
1016            v: 1,
1017            commands: vec![CommandSpec { name: "newer".into(), description: "n".into(), args: vec![] }],
1018        };
1019        let new_ev = manifest_event(&newer, &bot_a, 200);
1020        // B: newest is garbage — B has no usable interface (no fallback to older).
1021        let b_garbage = EventBuilder::new(Kind::Custom(KIND_BOT_MANIFEST), "not json")
1022            .custom_created_at(Timestamp::from_secs(300))
1023            .finalize(&bot_b)
1024            .unwrap();
1025        // Stranger: a VALID manifest outside the asked author set.
1026        let s_ev = price_manifest().to_event(&stranger).unwrap();
1027        for ev in [&old_ev, &new_ev, &b_garbage, &s_ev] {
1028            relay.publish(ev, &relays).await.unwrap();
1029        }
1030
1031        let found = fetch_manifests(&relay, &[bot_a.public_key(), bot_b.public_key()], &relays)
1032            .await
1033            .unwrap();
1034        assert_eq!(found.len(), 1, "only A has a usable newest manifest: {found:?}");
1035        assert_eq!(found[0].0, bot_a.public_key());
1036        assert!(found[0].1.command("newer").is_some(), "the newest edition won");
1037        assert!(found[0].1.command("price").is_none(), "the older edition lost");
1038        assert_eq!(found[0].2, 200, "the winning edition's timestamp rides along");
1039
1040        let none = fetch_manifests(&relay, &[], &relays).await.unwrap();
1041        assert!(none.is_empty(), "empty author set short-circuits");
1042    }
1043
1044    #[test]
1045    fn command_freshness_is_generation_ttl_and_botset_scoped() {
1046        // Serialize with bed tests — they bump the session generation mid-test.
1047        let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner());
1048        let generation = crate::db::current_session_id();
1049        let bots = vec!["aa".to_string(), "bb".to_string()];
1050
1051        assert!(!commands_fresh("cmd-fresh-a", &bots), "unseen chat is stale");
1052        mark_commands_fresh("cmd-fresh-a", generation, &bots);
1053        assert!(commands_fresh("cmd-fresh-a", &bots));
1054
1055        // A CHANGED bot set is immediately stale (a bot joined/left the room).
1056        let grown = vec!["aa".to_string(), "bb".to_string(), "cc".to_string()];
1057        assert!(!commands_fresh("cmd-fresh-a", &grown));
1058
1059        // Another generation's entry is invisible — the account-swap invalidation.
1060        mark_commands_fresh("cmd-fresh-b", generation.wrapping_add(1), &bots);
1061        assert!(!commands_fresh("cmd-fresh-b", &bots));
1062    }
1063
1064    #[test]
1065    fn typing_enforces_the_manifest() {
1066        let m = price_manifest();
1067        let spec = m.command("price").unwrap();
1068
1069        let ok = ParsedCommand {
1070            name: "price".into(),
1071            args: vec![("asset".into(), "btc".into())],
1072        };
1073        let t = typed_args(spec, &ok).unwrap();
1074        assert_eq!(t["asset"], ArgValue::Choice("btc".into()));
1075
1076        let bad_choice = ParsedCommand {
1077            name: "price".into(),
1078            args: vec![("asset".into(), "doge".into())],
1079        };
1080        assert!(typed_args(spec, &bad_choice).is_err());
1081
1082        let missing = ParsedCommand { name: "price".into(), args: vec![] };
1083        assert!(typed_args(spec, &missing).is_err());
1084
1085        // Unknown arg names are dropped, not fatal (newer-manifest tolerance).
1086        let extra = ParsedCommand {
1087            name: "price".into(),
1088            args: vec![("asset".into(), "btc".into()), ("future".into(), "1".into())],
1089        };
1090        let t = typed_args(spec, &extra).unwrap();
1091        assert!(!t.contains_key("future"));
1092
1093        let spec = m.command("say").unwrap();
1094        let typed = typed_args(
1095            spec,
1096            &ParsedCommand {
1097                name: "say".into(),
1098                args: vec![("count".into(), "5".into()), ("text".into(), "hi".into())],
1099            },
1100        )
1101        .unwrap();
1102        assert_eq!(typed["count"].as_int(), Some(5));
1103        assert_eq!(typed["text"].as_str(), "hi");
1104
1105        let not_int = ParsedCommand {
1106            name: "say".into(),
1107            args: vec![("count".into(), "many".into())],
1108        };
1109        assert!(typed_args(spec, &not_int).is_err());
1110    }
1111}