use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use vector_core::bot_interface::{
self, ArgSpec, ArgType, ArgValue, BotManifest, CommandSpec,
};
use crate::{IncomingMessage, VectorBot};
type CommandFuture = Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
type CommandHandler = Arc<dyn Fn(CommandCtx) -> CommandFuture + Send + Sync>;
struct Registration {
spec: CommandSpec,
handler: CommandHandler,
}
#[derive(Default)]
pub(crate) struct CommandRegistry {
commands: RwLock<Vec<Registration>>,
}
impl CommandRegistry {
fn insert(&self, spec: CommandSpec, handler: CommandHandler) -> Result<(), String> {
let manifest_probe = BotManifest { v: 1, commands: vec![spec.clone()] };
manifest_probe.validate()?;
let mut list = self.commands.write().unwrap_or_else(|e| e.into_inner());
if list.len() >= bot_interface::MAX_COMMANDS {
return Err(format!("too many commands (max {})", bot_interface::MAX_COMMANDS));
}
if list.iter().any(|r| r.spec.name == spec.name) {
return Err("command name already registered".into());
}
list.push(Registration { spec, handler });
Ok(())
}
pub(crate) fn manifest(&self) -> BotManifest {
let list = self.commands.read().unwrap_or_else(|e| e.into_inner());
BotManifest { v: 1, commands: list.iter().map(|r| r.spec.clone()).collect() }
}
pub(crate) fn is_empty(&self) -> bool {
self.commands.read().unwrap_or_else(|e| e.into_inner()).is_empty()
}
}
pub struct CommandCtx {
pub bot: VectorBot,
pub msg: IncomingMessage,
args: HashMap<String, ArgValue>,
}
impl CommandCtx {
pub fn str(&self, name: &str) -> Option<&str> {
self.args.get(name).map(|v| v.as_str()).filter(|s| !s.is_empty())
}
pub fn int(&self, name: &str) -> Option<i64> {
self.args.get(name).and_then(|v| v.as_int())
}
pub fn number(&self, name: &str) -> Option<f64> {
self.args.get(name).and_then(|v| v.as_number())
}
pub fn flag(&self, name: &str) -> Option<bool> {
self.args.get(name).and_then(|v| v.as_bool())
}
pub async fn reply(&self, text: impl AsRef<str>) -> crate::Result<String> {
self.msg.reply(text.as_ref()).await
}
}
pub struct CommandBuilder {
bot: VectorBot,
spec: CommandSpec,
}
impl CommandBuilder {
pub(crate) fn new(bot: VectorBot, name: &str, description: &str) -> Self {
Self {
bot,
spec: CommandSpec { name: name.to_string(), description: description.to_string(), args: vec![] },
}
}
fn push(mut self, name: &str, arg_type: ArgType, description: &str, required: bool, choices: Vec<String>) -> Self {
self.spec.args.push(ArgSpec {
name: name.to_string(),
arg_type,
description: description.to_string(),
required,
choices,
});
self
}
pub fn string(self, name: &str, description: &str, required: bool) -> Self {
self.push(name, ArgType::String, description, required, vec![])
}
pub fn int(self, name: &str, description: &str, required: bool) -> Self {
self.push(name, ArgType::Int, description, required, vec![])
}
pub fn number(self, name: &str, description: &str, required: bool) -> Self {
self.push(name, ArgType::Number, description, required, vec![])
}
pub fn flag(self, name: &str, description: &str, required: bool) -> Self {
self.push(name, ArgType::Bool, description, required, vec![])
}
pub fn user(self, name: &str, description: &str, required: bool) -> Self {
self.push(name, ArgType::User, description, required, vec![])
}
pub fn choice<S: Into<String>>(self, name: &str, description: &str, choices: impl IntoIterator<Item = S>, required: bool) -> Self {
self.push(name, ArgType::Choice, description, required, choices.into_iter().map(Into::into).collect())
}
pub fn run<F, Fut>(self, handler: F)
where
F: Fn(CommandCtx) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
let handler: CommandHandler = Arc::new(move |ctx| Box::pin(handler(ctx)));
if let Err(e) = self.bot.commands().insert(self.spec, handler) {
panic!("invalid command registration: {e}");
}
}
}
impl VectorBot {
pub fn command(&self, name: &str, description: &str) -> CommandBuilder {
CommandBuilder::new(self.clone(), name, description)
}
pub(crate) fn try_command(&self, incoming: &IncomingMessage) -> bool {
if incoming.is_mine() || self.commands().is_empty() {
return false;
}
let addressed = &incoming.message.addressed_bots;
if !addressed.is_empty() && !addressed.iter().any(|n| n == self.npub()) {
return false;
}
let content = incoming.text().trim();
if !content.starts_with('/') {
return false;
}
let manifest = self.commands().manifest();
let Some(parsed) = bot_interface::parse_command_text(&manifest, content) else {
return false; };
let registry = self.commands();
let list = registry.commands.read().unwrap_or_else(|e| e.into_inner());
let Some(reg) = list.iter().find(|r| r.spec.name == parsed.name) else { return false };
let spec = reg.spec.clone();
let handler = reg.handler.clone();
drop(list);
let bot = self.clone();
let incoming = incoming.clone();
tokio::spawn(async move {
match bot_interface::typed_args(&spec, &parsed) {
Ok(args) => {
println!("[CMD] /{} {:?}", parsed.name, parsed.args);
let ctx = CommandCtx { bot, msg: incoming, args };
handler(ctx).await;
}
Err(e) => {
println!("[CMD] /{} REJECTED ({e}) raw={:?}", parsed.name, parsed.args);
let usage = usage_line(&spec);
let _ = incoming.reply(&format!("{e}\nusage: {usage}")).await;
}
}
});
true
}
pub(crate) async fn publish_interface_manifest(&self) {
if self.commands().is_empty() {
return;
}
let manifest = self.commands().manifest();
let Some(keys) = vector_core::state::MY_SECRET_KEY.to_keys() else {
return; };
let mut relays: Vec<String> = DISCOVERY_RELAYS.iter().map(|s| s.to_string()).collect();
if let Some(client) = vector_core::state::nostr_client() {
relays.extend(client.relays().await.keys().map(|r| r.to_string()));
}
for id in vector_core::db::community::list_community_ids().unwrap_or_default() {
if let Ok(Some(c)) = vector_core::db::community::load_community_v2(&id) {
relays.extend(c.relays.clone());
} else if let Ok(Some(c)) = vector_core::db::community::load_community(&id) {
relays.extend(c.relays.clone());
}
}
relays.sort();
relays.dedup();
match bot_interface::publish_manifest(&manifest, &keys, &relays).await {
Ok(n) => println!("[vector-sdk] interface manifest ({} command(s)) stored on {n} relay(s)", manifest.commands.len()),
Err(e) => eprintln!("[vector-sdk] manifest publish failed: {e}"),
}
}
}
pub use vector_core::bot_interface::DISCOVERY_RELAYS;
fn usage_line(spec: &CommandSpec) -> String {
let mut out = format!("/{}", spec.name);
for a in &spec.args {
let ty = match a.arg_type {
ArgType::String => "text",
ArgType::Int => "int",
ArgType::Number => "number",
ArgType::Bool => "true|false",
ArgType::User => "npub",
ArgType::Choice => "choice",
};
if a.required {
out.push_str(&format!(" <{}:{}>", a.name, ty));
} else {
out.push_str(&format!(" [{}:{}]", a.name, ty));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_builds_the_manifest_in_registration_order() {
let reg = CommandRegistry::default();
let noop: CommandHandler = Arc::new(|_| Box::pin(async {}));
reg.insert(
CommandSpec { name: "zeta".into(), description: "z".into(), args: vec![] },
noop.clone(),
)
.unwrap();
reg.insert(
CommandSpec {
name: "alpha".into(),
description: "a".into(),
args: vec![ArgSpec {
name: "n".into(),
arg_type: ArgType::Int,
description: String::new(),
required: true,
choices: vec![],
}],
},
noop.clone(),
)
.unwrap();
let m = reg.manifest();
m.validate().unwrap();
assert_eq!(m.commands[0].name, "zeta");
assert_eq!(m.commands[1].name, "alpha");
assert!(reg
.insert(CommandSpec { name: "alpha".into(), description: String::new(), args: vec![] }, noop)
.is_err());
}
#[tokio::test]
async fn recipient_tag_routes_commands() {
use std::sync::atomic::{AtomicBool, Ordering};
use vector_core::VectorCore;
let ran = Arc::new(AtomicBool::new(false));
let me = "npub1me000000000000000000000000000000000000000000000000000000".to_string();
let bot = VectorBot {
core: VectorCore,
npub: me.clone(),
invite_policy: Arc::new(crate::InvitePolicy::Manual),
commands: Arc::new(Default::default()),
};
let flag = ran.clone();
bot.command("ping", "test").run(move |_ctx| {
let flag = flag.clone();
async move {
flag.store(true, Ordering::SeqCst);
}
});
let incoming = |bots: Vec<String>| crate::IncomingMessage {
chat_id: "chan".into(),
is_group: true,
is_file: false,
message: vector_core::Message {
content: "/ping".into(),
addressed_bots: bots,
..Default::default()
},
};
assert!(!bot.try_command(&incoming(vec!["npub1other".into()])));
assert!(bot.try_command(&incoming(vec![me.clone()])));
assert!(bot.try_command(&incoming(vec![])));
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(ran.load(Ordering::SeqCst));
}
#[test]
fn usage_line_renders_required_and_optional() {
let spec = CommandSpec {
name: "roll".into(),
description: String::new(),
args: vec![
ArgSpec { name: "sides".into(), arg_type: ArgType::Int, description: String::new(), required: true, choices: vec![] },
ArgSpec { name: "label".into(), arg_type: ArgType::String, description: String::new(), required: false, choices: vec![] },
],
};
assert_eq!(usage_line(&spec), "/roll <sides:int> [label:text]");
}
}