use std::collections::HashMap;
use nostr_sdk::prelude::{Event, EventBuilder, Keys, Kind, Tag};
use serde::{Deserialize, Serialize};
pub const KIND_BOT_MANIFEST: u16 = 10304;
pub const TAG_BOT: &str = "bot";
pub const MAX_BOT_TAGS: usize = 8;
pub fn bot_tag(bot: &nostr_sdk::prelude::PublicKey) -> Tag {
Tag::custom(nostr_sdk::prelude::TagKind::Custom(TAG_BOT.into()), [bot.to_hex()])
}
pub fn addressed_bots<'a, I: IntoIterator<Item = &'a Tag>>(tags: I) -> Vec<String> {
use nostr_sdk::prelude::ToBech32;
let mut out: Vec<String> = Vec::new();
for t in tags {
let s = t.as_slice();
if s.first().map(|k| k.as_str()) != Some(TAG_BOT) {
continue;
}
let Some(v) = s.get(1) else { continue };
let Ok(pk) = nostr_sdk::prelude::PublicKey::from_hex(v) else { continue };
let Ok(npub) = pk.to_bech32() else { continue };
if !out.contains(&npub) {
out.push(npub);
}
if out.len() >= MAX_BOT_TAGS {
break;
}
}
out
}
pub const MAX_COMMANDS: usize = 64;
pub const MAX_ARGS: usize = 8;
pub const MAX_CHOICES: usize = 32;
pub const MAX_NAME_LEN: usize = 32;
pub const MAX_DESCRIPTION_LEN: usize = 200;
pub const MAX_MANIFEST_BYTES: usize = 32_768;
pub const MAX_ARG_VALUE_LEN: usize = 1_024;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ArgType {
String,
Int,
Number,
Bool,
User,
Choice,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ArgSpec {
pub name: String,
#[serde(rename = "type")]
pub arg_type: ArgType,
#[serde(default)]
pub description: String,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub choices: Vec<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CommandSpec {
pub name: String,
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<ArgSpec>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct BotManifest {
pub v: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<CommandSpec>,
}
fn valid_name(s: &str) -> bool {
!s.is_empty()
&& s.len() <= MAX_NAME_LEN
&& s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
}
impl BotManifest {
pub fn validate(&self) -> Result<(), String> {
if self.v != 1 {
return Err(format!("unsupported manifest version {}", self.v));
}
if self.commands.len() > MAX_COMMANDS {
return Err(format!("too many commands ({} > {MAX_COMMANDS})", self.commands.len()));
}
let mut seen = std::collections::HashSet::new();
for c in &self.commands {
if !valid_name(&c.name) {
return Err(format!("bad command name {:?}", c.name));
}
if !seen.insert(c.name.as_str()) {
return Err(format!("duplicate command {:?}", c.name));
}
if c.description.len() > MAX_DESCRIPTION_LEN {
return Err(format!("description too long on /{}", c.name));
}
if c.args.len() > MAX_ARGS {
return Err(format!("too many args on /{}", c.name));
}
let mut arg_seen = std::collections::HashSet::new();
let mut optional_seen = false;
for a in &c.args {
if !valid_name(&a.name) {
return Err(format!("bad arg name {:?} on /{}", a.name, c.name));
}
if !arg_seen.insert(a.name.as_str()) {
return Err(format!("duplicate arg {:?} on /{}", a.name, c.name));
}
if a.description.len() > MAX_DESCRIPTION_LEN {
return Err(format!("arg description too long on /{}", c.name));
}
if a.required && optional_seen {
return Err(format!("required arg {:?} after an optional one on /{}", a.name, c.name));
}
optional_seen |= !a.required;
match a.arg_type {
ArgType::Choice => {
if a.choices.is_empty() || a.choices.len() > MAX_CHOICES {
return Err(format!("choice arg {:?} needs 1..={MAX_CHOICES} choices", a.name));
}
if a.choices.iter().any(|ch| ch.is_empty() || ch.len() > MAX_NAME_LEN) {
return Err(format!("bad choice value on {:?}", a.name));
}
}
_ if !a.choices.is_empty() => {
return Err(format!("choices on non-choice arg {:?}", a.name));
}
_ => {}
}
}
}
let bytes = serde_json::to_string(self).map_err(|e| e.to_string())?.len();
if bytes > MAX_MANIFEST_BYTES {
return Err(format!("manifest too large ({bytes} > {MAX_MANIFEST_BYTES} bytes)"));
}
Ok(())
}
pub fn command(&self, name: &str) -> Option<&CommandSpec> {
self.commands.iter().find(|c| c.name == name)
}
pub fn from_event(event: &Event) -> Result<Self, String> {
if event.kind != Kind::Custom(KIND_BOT_MANIFEST) {
return Err(format!("not a bot manifest (kind {})", event.kind));
}
if event.content.len() > MAX_MANIFEST_BYTES {
return Err("manifest content over the size cap".into());
}
let m: BotManifest = serde_json::from_str(&event.content).map_err(|e| format!("manifest parse: {e}"))?;
m.validate()?;
Ok(m)
}
pub fn to_event(&self, keys: &Keys) -> Result<Event, String> {
self.validate()?;
let content = serde_json::to_string(self).map_err(|e| e.to_string())?;
EventBuilder::new(Kind::Custom(KIND_BOT_MANIFEST), content)
.sign_with_keys(keys)
.map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ParsedCommand {
pub name: String,
pub args: Vec<(String, String)>,
}
pub fn command_text(name: &str, args: &[(String, String)]) -> String {
let mut out = format!("/{name}");
for (_, v) in args {
out.push(' ');
if v.is_empty() || v.contains(char::is_whitespace) || v.contains('"') {
out.push('"');
out.push_str(&v.replace('\\', "\\\\").replace('"', "\\\""));
out.push('"');
} else {
out.push_str(v);
}
}
out
}
fn next_token(s: &str, mut i: usize) -> Option<(String, usize)> {
let b = s.as_bytes();
while i < b.len() && b[i].is_ascii_whitespace() {
i += 1;
}
if i >= b.len() {
return None;
}
let mut out = String::new();
if b[i] == b'"' {
i += 1;
while i < b.len() {
match b[i] {
b'\\' if i + 1 < b.len() && (b[i + 1] == b'"' || b[i + 1] == b'\\') => {
out.push(b[i + 1] as char);
i += 2;
}
b'"' => return Some((out, i + 1)),
_ => {
let ch = s[i..].chars().next()?;
out.push(ch);
i += ch.len_utf8();
}
}
}
None } else {
let start = i;
while i < b.len() && !b[i].is_ascii_whitespace() {
i += 1;
}
out.push_str(&s[start..i]);
Some((out, i))
}
}
pub fn parse_command_text(manifest: &BotManifest, content: &str) -> Option<ParsedCommand> {
let content = content.trim();
let rest = content.strip_prefix('/')?;
if rest.starts_with('"') {
return None;
}
let (raw_name, mut cursor) = next_token(rest, 0)?;
if raw_name.is_empty() {
return None;
}
let name = raw_name.to_ascii_lowercase();
let spec = manifest.command(&name)?;
let mut args: Vec<(String, String)> = Vec::new();
for (i, a) in spec.args.iter().enumerate() {
let remainder = rest.get(cursor..).unwrap_or("").trim_start();
if remainder.is_empty() {
break;
}
let is_last_declared = i + 1 == spec.args.len();
let value = if is_last_declared && matches!(a.arg_type, ArgType::String) && !remainder.starts_with('"') {
cursor = rest.len();
remainder.trim_end().to_string()
} else {
let (tok, next) = next_token(rest, cursor)?;
cursor = next;
tok
};
if value.len() > MAX_ARG_VALUE_LEN {
return None;
}
args.push((a.name.clone(), value));
}
Some(ParsedCommand { name, args })
}
#[derive(Clone, Debug, PartialEq)]
pub enum ArgValue {
String(String),
Int(i64),
Number(f64),
Bool(bool),
User(String),
Choice(String),
}
impl ArgValue {
pub fn as_str(&self) -> &str {
match self {
ArgValue::String(s) | ArgValue::User(s) | ArgValue::Choice(s) => s,
_ => "",
}
}
pub fn as_int(&self) -> Option<i64> {
match self {
ArgValue::Int(i) => Some(*i),
_ => None,
}
}
pub fn as_number(&self) -> Option<f64> {
match self {
ArgValue::Number(n) => Some(*n),
ArgValue::Int(i) => Some(*i as f64),
_ => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
ArgValue::Bool(b) => Some(*b),
_ => None,
}
}
}
pub fn typed_args(spec: &CommandSpec, parsed: &ParsedCommand) -> Result<HashMap<String, ArgValue>, String> {
let mut out = HashMap::new();
for (k, v) in &parsed.args {
let Some(a) = spec.args.iter().find(|a| &a.name == k) else {
continue;
};
let typed = match a.arg_type {
ArgType::String => ArgValue::String(v.clone()),
ArgType::Int => ArgValue::Int(v.parse::<i64>().map_err(|_| format!("{k}: not an integer"))?),
ArgType::Number => ArgValue::Number(v.parse::<f64>().map_err(|_| format!("{k}: not a number"))?),
ArgType::Bool => match v.to_ascii_lowercase().as_str() {
"true" | "yes" | "1" => ArgValue::Bool(true),
"false" | "no" | "0" => ArgValue::Bool(false),
_ => return Err(format!("{k}: not a boolean")),
},
ArgType::User => {
let raw = v.strip_prefix("nostr:").unwrap_or(v);
if !raw.starts_with("npub1") {
return Err(format!("{k}: not an npub"));
}
let pk = nostr_sdk::prelude::PublicKey::parse(raw).map_err(|_| format!("{k}: not an npub"))?;
let npub = nostr_sdk::prelude::ToBech32::to_bech32(&pk).map_err(|_| format!("{k}: not an npub"))?;
ArgValue::User(npub)
}
ArgType::Choice => {
if !a.choices.iter().any(|c| c == v) {
return Err(format!("{k}: not one of {}", a.choices.join(", ")));
}
ArgValue::Choice(v.clone())
}
};
out.insert(k.clone(), typed);
}
for a in &spec.args {
if a.required && !out.contains_key(&a.name) {
return Err(format!("{}: required", a.name));
}
}
Ok(out)
}
#[derive(Serialize, Clone, Debug)]
pub struct ChatBotCommands {
pub bot: String,
pub commands: Vec<CommandSpec>,
}
pub const DISCOVERY_RELAYS: &[&str] =
&["wss://purplepag.es", "wss://relay.nostr.band", "wss://relay.damus.io", "wss://nos.lol"];
#[derive(Serialize, Clone, Debug)]
pub struct ChatCommandsSnapshot {
pub bots: usize,
pub commands: Vec<ChatBotCommands>,
pub fresh: bool,
}
pub async fn fetch_manifests<T: crate::community::transport::Transport + ?Sized>(
transport: &T,
authors: &[nostr_sdk::prelude::PublicKey],
relays: &[String],
) -> Result<Vec<(nostr_sdk::prelude::PublicKey, BotManifest, u64)>, String> {
use crate::community::transport::Query;
if authors.is_empty() {
return Ok(Vec::new());
}
let query = Query {
kinds: vec![KIND_BOT_MANIFEST],
authors: authors.iter().map(|p| p.to_hex()).collect(),
..Default::default()
};
let events = transport.fetch(&query, relays).await?;
let mut best: HashMap<nostr_sdk::prelude::PublicKey, &Event> = HashMap::new();
for ev in &events {
if !authors.contains(&ev.pubkey) || ev.verify().is_err() {
continue;
}
match best.get(&ev.pubkey) {
Some(b) if b.created_at >= ev.created_at => {}
_ => {
best.insert(ev.pubkey, ev);
}
}
}
let mut out: Vec<(nostr_sdk::prelude::PublicKey, BotManifest, u64)> = best
.into_iter()
.filter_map(|(pk, ev)| BotManifest::from_event(ev).ok().map(|m| (pk, m, ev.created_at.as_secs())))
.collect();
out.sort_by_key(|(pk, _, _)| pk.to_hex());
Ok(out)
}
pub fn assemble_from_store(bot_hexes: &[String]) -> Vec<ChatBotCommands> {
use nostr_sdk::prelude::ToBech32;
let rows = crate::db::bots::get_bot_manifests(bot_hexes).unwrap_or_default();
let by_pk: HashMap<&str, &str> = rows.iter().map(|(pk, m)| (pk.as_str(), m.as_str())).collect();
bot_hexes
.iter()
.filter_map(|hex| {
let json = by_pk.get(hex.as_str())?;
let manifest: BotManifest = serde_json::from_str(json).ok()?;
manifest.validate().ok()?;
let npub = nostr_sdk::prelude::PublicKey::from_hex(hex).ok()?.to_bech32().ok()?;
Some(ChatBotCommands { bot: npub, commands: manifest.commands })
})
.collect()
}
const COMMANDS_TTL: std::time::Duration = std::time::Duration::from_secs(60);
static COMMANDS_FRESH: std::sync::LazyLock<
std::sync::Mutex<HashMap<String, (u64, std::time::Instant, Vec<String>)>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
static REFRESH_INFLIGHT: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
pub fn commands_fresh(chat_id: &str, bot_hexes: &[String]) -> bool {
let generation = crate::state::SessionGuard::capture().generation();
let map = match COMMANDS_FRESH.lock() {
Ok(m) => m,
Err(_) => return false,
};
match map.get(chat_id) {
Some((g, at, bots)) => *g == generation && at.elapsed() < COMMANDS_TTL && bots == bot_hexes,
None => false,
}
}
fn mark_commands_fresh(chat_id: &str, generation: u64, bot_hexes: &[String]) {
if let Ok(mut map) = COMMANDS_FRESH.lock() {
if map.len() > 256 {
map.clear();
}
map.insert(chat_id.to_string(), (generation, std::time::Instant::now(), bot_hexes.to_vec()));
}
}
pub fn spawn_commands_refresh(chat_id: String, bots: Vec<nostr_sdk::prelude::PublicKey>, relays: Vec<String>) {
{
let Ok(mut inflight) = REFRESH_INFLIGHT.lock() else { return };
if !inflight.insert(chat_id.clone()) {
return; }
}
let session = crate::state::SessionGuard::capture();
tokio::spawn(async move {
let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(5));
let fetched = fetch_manifests(&transport, &bots, &relays).await;
if let Ok(mut inflight) = REFRESH_INFLIGHT.lock() {
inflight.remove(&chat_id);
}
let Ok(found) = fetched else { return }; if !session.is_valid() {
return;
}
for (pk, manifest, created_at) in &found {
if let Ok(json) = serde_json::to_string(manifest) {
let _ = crate::db::bots::upsert_bot_manifest(&pk.to_hex(), &json, *created_at);
}
}
let bot_hexes: Vec<String> = bots.iter().map(|p| p.to_hex()).collect();
let commands = assemble_from_store(&bot_hexes);
if !session.is_valid() {
return;
}
mark_commands_fresh(&chat_id, session.generation(), &bot_hexes);
crate::traits::emit_event(
"chat_commands_updated",
&serde_json::json!({ "chat_id": chat_id, "bots": bots.len(), "commands": commands }),
);
});
}
pub async fn publish_manifest(manifest: &BotManifest, keys: &Keys, relays: &[String]) -> Result<usize, String> {
let event = manifest.to_event(keys)?;
let client = crate::state::nostr_client().ok_or("no client connected")?;
for r in relays {
let _ = client.add_relay(r.as_str()).await;
}
client.connect().await;
let out = client
.send_event_to(relays.to_vec(), &event)
.await
.map_err(|e| e.to_string())?;
Ok(out.success.len())
}
pub async fn fetch_manifest(bot: &nostr_sdk::prelude::PublicKey, relays: &[String]) -> Option<BotManifest> {
let client = crate::state::nostr_client()?;
let filter = nostr_sdk::prelude::Filter::new()
.kind(Kind::Custom(KIND_BOT_MANIFEST))
.author(*bot)
.limit(1);
let events = if relays.is_empty() {
client.fetch_events(filter, std::time::Duration::from_secs(8)).await.ok()?
} else {
client
.fetch_events_from(relays.to_vec(), filter, std::time::Duration::from_secs(8))
.await
.ok()?
};
events
.into_iter()
.max_by_key(|e| e.created_at)
.and_then(|e| BotManifest::from_event(&e).ok())
}
#[cfg(test)]
mod tests {
use super::*;
use nostr_sdk::prelude::Keys;
fn price_manifest() -> BotManifest {
BotManifest {
v: 1,
commands: vec![
CommandSpec {
name: "price".into(),
description: "Get a coin price".into(),
args: vec![ArgSpec {
name: "asset".into(),
arg_type: ArgType::Choice,
description: "Which coin".into(),
required: true,
choices: vec!["btc".into(), "xmr".into(), "pivx".into()],
}],
},
CommandSpec {
name: "say".into(),
description: "Echo".into(),
args: vec![
ArgSpec {
name: "count".into(),
arg_type: ArgType::Int,
description: String::new(),
required: true,
choices: vec![],
},
ArgSpec {
name: "text".into(),
arg_type: ArgType::String,
description: String::new(),
required: false,
choices: vec![],
},
],
},
],
}
}
#[test]
fn manifest_round_trips_through_its_event() {
let keys = Keys::generate();
let m = price_manifest();
let ev = m.to_event(&keys).unwrap();
assert_eq!(ev.kind, Kind::Custom(KIND_BOT_MANIFEST));
let back = BotManifest::from_event(&ev).unwrap();
assert_eq!(back.commands.len(), 2);
assert_eq!(back.command("price").unwrap().args[0].choices.len(), 3);
}
#[test]
fn a_quoted_command_word_is_ordinary_chat() {
let m = price_manifest();
assert!(parse_command_text(&m, r#"/"price" btc"#).is_none());
assert!(parse_command_text(&m, r#"/"" btc"#).is_none());
assert!(parse_command_text(&m, "/price btc").is_some());
}
#[test]
fn the_command_word_is_case_folded() {
let m = price_manifest();
let p = parse_command_text(&m, "/PRICE btc").expect("an uppercase command resolves");
assert_eq!(p.name, "price");
assert_eq!(p.args, vec![("asset".to_string(), "btc".to_string())]);
let p = parse_command_text(&m, "/Say 2 Hello There").expect("a mixed-case command resolves");
assert_eq!(p.name, "say");
assert_eq!(p.args[1], ("text".to_string(), "Hello There".to_string()));
}
#[test]
fn user_args_accept_the_nip21_uri_and_normalize_to_a_bare_npub() {
use nostr_sdk::prelude::ToBech32;
let npub = Keys::generate().public_key().to_bech32().unwrap();
let spec = CommandSpec {
name: "greet".into(),
description: String::new(),
args: vec![ArgSpec {
name: "who".into(),
arg_type: ArgType::User,
description: String::new(),
required: true,
choices: vec![],
}],
};
let m = BotManifest { v: 1, commands: vec![spec.clone()] };
let p = parse_command_text(&m, &format!("/greet {npub}")).unwrap();
assert_eq!(typed_args(&spec, &p).unwrap()["who"], ArgValue::User(npub.clone()));
let p = parse_command_text(&m, &format!("/greet nostr:{npub}")).unwrap();
assert_eq!(typed_args(&spec, &p).unwrap()["who"], ArgValue::User(npub.clone()));
let p = parse_command_text(&m, "/greet npub1nope").unwrap();
assert!(typed_args(&spec, &p).is_err());
}
#[test]
fn typing_errors_are_canonical_and_parsable() {
let m = price_manifest();
let price = m.command("price").unwrap().clone();
let say = m.command("say").unwrap().clone();
let p = parse_command_text(&m, "/price doge").unwrap();
assert_eq!(typed_args(&price, &p).unwrap_err(), "asset: not one of btc, xmr, pivx");
let p = parse_command_text(&m, "/price").unwrap();
assert_eq!(typed_args(&price, &p).unwrap_err(), "asset: required");
let p = parse_command_text(&m, "/say notanint hi").unwrap();
assert_eq!(typed_args(&say, &p).unwrap_err(), "count: not an integer");
}
#[test]
fn validation_rejects_the_sharp_edges() {
let mut m = price_manifest();
m.commands[0].name = "Bad Name".into();
assert!(m.validate().is_err());
let mut m = price_manifest();
m.commands.push(m.commands[0].clone());
assert!(m.validate().is_err(), "duplicate command name");
let mut m = price_manifest();
m.commands[0].args[0].choices.clear();
assert!(m.validate().is_err(), "choice without choices");
let mut m = price_manifest();
m.commands[1].args[0].required = false;
m.commands[1].args[1].required = true;
assert!(m.validate().is_err());
}
fn announce_manifest() -> BotManifest {
BotManifest {
v: 1,
commands: vec![CommandSpec {
name: "announce".into(),
description: "Post an announcement".into(),
args: vec![
ArgSpec { name: "title".into(), arg_type: ArgType::String, description: String::new(), required: true, choices: vec![] },
ArgSpec { name: "body".into(), arg_type: ArgType::String, description: String::new(), required: true, choices: vec![] },
],
}],
}
}
#[test]
fn command_text_and_parse_are_inverses() {
let m = price_manifest();
let args = vec![("asset".to_string(), "btc".to_string())];
let content = command_text("price", &args);
assert_eq!(content, "/price btc");
let p = parse_command_text(&m, &content).unwrap();
assert_eq!(p.name, "price");
assert_eq!(p.args, args);
let m = announce_manifest();
let args = vec![
("title".to_string(), "Big \"news\" day".to_string()),
("body".to_string(), "Meeting at 5pm".to_string()),
];
let content = command_text("announce", &args);
let p = parse_command_text(&m, &content).unwrap();
assert_eq!(p.args, args);
for nasty in [
"\"", "\\", "ends with backslash \\", "\\\" fake close", "line one\nline two", "\" \\\" \\\\ \"\"", " leading and trailing ", ] {
let args = vec![
("title".to_string(), nasty.to_string()),
("body".to_string(), format!("after {nasty} end")),
];
let content = command_text("announce", &args);
let p = parse_command_text(&m, &content)
.unwrap_or_else(|| panic!("adversarial value failed to re-parse: {nasty:?}"));
assert_eq!(p.args, args, "value must survive the wire byte-exact: {nasty:?}");
}
}
#[test]
fn text_parses_positionally_and_greedily() {
let m = price_manifest();
let p = parse_command_text(&m, "/price btc").unwrap();
assert_eq!(p.args, vec![("asset".to_string(), "btc".to_string())]);
let p = parse_command_text(&m, "/say 3 hello there world").unwrap();
assert_eq!(p.args[0], ("count".to_string(), "3".to_string()));
assert_eq!(p.args[1], ("text".to_string(), "hello there world".to_string()));
assert!(parse_command_text(&m, "/unknown x").is_none());
assert!(parse_command_text(&m, "not a command").is_none());
assert!(parse_command_text(&m, "/").is_none());
}
#[test]
fn bot_recipient_tags_extract_dedup_and_cap() {
use nostr_sdk::prelude::ToBech32;
let a = Keys::generate().public_key();
let b = Keys::generate().public_key();
let tags: Vec<Tag> = vec![
bot_tag(&a),
bot_tag(&a), bot_tag(&b),
Tag::custom(nostr_sdk::prelude::TagKind::Custom(TAG_BOT.into()), ["nothex"]),
Tag::custom(nostr_sdk::prelude::TagKind::Custom("p".into()), [a.to_hex()]), ];
let out = addressed_bots(tags.iter());
assert_eq!(out, vec![a.to_bech32().unwrap(), b.to_bech32().unwrap()]);
let many: Vec<Tag> = (0..20).map(|_| bot_tag(&Keys::generate().public_key())).collect();
assert_eq!(addressed_bots(many.iter()).len(), MAX_BOT_TAGS);
assert!(addressed_bots([].iter()).is_empty());
}
#[test]
fn quoting_terminates_multi_word_values() {
let m = announce_manifest();
let p = parse_command_text(&m, r#"/announce "Hello everyone" "Meeting at 5pm""#).unwrap();
assert_eq!(p.args[0].1, "Hello everyone");
assert_eq!(p.args[1].1, "Meeting at 5pm");
let p = parse_command_text(&m, r#"/announce "Hello everyone" Meeting at 5pm"#).unwrap();
assert_eq!(p.args[0].1, "Hello everyone");
assert_eq!(p.args[1].1, "Meeting at 5pm");
let p = parse_command_text(&m, "/announce Hello Meeting at 5pm").unwrap();
assert_eq!(p.args[0].1, "Hello");
assert_eq!(p.args[1].1, "Meeting at 5pm");
let p = parse_command_text(&m, r#"/announce "say \"hi\" \\ ok" done"#).unwrap();
assert_eq!(p.args[0].1, r#"say "hi" \ ok"#);
assert!(parse_command_text(&m, r#"/announce "dangling"#).is_none());
}
#[tokio::test]
async fn batch_fetch_returns_newest_valid_per_author_and_ignores_strangers() {
use crate::community::transport::{memory::MemoryRelay, Transport};
use nostr_sdk::prelude::Timestamp;
let relay = MemoryRelay::new();
let relays = vec!["r1".to_string()];
let bot_a = Keys::generate();
let bot_b = Keys::generate();
let stranger = Keys::generate();
let manifest_event = |m: &BotManifest, keys: &Keys, at: u64| {
EventBuilder::new(Kind::Custom(KIND_BOT_MANIFEST), serde_json::to_string(m).unwrap())
.custom_created_at(Timestamp::from_secs(at))
.sign_with_keys(keys)
.unwrap()
};
let old_ev = manifest_event(&price_manifest(), &bot_a, 100);
let newer = BotManifest {
v: 1,
commands: vec![CommandSpec { name: "newer".into(), description: "n".into(), args: vec![] }],
};
let new_ev = manifest_event(&newer, &bot_a, 200);
let b_garbage = EventBuilder::new(Kind::Custom(KIND_BOT_MANIFEST), "not json")
.custom_created_at(Timestamp::from_secs(300))
.sign_with_keys(&bot_b)
.unwrap();
let s_ev = price_manifest().to_event(&stranger).unwrap();
for ev in [&old_ev, &new_ev, &b_garbage, &s_ev] {
relay.publish(ev, &relays).await.unwrap();
}
let found = fetch_manifests(&relay, &[bot_a.public_key(), bot_b.public_key()], &relays)
.await
.unwrap();
assert_eq!(found.len(), 1, "only A has a usable newest manifest: {found:?}");
assert_eq!(found[0].0, bot_a.public_key());
assert!(found[0].1.command("newer").is_some(), "the newest edition won");
assert!(found[0].1.command("price").is_none(), "the older edition lost");
assert_eq!(found[0].2, 200, "the winning edition's timestamp rides along");
let none = fetch_manifests(&relay, &[], &relays).await.unwrap();
assert!(none.is_empty(), "empty author set short-circuits");
}
#[test]
fn command_freshness_is_generation_ttl_and_botset_scoped() {
let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner());
let generation = crate::state::SessionGuard::capture().generation();
let bots = vec!["aa".to_string(), "bb".to_string()];
assert!(!commands_fresh("cmd-fresh-a", &bots), "unseen chat is stale");
mark_commands_fresh("cmd-fresh-a", generation, &bots);
assert!(commands_fresh("cmd-fresh-a", &bots));
let grown = vec!["aa".to_string(), "bb".to_string(), "cc".to_string()];
assert!(!commands_fresh("cmd-fresh-a", &grown));
mark_commands_fresh("cmd-fresh-b", generation.wrapping_add(1), &bots);
assert!(!commands_fresh("cmd-fresh-b", &bots));
}
#[test]
fn typing_enforces_the_manifest() {
let m = price_manifest();
let spec = m.command("price").unwrap();
let ok = ParsedCommand {
name: "price".into(),
args: vec![("asset".into(), "btc".into())],
};
let t = typed_args(spec, &ok).unwrap();
assert_eq!(t["asset"], ArgValue::Choice("btc".into()));
let bad_choice = ParsedCommand {
name: "price".into(),
args: vec![("asset".into(), "doge".into())],
};
assert!(typed_args(spec, &bad_choice).is_err());
let missing = ParsedCommand { name: "price".into(), args: vec![] };
assert!(typed_args(spec, &missing).is_err());
let extra = ParsedCommand {
name: "price".into(),
args: vec![("asset".into(), "btc".into()), ("future".into(), "1".into())],
};
let t = typed_args(spec, &extra).unwrap();
assert!(!t.contains_key("future"));
let spec = m.command("say").unwrap();
let typed = typed_args(
spec,
&ParsedCommand {
name: "say".into(),
args: vec![("count".into(), "5".into()), ("text".into(), "hi".into())],
},
)
.unwrap();
assert_eq!(typed["count"].as_int(), Some(5));
assert_eq!(typed["text"].as_str(), "hi");
let not_int = ParsedCommand {
name: "say".into(),
args: vec![("count".into(), "many".into())],
};
assert!(typed_args(spec, ¬_int).is_err());
}
}