1use std::collections::HashMap;
25
26use nostr_sdk::prelude::{Event, EventBuilder, Keys, Kind, Tag};
27use serde::{Deserialize, Serialize};
28
29pub const KIND_BOT_MANIFEST: u16 = 10304;
32
33pub const TAG_BOT: &str = "bot";
44
45pub const MAX_BOT_TAGS: usize = 8;
47
48pub fn bot_tag(bot: &nostr_sdk::prelude::PublicKey) -> Tag {
50 Tag::custom(nostr_sdk::prelude::TagKind::Custom(TAG_BOT.into()), [bot.to_hex()])
51}
52
53pub fn addressed_bots<'a, I: IntoIterator<Item = &'a Tag>>(tags: I) -> Vec<String> {
56 use nostr_sdk::prelude::ToBech32;
57 let mut out: Vec<String> = Vec::new();
58 for t in tags {
59 let s = t.as_slice();
60 if s.first().map(|k| k.as_str()) != Some(TAG_BOT) {
61 continue;
62 }
63 let Some(v) = s.get(1) else { continue };
64 let Ok(pk) = nostr_sdk::prelude::PublicKey::from_hex(v) else { continue };
65 let Ok(npub) = pk.to_bech32() else { continue };
66 if !out.contains(&npub) {
67 out.push(npub);
68 }
69 if out.len() >= MAX_BOT_TAGS {
70 break;
71 }
72 }
73 out
74}
75
76pub const MAX_COMMANDS: usize = 64;
79pub const MAX_ARGS: usize = 8;
80pub const MAX_CHOICES: usize = 32;
81pub const MAX_NAME_LEN: usize = 32;
82pub const MAX_DESCRIPTION_LEN: usize = 200;
83pub const MAX_MANIFEST_BYTES: usize = 32_768;
84pub const MAX_ARG_VALUE_LEN: usize = 1_024;
86
87#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
91#[serde(rename_all = "lowercase")]
92pub enum ArgType {
93 String,
96 Int,
98 Number,
100 Bool,
102 User,
104 Choice,
106}
107
108#[derive(Serialize, Deserialize, Clone, Debug)]
110pub struct ArgSpec {
111 pub name: String,
112 #[serde(rename = "type")]
113 pub arg_type: ArgType,
114 #[serde(default)]
115 pub description: String,
116 #[serde(default)]
117 pub required: bool,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub choices: Vec<String>,
121}
122
123#[derive(Serialize, Deserialize, Clone, Debug)]
125pub struct CommandSpec {
126 pub name: String,
127 pub description: String,
128 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub args: Vec<ArgSpec>,
130}
131
132#[derive(Serialize, Deserialize, Clone, Debug)]
135pub struct BotManifest {
136 pub v: u32,
137 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 pub commands: Vec<CommandSpec>,
139}
140
141fn valid_name(s: &str) -> bool {
144 !s.is_empty()
145 && s.len() <= MAX_NAME_LEN
146 && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
147}
148
149impl BotManifest {
150 pub fn validate(&self) -> Result<(), String> {
153 if self.v != 1 {
154 return Err(format!("unsupported manifest version {}", self.v));
155 }
156 if self.commands.len() > MAX_COMMANDS {
157 return Err(format!("too many commands ({} > {MAX_COMMANDS})", self.commands.len()));
158 }
159 let mut seen = std::collections::HashSet::new();
160 for c in &self.commands {
161 if !valid_name(&c.name) {
162 return Err(format!("bad command name {:?}", c.name));
163 }
164 if !seen.insert(c.name.as_str()) {
165 return Err(format!("duplicate command {:?}", c.name));
166 }
167 if c.description.len() > MAX_DESCRIPTION_LEN {
168 return Err(format!("description too long on /{}", c.name));
169 }
170 if c.args.len() > MAX_ARGS {
171 return Err(format!("too many args on /{}", c.name));
172 }
173 let mut arg_seen = std::collections::HashSet::new();
174 let mut optional_seen = false;
175 for a in &c.args {
176 if !valid_name(&a.name) {
177 return Err(format!("bad arg name {:?} on /{}", a.name, c.name));
178 }
179 if !arg_seen.insert(a.name.as_str()) {
180 return Err(format!("duplicate arg {:?} on /{}", a.name, c.name));
181 }
182 if a.description.len() > MAX_DESCRIPTION_LEN {
183 return Err(format!("arg description too long on /{}", c.name));
184 }
185 if a.required && optional_seen {
188 return Err(format!("required arg {:?} after an optional one on /{}", a.name, c.name));
189 }
190 optional_seen |= !a.required;
191 match a.arg_type {
192 ArgType::Choice => {
193 if a.choices.is_empty() || a.choices.len() > MAX_CHOICES {
194 return Err(format!("choice arg {:?} needs 1..={MAX_CHOICES} choices", a.name));
195 }
196 if a.choices.iter().any(|ch| ch.is_empty() || ch.len() > MAX_NAME_LEN) {
197 return Err(format!("bad choice value on {:?}", a.name));
198 }
199 }
200 _ if !a.choices.is_empty() => {
201 return Err(format!("choices on non-choice arg {:?}", a.name));
202 }
203 _ => {}
204 }
205 }
206 }
207 let bytes = serde_json::to_string(self).map_err(|e| e.to_string())?.len();
208 if bytes > MAX_MANIFEST_BYTES {
209 return Err(format!("manifest too large ({bytes} > {MAX_MANIFEST_BYTES} bytes)"));
210 }
211 Ok(())
212 }
213
214 pub fn command(&self, name: &str) -> Option<&CommandSpec> {
216 self.commands.iter().find(|c| c.name == name)
217 }
218
219 pub fn from_event(event: &Event) -> Result<Self, String> {
222 if event.kind != Kind::Custom(KIND_BOT_MANIFEST) {
223 return Err(format!("not a bot manifest (kind {})", event.kind));
224 }
225 if event.content.len() > MAX_MANIFEST_BYTES {
226 return Err("manifest content over the size cap".into());
227 }
228 let m: BotManifest = serde_json::from_str(&event.content).map_err(|e| format!("manifest parse: {e}"))?;
229 m.validate()?;
230 Ok(m)
231 }
232
233 pub fn to_event(&self, keys: &Keys) -> Result<Event, String> {
236 self.validate()?;
237 let content = serde_json::to_string(self).map_err(|e| e.to_string())?;
238 EventBuilder::new(Kind::Custom(KIND_BOT_MANIFEST), content)
239 .sign_with_keys(keys)
240 .map_err(|e| e.to_string())
241 }
242}
243
244#[derive(Clone, Debug, PartialEq)]
249pub struct ParsedCommand {
250 pub name: String,
251 pub args: Vec<(String, String)>,
253}
254
255pub fn command_text(name: &str, args: &[(String, String)]) -> String {
260 let mut out = format!("/{name}");
261 for (_, v) in args {
262 out.push(' ');
263 if v.is_empty() || v.contains(char::is_whitespace) || v.contains('"') {
264 out.push('"');
265 out.push_str(&v.replace('\\', "\\\\").replace('"', "\\\""));
266 out.push('"');
267 } else {
268 out.push_str(v);
269 }
270 }
271 out
272}
273
274fn next_token(s: &str, mut i: usize) -> Option<(String, usize)> {
278 let b = s.as_bytes();
279 while i < b.len() && b[i].is_ascii_whitespace() {
280 i += 1;
281 }
282 if i >= b.len() {
283 return None;
284 }
285 let mut out = String::new();
286 if b[i] == b'"' {
287 i += 1;
288 while i < b.len() {
289 match b[i] {
290 b'\\' if i + 1 < b.len() && (b[i + 1] == b'"' || b[i + 1] == b'\\') => {
291 out.push(b[i + 1] as char);
292 i += 2;
293 }
294 b'"' => return Some((out, i + 1)),
295 _ => {
296 let ch = s[i..].chars().next()?;
298 out.push(ch);
299 i += ch.len_utf8();
300 }
301 }
302 }
303 None } else {
305 let start = i;
306 while i < b.len() && !b[i].is_ascii_whitespace() {
307 i += 1;
308 }
309 out.push_str(&s[start..i]);
310 Some((out, i))
311 }
312}
313
314pub fn parse_command_text(manifest: &BotManifest, content: &str) -> Option<ParsedCommand> {
324 let content = content.trim();
325 let rest = content.strip_prefix('/')?;
326 if rest.starts_with('"') {
330 return None;
331 }
332 let (raw_name, mut cursor) = next_token(rest, 0)?;
333 if raw_name.is_empty() {
334 return None;
335 }
336 let name = raw_name.to_ascii_lowercase();
339 let spec = manifest.command(&name)?;
340 let mut args: Vec<(String, String)> = Vec::new();
341 for (i, a) in spec.args.iter().enumerate() {
342 let remainder = rest.get(cursor..).unwrap_or("").trim_start();
343 if remainder.is_empty() {
344 break;
345 }
346 let is_last_declared = i + 1 == spec.args.len();
347 let value = if is_last_declared && matches!(a.arg_type, ArgType::String) && !remainder.starts_with('"') {
348 cursor = rest.len();
350 remainder.trim_end().to_string()
351 } else {
352 let (tok, next) = next_token(rest, cursor)?;
353 cursor = next;
354 tok
355 };
356 if value.len() > MAX_ARG_VALUE_LEN {
357 return None;
358 }
359 args.push((a.name.clone(), value));
360 }
361 Some(ParsedCommand { name, args })
362}
363
364#[derive(Clone, Debug, PartialEq)]
368pub enum ArgValue {
369 String(String),
370 Int(i64),
371 Number(f64),
372 Bool(bool),
373 User(String),
375 Choice(String),
376}
377
378impl ArgValue {
379 pub fn as_str(&self) -> &str {
380 match self {
381 ArgValue::String(s) | ArgValue::User(s) | ArgValue::Choice(s) => s,
382 _ => "",
383 }
384 }
385 pub fn as_int(&self) -> Option<i64> {
386 match self {
387 ArgValue::Int(i) => Some(*i),
388 _ => None,
389 }
390 }
391 pub fn as_number(&self) -> Option<f64> {
392 match self {
393 ArgValue::Number(n) => Some(*n),
394 ArgValue::Int(i) => Some(*i as f64),
395 _ => None,
396 }
397 }
398 pub fn as_bool(&self) -> Option<bool> {
399 match self {
400 ArgValue::Bool(b) => Some(*b),
401 _ => None,
402 }
403 }
404}
405
406pub fn typed_args(spec: &CommandSpec, parsed: &ParsedCommand) -> Result<HashMap<String, ArgValue>, String> {
416 let mut out = HashMap::new();
417 for (k, v) in &parsed.args {
418 let Some(a) = spec.args.iter().find(|a| &a.name == k) else {
419 continue;
420 };
421 let typed = match a.arg_type {
422 ArgType::String => ArgValue::String(v.clone()),
423 ArgType::Int => ArgValue::Int(v.parse::<i64>().map_err(|_| format!("{k}: not an integer"))?),
424 ArgType::Number => ArgValue::Number(v.parse::<f64>().map_err(|_| format!("{k}: not a number"))?),
425 ArgType::Bool => match v.to_ascii_lowercase().as_str() {
426 "true" | "yes" | "1" => ArgValue::Bool(true),
427 "false" | "no" | "0" => ArgValue::Bool(false),
428 _ => return Err(format!("{k}: not a boolean")),
429 },
430 ArgType::User => {
431 let raw = v.strip_prefix("nostr:").unwrap_or(v);
435 if !raw.starts_with("npub1") {
436 return Err(format!("{k}: not an npub"));
437 }
438 let pk = nostr_sdk::prelude::PublicKey::parse(raw).map_err(|_| format!("{k}: not an npub"))?;
439 let npub = nostr_sdk::prelude::ToBech32::to_bech32(&pk).map_err(|_| format!("{k}: not an npub"))?;
440 ArgValue::User(npub)
441 }
442 ArgType::Choice => {
443 if !a.choices.iter().any(|c| c == v) {
444 return Err(format!("{k}: not one of {}", a.choices.join(", ")));
445 }
446 ArgValue::Choice(v.clone())
447 }
448 };
449 out.insert(k.clone(), typed);
450 }
451 for a in &spec.args {
452 if a.required && !out.contains_key(&a.name) {
453 return Err(format!("{}: required", a.name));
454 }
455 }
456 Ok(out)
457}
458
459#[derive(Serialize, Clone, Debug)]
464pub struct ChatBotCommands {
465 pub bot: String,
467 pub commands: Vec<CommandSpec>,
468}
469
470pub const DISCOVERY_RELAYS: &[&str] =
476 &["wss://purplepag.es", "wss://relay.nostr.band", "wss://relay.damus.io", "wss://nos.lol"];
477
478#[derive(Serialize, Clone, Debug)]
482pub struct ChatCommandsSnapshot {
483 pub bots: usize,
485 pub commands: Vec<ChatBotCommands>,
488 pub fresh: bool,
490}
491
492pub async fn fetch_manifests<T: crate::community::transport::Transport + ?Sized>(
501 transport: &T,
502 authors: &[nostr_sdk::prelude::PublicKey],
503 relays: &[String],
504) -> Result<Vec<(nostr_sdk::prelude::PublicKey, BotManifest, u64)>, String> {
505 use crate::community::transport::Query;
506 if authors.is_empty() {
507 return Ok(Vec::new());
508 }
509 let query = Query {
510 kinds: vec![KIND_BOT_MANIFEST],
511 authors: authors.iter().map(|p| p.to_hex()).collect(),
512 ..Default::default()
513 };
514 let events = transport.fetch(&query, relays).await?;
515 let mut best: HashMap<nostr_sdk::prelude::PublicKey, &Event> = HashMap::new();
516 for ev in &events {
517 if !authors.contains(&ev.pubkey) || ev.verify().is_err() {
518 continue;
519 }
520 match best.get(&ev.pubkey) {
521 Some(b) if b.created_at >= ev.created_at => {}
522 _ => {
523 best.insert(ev.pubkey, ev);
524 }
525 }
526 }
527 let mut out: Vec<(nostr_sdk::prelude::PublicKey, BotManifest, u64)> = best
528 .into_iter()
529 .filter_map(|(pk, ev)| BotManifest::from_event(ev).ok().map(|m| (pk, m, ev.created_at.as_secs())))
530 .collect();
531 out.sort_by_key(|(pk, _, _)| pk.to_hex());
532 Ok(out)
533}
534
535pub fn assemble_from_store(bot_hexes: &[String]) -> Vec<ChatBotCommands> {
539 use nostr_sdk::prelude::ToBech32;
540 let rows = crate::db::bots::get_bot_manifests(bot_hexes).unwrap_or_default();
541 let by_pk: HashMap<&str, &str> = rows.iter().map(|(pk, m)| (pk.as_str(), m.as_str())).collect();
542 bot_hexes
543 .iter()
544 .filter_map(|hex| {
545 let json = by_pk.get(hex.as_str())?;
546 let manifest: BotManifest = serde_json::from_str(json).ok()?;
547 manifest.validate().ok()?;
548 let npub = nostr_sdk::prelude::PublicKey::from_hex(hex).ok()?.to_bech32().ok()?;
549 Some(ChatBotCommands { bot: npub, commands: manifest.commands })
550 })
551 .collect()
552}
553
554const COMMANDS_TTL: std::time::Duration = std::time::Duration::from_secs(60);
559static COMMANDS_FRESH: std::sync::LazyLock<
560 std::sync::Mutex<HashMap<String, (u64, std::time::Instant, Vec<String>)>>,
561> = std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
562static REFRESH_INFLIGHT: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
564 std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
565
566pub fn commands_fresh(chat_id: &str, bot_hexes: &[String]) -> bool {
569 let generation = crate::state::SessionGuard::capture().generation();
570 let map = match COMMANDS_FRESH.lock() {
571 Ok(m) => m,
572 Err(_) => return false,
573 };
574 match map.get(chat_id) {
575 Some((g, at, bots)) => *g == generation && at.elapsed() < COMMANDS_TTL && bots == bot_hexes,
576 None => false,
577 }
578}
579
580fn mark_commands_fresh(chat_id: &str, generation: u64, bot_hexes: &[String]) {
581 if let Ok(mut map) = COMMANDS_FRESH.lock() {
582 if map.len() > 256 {
583 map.clear();
584 }
585 map.insert(chat_id.to_string(), (generation, std::time::Instant::now(), bot_hexes.to_vec()));
586 }
587}
588
589pub fn spawn_commands_refresh(chat_id: String, bots: Vec<nostr_sdk::prelude::PublicKey>, relays: Vec<String>) {
594 {
595 let Ok(mut inflight) = REFRESH_INFLIGHT.lock() else { return };
596 if !inflight.insert(chat_id.clone()) {
597 return; }
599 }
600 let session = crate::state::SessionGuard::capture();
601 tokio::spawn(async move {
602 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(5));
603 let fetched = fetch_manifests(&transport, &bots, &relays).await;
604 if let Ok(mut inflight) = REFRESH_INFLIGHT.lock() {
605 inflight.remove(&chat_id);
606 }
607 let Ok(found) = fetched else { return }; if !session.is_valid() {
609 return;
610 }
611 for (pk, manifest, created_at) in &found {
612 if let Ok(json) = serde_json::to_string(manifest) {
613 let _ = crate::db::bots::upsert_bot_manifest(&pk.to_hex(), &json, *created_at);
614 }
615 }
616 let bot_hexes: Vec<String> = bots.iter().map(|p| p.to_hex()).collect();
617 let commands = assemble_from_store(&bot_hexes);
618 if !session.is_valid() {
619 return;
620 }
621 mark_commands_fresh(&chat_id, session.generation(), &bot_hexes);
622 crate::traits::emit_event(
623 "chat_commands_updated",
624 &serde_json::json!({ "chat_id": chat_id, "bots": bots.len(), "commands": commands }),
625 );
626 });
627}
628
629pub async fn publish_manifest(manifest: &BotManifest, keys: &Keys, relays: &[String]) -> Result<usize, String> {
635 let event = manifest.to_event(keys)?;
636 let client = crate::state::nostr_client().ok_or("no client connected")?;
637 for r in relays {
638 let _ = client.add_relay(r.as_str()).await;
639 }
640 client.connect().await;
641 let out = client
642 .send_event_to(relays.to_vec(), &event)
643 .await
644 .map_err(|e| e.to_string())?;
645 Ok(out.success.len())
646}
647
648pub async fn fetch_manifest(bot: &nostr_sdk::prelude::PublicKey, relays: &[String]) -> Option<BotManifest> {
651 let client = crate::state::nostr_client()?;
652 let filter = nostr_sdk::prelude::Filter::new()
653 .kind(Kind::Custom(KIND_BOT_MANIFEST))
654 .author(*bot)
655 .limit(1);
656 let events = if relays.is_empty() {
657 client.fetch_events(filter, std::time::Duration::from_secs(8)).await.ok()?
658 } else {
659 client
660 .fetch_events_from(relays.to_vec(), filter, std::time::Duration::from_secs(8))
661 .await
662 .ok()?
663 };
664 events
665 .into_iter()
666 .max_by_key(|e| e.created_at)
667 .and_then(|e| BotManifest::from_event(&e).ok())
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use nostr_sdk::prelude::Keys;
674
675 fn price_manifest() -> BotManifest {
676 BotManifest {
677 v: 1,
678 commands: vec![
679 CommandSpec {
680 name: "price".into(),
681 description: "Get a coin price".into(),
682 args: vec![ArgSpec {
683 name: "asset".into(),
684 arg_type: ArgType::Choice,
685 description: "Which coin".into(),
686 required: true,
687 choices: vec!["btc".into(), "xmr".into(), "pivx".into()],
688 }],
689 },
690 CommandSpec {
691 name: "say".into(),
692 description: "Echo".into(),
693 args: vec![
694 ArgSpec {
695 name: "count".into(),
696 arg_type: ArgType::Int,
697 description: String::new(),
698 required: true,
699 choices: vec![],
700 },
701 ArgSpec {
702 name: "text".into(),
703 arg_type: ArgType::String,
704 description: String::new(),
705 required: false,
706 choices: vec![],
707 },
708 ],
709 },
710 ],
711 }
712 }
713
714 #[test]
715 fn manifest_round_trips_through_its_event() {
716 let keys = Keys::generate();
717 let m = price_manifest();
718 let ev = m.to_event(&keys).unwrap();
719 assert_eq!(ev.kind, Kind::Custom(KIND_BOT_MANIFEST));
720 let back = BotManifest::from_event(&ev).unwrap();
721 assert_eq!(back.commands.len(), 2);
722 assert_eq!(back.command("price").unwrap().args[0].choices.len(), 3);
723 }
724
725 #[test]
726 fn a_quoted_command_word_is_ordinary_chat() {
727 let m = price_manifest();
733 assert!(parse_command_text(&m, r#"/"price" btc"#).is_none());
734 assert!(parse_command_text(&m, r#"/"" btc"#).is_none());
735
736 assert!(parse_command_text(&m, "/price btc").is_some());
738 }
739
740 #[test]
741 fn the_command_word_is_case_folded() {
742 let m = price_manifest();
744 let p = parse_command_text(&m, "/PRICE btc").expect("an uppercase command resolves");
745 assert_eq!(p.name, "price");
746 assert_eq!(p.args, vec![("asset".to_string(), "btc".to_string())]);
747
748 let p = parse_command_text(&m, "/Say 2 Hello There").expect("a mixed-case command resolves");
750 assert_eq!(p.name, "say");
751 assert_eq!(p.args[1], ("text".to_string(), "Hello There".to_string()));
752 }
753
754 #[test]
755 fn user_args_accept_the_nip21_uri_and_normalize_to_a_bare_npub() {
756 use nostr_sdk::prelude::ToBech32;
757 let npub = Keys::generate().public_key().to_bech32().unwrap();
758 let spec = CommandSpec {
759 name: "greet".into(),
760 description: String::new(),
761 args: vec![ArgSpec {
762 name: "who".into(),
763 arg_type: ArgType::User,
764 description: String::new(),
765 required: true,
766 choices: vec![],
767 }],
768 };
769 let m = BotManifest { v: 1, commands: vec![spec.clone()] };
770
771 let p = parse_command_text(&m, &format!("/greet {npub}")).unwrap();
773 assert_eq!(typed_args(&spec, &p).unwrap()["who"], ArgValue::User(npub.clone()));
774
775 let p = parse_command_text(&m, &format!("/greet nostr:{npub}")).unwrap();
777 assert_eq!(typed_args(&spec, &p).unwrap()["who"], ArgValue::User(npub.clone()));
778
779 let p = parse_command_text(&m, "/greet npub1nope").unwrap();
781 assert!(typed_args(&spec, &p).is_err());
782 }
783
784 #[test]
785 fn typing_errors_are_canonical_and_parsable() {
786 let m = price_manifest();
789 let price = m.command("price").unwrap().clone();
790 let say = m.command("say").unwrap().clone();
791
792 let p = parse_command_text(&m, "/price doge").unwrap();
793 assert_eq!(typed_args(&price, &p).unwrap_err(), "asset: not one of btc, xmr, pivx");
794
795 let p = parse_command_text(&m, "/price").unwrap();
796 assert_eq!(typed_args(&price, &p).unwrap_err(), "asset: required");
797
798 let p = parse_command_text(&m, "/say notanint hi").unwrap();
799 assert_eq!(typed_args(&say, &p).unwrap_err(), "count: not an integer");
800 }
801
802 #[test]
803 fn validation_rejects_the_sharp_edges() {
804 let mut m = price_manifest();
805 m.commands[0].name = "Bad Name".into();
806 assert!(m.validate().is_err());
807
808 let mut m = price_manifest();
809 m.commands.push(m.commands[0].clone());
810 assert!(m.validate().is_err(), "duplicate command name");
811
812 let mut m = price_manifest();
813 m.commands[0].args[0].choices.clear();
814 assert!(m.validate().is_err(), "choice without choices");
815
816 let mut m = price_manifest();
818 m.commands[1].args[0].required = false;
819 m.commands[1].args[1].required = true;
820 assert!(m.validate().is_err());
821 }
822
823 fn announce_manifest() -> BotManifest {
825 BotManifest {
826 v: 1,
827 commands: vec![CommandSpec {
828 name: "announce".into(),
829 description: "Post an announcement".into(),
830 args: vec![
831 ArgSpec { name: "title".into(), arg_type: ArgType::String, description: String::new(), required: true, choices: vec![] },
832 ArgSpec { name: "body".into(), arg_type: ArgType::String, description: String::new(), required: true, choices: vec![] },
833 ],
834 }],
835 }
836 }
837
838 #[test]
839 fn command_text_and_parse_are_inverses() {
840 let m = price_manifest();
841 let args = vec![("asset".to_string(), "btc".to_string())];
842 let content = command_text("price", &args);
843 assert_eq!(content, "/price btc");
844 let p = parse_command_text(&m, &content).unwrap();
845 assert_eq!(p.name, "price");
846 assert_eq!(p.args, args);
847
848 let m = announce_manifest();
850 let args = vec![
851 ("title".to_string(), "Big \"news\" day".to_string()),
852 ("body".to_string(), "Meeting at 5pm".to_string()),
853 ];
854 let content = command_text("announce", &args);
855 let p = parse_command_text(&m, &content).unwrap();
856 assert_eq!(p.args, args);
857
858 for nasty in [
861 "\"", "\\", "ends with backslash \\", "\\\" fake close", "line one\nline two", "\" \\\" \\\\ \"\"", " leading and trailing ", ] {
869 let args = vec![
870 ("title".to_string(), nasty.to_string()),
871 ("body".to_string(), format!("after {nasty} end")),
872 ];
873 let content = command_text("announce", &args);
874 let p = parse_command_text(&m, &content)
875 .unwrap_or_else(|| panic!("adversarial value failed to re-parse: {nasty:?}"));
876 assert_eq!(p.args, args, "value must survive the wire byte-exact: {nasty:?}");
877 }
878 }
879
880 #[test]
881 fn text_parses_positionally_and_greedily() {
882 let m = price_manifest();
883 let p = parse_command_text(&m, "/price btc").unwrap();
884 assert_eq!(p.args, vec![("asset".to_string(), "btc".to_string())]);
885
886 let p = parse_command_text(&m, "/say 3 hello there world").unwrap();
888 assert_eq!(p.args[0], ("count".to_string(), "3".to_string()));
889 assert_eq!(p.args[1], ("text".to_string(), "hello there world".to_string()));
890
891 assert!(parse_command_text(&m, "/unknown x").is_none());
892 assert!(parse_command_text(&m, "not a command").is_none());
893 assert!(parse_command_text(&m, "/").is_none());
894 }
895
896 #[test]
897 fn bot_recipient_tags_extract_dedup_and_cap() {
898 use nostr_sdk::prelude::ToBech32;
899 let a = Keys::generate().public_key();
900 let b = Keys::generate().public_key();
901 let tags: Vec<Tag> = vec![
902 bot_tag(&a),
903 bot_tag(&a), bot_tag(&b),
905 Tag::custom(nostr_sdk::prelude::TagKind::Custom(TAG_BOT.into()), ["nothex"]),
906 Tag::custom(nostr_sdk::prelude::TagKind::Custom("p".into()), [a.to_hex()]), ];
908 let out = addressed_bots(tags.iter());
909 assert_eq!(out, vec![a.to_bech32().unwrap(), b.to_bech32().unwrap()]);
910
911 let many: Vec<Tag> = (0..20).map(|_| bot_tag(&Keys::generate().public_key())).collect();
913 assert_eq!(addressed_bots(many.iter()).len(), MAX_BOT_TAGS);
914
915 assert!(addressed_bots([].iter()).is_empty());
917 }
918
919 #[test]
920 fn quoting_terminates_multi_word_values() {
921 let m = announce_manifest();
922 let p = parse_command_text(&m, r#"/announce "Hello everyone" "Meeting at 5pm""#).unwrap();
924 assert_eq!(p.args[0].1, "Hello everyone");
925 assert_eq!(p.args[1].1, "Meeting at 5pm");
926
927 let p = parse_command_text(&m, r#"/announce "Hello everyone" Meeting at 5pm"#).unwrap();
929 assert_eq!(p.args[0].1, "Hello everyone");
930 assert_eq!(p.args[1].1, "Meeting at 5pm");
931
932 let p = parse_command_text(&m, "/announce Hello Meeting at 5pm").unwrap();
934 assert_eq!(p.args[0].1, "Hello");
935 assert_eq!(p.args[1].1, "Meeting at 5pm");
936
937 let p = parse_command_text(&m, r#"/announce "say \"hi\" \\ ok" done"#).unwrap();
939 assert_eq!(p.args[0].1, r#"say "hi" \ ok"#);
940
941 assert!(parse_command_text(&m, r#"/announce "dangling"#).is_none());
943 }
944
945 #[tokio::test]
946 async fn batch_fetch_returns_newest_valid_per_author_and_ignores_strangers() {
947 use crate::community::transport::{memory::MemoryRelay, Transport};
948 use nostr_sdk::prelude::Timestamp;
949 let relay = MemoryRelay::new();
950 let relays = vec!["r1".to_string()];
951 let bot_a = Keys::generate();
952 let bot_b = Keys::generate();
953 let stranger = Keys::generate();
954
955 let manifest_event = |m: &BotManifest, keys: &Keys, at: u64| {
956 EventBuilder::new(Kind::Custom(KIND_BOT_MANIFEST), serde_json::to_string(m).unwrap())
957 .custom_created_at(Timestamp::from_secs(at))
958 .sign_with_keys(keys)
959 .unwrap()
960 };
961 let old_ev = manifest_event(&price_manifest(), &bot_a, 100);
963 let newer = BotManifest {
964 v: 1,
965 commands: vec![CommandSpec { name: "newer".into(), description: "n".into(), args: vec![] }],
966 };
967 let new_ev = manifest_event(&newer, &bot_a, 200);
968 let b_garbage = EventBuilder::new(Kind::Custom(KIND_BOT_MANIFEST), "not json")
970 .custom_created_at(Timestamp::from_secs(300))
971 .sign_with_keys(&bot_b)
972 .unwrap();
973 let s_ev = price_manifest().to_event(&stranger).unwrap();
975 for ev in [&old_ev, &new_ev, &b_garbage, &s_ev] {
976 relay.publish(ev, &relays).await.unwrap();
977 }
978
979 let found = fetch_manifests(&relay, &[bot_a.public_key(), bot_b.public_key()], &relays)
980 .await
981 .unwrap();
982 assert_eq!(found.len(), 1, "only A has a usable newest manifest: {found:?}");
983 assert_eq!(found[0].0, bot_a.public_key());
984 assert!(found[0].1.command("newer").is_some(), "the newest edition won");
985 assert!(found[0].1.command("price").is_none(), "the older edition lost");
986 assert_eq!(found[0].2, 200, "the winning edition's timestamp rides along");
987
988 let none = fetch_manifests(&relay, &[], &relays).await.unwrap();
989 assert!(none.is_empty(), "empty author set short-circuits");
990 }
991
992 #[test]
993 fn command_freshness_is_generation_ttl_and_botset_scoped() {
994 let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner());
996 let generation = crate::state::SessionGuard::capture().generation();
997 let bots = vec!["aa".to_string(), "bb".to_string()];
998
999 assert!(!commands_fresh("cmd-fresh-a", &bots), "unseen chat is stale");
1000 mark_commands_fresh("cmd-fresh-a", generation, &bots);
1001 assert!(commands_fresh("cmd-fresh-a", &bots));
1002
1003 let grown = vec!["aa".to_string(), "bb".to_string(), "cc".to_string()];
1005 assert!(!commands_fresh("cmd-fresh-a", &grown));
1006
1007 mark_commands_fresh("cmd-fresh-b", generation.wrapping_add(1), &bots);
1009 assert!(!commands_fresh("cmd-fresh-b", &bots));
1010 }
1011
1012 #[test]
1013 fn typing_enforces_the_manifest() {
1014 let m = price_manifest();
1015 let spec = m.command("price").unwrap();
1016
1017 let ok = ParsedCommand {
1018 name: "price".into(),
1019 args: vec![("asset".into(), "btc".into())],
1020 };
1021 let t = typed_args(spec, &ok).unwrap();
1022 assert_eq!(t["asset"], ArgValue::Choice("btc".into()));
1023
1024 let bad_choice = ParsedCommand {
1025 name: "price".into(),
1026 args: vec![("asset".into(), "doge".into())],
1027 };
1028 assert!(typed_args(spec, &bad_choice).is_err());
1029
1030 let missing = ParsedCommand { name: "price".into(), args: vec![] };
1031 assert!(typed_args(spec, &missing).is_err());
1032
1033 let extra = ParsedCommand {
1035 name: "price".into(),
1036 args: vec![("asset".into(), "btc".into()), ("future".into(), "1".into())],
1037 };
1038 let t = typed_args(spec, &extra).unwrap();
1039 assert!(!t.contains_key("future"));
1040
1041 let spec = m.command("say").unwrap();
1042 let typed = typed_args(
1043 spec,
1044 &ParsedCommand {
1045 name: "say".into(),
1046 args: vec![("count".into(), "5".into()), ("text".into(), "hi".into())],
1047 },
1048 )
1049 .unwrap();
1050 assert_eq!(typed["count"].as_int(), Some(5));
1051 assert_eq!(typed["text"].as_str(), "hi");
1052
1053 let not_int = ParsedCommand {
1054 name: "say".into(),
1055 args: vec![("count".into(), "many".into())],
1056 };
1057 assert!(typed_args(spec, ¬_int).is_err());
1058 }
1059}