* init

* bot framework done

* here and ready for orm

* might use sea-orm

* orm done

* use teloxide

* ready to go?

* 需要完成命令部分

* 需要完成:list_handler()

* 查询用户名应当以@开头

* use rustls to avoid segfault?

* postgresql ready

* inline query done

* list_handler

* flattern code

* test needed

* ready to build

* some bugs

* almost done

* ready to take off

Co-authored-by: senset <dummy@dummy.d>
This commit is contained in:
senseab 2022-06-28 18:11:47 +08:00 committed by GitHub
parent fe53f1abd3
commit 56635e0e1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 4723 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
.vscode
target
.env
.gitignore
README.md
.github
.DS_Store

66
.github/workflows/docker-publish.yml vendored Normal file
View File

@ -0,0 +1,66 @@
name: Docker
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
push:
branches: [master]
# Publish semver tags as releases.
tags: ["v*.*.*"]
pull_request:
branches: [master]
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
.env
.DS_Store

45
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,45 @@
{
// 使 IntelliSense
//
// 访: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'saysthbot-reborn'",
"cargo": {
"args": [
"build",
"--bin=saysthbot-reborn",
"--package=saysthbot-reborn"
],
"filter": {
"name": "saysthbot-reborn",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'saysthbot-reborn'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=saysthbot-reborn",
"--package=saysthbot-reborn"
],
"filter": {
"name": "saysthbot-reborn",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}

3209
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

36
Cargo.toml Normal file
View File

@ -0,0 +1,36 @@
[package]
name = "saysthbot-reborn"
version = "0.1.0"
edition = "2021"
description = "A telegram bot to record someone's message by forwarding"
license = "MIT OR Apache-2.0"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
wd_log = "0.1.5"
futures = "^0.3"
#lazy_static = "*"
strfmt = "^0.1.6"
[dependencies.clap]
version = "3.2.6"
features = ["derive", "env"]
[dependencies.tokio]
version = "^1.0"
features = ["full"]
[dependencies.teloxide]
version = "^0.9"
features = ["macros"]
[dependencies.sea-orm]
version = "^0.8.0"
features = ["macros", "sqlx-mysql", "sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls"]
[dependencies.models]
path = "entity"
[dependencies.migration]
path = "migration"

14
Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM rust as build
WORKDIR /usr/src/saysthbot
COPY . .
RUN rustup default nightly && cargo build --release
FROM debian:stable-slim
RUN apt update && apt install -y proxychains4 ca-certificates && apt clean
ENV TGBOT_TOKEN="" DATABASE_URI="" WRAPPER=""
CMD ["-c", "${WRAPPER} ./saysthbot-reborn ${OPTIONS}"]
ENTRYPOINT [ "/bin/sh" ]
COPY --from=build /usr/src/saysthbot/target/release/saysthbot-reborn ./

View File

@ -1 +1,43 @@
# Say something bot - Reborn
A telegram bot to record someone's message by forwarding
```usage
saysthbot-reborn 0.1.0
A telegram bot to record someone's message by forwarding
USAGE:
saysthbot-reborn [OPTIONS] --tgbot-token <TGBOT_TOKEN>
OPTIONS:
-d, --database-uri <DATABASE_URI>
Database URI [env: DATABASE_URI=] [default:
sqlite:///saysthbot.db]
-D, --debug
Enable debug mode
-h, --help
Print help information
-t, --tgbot-token <TGBOT_TOKEN>
Telegram bot token [env: TGBOT_TOKEN=]
-V, --version
Print version information
```
## build
You should use `nightly` build kit.
```bash
rustup default nightly
cargo build
```
Or simply use docker.
```bash
docker build -t bot .
```

12
entity/Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "models"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "models"
path = "src/lib.rs"
[dependencies.sea-orm]
version = "^0.8.0"

View File

@ -0,0 +1,4 @@
pub mod prelude;
pub mod record;
pub mod user;

View File

@ -0,0 +1,8 @@
pub use super::record::{
ActiveModel as RecordActiveModel, Column as RecordColumn, Entity as Record,
Model as RecordModel, PrimaryKey as RecordPrimaryKey, Relation as RecordRelation,
};
pub use super::user::{
ActiveModel as UserActiveModel, Column as UserColumn, Entity as User, Model as UserModel,
PrimaryKey as UserPrimaryKey, Relation as UserRelation,
};

View File

@ -0,0 +1,35 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "records")]
pub struct Model {
/// internal ID
#[sea_orm(primary_key)]
pub id: i64,
/// relation user id
#[sea_orm(indexed)]
pub user_id: i64,
/// records
#[sea_orm(indexed, column_type = "Text", unique)]
pub message: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,35 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
/// internal ID
#[sea_orm(primary_key)]
pub id: i64,
/// Telegram user ID
#[sea_orm(unique)]
pub tg_uid: i64,
/// Telegram user name
#[sea_orm(nullable)]
pub username: Option<String>,
/// use notify
#[sea_orm(default_value = true)]
pub notify: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::record::Entity")]
Record,
}
impl Related<super::record::Entity> for Entity {
fn to() -> RelationDef {
Relation::Record.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

2
entity/src/lib.rs Normal file
View File

@ -0,0 +1,2 @@
mod entities;
pub use entities::*;

15
migration/Cargo.toml Normal file
View File

@ -0,0 +1,15 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
models = { path = "../entity" }
[dependencies.sea-orm-migration]
version = "^0.8.0"

37
migration/README.md Normal file
View File

@ -0,0 +1,37 @@
# Running Migrator CLI
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```

16
migration/src/lib.rs Normal file
View File

@ -0,0 +1,16 @@
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table;
mod m20220625_222908_message_unique;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20220101_000001_create_table::Migration),
Box::new(m20220625_222908_message_unique::Migration),
]
}
}

View File

@ -0,0 +1,42 @@
use models::*;
use sea_orm_migration::{
prelude::*,
sea_orm::{ConnectionTrait, Schema},
};
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20220101_000001_create_table"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
let builder = db.get_database_backend();
let schema = Schema::new(builder);
db.execute(builder.build(&schema.create_table_from_entity(user::Entity)))
.await?;
db.execute(builder.build(&schema.create_table_from_entity(record::Entity)))
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(user::Entity).to_owned())
.await?;
manager
.drop_table(Table::drop().table(record::Entity).to_owned())
.await?;
Ok(())
}
}

View File

@ -0,0 +1,39 @@
use models::prelude::{Record, RecordColumn};
use sea_orm_migration::prelude::*;
pub struct Migration;
const RECORD_MESSAGE_UNIQUE: &str = "record_message_unique";
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20220625_222908_message_unique"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_index(
Index::create()
.table(Record)
.col(RecordColumn::Message)
.name(RECORD_MESSAGE_UNIQUE)
.unique()
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_index(
Index::drop()
.table(Record)
.name(RECORD_MESSAGE_UNIQUE)
.to_owned(),
)
.await
}
}

6
migration/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

21
src/callback_commands.rs Normal file
View File

@ -0,0 +1,21 @@
use teloxide::utils::command::BotCommands;
#[derive(PartialEq, Debug, BotCommands)]
#[command(rename = "lowercase", prefix = "!")]
pub enum CallbackCommands {
#[command(description = "internal command page", parse_with = "split")]
Page {
msg_id: i32,
username: String,
page: usize,
},
#[command(description = "default dummy command")]
Default,
}
impl Default for CallbackCommands {
fn default() -> Self {
CallbackCommands::Default
}
}

330
src/commands.rs Normal file
View File

@ -0,0 +1,330 @@
use std::collections::HashMap;
use strfmt::Format;
use teloxide::{
prelude::*,
types::{InlineKeyboardButton, InlineKeyboardMarkup},
types::{InlineKeyboardButtonKind, ReplyMarkup},
utils::command::{BotCommands, ParseError},
};
use wd_log::log_debug_ln;
use crate::{
db_controller::PaginatedRecordData,
messages::{
BOT_ABOUT, BOT_BUTTON_END, BOT_BUTTON_HEAD, BOT_BUTTON_NEXT, BOT_BUTTON_PREV, BOT_HELP,
BOT_TEXT_DELETED, BOT_TEXT_LOADING, BOT_TEXT_MUTE_STATUS, BOT_TEXT_STATUS_OFF,
BOT_TEXT_STATUS_ON, BOT_TEXT_WELCOME,
},
telegram_bot::BotServer,
};
#[derive(BotCommands, PartialEq, Debug)]
#[command(rename = "lowercase")]
pub enum Commands {
#[command(description = "显示帮助信息")]
Help,
#[command(description = "关于本 Bot")]
About,
#[command(description = "关闭提醒")]
Mute,
#[command(description = "开启提醒")]
Unmute,
#[command(description = "列出已记录的内容", parse_with = "list_command_parser")]
List { username: String },
#[command(description = "删除记录")]
Del { id: i64 },
#[command(description = "注册")]
Start,
}
impl Default for Commands {
fn default() -> Self {
Commands::Help
}
}
fn list_command_parser(input: String) -> Result<(String,), ParseError> {
log_debug_ln!(
"list_command_parse = \"{}\", is empty = {}",
input,
input.trim().is_empty()
);
let output: String;
if input.trim().is_empty() {
output = "me".to_string();
} else {
output = input
}
Ok((output,))
}
pub struct CommandHandler {}
impl CommandHandler {
pub async fn about_handler(bot_s: &BotServer, message: &Message) {
bot_s.send_text_reply(message, BOT_ABOUT).await;
}
pub async fn help_handler(bot_s: &BotServer, message: &Message) {
bot_s.send_text_reply(message, BOT_HELP).await;
}
pub async fn notify_handler(bot_s: &BotServer, message: &Message, enabled: bool) {
let user = match message.from() {
Some(user) => user,
None => return,
};
if user.is_bot {
if let Err(error) = bot_s
.controller
.set_user_notify(&user.id.0.try_into().unwrap(), enabled)
.await
{
bot_s.controller.err_handler(error);
}
let mut vars = HashMap::new();
vars.insert(
"status".to_string(),
match enabled {
true => BOT_TEXT_STATUS_ON,
false => BOT_TEXT_STATUS_OFF,
},
);
bot_s
.send_text_reply(message, &BOT_TEXT_MUTE_STATUS.format(&vars).unwrap())
.await;
}
}
pub async fn setup_handler(bot_s: &BotServer, message: &Message) {
let user = match message.from() {
Some(user) => user,
None => return,
};
if user.is_bot {
return;
}
let user_id: i64 = user.id.0.try_into().unwrap();
let username = match user.username.to_owned() {
Some(username) => format!("@{}", username),
None => user.first_name.to_owned(),
};
if let Err(error) = bot_s.controller.register_user(&user_id, &username).await {
bot_s.controller.err_handler(error);
}
bot_s.send_text_reply(message, BOT_TEXT_WELCOME).await;
}
pub async fn del_handler(bot_s: &BotServer, message: &Message, id: i64) {
let user = match message.from() {
Some(user) => user,
None => return,
};
if user.is_bot {
return;
}
if let Err(error) = bot_s
.controller
.del_record(id, user.id.0.try_into().unwrap())
.await
{
bot_s.controller.err_handler(error);
}
bot_s.send_text_reply(message, BOT_TEXT_DELETED).await;
}
pub async fn list_handler(bot_s: &BotServer, message: &Message, username: &str, page: usize) {
let user = match message.from() {
Some(user) => user,
None => return,
};
if user.is_bot {
return;
}
let msg_id = match bot_s.send_text_reply(message, BOT_TEXT_LOADING).await {
Some(id) => id,
None => return,
};
let (msg, markup) = match Self::record_msg_genrator(bot_s, message, username, page).await {
Some(d) => d,
None => return,
};
bot_s
.edit_text_reply_with_inline_key(message, msg_id, msg.as_str(), markup)
.await;
}
pub async fn record_msg_genrator(
bot_s: &BotServer,
message: &Message,
username: &str,
page: usize,
) -> Option<(String, ReplyMarkup)> {
let someone = match bot_s.controller.get_user_by_username(username).await {
Ok(someone) => someone,
Err(error) => {
bot_s.controller.err_handler(error);
return None;
}
};
let someone = match someone {
Some(someone) => someone,
None => return None,
};
let data = match bot_s
.controller
.get_records_by_userid_with_pagination(someone.id, page)
.await
{
Ok(data) => data,
Err(error) => {
bot_s.controller.err_handler(error);
return None;
}
};
let paginated_record_data = match data {
Some(d) => d,
None => return None,
};
Some((
Self::generate_text_record_msg(&paginated_record_data, page),
Self::generate_inline_keyboard(
page,
paginated_record_data.pages_count,
username,
message,
),
))
}
fn generate_inline_keyboard(
page: usize,
pages_count: usize,
username: &str,
message: &Message,
) -> ReplyMarkup {
let inline_keyboards = match page {
page if page == 0 && pages_count > 1 => vec![
InlineKeyboardButton {
text: BOT_BUTTON_NEXT.to_string(),
kind: InlineKeyboardButtonKind::CallbackData(format!(
"!page {} {} {}",
message.id,
username,
page + 1
)),
},
InlineKeyboardButton {
text: BOT_BUTTON_END.to_string(),
kind: InlineKeyboardButtonKind::CallbackData(format!(
"!page {} {} {}",
message.id,
username,
pages_count - 1
)),
},
],
page if page == 0 && pages_count <= 1 => vec![],
page if page >= pages_count - 1 => vec![
InlineKeyboardButton {
text: BOT_BUTTON_HEAD.to_string(),
kind: InlineKeyboardButtonKind::CallbackData(format!(
"!page {} {} {}",
message.id, username, 0
)),
},
InlineKeyboardButton {
text: BOT_BUTTON_PREV.to_string(),
kind: InlineKeyboardButtonKind::CallbackData(format!(
"!page {} {} {}",
message.id,
username,
page - 1
)),
},
],
_ => vec![
InlineKeyboardButton {
text: BOT_BUTTON_HEAD.to_string(),
kind: InlineKeyboardButtonKind::CallbackData(format!(
"!page {} {} {}",
message.id, username, 0
)),
},
InlineKeyboardButton {
text: BOT_BUTTON_PREV.to_string(),
kind: InlineKeyboardButtonKind::CallbackData(format!(
"!page {} {} {}",
message.id,
username,
page - 1
)),
},
InlineKeyboardButton {
text: BOT_BUTTON_NEXT.to_string(),
kind: InlineKeyboardButtonKind::CallbackData(format!(
"!page {} {} {}",
message.id,
username,
page + 1
)),
},
InlineKeyboardButton {
text: BOT_BUTTON_END.to_string(),
kind: InlineKeyboardButtonKind::CallbackData(format!(
"!page {} {} {}",
message.id,
username,
pages_count - 1
)),
},
],
};
ReplyMarkup::InlineKeyboard(InlineKeyboardMarkup {
inline_keyboard: vec![inline_keyboards],
})
}
fn generate_text_record_msg(
paginated_record_data: &PaginatedRecordData,
page: usize,
) -> String {
let mut msg = String::from("```");
for (message, _) in paginated_record_data.current_data.iter() {
msg = format!("{}\n{}\t\t\t\t{}", msg, message.id, message.message);
}
msg = format!(
"{}\n```\n{}/{}",
msg,
page + 1,
paginated_record_data.pages_count
);
msg
}
}

