use crate::{
accounts::Accounts,
adapters,
command::{Command, Handler},
error::{Error, Result},
platform::{Platform, PlatformConfig},
ratelimit::{RateLimit, RateLimiter},
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>,
rate_limiter: Option<Arc<RateLimiter>>,
on_rate_limited: Option<Handler>,
max_update_age: Option<std::time::Duration>,
on_flood: Option<FloodHook>,
notifier: Option<crate::notifier::Notifier>,
}
#[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>,
category: Option<String>,
takes_user: bool,
}
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,
rate_limiter: Some(Arc::new(RateLimiter::new(RateLimit::relaxed()))),
on_rate_limited: None,
max_update_age: Some(std::time::Duration::from_secs(60)),
on_flood: None,
notifier: None,
}
}
pub fn with_notifier(mut self, notifier: crate::notifier::Notifier) -> Self {
self.notifier = Some(notifier);
self
}
pub fn rate_limit(mut self, policy: RateLimit) -> Self {
self.rate_limiter = Some(Arc::new(RateLimiter::new(policy)));
self
}
pub fn no_rate_limit(mut self) -> Self {
self.rate_limiter = None;
self
}
pub fn on_rate_limited<F, Fut>(mut self, handler: F) -> Self
where
F: Fn(Ctx) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
self.on_rate_limited = Some(Handler::new(handler));
self
}
pub fn on_flood<F, Fut>(
mut self,
per_minute: u32,
cooldown: std::time::Duration,
handler: F,
) -> Self
where
F: Fn(u64) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
self.on_flood = Some(FloodHook {
window: FloodWindow::new(per_minute, cooldown),
handler: Arc::new(move |count| Box::pin(handler(count))),
});
self
}
pub fn max_update_age(mut self, age: std::time::Duration) -> Self {
self.max_update_age = Some(age);
self
}
pub fn process_all_updates(mut self) -> Self {
self.max_update_age = None;
self
}
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(
normalize_command_name(&name.into()),
CommandSlot {
handler: Handler::new(handler),
platforms: None,
description: None,
descriptions_i18n: HashMap::new(),
category: None,
takes_user: false,
},
);
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(
normalize_command_name(&name.into()),
CommandSlot {
handler: Handler::new(handler),
platforms: None,
description: Some(description.into()),
descriptions_i18n: HashMap::new(),
category: None,
takes_user: false,
},
);
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(
normalize_command_name(&name.into()),
CommandSlot {
handler: Handler::new(handler),
platforms: None,
description: default,
descriptions_i18n: descriptions,
category: None,
takes_user: false,
},
);
self
}
pub fn only_on(mut self, name: &str, platforms: &[crate::PlatformKind]) -> Self {
if let Some(slot) = self.commands.get_mut(&normalize_command_name(name)) {
slot.platforms = Some(platforms.to_vec());
}
self
}
pub fn category(mut self, name: &str, category: impl Into<String>) -> Self {
if let Some(slot) = self.commands.get_mut(&normalize_command_name(name)) {
slot.category = Some(category.into());
}
self
}
pub fn user_option(mut self, name: &str) -> Self {
if let Some(slot) = self.commands.get_mut(&normalize_command_name(name)) {
slot.takes_user = true;
}
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 {
type HelpEntry = (
String,
Option<String>,
HashMap<String, String>,
Option<String>,
Option<Vec<crate::PlatformKind>>,
);
let snapshot: Vec<HelpEntry> = 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(),
slot.category.clone(),
slot.platforms.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 (title, intro) = match lang.as_str() {
"ru" => ("\u{1F4D6} Команды", "Вот что я умею:"),
_ => ("\u{1F4D6} Commands", "Here's what I can do:"),
};
let mut sorted = snapshot;
sorted.sort_by(|a, b| a.0.cmp(&b.0));
let mut groups: Vec<(String, String)> = Vec::new();
let has_categories = sorted.iter().any(|(_, _, _, cat, _)| cat.is_some());
for (name, default_desc, i18n, category, platforms) in sorted {
if let Some(allowed) = &platforms {
if !allowed.contains(&ctx.platform()) {
continue;
}
}
let desc = i18n
.get(&lang)
.cloned()
.or(default_desc)
.unwrap_or_default();
let line = if desc.is_empty() {
format!("{name}\n")
} else {
format!("{name} - {desc}\n")
};
let group = category.unwrap_or_default();
match groups.iter_mut().find(|(g, _)| g == &group) {
Some((_, body)) => body.push_str(&line),
None => groups.push((group, line)),
}
}
let mut em = crate::Embed::new().title(title).color(0x5B8DEF);
if has_categories {
em = em.description(intro);
for (group, body) in groups {
let heading = if group.is_empty() {
match lang.as_str() {
"ru" => "Прочее".to_owned(),
_ => "Other".to_owned(),
}
} else {
group
};
em = em.field(heading, body.trim_end().to_owned());
}
} else {
let body = groups.into_iter().map(|(_, b)| b).collect::<String>();
em = em.description(format!("{intro}\n\n{}", body.trim_end()));
}
ctx.reply_with(crate::Reply::embed(em)).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>, bool)> {
self.commands
.iter()
.map(|(name, slot)| (name.clone(), slot.description.clone(), slot.takes_user))
.collect()
}
pub async fn run(self) -> Result<()> {
if self.platforms.is_empty() {
return Err(Error::NoPlatforms);
}
let commands_meta: Vec<(String, Option<String>, bool)> = self
.commands
.iter()
.map(|(name, slot)| (name.clone(), slot.description.clone(), slot.takes_user))
.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,
rate_limiter: self.rate_limiter,
on_rate_limited: self.on_rate_limited,
max_update_age: self.max_update_age,
on_flood: self.on_flood,
});
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();
let notifier = self.notifier.clone();
match platform.config {
#[cfg(feature = "telegram")]
PlatformConfig::Telegram { token } => {
let cmds = commands_meta.clone();
tasks.push(Box::pin(async move {
adapters::telegram::run(token, router, cmds, notifier).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, notifier).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 normalize_command_name(name: &str) -> String {
name.to_lowercase()
}
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(normalize_command_name(&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 partner_already = accounts
.storage_ref()
.get(&partner_lock)
.await
.ok()
.flatten()
.is_some();
let claimed = if partner_already {
false
} else {
match accounts.storage_ref().set_nx(&lock_key, "1").await {
Ok(won) => won,
Err(e) => {
tracing::warn!(error = %e, "link: could not claim primary lock");
let (title, desc) = match lang {
"ru" => (
"\u{274C} Ошибка",
"Не получилось сохранить выбор. Попробуй ещё раз.",
),
_ => ("\u{274C} Error", "Could not save your choice. Try again."),
};
let em = crate::Embed::new()
.title(title)
.description(desc)
.color(ERROR_COLOR);
return ctx.edit_reply(crate::Reply::embed(em)).await;
}
}
};
if !claimed {
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?;
if let Err(e) = accounts.storage_ref().set(&partner_lock, "1").await {
tracing::warn!(error = %e, "link: could not set partner primary lock");
}
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>,
pub(crate) rate_limiter: Option<Arc<RateLimiter>>,
pub(crate) on_rate_limited: Option<Handler>,
pub(crate) max_update_age: Option<std::time::Duration>,
pub(crate) on_flood: Option<FloodHook>,
}
impl Router {
#[doc(hidden)]
pub fn max_update_age(&self) -> Option<std::time::Duration> {
self.max_update_age
}
}
#[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,
}
pub(crate) type FloodHandler = Arc<dyn Fn(u64) -> BoxFuture<'static, ()> + Send + Sync + 'static>;
#[derive(Clone)]
pub(crate) struct FloodHook {
pub(crate) window: FloodWindow,
pub(crate) handler: FloodHandler,
}
#[derive(Clone)]
pub(crate) struct FloodWindow {
inner: Arc<FloodWindowInner>,
}
struct FloodWindowInner {
per_minute: u64,
cooldown: std::time::Duration,
count: std::sync::atomic::AtomicU64,
state: std::sync::Mutex<FloodState>,
}
struct FloodState {
window_start: std::time::Instant,
last_fired: Option<std::time::Instant>,
}
const FLOOD_WINDOW: std::time::Duration = std::time::Duration::from_secs(60);
impl FloodWindow {
pub(crate) fn new(per_minute: u32, cooldown: std::time::Duration) -> Self {
Self {
inner: Arc::new(FloodWindowInner {
per_minute: u64::from(per_minute),
cooldown,
count: std::sync::atomic::AtomicU64::new(0),
state: std::sync::Mutex::new(FloodState {
window_start: std::time::Instant::now(),
last_fired: None,
}),
}),
}
}
pub(crate) fn hit(&self, now: std::time::Instant) -> Option<u64> {
let mut state = match self.inner.state.lock() {
Ok(s) => s,
Err(poisoned) => poisoned.into_inner(),
};
if now.duration_since(state.window_start) >= FLOOD_WINDOW {
state.window_start = now;
self.inner
.count
.store(0, std::sync::atomic::Ordering::Relaxed);
}
let count = self
.inner
.count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1;
if count <= self.inner.per_minute {
return None;
}
let cooled = match state.last_fired {
Some(t) => now.duration_since(t) >= self.inner.cooldown,
None => true,
};
if !cooled {
return None;
}
state.last_fired = Some(now);
Some(count)
}
}
impl Router {
#[doc(hidden)]
pub async fn dispatch(&self, ctx: Ctx) -> Result<()> {
if let Some(flood) = &self.on_flood {
if let Some(count) = flood.window.hit(std::time::Instant::now()) {
let handler = Arc::clone(&flood.handler);
tokio::spawn(async move {
handler(count).await;
});
}
}
if let Some(limiter) = &self.rate_limiter {
let key = format!("{}:{}", ctx.platform(), ctx.user_id());
if let crate::ratelimit::Decision::Deny { first, .. } = limiter.check(&key) {
if first {
if let Some(hook) = &self.on_rate_limited {
if let Err(e) = hook.call(ctx).await {
tracing::debug!(error = %e, "on_rate_limited hook error");
}
}
}
return Ok(());
}
}
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);
let cmd_key = normalize_command_name(cmd_clean);
if let Some(slot) = self.commands.get(&cmd_key) {
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(())
}
}
#[cfg(test)]
mod tests {
use super::FloodWindow;
use std::time::{Duration, Instant};
#[test]
fn under_the_threshold_stays_quiet() {
let w = FloodWindow::new(5, Duration::from_secs(60));
let now = Instant::now();
for _ in 0..5 {
assert_eq!(w.hit(now), None);
}
}
#[test]
fn crossing_the_threshold_fires_with_the_count() {
let w = FloodWindow::new(3, Duration::from_secs(60));
let now = Instant::now();
for _ in 0..3 {
assert_eq!(w.hit(now), None);
}
assert_eq!(w.hit(now), Some(4));
}
#[test]
fn cooldown_suppresses_repeat_alerts() {
let w = FloodWindow::new(2, Duration::from_secs(60));
let now = Instant::now();
w.hit(now);
w.hit(now);
assert_eq!(w.hit(now), Some(3));
assert_eq!(w.hit(now), None);
assert_eq!(w.hit(now), None);
}
#[test]
fn cooldown_expiry_allows_another_alert() {
let w = FloodWindow::new(1, Duration::from_secs(10));
let now = Instant::now();
w.hit(now);
assert_eq!(w.hit(now), Some(2));
assert_eq!(w.hit(now), None);
let later = now + Duration::from_secs(10);
assert_eq!(w.hit(later), Some(4));
}
#[test]
fn a_new_window_resets_the_count() {
let w = FloodWindow::new(2, Duration::from_secs(0));
let now = Instant::now();
w.hit(now);
w.hit(now);
assert_eq!(w.hit(now), Some(3));
let next = now + Duration::from_secs(60);
assert_eq!(w.hit(next), None);
assert_eq!(w.hit(next), None);
assert_eq!(w.hit(next), Some(3));
}
}