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 args.push((a.name.clone(), value));
362 }
363 Some(ParsedCommand { name, args })
364}
365
366#[derive(Clone, Debug, PartialEq)]
370pub enum ArgValue {
371 String(String),
372 Int(i64),
373 Number(f64),
374 Bool(bool),
375 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
408pub 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 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#[derive(Serialize, Clone, Debug)]
466pub struct ChatBotCommands {
467 pub bot: String,
469 pub commands: Vec<CommandSpec>,
470}
471
472pub const DISCOVERY_RELAYS: &[&str] =
478 &["wss://purplepag.es", "wss://relay.nostr.band", "wss://nos.lol"];
479
480#[derive(Serialize, Clone, Debug)]
484pub struct ChatCommandsSnapshot {
485 pub bots: usize,
487 pub commands: Vec<ChatBotCommands>,
490 pub fresh: bool,
492}
493
494pub 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
537pub 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
556const 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()));
564static 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
568pub 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
591pub 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; }
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 }; 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
631pub 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
651pub 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 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 assert!(parse_command_text(&m, "/price btc").is_some());
744 }
745
746 #[test]
747 fn the_command_word_is_case_folded() {
748 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 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 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 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 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 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 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 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 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 for nasty in [
867 "\"", "\\", "ends with backslash \\", "\\\" fake close", "line one\nline two", "\" \\\" \\\\ \"\"", " leading and trailing ", ] {
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 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), bot_tag(&b),
911 Tag::custom(TAG_BOT, ["nothex"]),
912 Tag::custom("p", [a.to_hex()]), ];
914 let out = addressed_bots(tags.iter());
915 assert_eq!(out, vec![a.to_bech32().unwrap(), b.to_bech32().unwrap()]);
916
917 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 assert!(addressed_bots([].iter()).is_empty());
923 }
924
925 #[test]
926 fn quoting_terminates_multi_word_values() {
927 let m = announce_manifest();
928 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 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 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 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 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 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 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 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 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 let grown = vec!["aa".to_string(), "bb".to_string(), "cc".to_string()];
1011 assert!(!commands_fresh("cmd-fresh-a", &grown));
1012
1013 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 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, ¬_int).is_err());
1064 }
1065}