diff --git a/Cargo.lock b/Cargo.lock index ecf2071..940476d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,8 @@ dependencies = [ "dotenvy", "log", "pretty_env_logger", + "rand", + "regex", "teloxide", "tokio", ] @@ -113,6 +115,12 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.8.0" @@ -1113,6 +1121,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + [[package]] name = "pretty_env_logger" version = "0.5.0" @@ -1165,6 +1182,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "rc-box" version = "1.2.0" @@ -2147,6 +2194,27 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "zerofrom" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index 4a54903..96e1273 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,10 @@ tokio = { version = "1.8", features = ["rt-multi-thread", "macros"] } diesel = { version = "2.2.4", features = ["sqlite"] } chrono = "0.4.38" dotenvy = "0.15.7" +rand = "0.8.5" +regex = "1.11.1" + +[profile.release] +debug = false +lto = true +codegen-units = 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f8d1ab --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ApollousaBot + +A Telegram bot to remind members in Posthuman-Community to take breaks and exercise. \ No newline at end of file diff --git a/db_helper.py b/db_helper.py new file mode 100644 index 0000000..932b7ea --- /dev/null +++ b/db_helper.py @@ -0,0 +1,100 @@ +import sqlite3 + +conn = sqlite3.connect("database.db") +cursor = conn.cursor() + +cursor.execute(""" +CREATE TABLE IF NOT EXISTS quotes ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + msg TEXT NOT NULL +) +""") + +messages = [ + "自然が呼んでいる、外に出て歩こう!🌿", + "椅子は動けないけど、あなたは動ける!立ってストレッチ!🚀", + "宠猫喊你起身活动,来吧喵 🐱", + "Your pet cat says it’s time to move, meow! 🐱", + "ネコが「動く時間だよ」、ニャー!🐱", + "锻炼身体,心情也会变好哦! 🌞", + "Exercise your body, uplift your mood! 🌈", + "体を動かして、気分もスッキリ!🌞", + "别只是动动手指,起来扭扭腰吧! 💃", + "Don't only move your fingers, get up and wiggle your waist! 💃", + "指だけ動かさないで、腰を振ろう!💃", + "起身运动一下,犒劳自己一片小饼干! 🍪", + "Move around and reward yourself with a cookie! 🍪", + "動いたら、クッキーを自分にご褒美!🍪", + "再多努力五分钟,然后起来活动一下吧! ✊", + "Work hard for another five minutes, then get up and move! ✊", + "あと 5 分頑張って、そして動こう!✊", + "用挤牙膏的劲去运动,身体会感谢你! 🏋️", + "Squeeze in some exercise like you squeeze toothpaste, your body will thank you! 🏋️", + "歯磨き粉を絞るように運動して、体が感謝してくれるよ!🏋️", + "会健身的椅子才是好椅子!让你的动起来! 🪑", + "A good chair gets some exercise too! Let's move yours! 🪑", + "健康な椅子は運動する椅子だ!動かそう!🪑", + "保持健康,今天走一万步啦! 💪", + "Keep fit, hit 10,000 steps today! 💪", + "健康を保つために、今日は 1 万歩歩こう!👟", + "左脚,右脚,一步两步,动起来! 🚶", + "Left foot, right foot, one two step, let's move! 🚶‍♀️", + "左右の足、一歩二歩、動こう!🚶", + "玩个小游戏,站起来跳十次! 🎮", + "Play a little game, jump up ten times! 🎮", + "ゲームしよう、10 回ジャンプ!🎮", + "起来动一动,心情棒棒哒! 🌟", + "Get up and move, feel super awesome! 🌟", + "立ち上がって動くと、気分が最高!✨", + "给自己来个 “动感时刻” 吧! 🕺", + "自分に「動の時間」を贈ろう!🕺", + "收拾收拾,活动一下筋骨! 🚶", + "Tidy up and stretch your muscles! 🚶‍♂️", + "身の回りを整理して、筋肉を伸ばそう!🧹", + "休息片刻,能量满满! ✨", + "Take a break, feel energized! ✨", + "休憩して、エネルギーを補充しよう!🔋", + "运动一下,神清气爽! 🌿", + "A quick workout, clear mind! 🌿", + "少し運動して、頭をリフレッシュ!🍃", + "每天一小步,健康一大步! 🤸", + "A little step each day, a big leap to health! 🤸", + "毎日少し動いて、大きな健康を得よう!🏃‍♂️", + "起身喝口水,再散步五分钟! 🚶", + "Stand up, have a sip of water, and take a five-minute walk! 🚶‍♀️", + "立ち上がって水を飲んで、5 分歩こう!🚶‍♂️", + "时间到!该起来动动啦! ⏰", + "Time's up! Get up and move around! ⏰", + "時間だよ!立ち上がって動こう!⏳", + "只需几步,就能解锁健康! 🚶", + "Just a few steps to unlock health! 🚶‍♀️", + "健康へのカギは、数歩歩くだけ!🔑", + "活动一下,用笑容迎接每一天! 😊", + "Move around, greet each day with a smile! 😊", + "動いて、毎日を笑顔で迎えよう!😊", + "别让自己 “长在” 椅子上,起来动动吧! 🌟", + "椅子に「根を張らない」ように、立ち上がって動こう!🌿", + "让你的小腿跑一跑,充电完毕!⚡", + "Let your legs run a bit, recharge complete! ⚡", + "少し足を動かして、充電完了!⚡", + "记得活动哦!短暂休息,长久健康! 🚶", + "Don't forget to move! Short breaks, long health! 🚶‍♂️", + "動くのを忘れないで!短い休憩、長い健康!🚶‍♀️", + "起身摇摆,工作更自在! 💃", + "Stand up and sway, work feels like play! 💃", + "立ち上がって揺れよう、仕事が楽しくなるよ!💃 ", + "久坐不好,起来活动活动吧! 🚶", + "记得起身活动活动,松松背拉拉筋。😊", +] + + +for idx, msg in enumerate(messages, start=1): + cursor.execute( + """ + INSERT INTO quotes (ID, msg) VALUES (?, ?) + """, + (idx, msg), + ) + +conn.commit() +conn.close() diff --git a/src/bot/handler.rs b/src/bot/handler.rs index 4a9775a..c11cc49 100644 --- a/src/bot/handler.rs +++ b/src/bot/handler.rs @@ -2,6 +2,7 @@ use crate::{ bot::commands::Command, db::action::{clear_reminder_time, set_reminder_time, set_user_timezone}, }; +use regex::Regex; use teloxide::{adaptors::DefaultParseMode, prelude::*, utils::html}; use chrono::naive::NaiveTime; @@ -20,7 +21,7 @@ pub async fn reply(bot: Bot, msg: Message, command: Command) -> ResponseResult<( Command::Help => { bot.send_message( msg.chat.id, - format!("Hi, {mentioned_user}, Welcome to the Exercise Reminder Bot! 😉\n\nPlease use /settime HH:MM to set the reminder time.\n\nAnd use /settimezone +01:00 (according to your location) to accurate the reminder.\n\nIf you are not sure your timezone, please check this page.", + format!("Hi, {mentioned_user}, Welcome to the Exercise Reminder Bot! 😉\n\nPlease use /settime HH:MM to set the reminder time. And use /settimezone +01:00 (according to your location) to accurate the reminder.\n\nIf you are not sure your timezone, please check this page.\n\nPlease settime first before settimezone.", ), ) .await?; @@ -54,14 +55,28 @@ pub async fn reply(bot: Bot, msg: Message, command: Command) -> ResponseResult<( } } Command::SetTimezone(timezone) => { - set_user_timezone(conn, user_id, msg.chat.id, timezone.as_str()); - bot.send_message( - msg.chat.id, - format!( - "Hi, {mentioned_user}, your timezone is set to UTC{timezone}." - ), - ) - .await?; + let tz_pattern = + Regex::new(r"^(\+|-)\d{2}:\d{2}$").expect("Failed to initialize regex pattern"); + + match tz_pattern.is_match(&timezone) { + true => { + set_user_timezone(conn, user_id, msg.chat.id, &username, timezone.as_str()); + bot.send_message( + msg.chat.id, + format!( + "Hi, {mentioned_user}, your timezone is set to UTC{timezone}." + ), + ) + .await?; + } + false => { + bot.send_message( + msg.chat.id, + "Invalid timezone format. Please use the format ±HH:MM.", + ) + .await?; + } + } } Command::Stop => { clear_reminder_time(conn, msg.chat.id, user_id); diff --git a/src/db/action.rs b/src/db/action.rs index 483990f..359ac2a 100644 --- a/src/db/action.rs +++ b/src/db/action.rs @@ -1,3 +1,4 @@ +use crate::db::schema::quotes::dsl::*; use crate::db::schema::users::dsl::*; use diesel::prelude::*; use teloxide::types::{ChatId, UserId}; @@ -11,6 +12,10 @@ pub fn set_reminder_time( _username: &str, time: &str, ) { + println!( + "set_reminder_time -> UserId: {}, ChatId: {}, Username: {}, Time: {}", + _user_id.0, _chat_id.0, _username, time + ); diesel::insert_into(users) .values(( chat_id.eq(_chat_id.0), @@ -18,7 +23,7 @@ pub fn set_reminder_time( username.eq(_username), reminder_time.eq(time), )) - .on_conflict(chat_id) + .on_conflict((chat_id, user_id)) .do_update() .set(reminder_time.eq(time)) .execute(conn) @@ -29,22 +34,46 @@ pub fn set_user_timezone( conn: &mut SqliteConnection, _user_id: UserId, _chat_id: ChatId, + _username: &str, _user_timezone: &str, ) { println!( - "UserId: {}, ChatId: {}, UserTimezone: {}", - _user_id.0, _chat_id.0, _user_timezone + "set_user_timezone -> UserId: {}, ChatId: {}, Username: {}, UserTimezone: {}", + _user_id.0, _chat_id.0, _username, _user_timezone ); - diesel::update( - users.filter( + let user_exists = users + .filter( chat_id .eq(_chat_id.0) .and(user_id.eq(i64::try_from(_user_id.0).unwrap())), - ), - ) - .set(tz_offset.eq(_user_timezone)) - .execute(conn) - .expect("Error update user timezone"); + ) + .select((chat_id, user_id)) + .first::<(i64, i64)>(conn) + .optional() + .expect("Error checking if user exists"); + + if user_exists.is_some() { + diesel::update( + users.filter( + chat_id + .eq(_chat_id.0) + .and(user_id.eq(i64::try_from(_user_id.0).unwrap())), + ), + ) + .set(tz_offset.eq(_user_timezone)) + .execute(conn) + .expect("Error updating user timezone"); + } else { + diesel::insert_into(users) + .values(( + chat_id.eq(_chat_id.0), + user_id.eq(i64::try_from(_user_id.0).unwrap()), + username.eq(_username), + tz_offset.eq(_user_timezone), + )) + .execute(conn) + .expect("Error inserting new user with timezone"); + } } pub fn clear_reminder_time(conn: &mut SqliteConnection, _chat_id: ChatId, _user_id: UserId) { @@ -60,5 +89,17 @@ pub fn clear_reminder_time(conn: &mut SqliteConnection, _chat_id: ChatId, _user_ } pub fn get_user_reminders(conn: &mut SqliteConnection) -> Vec { - users.load::(conn).expect("Error loading user") + // Only work for group members + users + .select((chat_id, user_id, username, reminder_time, tz_offset)) + .filter(chat_id.ne(user_id)) + .load::(conn) + .expect("Error loading user") +} + +pub fn get_quotes(conn: &mut SqliteConnection) -> Vec { + quotes + .select(msg) + .load::(conn) + .expect("Error loading Quotes") } diff --git a/src/db/model.rs b/src/db/model.rs index 4efc291..9a87d0e 100644 --- a/src/db/model.rs +++ b/src/db/model.rs @@ -1,13 +1,23 @@ -use crate::db::schema::users; +use crate::db::schema::{quotes, users}; use diesel::prelude::*; -#[derive(Queryable, Selectable)] +#[derive(Debug, Queryable, Selectable, QueryableByName, Insertable)] #[diesel(table_name = users)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Users { pub chat_id: i64, pub user_id: i64, pub username: String, - pub reminder_time: String, + pub reminder_time: Option, pub tz_offset: Option, } + +#[derive(Queryable, Selectable)] +#[diesel(table_name = quotes)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct Quotes { + #[allow(dead_code)] + pub id: i32, + #[allow(dead_code)] + pub msg: String, +} diff --git a/src/db/schema.rs b/src/db/schema.rs index f703e6f..4e044ad 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -1,11 +1,23 @@ // @generated automatically by Diesel CLI. diesel::table! { - users (chat_id) { + quotes (id) { + id -> Integer, + msg -> Text, + } +} + +diesel::table! { + users (chat_id, user_id) { chat_id -> BigInt, user_id -> BigInt, username -> Text, - reminder_time -> Text, tz_offset -> Nullable, + reminder_time -> Nullable, } } + +diesel::allow_tables_to_appear_in_same_query!( + quotes, + users, +); diff --git a/src/scheduler/reminder.rs b/src/scheduler/reminder.rs index 2d65c0b..88e5242 100644 --- a/src/scheduler/reminder.rs +++ b/src/scheduler/reminder.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use crate::db::action as operator; -use crate::utils::TimezoneOffest; +use crate::db::model::Users; +use crate::utils::{get_random_quote, TimezoneOffest}; use chrono::{FixedOffset, Utc}; use teloxide::adaptors::DefaultParseMode; use teloxide::prelude::*; @@ -12,32 +13,52 @@ use crate::db::establish_connection; type Bot = DefaultParseMode; +async fn handle_user_reminder( + user: &Users, + bot: &Arc, + selected_quote: &str, +) -> Result<(), Box> { + let tzoffset = user.tz_offset.as_ref().ok_or(format!( + "User {} does not have a timezone offset set.", + user.username + ))?; + let user_timezone = tzoffset + .parse::() + .map_err(|e| format!("Failed to parse timezone offset: {}", e))?; + let duration_secs = user_timezone.to_duration(); + let user_utc_time = FixedOffset::east_opt(duration_secs).map_or( + "Invalid timezone offset".to_string(), + |offset| { + Utc::now() + .with_timezone(&offset) + .format("%H:%M") + .to_string() + }, + ); + + if let Some(reminder) = &user.reminder_time { + if user_utc_time == *reminder { + let _user_id = UserId(u64::try_from(user.user_id)?); + let mentioned_user = html::user_mention(_user_id, &user.username); + let notification = format!("{mentioned_user} {selected_quote}"); + bot.send_message(ChatId(user.chat_id), notification).await?; + } + } + + Ok(()) +} + pub async fn schedule_reminders(bot: &Arc) { let conn = &mut establish_connection(); let users_to_remind = operator::get_user_reminders(conn); + let all_quotes = operator::get_quotes(conn); + let selected_quote = + get_random_quote(&all_quotes).unwrap_or("记得起身活动活动,松松背拉拉筋。"); + for user in users_to_remind { - if let Some(tzoffset) = user.tz_offset.as_ref() { - let user_timezone: TimezoneOffest = tzoffset.parse().unwrap(); - - let duration_secs = user_timezone.to_duration(); - let user_utc_time = Utc::now() - .with_timezone(&FixedOffset::east_opt(duration_secs).unwrap()) - .format("%H:%M") - .to_string(); - - // println!("User UTC time: {}", user_utc_time); - - if user_utc_time == user.reminder_time { - // println!("{}: {}", user.reminder_time, user.username); - let _user_id = UserId(u64::try_from(user.user_id).unwrap()); - let _username = user.username; - let mentioned_user = html::user_mention(_user_id, &_username); - let notification = format!("{mentioned_user},记得起身活动活动,松松背拉拉筋。"); - bot.send_message(ChatId(user.chat_id), notification) - .await - .unwrap(); - } + if let Err(e) = handle_user_reminder(&user, bot, selected_quote).await { + eprintln!("Error handling reminder for user {}: {}", user.username, e); } } } diff --git a/src/utils.rs b/src/utils.rs index 70c203e..dc2182d 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,3 +1,5 @@ +use rand::Rng; + #[derive(Debug, PartialEq)] pub struct TimezoneOffest { offset_hours: i32, @@ -47,6 +49,19 @@ impl TimezoneOffest { } } +pub fn get_random_quote(quotes: &[String]) -> Option<&str> { + if !quotes.is_empty() { + let length = quotes.len(); + let mut rng = rand::thread_rng(); + let random_index = rng.gen_range(0..length); + let random_quote = "es[random_index]; + + Some(random_quote) + } else { + None + } +} + #[cfg(test)] mod tests { @@ -87,4 +102,25 @@ mod tests { let ew_offset = Utc::now().with_timezone(&FixedOffset::east_opt(duration).unwrap()); println!("UTC+8:00 -> {}", ew_offset); } + + #[test] + fn test_get_random_quote() { + let quotes = vec![ + String::from("wwww"), + String::from("vvvv"), + String::from("xxxx"), + ]; + + let random_quote = get_random_quote("es); + assert!(random_quote.is_some()); + + if let Some(quote) = random_quote { + println!("{quote}"); + } + + let empty_quotes: Vec = vec![]; + + let random_quote = get_random_quote(&empty_quotes); + assert!(random_quote.is_none()); + } }