Compare commits

...

10 commits

10 changed files with 360 additions and 47 deletions

68
Cargo.lock generated
View file

@ -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"

View file

@ -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

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# ApollousaBot
A Telegram bot to remind members in Posthuman-Community to take breaks and exercise.

100
db_helper.py Normal file
View file

@ -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 its 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()

View file

@ -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 <code>/settime HH:MM</code> to set the reminder time.\n\nAnd use <code>/settimezone +01:00</code> <b>(according to your location)</b> to accurate the reminder.\n\nIf you are not sure your timezone, please check this <a href=\"https://en.wikipedia.org/wiki/List_of_UTC_offsets\">page</a>.",
format!("Hi, {mentioned_user}, Welcome to the Exercise Reminder Bot! 😉\n\nPlease use <code>/settime HH:MM</code> to set the reminder time. And use <code>/settimezone +01:00</code> <b>(according to your location)</b> to accurate the reminder.\n\nIf you are not sure your timezone, please check this <a href=\"https://en.wikipedia.org/wiki/List_of_UTC_offsets\">page</a>.\n\n<b>Please <code>settime</code> first before <code>settimezone</code></b>.",
),
)
.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 <code>UTC{timezone}</code>."
),
)
.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 <code>UTC{timezone}</code>."
),
)
.await?;
}
false => {
bot.send_message(
msg.chat.id,
"Invalid timezone format. Please use the format <code>±HH:MM</code>.",
)
.await?;
}
}
}
Command::Stop => {
clear_reminder_time(conn, msg.chat.id, user_id);

View file

@ -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> {
users.load::<Users>(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::<Users>(conn)
.expect("Error loading user")
}
pub fn get_quotes(conn: &mut SqliteConnection) -> Vec<String> {
quotes
.select(msg)
.load::<String>(conn)
.expect("Error loading Quotes")
}

View file

@ -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<String>,
pub tz_offset: Option<String>,
}
#[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,
}

View file

@ -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<Text>,
reminder_time -> Nullable<Text>,
}
}
diesel::allow_tables_to_appear_in_same_query!(
quotes,
users,
);

View file

@ -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<teloxide::Bot>;
async fn handle_user_reminder(
user: &Users,
bot: &Arc<Bot>,
selected_quote: &str,
) -> Result<(), Box<dyn std::error::Error>> {
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::<TimezoneOffest>()
.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<Bot>) {
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);
}
}
}

View file

@ -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 = &quotes[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(&quotes);
assert!(random_quote.is_some());
if let Some(quote) = random_quote {
println!("{quote}");
}
let empty_quotes: Vec<String> = vec![];
let random_quote = get_random_quote(&empty_quotes);
assert!(random_quote.is_none());
}
}