19
src/config.rs Normal file
View File

@ -0,0 +1,19 @@
use clap::Parser;
const DEFAULT_DATABASE: &'static str = "sqlite:///saysthbot.db";
#[derive(Parser, Debug)]
#[clap(author, version, about, long_about = None)]
pub struct Args {
/// Enable debug mode
#[clap(short = 'D', long, value_parser, default_value_t = false)]
pub debug: bool,
/// Telegram bot token
#[clap(short, long, value_parser, env = "TGBOT_TOKEN")]
pub tgbot_token: String,
/// Database URI
#[clap(short, long, value_parser, env = "DATABASE_URI", default_value=DEFAULT_DATABASE)]
pub database_uri: String,
}

190
src/db_controller.rs Normal file
View File

@ -0,0 +1,190 @@
use migration::{Migrator, MigratorTrait};
use models::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, Database, DatabaseConnection, DatabaseTransaction, DbErr,
EntityTrait, PaginatorTrait, QueryFilter, Set, TransactionTrait,
};
use wd_log::{log_error_ln, log_info_ln, log_warn_ln};
const PAGE_SIZE: usize = 25;
#[derive(Debug)]
pub struct Controller {
db: DatabaseConnection,
}
pub struct PaginatedRecordData {
pub items_count: usize,
pub pages_count: usize,
pub current_data: Vec<(RecordModel, Option<UserModel>)>,
}
impl Controller {
/// Create controller
pub async fn new(config: String) -> Result<Self, DbErr> {
Ok(Self {
db: Database::connect(config).await?,
})
}
/// Do migrate
pub async fn migrate(&self) -> Result<(), DbErr> {
if let Err(err) = Migrator::install(&self.db).await {
log_warn_ln!("{}", err)
}
if let Err(err) = Migrator::up(&self.db, None).await {
Err(err)
} else {
log_info_ln!("database initialized.");
Ok(())
}
}
/// register user when `/start` command called.
pub async fn register_user(&self, user_id: &i64, username: &String) -> Result<(), DbErr> {
let transaction = self.db.begin().await?;
self.setup_user(user_id, username, &transaction).await?;
transaction.commit().await
}
/// update user notify when `/mute` or `/unmute` command called.
pub async fn set_user_notify(&self, user_id: &i64, notify: bool) -> Result<(), DbErr> {
let transaction = self.db.begin().await?;
if let Some(user) = self.get_user(user_id, &transaction).await? {
let mut user_active: UserActiveModel = user.into();
user_active.notify = Set(notify);
user_active.save(&transaction).await?;
}
transaction.commit().await
}
pub async fn get_user_notify(&self, user_id: &i64) -> Result<bool, DbErr> {
let transaction = self.db.begin().await?;
if let Some(user) = self.get_user(&user_id, &transaction).await? {
Ok(user.notify)
} else {
Ok(false)
}
}
async fn setup_user(
&self,
user_id: &i64,
username: &String,
transaction: &DatabaseTransaction,
) -> Result<UserActiveModel, DbErr> {
match self.get_user(user_id, &transaction).await? {
Some(user) => {
let mut user_active: UserActiveModel = user.into();
user_active.username = Set(Some(username.to_string()));
user_active.save(transaction).await
}
None => {
UserActiveModel {
tg_uid: Set(user_id.to_owned()),
username: Set(Some(username.to_string())),
notify: Set(true),
..Default::default()
}
.save(transaction)
.await
}
}
}
async fn get_user(
&self,
user_id: &i64,
transaction: &DatabaseTransaction,
) -> Result<Option<UserModel>, DbErr> {
User::find()
.filter(UserColumn::Id.eq(user_id.to_owned()))
.one(transaction)
.await
}
pub async fn get_user_by_username(&self, username: &str) -> Result<Option<UserModel>, DbErr> {
let transaction = self.db.begin().await?;
User::find()
.filter(UserColumn::Username.eq(username.to_owned()))
.one(&transaction)
.await
}
/// get records when inline query called.
pub async fn get_records_by_keywords(
&self,
key_word: &String,
) -> Result<PaginatedRecordData, DbErr> {
let pagination = Record::find()
.find_also_related(User)
.filter(RecordColumn::Message.contains(key_word.as_str()))
.paginate(&self.db, PAGE_SIZE * 2); // 50 records seems ok.
Ok(PaginatedRecordData {
items_count: pagination.num_items().await?,
pages_count: pagination.num_pages().await?,
current_data: pagination.fetch().await?,
})
}
/// get records when `/list` command called or inline button request.
pub async fn get_records_by_userid_with_pagination(
&self,
user_id: i64,
page: usize,
) -> Result<Option<PaginatedRecordData>, DbErr> {
let transaction = self.db.begin().await?;
if let Some(user) = self.get_user(&user_id, &transaction).await? {
let pagination = Record::find()
.find_also_related(User)
.filter(RecordColumn::UserId.eq(user.id))
.paginate(&transaction, PAGE_SIZE);
Ok(Some(PaginatedRecordData {
current_data: pagination.fetch_page(page).await?,
items_count: pagination.num_items().await?,
pages_count: pagination.num_pages().await?,
}))
} else {
log_error_ln!("cannot find user tg_uid={}", user_id);
Ok(None)
}
}
/// add record forward a message to bot.
pub async fn add_record(
&self,
user_id: i64,
username: &String,
text: String,
) -> Result<(), DbErr> {
let transaction = self.db.begin().await?;
let user = self.setup_user(&user_id, &username, &transaction).await?;
RecordActiveModel {
message: Set(text),
user_id: user.id,
..Default::default()
}
.insert(&transaction)
.await?;
transaction.commit().await
}
/// del record when `/delete` command called.
pub async fn del_record(&self, id: i64, user_id: i64) -> Result<(), DbErr> {
let transaction = self.db.begin().await?;
if let Some(user) = self.get_user(&user_id, &transaction).await? {
RecordActiveModel {
id: Set(id),
user_id: Set(user.id),
..Default::default()
}
.delete(&transaction)
.await?;
}
transaction.commit().await
}
pub fn err_handler(&self, error: DbErr) {
log_error_ln!("{}", error);
}
}

