1use 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
31pub const KIND_BOT_MANIFEST: u16 = 10304;
34
35pub const TAG_BOT: &str = "bot";
46
47pub const MAX_BOT_TAGS: usize = 8;
49
50pub fn bot_tag(bot: &nostr_sdk::prelude::PublicKey) -> Tag {
52 Tag::custom(TAG_BOT, [bot.to_hex()])
53}
54
55pub 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
78pub 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;
86pub const MAX_ARG_VALUE_LEN: usize = 1_024;
88
89#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
93#[serde(rename_all = "lowercase")]
94pub enum ArgType {
95 String,
98 Int,
100 Number,
102 Bool,
104 User,
106 Choice,
108}
109
110#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
122 pub choices: Vec<String>,
123}
124
125#[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#[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
143fn 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 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 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 pub fn command(&self, name: &str) -> Option<&CommandSpec> {
218 self.commands.iter().find(|c| c.name == name)
219 }
220
221 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 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#[derive(Clone, Debug, PartialEq)]
251pub struct ParsedCommand {
252 pub name: String,
253 pub args: Vec<(String, String)>,
255}
256
257pub 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
276fn 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 let ch = s[i..].chars().next()?;
300 out.push(ch);
301 i += ch.len_utf8();
302 }
303 }
304 }
305 None } 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
316pub fn parse_command_text(manifest: &BotManifest, content: &str) -> Option<ParsedCommand> {
326 let content = content.trim();
327 let rest = content.strip_prefix('/')?;
328 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 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 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 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#[derive(Clone, Debug, PartialEq)]
378pub enum ArgValue {
379 String(String),
380 Int(i64),
381 Number(f64),
382 Bool(bool),
383 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
416pub 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 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#[derive(Serialize, Clone, Debug)]
474pub struct ChatBotCommands {
475 pub bot: String,
477 pub commands: Vec<CommandSpec>,
478}
479
480pub const DISCOVERY_RELAYS: &[&str] =
486 &["wss://purplepag.es", "wss://relay.nostr.band", "wss://nos.lol"];
487
488#[derive(Serialize, Clone, Debug)]
492pub struct ChatCommandsSnapshot {
493 pub bots: usize,
495 pub commands: Vec<ChatBotCommands>,
498 pub fresh: bool,
500}
501
502pub 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
545pub 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
564const 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()));
572static 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
576pub 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
599pub 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; }
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 }; 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
633pub 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
653pub 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 #[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 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 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 assert!(parse_command_text(&m, "/price btc").is_some());
790 }
791
792 #[test]
793 fn the_command_word_is_case_folded() {
794 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 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 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 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 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 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 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 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 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 for nasty in [
913 "\"", "\\", "ends with backslash \\", "\\\" fake close", "line one\nline two", "\" \\\" \\\\ \"\"", " leading and trailing ", ] {
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 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), bot_tag(&b),
957 Tag::custom(TAG_BOT, ["nothex"]),
958 Tag::custom("p", [a.to_hex()]), ];
960 let out = addressed_bots(tags.iter());
961 assert_eq!(out, vec![a.to_bech32().unwrap(), b.to_bech32().unwrap()]);
962
963 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 assert!(addressed_bots([].iter()).is_empty());
969 }
970
971 #[test]
972 fn quoting_terminates_multi_word_values() {
973 let m = announce_manifest();
974 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 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 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 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 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 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 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 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 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 let grown = vec!["aa".to_string(), "bb".to_string(), "cc".to_string()];
1057 assert!(!commands_fresh("cmd-fresh-a", &grown));
1058
1059 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 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, ¬_int).is_err());
1110 }
1111}