From 667c6fb879f8cd9146aa0fcd538d49c6dd31b2b8 Mon Sep 17 00:00:00 2001 From: Aaron Johnon Date: Tue, 18 Mar 2025 20:21:21 -0500 Subject: [PATCH] Initial commit --- .gitignore | 2 + Cargo.toml | 15 +++++ src/main.rs | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7cb970f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rust-tbot" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] } +teloxide = { version = "0.13", features = ["macros"] } +log = "0.4" +env_logger = "0.9" +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +clap = { version = "4.1", features = ["derive"] } +dirs = "4.0" + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d320d46 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,191 @@ +use clap::Parser; +use dirs::home_dir; +use log::{info}; // We'll call `info!()` for our logs +use serde::Deserialize; +use std::fs::File; +use std::io::BufReader; +use std::path::PathBuf; +use teloxide::dispatching::HandlerExt; +use teloxide::prelude::*; +use teloxide::types::{Message, UserId}; + +// Derive macro for commands: +use teloxide::macros::BotCommands as BotCommandsDerive; +// Trait for command descriptions(): +use teloxide::utils::command::BotCommands as BotCommandsTrait; + +#[derive(Parser)] +#[command(version, about)] +struct Cli { + /// Path to a YAML config file (defaults to ~/.config/rust-tbot.yaml) + #[arg(short = 'f', long = "file", value_name = "FILE")] + config_file: Option, +} + +/// Our YAML config format +#[derive(Debug, Deserialize)] +struct BotConfig { + bot_token: String, +} + +/// The commands we support. +#[derive(BotCommandsDerive, Clone)] +#[command(rename_rule = "lowercase", description = "These commands are supported:")] +enum Command { + /// Show your own user/chat ID + #[command(description = "Show your own user/chat ID.")] + Start, + /// Show help text + #[command(description = "Show help.")] + Help, + // Fallback for an unknown command + /// Unknown command + #[command(description = "Unknown command.")] + Unknown(String), +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging to stderr by default. If you want strictly stdout, see below. + env_logger::init(); + + // 1) Parse CLI flags + let cli = Cli::parse(); + + // 2) Determine config path + let config_path = match cli.config_file { + Some(path) => path, + None => default_config_path().ok_or("Couldn't determine default config path")?, + }; + info!("Using config file: {:?}", config_path); + + // 3) Read the config + let config = read_config(&config_path)?; + + // 4) Create the bot + let bot = Bot::new(config.bot_token); + + // 5) Build a dispatcher with two branches: + // A: new chat members, + // B: recognized commands (/start, /help, etc.) + let handler = dptree::entry() + .branch( + Update::filter_message() + .filter(|msg: Message| msg.new_chat_members().is_some()) + .endpoint(handle_new_chat_members), + ) + .branch( + Update::filter_message() + // parse the text into Command + .filter_command::() + .endpoint(handle_commands), + ); + + // 6) Launch the dispatcher + Dispatcher::builder(bot, handler) + .enable_ctrlc_handler() + .build() + .dispatch() + .await; + + Ok(()) +} + +/// Detect when the bot is added to a group, say the group ID, then leave. +async fn handle_new_chat_members(bot: Bot, msg: Message) -> Result<(), teloxide::RequestError> { + let me = bot.get_me().await?; + let chat_id = msg.chat.id; + + if let Some(new_members) = msg.new_chat_members() { + for user in new_members { + if user.id == me.id { + // BOT was just added to a group + let output_text = format!("Group Chat ID is: {chat_id}"); + bot.send_message(chat_id, &output_text).await?; + + // Log the event to stdout/stderr + // There's no specific "user ID" or "username" in this scenario + // because the "actor" is the admin who added the bot, + // but Telegram doesn't always provide that in new_chat_members. + // We'll just log that we joined a group, posted the chat ID, then left. + info!( + "Bot added to group (chatID: {chat_id}). Output: \"{output_text}\". Leaving group now..." + ); + + // Leave the group + bot.leave_chat(chat_id).await?; + } + } + } + + Ok(()) +} + +/// Handle commands in private chat +async fn handle_commands( + bot: Bot, + msg: Message, + cmd: Command, +) -> Result<(), teloxide::RequestError> { + let chat_id = msg.chat.id; + let (user_id, username) = extract_user_info(&msg); + + match cmd { + Command::Start => { + if msg.chat.is_private() { + let text_out = format!("Your chat/user ID is: {chat_id}"); + bot.send_message(chat_id, &text_out).await?; + + // Log: username, chatID, userID, command, output + info!( + "User \"{username}\" (chatID: {chat_id}, userID: {user_id}) ran /start. \ + Output => \"{text_out}\"" + ); + } + } + Command::Help => { + // The macro-generated `descriptions()` implements Display, so we `.to_string()` + let text_out = Command::descriptions().to_string(); + bot.send_message(chat_id, &text_out).await?; + + info!( + "User \"{username}\" (chatID: {chat_id}, userID: {user_id}) ran /help. \ + Output => \"{text_out}\"" + ); + } + Command::Unknown(unknown) => { + // By default we do not respond, but we can log it + info!( + "User \"{username}\" (chatID: {chat_id}, userID: {user_id}) \ + ran unknown command: /{unknown}" + ); + } + } + Ok(()) +} + +/// Extract user info from Message to log: user ID and username (if any) +fn extract_user_info(msg: &Message) -> (UserId, String) { + let user = msg.from.as_ref(); + let user_id = user.map(|u| u.id).unwrap_or_else(|| UserId(0)); + let username = user + .and_then(|u| u.username.clone()) + .unwrap_or_else(|| "".to_string()); + + (user_id, username) +} + +/// Read the YAML config from the given path. +fn read_config(path: &PathBuf) -> Result> { + let file = File::open(path)?; + let reader = BufReader::new(file); + let config: BotConfig = serde_yaml::from_reader(reader)?; + Ok(config) +} + +/// Default config path: `~/.config/rust-tbot.yaml` +fn default_config_path() -> Option { + let home = home_dir()?; + Some(home.join(".config").join("rust-tbot.yaml")) +} +