36
src/main.rs Normal file
View File

@ -0,0 +1,36 @@
mod callback_commands;
mod commands;
mod config;
mod db_controller;
mod messages;
mod telegram_bot;
use clap::Parser;
use config::Args;
use telegram_bot::BotServer;
use wd_log::{log_debug_ln, log_panic, set_level, set_prefix, DEBUG, INFO};
#[tokio::main]
async fn main() {
let args = Args::parse();
set_prefix("saysthbot");
if args.debug {
set_level(DEBUG);
log_debug_ln!("{:?}", args);
} else {
set_level(INFO);
}
let bot = match BotServer::new(args).await {
Ok(bot) => bot,
Err(err) => log_panic!("{}", err),
};
if let Err(err) = bot.init().await {
log_panic!("{}", err);
}
bot.run().await;
}

21
src/messages.rs Normal file
View File

@ -0,0 +1,21 @@
pub const BOT_TEXT_MESSAGE_ONLY: &'static str = "仅支持文本信息";
pub const BOT_TEXT_FORWARDED_ONLY: &'static str = "仅支持转发信息";
pub const BOT_TEXT_USER_ONLY: &'static str = "仅支持用户信息";
pub const BOT_TEXT_NO_BOT: &'static str = "不支持 bot 消息";
pub const BOT_TEXT_NOTED: &'static str = "✅ `{data}` 已记录";
pub const BOT_TEXT_NOTICE: &'static str = "[{username}](tg://user?id={user_id}) 转发了你的 `{data}`\n\n\t你可以使用 /list 命令查看自己或者他人被记录的信息\n\t你可以使用 /del 命令删除某条自己的信息\n\t你也可以使用 /mute 或者 /unmute 命令开启或者关闭提醒";
pub const BOT_TEXT_WELCOME: &'static str =
"✅ 注册成功!如果有别人记录了你的消息,这里会有提醒,可使用 /mute 命令关闭提醒";
pub const BOT_HELP: &'static str = "*帮助*\n\n\t/list `[@username]` 列出已记录的内容\n\t/del `id` 删除对应id的记录只能删除自己的\n\t/mute 关闭提醒\n\t/unmute 开启提醒";
pub const BOT_ABOUT: &'static str =
"Say something bot \\- Reborn\n\n[Github](https://github.com/senseab/saysthbot-reborn) @ssthbot";
pub const BOT_TEXT_MUTE_STATUS: &'static str = "提醒状态:{status}";
pub const BOT_TEXT_STATUS_ON: &'static str = "✅ 开启";
pub const BOT_TEXT_STATUS_OFF: &'static str = "❎ 关闭";
pub const BOT_TEXT_DELETED: &'static str = "已删除";
pub const BOT_TEXT_SHOULD_START_WITH_AT: &'static str = "用户名应当以 `@` 开头";
pub const BOT_BUTTON_HEAD: &'static str = "⏮ 首页";
pub const BOT_BUTTON_END: &'static str = "末页 ⏭";
pub const BOT_BUTTON_PREV: &'static str = "⏪ 上一页";
pub const BOT_BUTTON_NEXT: &'static str = "下一页 ⏩";
pub const BOT_TEXT_LOADING: &'static str = "⌛️ 载入中……";

