diff --git a/doc/command-handlers.md b/doc/command-handlers.md index a172d691..edcd09d4 100644 --- a/doc/command-handlers.md +++ b/doc/command-handlers.md @@ -14,17 +14,23 @@ Handlers are identified by the `command_handler` attribute macro. ## The command_handler macro -The `command_handler` attribute macro has two possible forms, allowing for -multiple command dispatchers to exist. +The `command_handler` attribute macro can take several arguments: -The single-argument form - e.g. `#[command_handler("CAP")]` defines a handler -for the 'default' global dispatcher, which is used to look up client protocol -commands. +```rs +#[command_handler("PRIMARY", "ALIAS", "ALIAS2", in("PARENT"), restricted)] +``` -The two-argument form - e.g. `#[command_handler("CERT", in("NS"))]` puts the -handler into a named secondary dispatcher, in this case `"NS"`. This form is +The first argument defines the primary name of the command. Any further strings +given will be used as aliases for the command. + +If an argument is given of the form `in("PARENT")`, the command handler will be +put into a named secondary dispatcher, in this case `"PARENT"`. This form is used to define handlers for services commands, and may have other uses in the -future. +future. If this argument is not given, the handler is added to the 'default' +global dispatcher, which is used to look up client protocol commands. + +If the `restricted` keyword is added, the command will be marked as for operators +and will not be shown in `HELP` output to users. ## Async handlers diff --git a/sable_ircd/src/command/dispatcher.rs b/sable_ircd/src/command/dispatcher.rs index 67b0bc5e..6553e89a 100644 --- a/sable_ircd/src/command/dispatcher.rs +++ b/sable_ircd/src/command/dispatcher.rs @@ -1,3 +1,5 @@ +use std::collections::hash_map; + use super::{plumbing::Command, *}; /// Type alias for a boxed command context @@ -7,17 +9,21 @@ pub type BoxCommand<'cmd> = Box; /// attribute macro pub type CommandHandlerWrapper = for<'a> fn(BoxCommand<'a>) -> Option>; +#[derive(Clone)] /// A command handler registration. Constructed by the `command_handler` attribute macro. pub struct CommandRegistration { pub(super) command: &'static str, + pub(super) aliases: &'static [&'static str], pub(super) dispatcher: Option<&'static str>, pub(super) handler: CommandHandlerWrapper, + pub(super) restricted: bool, + pub(super) docs: &'static [&'static str], } /// A command dispatcher. Collects registered command handlers and allows lookup by /// command name. pub struct CommandDispatcher { - handlers: HashMap, + commands: HashMap, } inventory::collect!(CommandRegistration); @@ -42,11 +48,14 @@ impl CommandDispatcher { for reg in inventory::iter:: { if reg.dispatcher == category_name { - map.insert(reg.command.to_ascii_uppercase(), reg.handler); + map.insert(reg.command.to_ascii_uppercase(), reg.clone()); + for alias in reg.aliases { + map.insert(alias.to_ascii_uppercase(), reg.clone()); + } } } - Self { handlers: map } + Self { commands: map } } /// Look up and execute the handler function for to a given command. @@ -59,12 +68,20 @@ impl CommandDispatcher { ) -> Option> { let command: BoxCommand<'cmd> = Box::new(command); - match self.handlers.get(&command.command().to_ascii_uppercase()) { - Some(handler) => handler(command), + match self.commands.get(&command.command().to_ascii_uppercase()) { + Some(cmd) => (cmd.handler)(command), None => { command.notify_error(CommandError::CommandNotFound(command.command().to_owned())); None } } } + + pub fn get_command(&self, command: &str) -> Option<&CommandRegistration> { + self.commands.get(&command.to_ascii_uppercase()) + } + + pub fn iter_commands(&self) -> hash_map::Iter<'_, String, CommandRegistration> { + self.commands.iter() + } } diff --git a/sable_ircd/src/command/handlers/admin.rs b/sable_ircd/src/command/handlers/admin.rs index f5158192..a322391d 100644 --- a/sable_ircd/src/command/handlers/admin.rs +++ b/sable_ircd/src/command/handlers/admin.rs @@ -1,6 +1,13 @@ use super::*; #[command_handler("ADMIN")] +/// ADMIN +/// +/// ADMIN shows the information that was set by the +/// administrator of the server. This information +/// can take any form that will fit in three lines +/// of text but is usually a list of contacts for +/// the persons that run the server. fn handle_admin(server: &ClientServer, response: &dyn CommandResponse) -> CommandResult { response.numeric(make_numeric!(AdminMe, server.name())); if let Some(admin_info) = &server.info_strings.admin_info { diff --git a/sable_ircd/src/command/handlers/away.rs b/sable_ircd/src/command/handlers/away.rs index 41e98a9b..a5eb2179 100644 --- a/sable_ircd/src/command/handlers/away.rs +++ b/sable_ircd/src/command/handlers/away.rs @@ -2,6 +2,11 @@ use super::*; use event::*; #[command_handler("AWAY")] +/// AWAY :[] +/// +/// With an argument, it will set you as AWAY with +/// the specified message. Without an argument, +/// it will set you back. async fn away_handler( cmd: &dyn Command, source: UserSource<'_>, diff --git a/sable_ircd/src/command/handlers/help.rs b/sable_ircd/src/command/handlers/help.rs new file mode 100644 index 00000000..1996455e --- /dev/null +++ b/sable_ircd/src/command/handlers/help.rs @@ -0,0 +1,143 @@ +use super::*; + +use itertools::Itertools; + +#[command_handler("HELP", "UHELP")] +/// HELP \[\\] +/// +/// HELP displays information for topic requested. +/// If no topic is requested, it will list available +/// help topics. +fn help_handler( + command: &dyn Command, + response: &dyn CommandResponse, + server: &ClientServer, + source: UserSource, + topic: Option<&str>, +) -> CommandResult { + // TODO: better restricted mechanism? + // TODO: non-command help topics + let is_oper = command.command().to_ascii_uppercase() != "UHELP" && source.is_oper(); + send_help(&server.command_dispatcher, topic, is_oper, response) +} + +#[command_handler("HELP", "UHELP", in("NS"))] +/// NS HELP \[\\] +/// +/// Displays information about the topic requested. +/// If no topic is requested, it will list available +/// help topics. +fn ns_help_handler( + command: &dyn Command, + response: &dyn CommandResponse, + source: UserSource, + topic: Option<&str>, +) -> CommandResult { + let is_oper = command.command().to_ascii_uppercase() != "UHELP" && source.is_oper(); + let dispatcher = CommandDispatcher::with_category("NS"); + send_help(&dispatcher, topic, is_oper, response) +} + +#[command_handler("HELP", "UHELP", in("CS"))] +/// CS HELP \[\\] +/// +/// Displays information about the topic requested. +/// If no topic is requested, it will list available +/// help topics. +fn cs_help_handler( + command: &dyn Command, + response: &dyn CommandResponse, + source: UserSource, + topic: Option<&str>, +) -> CommandResult { + let is_oper = command.command().to_ascii_uppercase() != "UHELP" && source.is_oper(); + let dispatcher = CommandDispatcher::with_category("CS"); + send_help(&dispatcher, topic, is_oper, response) +} + +fn send_help( + dispatcher: &CommandDispatcher, + topic: Option<&str>, + is_oper: bool, + response: &dyn CommandResponse, +) -> CommandResult { + match topic { + Some(t) => { + let topic = t.to_ascii_uppercase(); + let topic = topic + .split_once(' ') + .map_or(topic.clone(), |(t, _)| t.to_string()); + if let Some(mut lines) = get_help(&dispatcher, &topic, is_oper) { + response.numeric(make_numeric!( + HelpStart, + &topic, + lines.next().unwrap().as_ref() + )); + for line in lines { + response.numeric(make_numeric!(HelpText, &topic, line.as_ref())); + } + response.numeric(make_numeric!(EndOfHelp, &topic)); + return Ok(()); + } else { + response.numeric(make_numeric!(HelpNotFound, &topic)); + } + } + None => { + let topic = "*"; + if is_oper { + response.numeric(make_numeric!( + HelpStart, + &topic, + "Help topics available to opers:" + )); + } else { + response.numeric(make_numeric!( + HelpStart, + &topic, + "Help topics available to users:" + )); + } + response.numeric(make_numeric!(HelpText, &topic, "")); + for line in list_help(&dispatcher, is_oper) { + response.numeric(make_numeric!(HelpText, &topic, line.as_ref())); + } + response.numeric(make_numeric!(EndOfHelp, &topic)); + } + }; + Ok(()) +} + +fn get_help( + dispatcher: &CommandDispatcher, + topic: &str, + is_oper: bool, +) -> Option>> { + if let Some(cmd) = dispatcher.get_command(&topic) { + if cmd.docs.len() > 0 { + if cmd.restricted && !is_oper { + return None; + } + return Some(cmd.docs.iter()); + } + } + return None; +} + +fn list_help(dispatcher: &CommandDispatcher, is_oper: bool) -> Vec> { + let mut lines = vec![]; + for chunk in &dispatcher + .iter_commands() + .filter_map(|(k, v)| { + if (!v.restricted || is_oper) && (v.docs.len() > 0) { + Some(k.to_ascii_uppercase()) + } else { + None + } + }) + .sorted() + .chunks(4) + { + lines.push(format!("{:16}", chunk.format(" "))); + } + lines +} diff --git a/sable_ircd/src/command/handlers/invite.rs b/sable_ircd/src/command/handlers/invite.rs index 362f923c..54f532f0 100644 --- a/sable_ircd/src/command/handlers/invite.rs +++ b/sable_ircd/src/command/handlers/invite.rs @@ -1,6 +1,10 @@ use super::*; #[command_handler("INVITE")] +/// INVITE + +/// INVITE sends a notice to the specified user that +/// you have asked them to come to the specified channel. fn handle_invite( server: &ClientServer, source: UserSource, diff --git a/sable_ircd/src/command/handlers/join.rs b/sable_ircd/src/command/handlers/join.rs index 6250eb26..8225bb94 100644 --- a/sable_ircd/src/command/handlers/join.rs +++ b/sable_ircd/src/command/handlers/join.rs @@ -1,6 +1,16 @@ use super::*; #[command_handler("JOIN")] +/// JOIN <#channel> [] +/// +/// The JOIN command allows you to enter a public chat area known as +/// a channel. Channels are prefixed with a '#'. More than one +/// channel may be specified, separated with commas (no spaces). +/// +/// If the channel has a key set, the 2nd argument must be +/// given to enter. This allows channels to be password protected. +/// +/// See also: PART, LIST async fn handle_join( server: &ClientServer, net: &Network, diff --git a/sable_ircd/src/command/handlers/kick.rs b/sable_ircd/src/command/handlers/kick.rs index 52e5f60b..7078161c 100644 --- a/sable_ircd/src/command/handlers/kick.rs +++ b/sable_ircd/src/command/handlers/kick.rs @@ -1,6 +1,12 @@ use super::*; #[command_handler("KICK")] +/// KICK :[] +/// +/// The KICK command will remove the specified user +/// from the specified channel, using the optional +/// kick message. You must be a channel operator to +/// use this command. async fn handle_kick( server: &ClientServer, cmd: &dyn Command, diff --git a/sable_ircd/src/command/handlers/kill.rs b/sable_ircd/src/command/handlers/kill.rs index 11c8dfc1..1b37ea82 100644 --- a/sable_ircd/src/command/handlers/kill.rs +++ b/sable_ircd/src/command/handlers/kill.rs @@ -1,7 +1,13 @@ use super::*; use event::*; -#[command_handler("KILL")] +#[command_handler("KILL", restricted)] +/// KILL :[] +/// +/// Disconnects the specified user from the IRC server +/// they are connected to with reason . +/// +/// (TODO) Requires Oper Priv: oper:kill fn handle_kill( server: &ClientServer, source: UserSource, diff --git a/sable_ircd/src/command/handlers/kline.rs b/sable_ircd/src/command/handlers/kline.rs index ece3ad5a..01c7b76a 100644 --- a/sable_ircd/src/command/handlers/kline.rs +++ b/sable_ircd/src/command/handlers/kline.rs @@ -4,7 +4,7 @@ use sable_network::network::ban::*; const DEFAULT_KLINE_DURATION: u32 = 1440; -#[command_handler("KLINE")] +#[command_handler("KLINE", restricted)] fn handle_kline( server: &ClientServer, response: &dyn CommandResponse, diff --git a/sable_ircd/src/command/handlers/oper.rs b/sable_ircd/src/command/handlers/oper.rs index 0660aed9..cafc378c 100644 --- a/sable_ircd/src/command/handlers/oper.rs +++ b/sable_ircd/src/command/handlers/oper.rs @@ -1,7 +1,8 @@ use super::*; use event::*; -#[command_handler("OPER")] +#[command_handler("OPER", restricted)] +/// OPER \ \ fn handle_oper( response: &dyn CommandResponse, server: &ClientServer, diff --git a/sable_ircd/src/command/handlers/services/ns/login.rs b/sable_ircd/src/command/handlers/services/ns/login.rs index 59e9af33..d5defe0e 100644 --- a/sable_ircd/src/command/handlers/services/ns/login.rs +++ b/sable_ircd/src/command/handlers/services/ns/login.rs @@ -3,9 +3,11 @@ use sable_network::rpc::{RemoteServerResponse, RemoteServicesServerResponse}; use super::*; -#[command_handler("LOGIN", in("NS"))] -#[command_handler("IDENTIFY", in("NS"))] -#[command_handler("ID", in("NS"))] +#[command_handler("LOGIN", "IDENTIFY", "ID", in("NS"))] +/// LOGIN \[\\] \ +/// +/// Log into the specified account. If \ is not specified, +/// the current nickname is used. async fn handle_login( net: &Network, source: UserSource<'_>, diff --git a/sable_ircd/src/command/handlers/whowas.rs b/sable_ircd/src/command/handlers/whowas.rs index 1cddc2b1..3d0f0947 100644 --- a/sable_ircd/src/command/handlers/whowas.rs +++ b/sable_ircd/src/command/handlers/whowas.rs @@ -4,7 +4,17 @@ const DEFAULT_COUNT: usize = 8; // Arbitrary value, that happens to match the ca // historic_nick_users #[command_handler("WHOWAS")] -/// Syntax: WHOWAS \ \[\\] +/// WHOWAS \ \[\\] +/// +/// WHOWAS will show you the last known host and whois +/// information for the specified target nick. Depending on +/// the number of times they have connected to the network, +/// there may be more than one listing for a specific user. +/// +/// If a count is specified, WHOWAS will not show more +/// than that many listings. +/// +/// The WHOWAS data will expire after some time. fn whowas_handler( network: &Network, response: &dyn CommandResponse, diff --git a/sable_ircd/src/command/mod.rs b/sable_ircd/src/command/mod.rs index dbd58d2e..105a0f1e 100644 --- a/sable_ircd/src/command/mod.rs +++ b/sable_ircd/src/command/mod.rs @@ -42,6 +42,7 @@ mod handlers { mod ban; mod cap; mod chathistory; + mod help; mod info; mod invite; mod join; diff --git a/sable_ircd/src/messages/numeric.rs b/sable_ircd/src/messages/numeric.rs index 712c7869..4f425b11 100644 --- a/sable_ircd/src/messages/numeric.rs +++ b/sable_ircd/src/messages/numeric.rs @@ -73,6 +73,10 @@ define_messages! { 371(Info) => { (line: &str) => ":{line}" }, 374(EndOfInfo) => { () => ":End of /INFO list" }, + 704(HelpStart) => { (subj: &str, line: &str) => "{subj} :{line}" }, + 705(HelpText) => { (subj: &str, line: &str) => "{subj} :{line}" }, + 706(EndOfHelp) => { (subj: &str) => "{subj} :End of /HELP" }, + 400(UnknownError) => { (reason: &str) => ":{reason}" }, 401(NoSuchTarget) => { (unknown: &str) => "{unknown} :No such nick/channel" }, @@ -130,6 +134,8 @@ define_messages! { 440(ServicesNotAvailable) => { () => ":Services are not available"}, + 524(HelpNotFound) => { (subj: &str) => "{subj} :No help available on this topic" }, + // https://ircv3.net/specs/extensions/monitor 730(MonOnline) => { (content: &str ) => ":{content}" }, 731(MonOffline) => { (content: &str ) => ":{content}" }, diff --git a/sable_ircd/src/server/mod.rs b/sable_ircd/src/server/mod.rs index 5d48a62c..acc4feed 100644 --- a/sable_ircd/src/server/mod.rs +++ b/sable_ircd/src/server/mod.rs @@ -21,7 +21,7 @@ use tokio::{ }; use std::{ - collections::VecDeque, + collections::{hash_map, VecDeque}, sync::{Arc, Weak}, time::Duration, }; @@ -74,7 +74,7 @@ pub struct ClientServer { stored_response_sinks: RwLock, action_submitter: UnboundedSender, - command_dispatcher: command::CommandDispatcher, + pub command_dispatcher: command::CommandDispatcher, connections: RwLock, /// Connections which either did not complete registration or completed it recently, @@ -116,6 +116,16 @@ impl ClientServer { self.node.name() } + /// Get a command from the server's dispatcher + pub fn get_command(&self, cmd: &str) -> Option<&CommandRegistration> { + self.command_dispatcher.get_command(cmd) + } + + /// Get a command from the server's dispatcher + pub fn iter_commands(&self) -> hash_map::Iter<'_, String, CommandRegistration> { + self.command_dispatcher.iter_commands() + } + /// Submit a command action to process in the next loop iteration. #[tracing::instrument(skip(self))] pub fn add_action(&self, act: CommandAction) { diff --git a/sable_macros/src/command_handler.rs b/sable_macros/src/command_handler.rs index 373026d9..73116429 100644 --- a/sable_macros/src/command_handler.rs +++ b/sable_macros/src/command_handler.rs @@ -2,32 +2,95 @@ use super::*; use quote::{quote, quote_spanned}; use syn::{ - parenthesized, parse::Parse, parse_macro_input, token::In, Ident, ItemFn, LitStr, Token, + parenthesized, parse::Parse, parse_macro_input, token::In, Attribute, Ident, ItemFn, LitStr, + Meta, MetaNameValue, Token, }; struct CommandHandlerAttr { command_name: LitStr, + aliases: Vec, dispatcher: Option, + restricted: bool, } impl Parse for CommandHandlerAttr { fn parse(input: syn::parse::ParseStream) -> syn::Result { let command_name = input.parse()?; - let dispatcher = if input.parse::().is_ok() { - let content; - input.parse::()?; - let _paren = parenthesized!(content in input); - Some(content.parse()?) - } else { - None - }; + let mut aliases = vec![]; + let mut dispatcher = None; + let mut restricted = false; + while input.peek(Token![,]) { + if !input.peek2(LitStr) { + break; + } + let _ = input.parse::(); + aliases.push(input.parse()?); + } + while input.peek(Token![,]) { + let _ = input.parse::()?; + if input.peek(In) { + let content; + input.parse::()?; + let _paren = parenthesized!(content in input); + dispatcher = Some(content.parse()?); + } else if input.peek(Ident) { + if input.parse::()? == "restricted" { + restricted = true; + } + } + } Ok(Self { command_name, + aliases, dispatcher, + restricted, }) } } +pub fn command_docs(attrs: &[Attribute], aliases: &Vec) -> Vec { + let mut lines: Vec = attrs + .iter() + .filter(|a| a.path.is_ident("doc")) + .filter_map(|a| match a.parse_meta() { + Ok(Meta::NameValue(MetaNameValue { + lit: syn::Lit::Str(s), + .. + })) => Some(s.value()), + _ => None, + }) + // XXX: markdown-stripping could be a bit more robust/extensive + .map(|s| { + s.strip_prefix(' ') + .unwrap_or(&s) + .trim_end() + .replace(r"\[", "[") + .replace(r"\]", "]") + .replace(r"\<", "<") + .replace(r"\>", ">") + }) + .collect(); + if aliases.len() > 0 { + let mut first = true; + let mut extra_lines = vec![String::new()]; + for chunk in aliases.chunks(6) { + let line = chunk + .into_iter() + .map(LitStr::value) + .reduce(|acc, s| format!("{acc}, {s}")) + .unwrap_or_default(); + if first { + extra_lines.push(format!("Aliases: {line}")); + first = false; + } else { + extra_lines.push(format!(" {line}")); + } + } + lines.extend(extra_lines); + } + lines +} + pub fn command_handler(attr: TokenStream, item: TokenStream) -> TokenStream { let input = parse_macro_input!(attr as CommandHandlerAttr); let item = parse_macro_input!(item as ItemFn); @@ -43,11 +106,24 @@ pub fn command_handler(attr: TokenStream, item: TokenStream) -> TokenStream { } } + let aliases = input.aliases; + for alias in &aliases { + for c in alias.value().chars() { + if !c.is_ascii_uppercase() { + return quote_spanned!(command_name.span()=> compile_error!("Command aliases should be uppercase")).into(); + } + } + } + let dispatcher = match input.dispatcher { Some(name) => quote!( Some( #name ) ), None => quote!(None), }; + let restricted = input.restricted; + + let docs = command_docs(&item.attrs, &aliases); + let body = if asyncness.is_none() { quote!( if let Err(e) = crate::command::plumbing::call_handler(ctx.as_ref(), &super::#name, ctx.args()) @@ -90,8 +166,11 @@ pub fn command_handler(attr: TokenStream, item: TokenStream) -> TokenStream { inventory::submit!(crate::command::CommandRegistration { command: #command_name, + aliases: &[ #(#aliases),* ], dispatcher: #dispatcher, - handler: call_proxy + handler: call_proxy, + restricted: #restricted, + docs: &[ #(#docs),* ], }); } ).into()