teamtalk 6.0.0

TeamTalk SDK for Rust
Documentation
use super::command::{Command, CommandPattern, parse_command};
use super::context::Context;
use super::middleware::Middleware;
use crate::client::{Client, Message};
use crate::events::{Error, Event, Result};
use std::collections::HashMap;
use std::panic::{AssertUnwindSafe, catch_unwind};

mod helpers;

use helpers::{
    edit_distance, join_command_path, match_command_route, normalize_command_name, pattern_error,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HandlerResult {
    Continue,
    Stop,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnknownCommandPolicy {
    Ignore,
    Reply(String),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteMatcher {
    Any,
    Event(Event),
    Command(String),
}

type Handler = dyn FnMut(&mut Context<'_>) -> Result<HandlerResult> + Send;

struct Route {
    matcher: RouteMatcher,
    command_pattern: Option<CommandPattern>,
    dialog_filter: Option<DialogFilter>,
    handler: Box<Handler>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct DialogFilter {
    dialog: String,
    step: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct HelpEntry {
    usage: String,
    summary: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct AutoHelpConfig {
    enabled: bool,
    command: String,
    header: Option<String>,
    footer: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct SuggestionConfig {
    enabled: bool,
    limit: usize,
    max_distance: usize,
}

impl Default for SuggestionConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            limit: 3,
            max_distance: 3,
        }
    }
}

impl Default for AutoHelpConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            command: "help".to_owned(),
            header: None,
            footer: None,
        }
    }
}

pub struct RouteGroup<'a> {
    router: &'a mut Router,
    namespace: String,
}

impl<'a> RouteGroup<'a> {
    pub fn on_command<F>(self, name: impl Into<String>, handler: F) -> Self
    where
        F: FnMut(&mut Context<'_>) -> Result<HandlerResult> + Send + 'static,
    {
        let full = join_command_path(&self.namespace, &normalize_command_name(name.into()));
        self.router.push_command_route(full.clone(), None, handler);
        self.router.register_help(full, None);
        self
    }

    pub fn on_command_with_help<F>(
        self,
        name: impl Into<String>,
        summary: impl Into<String>,
        handler: F,
    ) -> Self
    where
        F: FnMut(&mut Context<'_>) -> Result<HandlerResult> + Send + 'static,
    {
        let full = join_command_path(&self.namespace, &normalize_command_name(name.into()));
        let summary = summary.into();
        self.router.push_command_route(full.clone(), None, handler);
        self.router.register_help(full, Some(summary));
        self
    }

    pub fn alias_command(self, alias: impl Into<String>, target: impl Into<String>) -> Self {
        let alias = join_command_path(&self.namespace, &normalize_command_name(alias.into()));
        let target = join_command_path(&self.namespace, &normalize_command_name(target.into()));
        if !alias.is_empty() && !target.is_empty() {
            self.router.command_aliases.insert(alias, target);
        }
        self
    }
}

pub struct Router {
    command_prefixes: Vec<char>,
    middlewares: Vec<Box<dyn Middleware + Send>>,
    routes: Vec<Route>,
    command_aliases: HashMap<String, String>,
    on_unknown_command: Option<Box<Handler>>,
    unknown_command_policy: UnknownCommandPolicy,
    help_entries: Vec<HelpEntry>,
    auto_help: AutoHelpConfig,
    suggestions: SuggestionConfig,
}

impl Default for Router {
    fn default() -> Self {
        Self {
            command_prefixes: vec!['/', '!'],
            middlewares: Vec::new(),
            routes: Vec::new(),
            command_aliases: HashMap::new(),
            on_unknown_command: None,
            unknown_command_policy: UnknownCommandPolicy::Ignore,
            help_entries: Vec::new(),
            auto_help: AutoHelpConfig::default(),
            suggestions: SuggestionConfig::default(),
        }
    }
}

mod builder;
mod dispatch;
mod help;

#[cfg(test)]
mod tests {
    use super::{HandlerResult, Router, match_command_route};
    use crate::bot::{Command, CommandPattern};

    #[test]
    fn command_route_matching_trims_namespace_tokens() {
        let command = Command {
            prefix: '/',
            name: "admin".to_owned(),
            args: vec!["ban".to_owned(), "alice".to_owned()],
            raw: "admin ban alice".to_owned(),
        };

        let routed = match_command_route(&command, "admin ban").expect("match route");
        assert_eq!(routed.name, "admin ban");
        assert_eq!(routed.args, vec!["alice".to_owned()]);
    }

    #[test]
    fn auto_help_uses_custom_command_name() {
        let router = Router::new()
            .on_command_with_help("ping", "Ping command", |_ctx| Ok(HandlerResult::Continue))
            .on_command_pattern_with_help(
                CommandPattern::parse("ban <user> [reason...]").expect("pattern"),
                "Ban user",
                |_ctx| Ok(HandlerResult::Continue),
            )
            .with_auto_help_command("commands")
            .with_help_header("Bot commands");

        let command = Command {
            prefix: '/',
            name: "commands".to_owned(),
            args: vec!["ban".to_owned()],
            raw: "commands ban".to_owned(),
        };

        let help = router.render_auto_help(&command).expect("help text");
        assert!(help.contains("Bot commands"));
        assert!(help.contains("/ban <user> [reason...]"));
        assert!(!help.contains("/ping"));
    }

    #[test]
    fn canonicalize_command_uses_alias() {
        let router = Router::new().alias_command("p", "ping");
        let command = Command {
            prefix: '/',
            name: "p".to_owned(),
            args: vec!["now".to_owned()],
            raw: "p now".to_owned(),
        };

        let canonical = router.canonicalize_command(&command);
        assert_eq!(canonical.name, "ping");
        assert_eq!(canonical.args, vec!["now".to_owned()]);
    }

    #[test]
    fn canonicalize_command_uses_subcommand_alias() {
        let router = Router::new().alias_command("admin b", "admin ban");
        let command = Command {
            prefix: '/',
            name: "admin".to_owned(),
            args: vec!["b".to_owned(), "alice".to_owned()],
            raw: "admin b alice".to_owned(),
        };

        let canonical = router.canonicalize_command(&command);
        assert_eq!(canonical.name, "admin");
        assert_eq!(canonical.args, vec!["ban".to_owned(), "alice".to_owned()]);
    }

    #[test]
    fn suggest_commands_prefers_close_matches() {
        let router = Router::new()
            .on_command("ping", |_ctx| Ok(HandlerResult::Continue))
            .on_command("pause", |_ctx| Ok(HandlerResult::Continue))
            .with_unknown_command_suggestions(2);

        let suggestions = router.suggest_commands("pnig");
        assert!(suggestions.iter().any(|item| item == "ping"));
    }

    #[test]
    fn suggest_commands_respects_max_distance() {
        let router = Router::new()
            .on_command("ping", |_ctx| Ok(HandlerResult::Continue))
            .with_unknown_command_suggestions(2)
            .with_unknown_command_suggestion_distance(1);

        let suggestions = router.suggest_commands("pnig");
        assert!(suggestions.is_empty());
    }
}