433
src/telegram_bot.rs Normal file
View File

@ -0,0 +1,433 @@
use std::collections::HashMap;
use crate::callback_commands::CallbackCommands;
use crate::db_controller::Controller;
use crate::messages::*;
use crate::{commands::CommandHandler, commands::Commands, config::Args};
use migration::DbErr;
use strfmt::Format;
use teloxide::utils::command::BotCommands;
use teloxide::{
prelude::*, types::ForwardedFrom, types::InlineQueryResult, types::InlineQueryResultArticle,
types::InputMessageContent, types::InputMessageContentText, types::ParseMode,
types::ReplyMarkup, types::UpdateKind, RequestError,
};
use wd_log::{log_debug_ln, log_error_ln, log_info_ln, log_panic, log_warn_ln};
pub struct BotServer {
pub controller: Controller,
bot: Bot,
}
impl BotServer {
/// Create new bot
pub async fn new(config: Args) -> Result<Self, DbErr> {
Ok(Self {
bot: Bot::new(config.tgbot_token),
controller: Controller::new(config.database_uri).await?,
})
}
pub async fn init(&self) -> Result<(), DbErr> {
self.controller.migrate().await
}
/// Run the bot
pub async fn run(&self) {
match self.bot.get_me().send().await {
Ok(result) => log_info_ln!(
"connect succeed: id={}, botname=\"{}\"",
result.id,
result.username()
),
Err(error) => log_panic!("{}", error),
}
self.register_commands().await;
let mut offset_id = 0;
loop {
let updates = match self.bot.get_updates().offset(offset_id).send().await {
Ok(it) => it,
_ => continue,
};
for update in updates {
self.update_handler(&update).await;
offset_id = update.id + 1;
}
}
}
async fn register_commands(&self) {
if let Err(error) = self
.bot
.set_my_commands(Commands::bot_commands())
.send()
.await
{
self.default_error_handler(&error);
} else {
log_info_ln!("commands registered")
}
}
async fn update_handler(&self, update: &Update) {
match &update.kind {
UpdateKind::Message(ref message) => self.message_handler(message).await,
UpdateKind::InlineQuery(inline_query) => self.inline_query_hander(inline_query).await,
UpdateKind::CallbackQuery(callback) => self.callback_handler(callback).await,
kind => self.default_update_hander(&kind).await,
}
}
async fn default_update_hander(&self, update_kind: &UpdateKind) {
log_debug_ln!("non-supported kind {:?}", update_kind);
}
async fn callback_handler(&self, callback: &CallbackQuery) {
log_debug_ln!("callback={:#?}", callback);
let message = match &callback.message {
Some(msg) => msg,
None => return,
};
let text = match &callback.data {
Some(text) => text,
None => return,
};
let bot_username = match self.bot.get_me().send().await {
Ok(result) => result.username.to_owned(),
Err(error) => {
self.default_error_handler(&error);
return;
}
};
let bot_username = match bot_username {
Some(b) => b,
None => return,
};
let commands = match CallbackCommands::parse(text, bot_username) {
Ok(c) => c,
Err(error) => {
log_warn_ln!("{}", error);
return;
}
};
match commands {
CallbackCommands::Page {
msg_id: _,
username,
page,
} => {
let (msg, keyboard) = match CommandHandler::record_msg_genrator(
self,
message,
username.as_str(),
page,
)
.await
{
Some(d) => d,
None => return,
};
self.edit_text_reply_with_inline_key(message, message.id, msg.as_str(), keyboard)
.await;
match self.bot.answer_callback_query(&callback.id).send().await {
Ok(_) => (),
Err(error) => self.default_error_handler(&error),
}
}
CallbackCommands::Default => return,
}
}
async fn inline_query_hander(&self, inline_query: &InlineQuery) {
let results = match self
.controller
.get_records_by_keywords(&inline_query.query)
.await
{
Ok(results) => results,
Err(error) => {
self.controller.err_handler(error);
return;
}
};
let mut r: Vec<InlineQueryResult> = vec![];
for (record, o_user) in results.current_data.iter() {
let user = match o_user {
Some(user) => user,
None => continue,
};
let username = match &user.username {
Some(username) => username,
None => continue,
};
r.push(InlineQueryResult::Article(InlineQueryResultArticle {
id: record.id.to_string(),
title: record.message.to_owned(),
input_message_content: InputMessageContent::Text(InputMessageContentText {
message_text: format!(
"*{}*: {}",
username.trim_start_matches("@"),
record.message
),
parse_mode: Some(ParseMode::MarkdownV2),
entities: None,
disable_web_page_preview: Some(true),
}),
reply_markup: None,
url: None,
hide_url: None,
description: Some(format!("By: {}", username)),
thumb_url: None,
thumb_width: None,
thumb_height: None,
}));
}
if let Err(error) = self
.bot
.answer_inline_query(&inline_query.id, r.into_iter())
.send()
.await
{
self.default_error_handler(&error);
}
}
async fn message_handler(&self, message: &Message) {
if let Some(data) = &message.text() {
self.text_message_heandler(message, data).await
} else {
self.default_message_handler(message).await
}
}
async fn text_message_heandler(&self, message: &Message, data: &str) {
let forward = match message.forward() {
Some(forward) => forward,
None => {
if data.starts_with("/") {
self.command_hanler(message).await;
} else {
self.send_text_reply(message, BOT_TEXT_FORWARDED_ONLY).await;
}
return;
}
};
match &forward.from {
ForwardedFrom::User(user) if !user.is_bot => {
let username = match &user.username {
Some(username) => format!("@{}", username),
None => user.first_name.to_owned(),
};
if let Err(err) = self
.controller
.add_record(user.id.0.try_into().unwrap(), &username, data.to_string())
.await
{
log_error_ln!("{}", err);
return;
}
let mut vars = HashMap::new();
vars.insert("data".to_string(), data);
self.send_text_reply(message, &BOT_TEXT_NOTED.format(&vars).unwrap())
.await;
let from = match message.from() {
Some(from) => from,
None => return,
};
if from.id == user.id {
return;
}
if match self
.controller
.get_user_notify(&user.id.0.try_into().unwrap())
.await
{
Ok(notify) => notify,
Err(error) => {
log_error_ln!("{}", error);
return;
}
} {
let mut vars = HashMap::new();
let user_id = user.id.to_string();
let data = data.to_string();
vars.insert("username".to_string(), &from.first_name);
vars.insert("user_id".to_string(), &user_id);
vars.insert("data".to_string(), &data);
match self
.bot
.send_message(user.id, &BOT_TEXT_NOTICE.format(&vars).unwrap())
.send()
.await
{
Ok(result) => {
log_debug_ln!("message sent {:?}", result)
}
Err(err) => self.default_error_handler(&err),
}
}
}
ForwardedFrom::User(_) => {
self.send_text_reply(message, BOT_TEXT_NO_BOT).await;
}
_ => {
self.send_text_message(message, BOT_TEXT_USER_ONLY).await;
}
}
}
async fn command_hanler(&self, message: &Message) {
let msg = match message.text() {
Some(msg) => msg,
None => return,
};
let bot_username = match self.bot.get_me().send().await {
Ok(result) => result.username.to_owned(),
Err(error) => {
self.default_error_handler(&error);
return;
}
};
let bot_username = match bot_username {
Some(b) => b,
None => return,
};
let commands = match Commands::parse(msg, bot_username) {
Ok(c) => c,
Err(error) => {
log_warn_ln!("{}", error);
return;
}
};
match commands {
Commands::Help => CommandHandler::help_handler(&self, message).await,
Commands::About => CommandHandler::about_handler(&self, message).await,
Commands::Mute => CommandHandler::notify_handler(&self, message, true).await,
Commands::Unmute => CommandHandler::notify_handler(&self, message, false).await,
Commands::List { mut username } => {
if username == "me" {
if let Some(from) = message.from() {
if let Some(_username) = &from.username {
username = format!("@{}", _username);
}
}
}
if username.starts_with("@") {
// always start from page=0
CommandHandler::list_handler(&self, message, &username, 0).await;
} else {
self.send_text_reply(message, BOT_TEXT_SHOULD_START_WITH_AT)
.await;
}
}
Commands::Del { id } => CommandHandler::del_handler(&self, message, id).await,
Commands::Start => CommandHandler::setup_handler(&self, message).await,
}
}
fn default_error_handler(&self, error: &RequestError) {
log_error_ln!("{:?}", error);
}
async fn default_message_handler(&self, message: &Message) {
log_debug_ln!(
"non-spported message {:?} from `{:?}`",
message.kind,
message.from()
);
self.send_text_reply(message, BOT_TEXT_MESSAGE_ONLY).await;
}
pub async fn send_text_message(&self, message: &Message, text: &str) -> Option<i32> {
match &self
.bot
.send_message(message.chat.id, text)
.parse_mode(ParseMode::MarkdownV2)
.send()
.await
{
Ok(result) => {
log_debug_ln!("message sent {:?}", result);
Some(result.id)
}
Err(error) => {
self.default_error_handler(error);
return None;
}
}
}
pub async fn send_text_reply(&self, message: &Message, text: &str) -> Option<i32> {
match &self
.bot
.send_message(message.chat.id, text)
.reply_to_message_id(message.id)
.parse_mode(ParseMode::MarkdownV2)
.send()
.await
{
Ok(result) => {
log_debug_ln!("reply sent {:?}", result);
Some(result.id)
}
Err(error) => {
self.default_error_handler(error);
None
}
}
}
pub async fn edit_text_reply_with_inline_key(
&self,
message: &Message,
msg_id: i32,
text: &str,
keyboard: ReplyMarkup,
) {
let keyboard = match keyboard {
ReplyMarkup::InlineKeyboard(keyboard) => keyboard,
_ => return,
};
match &self
.bot
.edit_message_text(message.chat.id, msg_id, text)
.reply_markup(keyboard)
.parse_mode(ParseMode::MarkdownV2)
.send()
.await
{
Ok(result) => log_debug_ln!("reply sent {:?}", result),
Err(error) => self.default_error_handler(error),
}
}
}