diff --git a/Cargo.lock b/Cargo.lock index a1a4d77..89fee8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -176,6 +176,7 @@ dependencies = [ "log", "once_cell", "pretty_env_logger", + "shlex", "teloxide", "tokio", "url-track-cleaner", @@ -1356,6 +1357,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.1" diff --git a/Cargo.toml b/Cargo.toml index f57ec2d..16844c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,4 @@ once_cell = "1.19.0" url-track-cleaner = "0.1.5" anyhow = "1.0.82" linkify = "0.10.0" +shlex = "1.3.0" diff --git a/src/app.rs b/src/app.rs index d8afa0c..c88762d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use crate::rpt::RepeaterStates; +use crate::features::rpt::RepeaterStates; use once_cell::sync::Lazy; use std::sync::Arc; use tokio::sync::Mutex; diff --git a/src/commands.rs b/src/commands.rs index af318d8..a7f7a53 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,3 +1,5 @@ +use shlex::Shlex; +use std::io; use teloxide::prelude::*; use teloxide::utils::command::{BotCommands, ParseError}; @@ -8,6 +10,8 @@ pub(crate) enum Command { Help, #[command(description = "清理 URL", parse_with = CleanUrlCommand::parse_to_command)] CleanUrl(CleanUrlCommand), + #[command(description = "替换原文中的关键字并发送", parse_with = ReplaceCommand::parse_to_command)] + Replace(ReplaceCommand), } #[derive(Clone)] @@ -23,6 +27,47 @@ impl CleanUrlCommand { } } +#[derive(Clone)] +pub(crate) struct ReplaceCommand { + pub keyword: String, + pub replacement: String, +} + +impl ReplaceCommand { + fn parse_to_command(s: String) -> Result<(Self,), ParseError> { + let mut l = Shlex::new(&s); + let parts: Vec = l.by_ref().collect(); + if l.had_error { + return Err(ParseError::IncorrectFormat( + io::Error::other("parse arguments error").into(), + )); + } + #[allow(non_upper_case_globals)] + const expected: usize = 2; + let found = parts.len(); + if found != expected { + let message = "replace args count should be 2".to_string(); + return Err(if found > expected { + ParseError::TooFewArguments { + expected, + found, + message, + } + } else { + ParseError::TooFewArguments { + expected, + found, + message, + } + }); + } + Ok((ReplaceCommand { + keyword: parts[0].clone(), + replacement: parts[1].clone(), + },)) + } +} + pub(crate) async fn handle_help_cmd(bot: Bot, msg: Message, _: Command) -> ResponseResult<()> { let descriptions = Command::descriptions(); bot.send_message(msg.chat.id, format!("{}", descriptions)) diff --git a/src/features/mod.rs b/src/features/mod.rs new file mode 100644 index 0000000..13b169a --- /dev/null +++ b/src/features/mod.rs @@ -0,0 +1,3 @@ +pub mod rpt; +pub mod urlenhance; +pub mod userinteract; diff --git a/src/rpt.rs b/src/features/rpt.rs similarity index 100% rename from src/rpt.rs rename to src/features/rpt.rs diff --git a/src/urlenhance.rs b/src/features/urlenhance.rs similarity index 100% rename from src/urlenhance.rs rename to src/features/urlenhance.rs diff --git a/src/features/userinteract.rs b/src/features/userinteract.rs new file mode 100644 index 0000000..27136ee --- /dev/null +++ b/src/features/userinteract.rs @@ -0,0 +1,89 @@ +use crate::commands::ReplaceCommand; +use crate::msgfmt::markup_username_with_link; +use std::any::Any; +use std::ptr::replace; +use teloxide::prelude::*; +use teloxide::types::{MediaKind, MediaText, MessageKind, ParseMode}; + +/// 处理用户对另一个用户的模拟指令行为 +/// +/// 当用户发送类似 `/do_sth` 的消息时,机器人会回复类似 `@user1 do_sth 了 @user2` 的消息 +/// 若消息不符合条件时,此函数会返回 None。此外,发送失败时不会传递错误到上游 +pub(crate) async fn handle_user_do_sth_to_another(bot: &Bot, msg: &Message) -> Option<()> { + let from_user = msg.from(); + let reply_user = msg.reply_to_message().and_then(|reply| reply.from()); + let text = msg.text(); + if text.is_none() || from_user.is_none() || reply_user.is_none() { + return None; + } + let text = text.unwrap(); + if text.starts_with("/") && text.find("@").is_none() { + let act = text.strip_prefix("/"); + if act.is_none() { + return None; + } + let acts: Vec<&str> = act.unwrap().split_ascii_whitespace().into_iter().collect(); + if acts.len() == 0 || acts[0] == "me" { + return None; + } + let mut text = format!( + "{} {}了 {}", + markup_username_with_link(from_user.unwrap()), + acts[0], + markup_username_with_link(reply_user.unwrap()) + ); + if acts.len() > 1 { + text.push_str(&format!(" {}", acts[1])); + } + let res = bot + .send_message(msg.chat.id, text) + .parse_mode(ParseMode::MarkdownV2) + .await; + if res.is_err() { + log::error!("failed to send message: {:?}", res.err()); + } + // TODO 统计行为次数 + return Some(()); + } + return None; +} + +/// 处理用户替换消息中的关键词 +/// +/// 当用户回复一条消息并发送类似 `/replace keyword replacement` 的消息时, +/// 机器人会将被回复的消息中的 `keyword` 替换为 `replacement` 并回复。 +/// 被回复的消息必须是纯文本消息,否则跳过处理。 +pub(crate) async fn handle_user_replace_words( + bot: &Bot, + msg: &Message, + cmd: ReplaceCommand, +) -> ResponseResult<()> { + let reply_msg = msg.reply_to_message(); + if reply_msg.is_none() { + bot.send_message(msg.chat.id, "此命令需要指定一条消息回复") + .reply_to_message_id(msg.id) + .await?; + return Ok(()); + } + // 限制仅支持纯文本消息 + if let MessageKind::Common(common) = &reply_msg.unwrap().kind { + if let MediaKind::Text(MediaText { text, entities: _ }) = &common.media_kind { + if msg.from().is_none() { + // ignored + return Ok(()); + } + let from = msg.from().unwrap(); + let replaced = text.replace(&cmd.keyword, &cmd.replacement); + let replacer = markup_username_with_link(from); + bot.send_message(msg.chat.id, format!("{} :{}", replacer, replaced)) + .parse_mode(ParseMode::MarkdownV2) + .reply_to_message_id(msg.id) + .await?; + return Ok(()); + } + } + bot.send_message(msg.chat.id, "无法处理此消息") + .reply_to_message_id(msg.id) + .await?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 4a98bdb..48576be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,14 @@ mod app; mod commands; mod configs; +mod features; mod msgfmt; -mod rpt; -mod urlenhance; -mod userinteract; use crate::app::BotApp; use crate::commands::*; use crate::configs::BotConfigs; -use crate::rpt::RepeaterNextAction; +use features::rpt::RepeaterNextAction; +use features::{urlenhance, userinteract}; use std::sync::Arc; use teloxide::prelude::*; use teloxide::types::{MediaKind, MediaText, MessageKind}; @@ -54,6 +53,7 @@ async fn handle_cmd(bot: Bot, msg: Message, cmd: Command) -> ResponseResult<()> match cmd { Command::Help => handle_help_cmd(bot, msg, cmd).await, Command::CleanUrl(cmd) => urlenhance::handle_clean_url_cmd(bot, msg, cmd).await, + Command::Replace(cmd) => userinteract::handle_user_replace_words(&bot, &msg, cmd).await, } } diff --git a/src/userinteract.rs b/src/userinteract.rs deleted file mode 100644 index 91bfb6f..0000000 --- a/src/userinteract.rs +++ /dev/null @@ -1,46 +0,0 @@ -use crate::msgfmt::markup_username_with_link; -use teloxide::prelude::*; -use teloxide::types::ParseMode; - -/// 处理用户对另一个用户的模拟指令行为 -/// -/// 当用户发送类似 `/do_sth` 的消息时,机器人会回复类似 `@user1 do_sth 了 @user2` 的消息 -/// 若消息不符合条件时,此函数会返回 None。此外,发送失败时不会传递错误到上游 -pub(crate) async fn handle_user_do_sth_to_another(bot: &Bot, msg: &Message) -> Option<()> { - let from_user = msg.from(); - let reply_user = msg.reply_to_message().and_then(|reply| reply.from()); - let text = msg.text(); - if text.is_none() || from_user.is_none() || reply_user.is_none() { - return None; - } - let text = text.unwrap(); - if text.starts_with("/") && text.find("@").is_none() { - let act = text.strip_prefix("/"); - if act.is_none() { - return None; - } - let acts: Vec<&str> = act.unwrap().split_ascii_whitespace().into_iter().collect(); - if acts.len() == 0 || acts[0] == "me" { - return None; - } - let mut text = format!( - "{} {}了 {}", - markup_username_with_link(from_user.unwrap()), - acts[0], - markup_username_with_link(reply_user.unwrap()) - ); - if acts.len() > 1 { - text.push_str(&format!(" {}", acts[1])); - } - let res = bot - .send_message(msg.chat.id, text) - .parse_mode(ParseMode::MarkdownV2) - .await; - if res.is_err() { - log::error!("failed to send message: {:?}", res.err()); - } - // TODO 统计行为次数 - return Some(()); - } - return None; -}