use crate::{
accounts::Accounts,
adapters,
command::{Command, Handler},
error::{Error, Result},
platform::{Platform, PlatformConfig},
Ctx,
};
use futures::future::{self, BoxFuture};
use std::{collections::HashMap, future::Future, sync::Arc};
#[must_use = "a Bot does nothing until you call .run().await on it"]
pub struct Bot {
platforms: Vec<Platform>,
commands: HashMap<String, CommandSlot>,
text_commands: Vec<TextSlot>,
fallback: Option<Handler>,
on_message: Vec<Handler>,
accounts: Option<Accounts>,
}
#[derive(Clone)]
struct TextSlot {
pattern: String,
matcher: TextMatch,
handler: Handler,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextMatch {
Exact,
Prefix,
Contains,
}
#[derive(Clone)]
struct CommandSlot {
handler: Handler,
platforms: Option<Vec<crate::PlatformKind>>,
description: Option<String>,
descriptions_i18n: HashMap<String, String>,
}
impl Bot {
pub fn new() -> Self {
Self {
platforms: Vec::new(),
commands: HashMap::new(),
text_commands: Vec::new(),
fallback: None,
on_message: Vec::new(),
accounts: None,
}
}
pub fn with_accounts(mut self, accounts: Accounts) -> Self {
self.accounts = Some(accounts);
self
}
pub fn add_platform(mut self, platform: Platform) -> Self {
self.platforms.push(platform);
self
}
pub fn command<F, Fut>(mut self, name: impl Into<String>, handler: F) -> Self
where
F: Fn(Ctx) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
self.commands.insert(
name.into(),
CommandSlot {
handler: Handler::new(handler),
platforms: None,
description: None,
descriptions_i18n: HashMap::new(),
},
);
self
}
pub fn command_described<F, Fut>(
mut self,
name: impl Into<String>,
description: impl Into<String>,
handler: F,
) -> Self
where
F: Fn(Ctx) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
self.commands.insert(
name.into(),
CommandSlot {
handler: Handler::new(handler),
platforms: None,
description: Some(description.into()),
descriptions_i18n: HashMap::new(),
},
);
self
}
pub fn command_described_i18n<F, Fut>(
mut self,
name: impl Into<String>,
descriptions: HashMap<String, String>,
handler: F,
) -> Self
where
F: Fn(Ctx) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
let default = descriptions
.get("en")
.cloned()
.or_else(|| descriptions.values().next().cloned());
self.commands.insert(
name.into(),
CommandSlot {
handler: Handler::new(handler),
platforms: None,
description: default,
descriptions_i18n: descriptions,
},
);
self
}
pub fn only_on(mut self, name: &str, platforms: &[crate::PlatformKind]) -> Self {
if let Some(slot) = self.commands.get_mut(name) {
slot.platforms = Some(platforms.to_vec());
}
self
}
pub fn on_message<F, Fut>(mut self, handler: F) -> Self
where
F: Fn(Ctx) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
self.on_message.push(Handler::new(handler));
self
}
pub fn text_command<F, Fut>(
mut self,
pattern: impl Into<String>,
matcher: TextMatch,
handler: F,
) -> Self
where
F: Fn(Ctx) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
self.text_commands.push(TextSlot {
pattern: pattern.into().to_lowercase(),
matcher,
handler: Handler::new(handler),
});
self
}
pub fn with_default_help(self) -> Self {
let snapshot: Vec<(String, Option<String>, HashMap<String, String>)> = self
.commands
.iter()
.filter(|(_, slot)| slot.description.is_some() || !slot.descriptions_i18n.is_empty())
.map(|(name, slot)| {
(
name.clone(),
slot.description.clone(),
slot.descriptions_i18n.clone(),
)
})
.collect();
let accounts = self.accounts.clone();
self.command("/help", move |ctx| {
let snapshot = snapshot.clone();
let accounts = accounts.clone();
async move {
let lang = match &accounts {
Some(a) => a
.lang_for(ctx.platform(), ctx.user_id())
.await
.unwrap_or_else(|_| "en".to_owned()),
None => "en".to_owned(),
};
let header = match lang.as_str() {
"ru" => "команды:",
_ => "commands:",
};
let mut body = String::from(header);
body.push('\n');
let mut sorted = snapshot;
sorted.sort_by(|a, b| a.0.cmp(&b.0));
for (name, default_desc, i18n) in sorted {
let desc = i18n
.get(&lang)
.cloned()
.or(default_desc)
.unwrap_or_default();
if desc.is_empty() {
body.push_str(&format!(" {name}\n"));
} else {
body.push_str(&format!(" {name} - {desc}\n"));
}
}
ctx.reply(body.trim_end()).await
}
})
}
pub fn with_default_lang_command<I, S>(self, supported: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
const CB_PREFIX: &str = "foukoapi:lang:";
let supported: Vec<String> = supported
.into_iter()
.map(|s| s.into().to_ascii_lowercase())
.collect();
let accounts = self.accounts.clone();
self.command_described(
"/lang",
"switch language, e.g. /lang ru",
move |ctx| {
let accounts = accounts.clone();
let supported = supported.clone();
async move {
let Some(accounts) = accounts else {
return ctx
.reply(
"language switching is not wired up on this bot (the author forgot to call Bot::with_accounts)",
)
.await;
};
let (requested, invoker): (Option<String>, Option<String>) =
if let Some(data) = ctx.callback_data() {
if let Some(rest) = data.strip_prefix(CB_PREFIX) {
let mut parts = rest.splitn(2, ':');
let invoker = parts.next().map(|s| s.to_owned());
let code = parts.next().map(|s| s.to_ascii_lowercase());
(code, invoker)
} else {
(None, None)
}
} else {
let arg = ctx.args().trim().to_ascii_lowercase();
if arg.is_empty() {
(None, None)
} else {
(Some(arg), None)
}
};
if let Some(inv) = invoker.as_deref() {
if !inv.is_empty() && inv != ctx.user_id() {
let lang_now = accounts
.lang_for(ctx.platform(), ctx.user_id())
.await
.unwrap_or_else(|_| "en".into());
let msg = match lang_now.as_str() {
"ru" => "эта кнопка не для тебя",
_ => "this button isn't for you",
};
return ctx.reply(msg).await;
}
}
let current = accounts
.lang_for(ctx.platform(), ctx.user_id())
.await
.unwrap_or_else(|_| "en".into());
match requested {
None => {
let body = match current.as_str() {
"ru" => format!(
"текущий язык: {current}\nвыбери язык:"
),
_ => format!(
"current language: {current}\npick a language:"
),
};
if supported.is_empty() {
return ctx.reply(body).await;
}
let invoker_id = ctx.user_id().to_owned();
let kb =
build_lang_keyboard(&supported, ¤t, &invoker_id, CB_PREFIX);
ctx.reply_with(crate::Reply::text(body).keyboard(kb)).await
}
Some(code) => {
if !supported.is_empty() && !supported.contains(&code) {
let list = supported.join(", ");
let msg = match current.as_str() {
"ru" => format!("поддерживаемые языки: {list}"),
_ => format!("supported languages: {list}"),
};
return ctx.reply(msg).await;
}
accounts
.set_lang(ctx.platform(), ctx.user_id(), &code)
.await?;
let invoker_id = ctx.user_id().to_owned();
let body = match code.as_str() {
"ru" => format!(
"язык: {code} \u{2705}\nвыбери другой:"
),
_ => format!(
"language set to {code} \u{2705}\npick another:"
),
};
if supported.is_empty() {
return ctx.edit_reply(body).await;
}
let kb =
build_lang_keyboard(&supported, &code, &invoker_id, CB_PREFIX);
ctx.edit_reply(crate::Reply::text(body).keyboard(kb)).await
}
}
}
},
)
}
pub fn with_default_link_command(self) -> Self {
const CB_UNLINK: &str = "foukoapi:link:unlink";
const CB_CANCEL: &str = "foukoapi:link:cancel";
const CB_PRIMARY_ME: &str = "foukoapi:link:primary_me";
const CB_PRIMARY_PARTNER: &str = "foukoapi:link:primary_partner";
let mut descs = HashMap::new();
descs.insert(
"en".to_owned(),
"Link or unlink accounts across platforms".to_owned(),
);
descs.insert(
"ru".to_owned(),
"Связать или отвязать аккаунты между платформами".to_owned(),
);
let accounts = self.accounts.clone();
self.command_described_i18n("/link", descs, move |ctx| {
let accounts = accounts.clone();
async move {
let Some(accounts) = accounts else {
return ctx
.reply(
"account linking is not wired up on this bot (the author forgot to call Bot::with_accounts)",
)
.await;
};
let lang = accounts
.lang_for(ctx.platform(), ctx.user_id())
.await
.unwrap_or_else(|_| "en".into());
if !ctx.is_dm() {
let body = match lang.as_str() {
"ru" => "команда /link работает только в личке с ботом. открой переписку и попробуй снова.",
_ => "/link only works in a private chat with the bot. open the bot's DM and try again.",
};
return ctx.reply(body).await;
}
if let Some(data) = ctx.callback_data() {
return handle_link_callback(
&ctx,
&accounts,
data,
&lang,
CB_UNLINK,
CB_CANCEL,
CB_PRIMARY_ME,
CB_PRIMARY_PARTNER,
)
.await;
}
let arg = ctx.args().trim().to_owned();
if arg.starts_with("foukoapi:link:") {
return handle_link_callback(
&ctx,
&accounts,
&arg,
&lang,
CB_UNLINK,
CB_CANCEL,
CB_PRIMARY_ME,
CB_PRIMARY_PARTNER,
)
.await;
}
if arg.is_empty() {
return link_status_reply(&ctx, &accounts, &lang, CB_UNLINK, CB_CANCEL)
.await;
}
match accounts
.redeem_link(&arg, ctx.platform(), ctx.user_id())
.await
{
Ok(res) => {
let (title, body, footer) = match lang.as_str() {
"ru" => (
"\u{2705} Аккаунты связаны",
format!(
"**{}** \u{2194} **{}**",
platform_title(platform_of(&res.primary)),
platform_title(platform_of(&res.partner))
),
"Выбери основной аккаунт. На нём остаются XP, монеты и настройки. Выбор делается **один раз** — потом поменять нельзя, можно только /link → Отвязать.",
),
_ => (
"\u{2705} Accounts Linked",
format!(
"**{}** \u{2194} **{}**",
platform_title(platform_of(&res.primary)),
platform_title(platform_of(&res.partner))
),
"Pick the primary — XP, coins and settings live there. You can only pick **once**; after that, /link → Unlink is the only reset.",
),
};
let me = format!("{}:{}", ctx.platform(), ctx.user_id());
let em = crate::Embed::new()
.title(title)
.description(body)
.footer(footer)
.color(LINK_COLOR);
let kb = primary_picker_keyboard(
&lang,
&me,
&res.partner,
CB_PRIMARY_ME,
CB_PRIMARY_PARTNER,
);
ctx.reply_with(crate::Reply::embed(em).keyboard(kb)).await
}
Err(e) => {
let (title, desc) = match lang.as_str() {
"ru" => ("\u{274C} Не получилось", format!("{e}")),
_ => ("\u{274C} Link Failed", format!("{e}")),
};
let em = crate::Embed::new()
.title(title)
.description(desc)
.color(ERROR_COLOR);
ctx.reply_with(crate::Reply::embed(em)).await
}
}
}
})
}
pub fn fallback<F, Fut>(mut self, handler: F) -> Self
where
F: Fn(Ctx) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
self.fallback = Some(Handler::new(handler));
self
}
pub fn commands(&self) -> Vec<Command> {
self.commands
.iter()
.map(|(name, slot)| Command::new(name.clone(), slot.handler.clone()))
.collect()
}
#[doc(hidden)]
pub fn command_snapshot(&self) -> Vec<(String, Option<String>)> {
self.commands
.iter()
.map(|(name, slot)| (name.clone(), slot.description.clone()))
.collect()
}
pub async fn run(self) -> Result<()> {
if self.platforms.is_empty() {
return Err(Error::NoPlatforms);
}
let commands_meta: Vec<(String, Option<String>)> = self
.commands
.iter()
.map(|(name, slot)| (name.clone(), slot.description.clone()))
.collect();
let router = Arc::new(Router {
commands: self
.commands
.into_iter()
.map(|(k, v)| {
(
k,
RouterSlot {
handler: v.handler,
platforms: v.platforms,
},
)
})
.collect(),
text_commands: self
.text_commands
.into_iter()
.map(|t| RouterTextSlot {
pattern: t.pattern,
matcher: t.matcher,
handler: t.handler,
})
.collect(),
fallback: self.fallback,
on_message: self.on_message,
});
let mut tasks: Vec<BoxFuture<'static, Result<()>>> = Vec::new();
for platform in self.platforms {
let router = Arc::clone(&router);
let name = platform.kind.to_string();
match platform.config {
#[cfg(feature = "telegram")]
PlatformConfig::Telegram { token } => {
tasks.push(Box::pin(async move {
adapters::telegram::run(token, router).await
}));
}
#[cfg(not(feature = "telegram"))]
PlatformConfig::Telegram { .. } => {
tracing::warn!(
"Telegram platform configured but the `telegram` feature is off; skipping"
);
}
#[cfg(feature = "discord")]
PlatformConfig::Discord { token } => {
let cmds = commands_meta.clone();
tasks.push(Box::pin(async move {
adapters::discord::run(token, router, cmds).await
}));
}
#[cfg(not(feature = "discord"))]
PlatformConfig::Discord { .. } => {
tracing::warn!(
"Discord platform configured but the `discord` feature is off; skipping"
);
}
}
tracing::info!(platform = %name, "adapter registered");
}
if tasks.is_empty() {
return Err(Error::NoPlatforms);
}
let (result, _, _rest) = future::select_all(tasks).await;
result
}
}
impl Default for Bot {
fn default() -> Self {
Self::new()
}
}
fn callback_to_command(data: &str) -> Option<String> {
let first = data.split(':').next()?;
let cmd = if first == "foukoapi" {
data.split(':').nth(1)?
} else {
first
};
if cmd.is_empty() {
return None;
}
Some(format!("/{cmd}"))
}
fn build_lang_keyboard(
supported: &[String],
current: &str,
invoker_id: &str,
cb_prefix: &str,
) -> crate::Keyboard {
let mut kb = crate::Keyboard::new();
for chunk in supported.chunks(3) {
let row: Vec<crate::Button> = chunk
.iter()
.map(|code| {
let marker = if code == current { "\u{2B50} " } else { "" };
let label = format!("{marker}{}", lang_label(code));
crate::Button::callback(label, format!("{cb_prefix}{invoker_id}:{code}"))
})
.collect();
kb = kb.row(row);
}
kb
}
fn lang_label(code: &str) -> String {
match code {
"en" => "\u{1F1FA}\u{1F1F8} English".to_owned(),
"ru" => "\u{1F1F7}\u{1F1FA} Русский".to_owned(),
"uk" => "\u{1F1FA}\u{1F1E6} Українська".to_owned(),
"de" => "\u{1F1E9}\u{1F1EA} Deutsch".to_owned(),
"fr" => "\u{1F1EB}\u{1F1F7} Français".to_owned(),
"es" => "\u{1F1EA}\u{1F1F8} Español".to_owned(),
"it" => "\u{1F1EE}\u{1F1F9} Italiano".to_owned(),
"pt" => "\u{1F1F5}\u{1F1F9} Português".to_owned(),
"pl" => "\u{1F1F5}\u{1F1F1} Polski".to_owned(),
"tr" => "\u{1F1F9}\u{1F1F7} Türkçe".to_owned(),
"ja" => "\u{1F1EF}\u{1F1F5} 日本語".to_owned(),
"zh" => "\u{1F1E8}\u{1F1F3} 中文".to_owned(),
"ko" => "\u{1F1F0}\u{1F1F7} 한국어".to_owned(),
other => other.to_ascii_uppercase(),
}
}
const LINK_COLOR: u32 = 0x5B8DEF;
const ERROR_COLOR: u32 = 0xE74C3C;
fn platform_of(ident: &str) -> &str {
ident.split(':').next().unwrap_or(ident)
}
async fn link_status_reply(
ctx: &Ctx,
accounts: &Accounts,
lang: &str,
cb_unlink: &str,
cb_cancel: &str,
) -> Result<()> {
let me = format!("{}:{}", ctx.platform(), ctx.user_id());
let partner = accounts.partner_for(ctx.platform(), ctx.user_id()).await?;
match partner {
Some(p) => {
let (title, platforms_label, foot) = match lang {
"ru" => (
"\u{1F517} Связанные аккаунты",
"Платформы",
"Отвязать можно один раз. После отвязки у партнёрской платформы будет чистый профиль.",
),
_ => (
"\u{1F517} Linked Accounts",
"Platforms",
"You can unlink once. After that the other side starts with a fresh profile.",
),
};
let em = crate::Embed::new()
.title(title)
.field(
platforms_label,
format!(
"**{}** \u{2194} **{}**",
platform_title(platform_of(&me)),
platform_title(platform_of(&p))
),
)
.footer(foot)
.color(LINK_COLOR);
let kb = crate::Keyboard::new().row([crate::Button::callback(
match lang {
"ru" => "\u{1F494} Отвязать",
_ => "\u{1F494} Unlink",
},
cb_unlink,
)]);
ctx.reply_with(crate::Reply::embed(em).keyboard(kb)).await
}
None => {
let code = accounts.start_link(ctx.platform(), ctx.user_id()).await?;
let (title, code_label, how_label, how_value, foot) = match lang {
"ru" => (
"\u{1F517} Код привязки",
"Код",
"Как использовать",
format!("Открой бота на другой платформе и отправь:\n`/link {code}`"),
"Код живёт 5 минут.",
),
_ => (
"\u{1F517} Link Code",
"Code",
"How To Use",
format!("Open the bot on another platform and send:\n`/link {code}`"),
"The code expires in 5 minutes.",
),
};
let em = crate::Embed::new()
.title(title)
.field(code_label, format!("`{code}`"))
.field(how_label, how_value)
.footer(foot)
.color(LINK_COLOR);
let kb = crate::Keyboard::new().row([crate::Button::callback(
match lang {
"ru" => "\u{274C} Отмена",
_ => "\u{274C} Cancel",
},
cb_cancel,
)]);
ctx.reply_with(crate::Reply::embed(em).keyboard(kb)).await
}
}
}
fn platform_title(raw: &str) -> String {
let mut chars = raw.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn primary_picker_keyboard(
lang: &str,
me: &str,
partner: &str,
cb_me: &str,
cb_partner: &str,
) -> crate::Keyboard {
let (me_label, partner_label) = match lang {
"ru" => (
format!("\u{1F4CD} Эта ({})", platform_title(platform_of(me))),
format!(
"\u{1F517} Другая ({})",
platform_title(platform_of(partner))
),
),
_ => (
format!("\u{1F4CD} This ({})", platform_title(platform_of(me))),
format!("\u{1F517} Other ({})", platform_title(platform_of(partner))),
),
};
crate::Keyboard::new().row([
crate::Button::callback(me_label, cb_me),
crate::Button::callback(partner_label, cb_partner),
])
}
#[allow(clippy::too_many_arguments)]
async fn handle_link_callback(
ctx: &Ctx,
accounts: &Accounts,
data: &str,
lang: &str,
cb_unlink: &str,
cb_cancel: &str,
cb_primary_me: &str,
cb_primary_partner: &str,
) -> Result<()> {
if data == cb_cancel {
let (title, desc) = match lang {
"ru" => (
"\u{274C} Отменено",
"Запусти /link ещё раз, когда будешь готов.",
),
_ => (
"\u{274C} Cancelled",
"Run /link again whenever you're ready.",
),
};
let em = crate::Embed::new()
.title(title)
.description(desc)
.color(ERROR_COLOR);
return ctx.edit_reply(crate::Reply::embed(em)).await;
}
if data == cb_primary_me || data == cb_primary_partner {
let me = format!("{}:{}", ctx.platform(), ctx.user_id());
let partner = match accounts.partner_for(ctx.platform(), ctx.user_id()).await? {
Some(p) => p,
None => {
let (title, desc) = match lang {
"ru" => ("\u{2753} Нет связки", "Сначала выполни /link CODE."),
_ => ("\u{2753} No Link", "Redeem a /link CODE first."),
};
let em = crate::Embed::new()
.title(title)
.description(desc)
.color(ERROR_COLOR);
return ctx.edit_reply(crate::Reply::embed(em)).await;
}
};
let lock_key = format!("foukoapi:link:primary_locked:{me}");
let partner_lock = format!("foukoapi:link:primary_locked:{partner}");
let already = accounts
.storage_ref()
.get(&lock_key)
.await
.ok()
.flatten()
.is_some()
|| accounts
.storage_ref()
.get(&partner_lock)
.await
.ok()
.flatten()
.is_some();
if already {
let (title, desc) = match lang {
"ru" => (
"\u{1F512} Уже выбрано",
"Основная платформа уже зафиксирована. Поменять можно только через /link → Отвязать.",
),
_ => (
"\u{1F512} Already Locked",
"The primary is already locked in. You can only change it via /link → Unlink.",
),
};
let em = crate::Embed::new()
.title(title)
.description(desc)
.color(ERROR_COLOR);
return ctx.edit_reply(crate::Reply::embed(em)).await;
}
let chosen = if data == cb_primary_me {
me.clone()
} else {
partner.clone()
};
accounts
.set_primary(ctx.platform(), ctx.user_id(), &chosen)
.await?;
let _ = accounts.storage_ref().set(&lock_key, "1").await;
let _ = accounts.storage_ref().set(&partner_lock, "1").await;
let (title, desc) = match lang {
"ru" => (
"\u{2705} Основная выбрана",
format!(
"Основной аккаунт: **{}**. Поменять больше нельзя — только через /link → Отвязать.",
platform_title(platform_of(&chosen))
),
),
_ => (
"\u{2705} Primary Locked",
format!(
"Primary: **{}**. It can't be changed anymore — use /link → Unlink to reset.",
platform_title(platform_of(&chosen))
),
),
};
let em = crate::Embed::new()
.title(title)
.description(desc)
.color(LINK_COLOR);
return ctx.edit_reply(crate::Reply::embed(em)).await;
}
if data == cb_unlink {
let me = format!("{}:{}", ctx.platform(), ctx.user_id());
let partner_before = accounts.partner_for(ctx.platform(), ctx.user_id()).await?;
return match accounts.unlink(ctx.platform(), ctx.user_id()).await? {
Some(partner) => {
let _ = accounts
.storage_ref()
.del(&format!("foukoapi:link:primary_locked:{me}"))
.await;
if let Some(p) = partner_before.as_deref() {
let _ = accounts
.storage_ref()
.del(&format!("foukoapi:link:primary_locked:{p}"))
.await;
}
let (title, desc) = match lang {
"ru" => (
"\u{1F494} Отвязано",
format!(
"Больше не связан с **{}**. Основная платформа сохраняет свой профиль, остальные — получают чистый.",
platform_title(platform_of(&partner))
),
),
_ => (
"\u{1F494} Unlinked",
format!(
"No longer linked to **{}**. The primary keeps its profile; the other side starts fresh.",
platform_title(platform_of(&partner))
),
),
};
let em = crate::Embed::new()
.title(title)
.description(desc)
.color(LINK_COLOR);
ctx.edit_reply(crate::Reply::embed(em)).await
}
None => {
let (title, desc) = match lang {
"ru" => ("\u{2753} Нечего отвязывать", "Связанного аккаунта нет."),
_ => ("\u{2753} Nothing To Unlink", "No partner was set."),
};
let em = crate::Embed::new()
.title(title)
.description(desc)
.color(ERROR_COLOR);
ctx.reply_with(crate::Reply::embed(em)).await
}
};
}
Ok(())
}
#[doc(hidden)]
#[derive(Clone)]
pub struct Router {
pub(crate) commands: HashMap<String, RouterSlot>,
pub(crate) text_commands: Vec<RouterTextSlot>,
pub(crate) fallback: Option<Handler>,
pub(crate) on_message: Vec<Handler>,
}
#[doc(hidden)]
#[derive(Clone)]
pub(crate) struct RouterSlot {
pub(crate) handler: Handler,
pub(crate) platforms: Option<Vec<crate::PlatformKind>>,
}
#[doc(hidden)]
#[derive(Clone)]
pub(crate) struct RouterTextSlot {
pub(crate) pattern: String,
pub(crate) matcher: TextMatch,
pub(crate) handler: Handler,
}
impl Router {
#[doc(hidden)]
pub async fn dispatch(&self, ctx: Ctx) -> Result<()> {
for hook in &self.on_message {
if let Err(e) = hook.call(ctx.clone()).await {
tracing::debug!(error = %e, "on_message hook error");
}
}
if let Some(data) = ctx.callback_data() {
if let Some(cmd_name) = callback_to_command(data) {
if let Some(slot) = self.commands.get(&cmd_name) {
if let Some(allowed) = &slot.platforms {
if !allowed.contains(&ctx.platform()) {
return Ok(());
}
}
return slot.handler.call(ctx).await;
}
}
}
let text = ctx.text().trim();
if text.starts_with('/') {
let cmd_word = text.split_whitespace().next().unwrap_or("");
let cmd_clean = cmd_word.split('@').next().unwrap_or(cmd_word);
if let Some(slot) = self.commands.get(cmd_clean) {
if let Some(allowed) = &slot.platforms {
if !allowed.contains(&ctx.platform()) {
return Ok(());
}
}
return slot.handler.call(ctx).await;
}
if let Some(fallback) = &self.fallback {
return fallback.call(ctx).await;
}
return Ok(());
}
let lower = text.to_lowercase();
for slot in &self.text_commands {
let hit = match slot.matcher {
TextMatch::Exact => lower == slot.pattern,
TextMatch::Prefix => lower.starts_with(&slot.pattern),
TextMatch::Contains => lower.contains(&slot.pattern),
};
if hit {
return slot.handler.call(ctx).await;
}
}
Ok(())
}
}