Add rust implementation

This commit is contained in:
2022-09-14 18:19:02 +03:00
parent 40da6d8b56
commit d23a3c3ab1
61 changed files with 5596 additions and 3820 deletions

View File

@@ -1,7 +0,0 @@
.vscode
build
node_modules
package-lock.json

View File

@@ -12,7 +12,7 @@ jobs:
- -
name: Checkout name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- -
name: Set up QEMU name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
@@ -23,6 +23,14 @@ jobs:
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- id: repository_name - id: repository_name
uses: ASzc/change-string-case-action@v2 uses: ASzc/change-string-case-action@v2
with: with:
@@ -30,7 +38,7 @@ jobs:
- -
name: Login to ghcr.io name: Login to ghcr.io
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -48,8 +56,20 @@ jobs:
tags: ghcr.io/${{ env.IMAGE }}:latest tags: ghcr.io/${{ env.IMAGE }}:latest
context: . context: .
file: ./docker/build.dockerfile file: ./docker/build.dockerfile
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- # This ugly bit is necessary if you don't want your cache to grow forever
# until it hits GitHub's limit of 5GB.
# Temp fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
-
name: Invoke deployment hook name: Invoke deployment hook
uses: joelwmale/webhook-action@master uses: joelwmale/webhook-action@master
with: with:

7
.gitignore vendored
View File

@@ -1,7 +1,6 @@
.vscode .vscode
build target
node_modules test_env
.DS_Store
package-lock.json

2018
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

28
Cargo.toml Normal file
View File

@@ -0,0 +1,28 @@
[package]
name = "book_bot"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
tokio = { version = "1.20.1", features = ["rt-multi-thread", "macros"] }
reqwest = { version = "0.11.11", features = ["json"] }
serde = { version = "1.0.144", features = ["derive"] }
serde_json = "1.0.85"
log = "0.4"
env_logger = "0.9.0"
teloxide = { version = "0.10.1", features = ["macros", "auto-send", "webhooks-axum"] }
url = "2.2.2"
ctrlc = { version = "3.2.3", features = ["termination"] }
strum = "0.24"
strum_macros = "0.24"
futures = "0.3.24"
base64 = "0.13.0"
tokio-util = { version = "0.7.3", features = ["compat"] }
textwrap = "0.15.0"
regex = "1.6.0"
chrono = "0.4.22"
dateparser = "0.1.7"
sentry = "0.27.0"
lazy_static = "1.4.0"

View File

@@ -1,22 +1,23 @@
FROM node:lts-alpine as build-image FROM rust:slim-bullseye as builder
WORKDIR /root/app RUN apt-get update \
&& apt-get install -y pkg-config libssl-dev \
&& rm -rf /var/lib/apt/lists/*
COPY ./package.json ./ WORKDIR /usr/src/myapp
COPY ./yarn.lock ./ COPY . .
COPY ./tsconfig.json ./
COPY ./src ./src
RUN yarn install --production && yarn build RUN cargo install --path .
FROM node:lts-alpine as runtime-image FROM debian:bullseye-slim
WORKDIR /root/app RUN apt-get update \
&& apt-get install -y openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY ./package.json ./ RUN update-ca-certificates
COPY ./scripts/healthcheck.js ./
COPY --from=build-image /root/app/build ./build COPY --from=builder /usr/local/cargo/bin/book_bot /usr/local/bin/book_bot
CMD yarn run run CMD book_bot

View File

@@ -1,40 +0,0 @@
{
"name": "flibusta_bot",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"type": "module",
"scripts": {
"build": "esbuild ./src/main.ts --bundle --platform=node --outfile=./build/main.cjs",
"build-minify": "npm run build -- --minify --sourcemap",
"build-watch": "npm run build -- --watch",
"run": "node ./build/main.cjs",
"run-watch": "nodemon build/main.cjs"
},
"author": "",
"license": "ISC",
"dependencies": {
"@sentry/node": "^7.8.1",
"chunk-text": "^2.0.1",
"debug": "^4.3.4",
"docker-ip-get": "^1.1.5",
"envalid": "^7.3.1",
"esbuild": "^0.14.53",
"express": "^4.18.1",
"got": "^12.3.0",
"js-base64": "^3.7.2",
"moment": "^2.29.4",
"redis": "^4.2.0",
"safe-compare": "^1.1.4",
"telegraf": "^4.8.6",
"typescript": "^4.7.4"
},
"devDependencies": {
"@types/chunk-text": "^1.0.0",
"@types/debug": "^4.1.7",
"@types/express": "^4.17.13",
"@types/node": "^16.11.9",
"@types/safe-compare": "^1.1.0",
"nodemon": "^2.0.19"
}
}

View File

@@ -1,20 +0,0 @@
(async () => {
const http = await import('http');
const healthCheck = http.request("http://localhost:8080/healthcheck", (res) => {
console.log(`HEALTHCHECK STATUS: ${res.statusCode}`);
if (res.statusCode == 200) {
process.exit(0);
}
else {
process.exit(1);
}
});
healthCheck.on('error', function (err) {
console.error('ERROR');
process.exit(1);
});
healthCheck.end();
})();

View File

@@ -1,143 +0,0 @@
import { createClient, RedisClientType } from 'redis';
import env from '@/config';
import BotsManager from '@/bots/manager';
import Sentry from '@/sentry';
enum RedisKeys {
UsersActivity = "users_activity",
UsersActivity24 = "users_activity_24",
RequestsCount = "requests_count",
}
export default class UsersCounter {
static _redisClient: RedisClientType | null = null;
static async _getClient() {
if (this._redisClient === null) {
this._redisClient = createClient({
url: `redis://${env.REDIS_HOST}:${env.REDIS_PORT}/${env.REDIS_DB}`
});
this._redisClient.on('error', (err) => {
console.log(err);
Sentry.captureException(err);
});
await this._redisClient.connect();
}
return this._redisClient;
}
static async _getBotsUsernames(): Promise<string[]> {
const promises = Object.values(BotsManager.bots).map(async (bot) => {
const botInfo = await bot.telegram.getMe();
return botInfo.username;
});
return Promise.all(promises);
}
static async _getUsersByBot(bot: string, lastDay: boolean): Promise<number[]> {
const client = await this._getClient();
const prefix = lastDay ? RedisKeys.UsersActivity24 : RedisKeys.UsersActivity;
const template = `${prefix}_${bot}_`;
return (await client.keys(template + '*')).map(
(key) => parseInt(key.replace(template, ""))
);
}
static async _getAllUsersCount(botsUsernames: string[], lastDay: boolean): Promise<number> {
const users = new Set<number>();
await Promise.all(
botsUsernames.map(async (bot) => {
(await this._getUsersByBot(bot, lastDay)).forEach((user) => users.add(user));
})
);
return users.size;
}
static async _getUsersByBots(botsUsernames: string[], lastDay: boolean): Promise<{[bot: string]: number}> {
const result: {[bot: string]: number} = {};
await Promise.all(
botsUsernames.map(async (bot) => {
result[bot] = (await this._getUsersByBot(bot, lastDay)).length;
})
);
return result;
}
static async _incrementRequests(bot: string) {
const client = await this._getClient();
const key = `${RedisKeys.RequestsCount}_${bot}`;
const exists = await client.exists(key);
if (!exists) {
await client.set(key, 0);
}
await client.incr(key);
}
static async _getRequestsByBotCount(botsUsernames: string[]): Promise<{[bot: string]: number}> {
const client = await this._getClient();
const result: {[bot: string]: number} = {};
await Promise.all(
botsUsernames.map(async (bot) => {
const count = await client.get(`${RedisKeys.RequestsCount}_${bot}`);
result[bot] = count !== null ? parseInt(count) : 0;
})
);
return result;
}
static async take(userId: number, bot: string) {
const client = await this._getClient();
await client.set(`${RedisKeys.UsersActivity}_${bot}_${userId}`, 1);
await client.set(`${RedisKeys.UsersActivity24}_${bot}_${userId}`, 1, {EX: 24 * 60 * 60});
await this._incrementRequests(bot);
}
static async getMetrics(): Promise<string> {
const botUsernames = await this._getBotsUsernames();
const lines = [];
lines.push(`all_users_count ${await this._getAllUsersCount(botUsernames, false)}`);
lines.push(`all_users_count_24h ${await this._getAllUsersCount(botUsernames, true)}`);
const requestsByBotCount = await this._getRequestsByBotCount(botUsernames);
Object.keys(requestsByBotCount).forEach((bot: string) => {
lines.push(`requests_count{bot="${bot}"} ${requestsByBotCount[bot]}`);
});
const usersByBots = await this._getUsersByBots(botUsernames, false);
Object.keys(usersByBots).forEach((bot: string) => {
lines.push(`users_count{bot="${bot}"} ${usersByBots[bot]}`)
});
const usersByBots24h = await this._getUsersByBots(botUsernames, true);
Object.keys(usersByBots24h).forEach((bot: string) => {
lines.push(`users_count_24h{bot="${bot}"} ${usersByBots24h[bot]}`)
});
return lines.join("\n");
}
}

View File

@@ -0,0 +1,47 @@
pub mod modules;
pub mod services;
mod tools;
use teloxide::{prelude::*, types::BotCommand};
use self::modules::{
annotations::get_annotations_handler, book::get_book_handler, download::get_download_hander,
help::get_help_handler, random::get_random_hander, search::get_search_hanlder,
settings::get_settings_handler, support::get_support_handler,
update_history::get_update_log_handler,
};
use super::{BotCommands, BotHandler};
pub fn get_approved_handler() -> (BotHandler, BotCommands) {
(
dptree::entry()
.branch(get_help_handler())
.branch(get_settings_handler())
.branch(get_support_handler())
.branch(get_random_hander())
.branch(get_download_hander())
.branch(get_annotations_handler())
.branch(get_book_handler())
.branch(get_update_log_handler())
.branch(get_search_hanlder()),
Some(vec![
BotCommand {
command: String::from("random"),
description: String::from("Попытать удачу"),
},
BotCommand {
command: String::from("update_log"),
description: String::from("Обновления каталога"),
},
BotCommand {
command: String::from("settings"),
description: String::from("Настройки"),
},
BotCommand {
command: String::from("support"),
description: String::from("Поддержать разработчика"),
},
]),
)
}

View File

@@ -0,0 +1,354 @@
use std::{convert::TryInto, str::FromStr};
use futures::TryStreamExt;
use regex::Regex;
use teloxide::{dispatching::UpdateFilterExt, dptree, prelude::*, types::*};
use tokio_util::compat::FuturesAsyncReadCompatExt;
use crate::bots::{
approved_bot::{
modules::utils::generic_get_pagination_keyboard,
services::book_library::{
get_author_annotation, get_book_annotation,
types::{AuthorAnnotation, BookAnnotation},
},
tools::filter_callback_query,
},
BotHandlerInternal,
};
use super::utils::{filter_command, CommandParse, GetPaginationCallbackData};
#[derive(Clone)]
pub enum AnnotationCommand {
Book { id: u32 },
Author { id: u32 },
}
impl CommandParse<Self> for AnnotationCommand {
fn parse(s: &str, bot_name: &str) -> Result<Self, strum::ParseError> {
let re = Regex::new(r"^/(?P<an_type>a|b)_an_(?P<id>\d+)$").unwrap();
let full_bot_name = format!("@{bot_name}");
let after_replace = s.replace(&full_bot_name, "");
let caps = re.captures(&after_replace);
let caps = match caps {
Some(v) => v,
None => return Err(strum::ParseError::VariantNotFound),
};
let annotation_type = &caps["an_type"];
let id: u32 = caps["id"].parse().unwrap();
match annotation_type {
"a" => Ok(AnnotationCommand::Author { id }),
"b" => Ok(AnnotationCommand::Book { id }),
_ => Err(strum::ParseError::VariantNotFound),
}
}
}
#[derive(Clone)]
pub enum AnnotationCallbackData {
Book { id: u32, page: u32 },
Author { id: u32, page: u32 },
}
impl FromStr for AnnotationCallbackData {
type Err = strum::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let re = Regex::new(r"^(?P<an_type>a|b)_an_(?P<id>\d+)_(?P<page>\d+)$").unwrap();
let caps = re.captures(s);
let caps = match caps {
Some(v) => v,
None => return Err(strum::ParseError::VariantNotFound),
};
let annotation_type = &caps["an_type"];
let id = caps["id"].parse::<u32>().unwrap();
let page = caps["page"].parse::<u32>().unwrap();
match annotation_type {
"a" => Ok(AnnotationCallbackData::Author { id, page }),
"b" => Ok(AnnotationCallbackData::Book { id, page }),
_ => Err(strum::ParseError::VariantNotFound),
}
}
}
impl ToString for AnnotationCallbackData {
fn to_string(&self) -> String {
match self {
AnnotationCallbackData::Book { id, page } => format!("b_an_{id}_{page}"),
AnnotationCallbackData::Author { id, page } => format!("a_an_{id}_{page}"),
}
}
}
pub trait AnnotationFormat {
fn get_file(&self) -> Option<&String>;
fn get_text(&self) -> &str;
fn is_normal_text(&self) -> bool;
}
impl AnnotationFormat for BookAnnotation {
fn get_file(&self) -> Option<&String> {
self.file.as_ref()
}
fn get_text(&self) -> &str {
self.text.as_str()
}
fn is_normal_text(&self) -> bool {
self.text.replace('\n', "").replace(' ', "").len() != 0
}
}
impl GetPaginationCallbackData for AnnotationCallbackData {
fn get_pagination_callback_data(&self, target_page: u32) -> String {
match self {
AnnotationCallbackData::Book { id, .. } => AnnotationCallbackData::Book {
id: id.clone(),
page: target_page,
},
AnnotationCallbackData::Author { id, .. } => AnnotationCallbackData::Author {
id: id.clone(),
page: target_page,
},
}
.to_string()
}
}
impl AnnotationFormat for AuthorAnnotation {
fn get_file(&self) -> Option<&String> {
self.file.as_ref()
}
fn get_text(&self) -> &str {
self.text.as_str()
}
fn is_normal_text(&self) -> bool {
self.text.replace('\n', "").replace(' ', "").len() != 0
}
}
async fn download_image(
file: &String,
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
let response = reqwest::get(file).await;
let response = match response {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
let response = match response.error_for_status() {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
Ok(response)
}
pub async fn send_annotation_handler<T, Fut>(
message: Message,
bot: AutoSend<Bot>,
command: AnnotationCommand,
annotation_getter: fn(id: u32) -> Fut,
) -> BotHandlerInternal
where
T: AnnotationFormat,
Fut: std::future::Future<Output = Result<T, Box<dyn std::error::Error + Send + Sync>>>,
{
let id = match command {
AnnotationCommand::Book { id } => id,
AnnotationCommand::Author { id } => id,
};
let annotation = match annotation_getter(id).await {
Ok(v) => v,
Err(err) => return Err(err),
};
if annotation.get_file().is_none() && !annotation.is_normal_text() {
return match bot
.send_message(message.chat.id, "Аннотация недоступна :(")
.reply_to_message_id(message.id)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
};
};
if let Some(file) = annotation.get_file() {
let image_response = download_image(file).await;
if let Ok(v) = image_response {
let data = v
.bytes_stream()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
.into_async_read()
.compat();
log::info!("{}", file);
match bot
.send_photo(message.chat.id, InputFile::read(data))
.send()
.await
{
Ok(_) => (),
Err(err) => log::info!("{}", err),
}
}
};
if !annotation.is_normal_text() {
return Ok(());
}
let chunked_text: Vec<String> = textwrap::wrap(annotation.get_text(), 512)
.into_iter()
.filter(|text| text.replace('\r', "").len() != 0)
.map(|text| text.to_string())
.collect();
let current_text = chunked_text.get(0).unwrap();
let callback_data = match command {
AnnotationCommand::Book { id } => AnnotationCallbackData::Book { id, page: 1 },
AnnotationCommand::Author { id } => AnnotationCallbackData::Author { id, page: 1 },
};
let keyboard = generic_get_pagination_keyboard(
1,
chunked_text.len().try_into().unwrap(),
callback_data,
false,
);
match bot
.send_message(message.chat.id, current_text)
.reply_markup(keyboard)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
pub async fn annotation_pagination_handler<T, Fut>(
cq: CallbackQuery,
bot: AutoSend<Bot>,
callback_data: AnnotationCallbackData,
annotation_getter: fn(id: u32) -> Fut,
) -> BotHandlerInternal
where
T: AnnotationFormat,
Fut: std::future::Future<Output = Result<T, Box<dyn std::error::Error + Send + Sync>>>,
{
let (id, page) = match callback_data {
AnnotationCallbackData::Book { id, page } => (id, page),
AnnotationCallbackData::Author { id, page } => (id, page),
};
let annotation = match annotation_getter(id).await {
Ok(v) => v,
Err(err) => return Err(err),
};
let message = match cq.message {
Some(v) => v,
None => return Ok(()),
};
let page_index: usize = page.try_into().unwrap();
let chunked_text: Vec<String> = textwrap::wrap(annotation.get_text(), 512)
.into_iter()
.filter(|text| text.replace('\r', "").len() != 0)
.map(|text| text.to_string())
.collect();
let current_text = chunked_text.get(page_index - 1).unwrap();
let keyboard = generic_get_pagination_keyboard(
page,
chunked_text.len().try_into().unwrap(),
callback_data,
false,
);
match bot
.edit_message_text(message.chat.id, message.id, current_text)
.reply_markup(keyboard)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
pub fn get_annotations_handler() -> crate::bots::BotHandler {
dptree::entry()
.branch(
Update::filter_message()
.chain(filter_command::<AnnotationCommand>())
.endpoint(
|message: Message, bot: AutoSend<Bot>, command: AnnotationCommand| async move {
match command {
AnnotationCommand::Book { .. } => {
send_annotation_handler(message, bot, command, get_book_annotation)
.await
}
AnnotationCommand::Author { .. } => {
send_annotation_handler(
message,
bot,
command,
get_author_annotation,
)
.await
}
}
},
),
)
.branch(
Update::filter_callback_query()
.chain(filter_callback_query::<AnnotationCallbackData>())
.endpoint(
|cq: CallbackQuery,
bot: AutoSend<Bot>,
callback_data: AnnotationCallbackData| async move {
match callback_data {
AnnotationCallbackData::Book { .. } => {
annotation_pagination_handler(
cq,
bot,
callback_data,
get_book_annotation,
)
.await
}
AnnotationCallbackData::Author { .. } => {
annotation_pagination_handler(
cq,
bot,
callback_data,
get_author_annotation,
)
.await
}
}
},
),
)
}

View File

@@ -0,0 +1,346 @@
use std::str::FromStr;
use regex::Regex;
use teloxide::{dispatching::UpdateFilterExt, dptree, prelude::*};
use crate::bots::approved_bot::{
services::{
book_library::{
formaters::Format, get_author_books, get_sequence_books, get_translator_books,
types::Page,
},
user_settings::get_user_or_default_lang_codes,
},
tools::filter_callback_query,
};
use super::utils::{
filter_command, generic_get_pagination_keyboard, CommandParse, GetPaginationCallbackData,
};
#[derive(Clone)]
pub enum BookCommand {
Author { id: u32 },
Translator { id: u32 },
Sequence { id: u32 },
}
impl CommandParse<Self> for BookCommand {
fn parse(s: &str, bot_name: &str) -> Result<Self, strum::ParseError> {
let re = Regex::new(r"^/(?P<an_type>a|t|s)_(?P<id>\d+)$").unwrap();
let full_bot_name = format!("@{bot_name}");
let after_replace = s.replace(&full_bot_name, "");
let caps = re.captures(&after_replace);
let caps = match caps {
Some(v) => v,
None => return Err(strum::ParseError::VariantNotFound),
};
let annotation_type = &caps["an_type"];
let id: u32 = caps["id"].parse().unwrap();
match annotation_type {
"a" => Ok(BookCommand::Author { id }),
"t" => Ok(BookCommand::Translator { id }),
"s" => Ok(BookCommand::Sequence { id }),
_ => Err(strum::ParseError::VariantNotFound),
}
}
}
#[derive(Clone)]
pub enum BookCallbackData {
Author { id: u32, page: u32 },
Translator { id: u32, page: u32 },
Sequence { id: u32, page: u32 },
}
impl FromStr for BookCallbackData {
type Err = strum::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let re = Regex::new(r"^b(?P<an_type>a|t|s)_(?P<id>\d+)_(?P<page>\d+)$").unwrap();
let caps = re.captures(s);
let caps = match caps {
Some(v) => v,
None => return Err(strum::ParseError::VariantNotFound),
};
let annotation_type = &caps["an_type"];
let id = caps["id"].parse::<u32>().unwrap();
let page = caps["page"].parse::<u32>().unwrap();
match annotation_type {
"a" => Ok(BookCallbackData::Author { id, page }),
"t" => Ok(BookCallbackData::Translator { id, page }),
"s" => Ok(BookCallbackData::Sequence { id, page }),
_ => Err(strum::ParseError::VariantNotFound),
}
}
}
impl ToString for BookCallbackData {
fn to_string(&self) -> String {
match self {
BookCallbackData::Author { id, page } => format!("ba_{id}_{page}"),
BookCallbackData::Translator { id, page } => format!("bt_{id}_{page}"),
BookCallbackData::Sequence { id, page } => format!("bs_{id}_{page}"),
}
}
}
impl GetPaginationCallbackData for BookCallbackData {
fn get_pagination_callback_data(&self, target_page: u32) -> String {
match self {
BookCallbackData::Author { id, .. } => BookCallbackData::Author {
id: id.clone(),
page: target_page,
},
BookCallbackData::Translator { id, .. } => BookCallbackData::Translator {
id: id.clone(),
page: target_page,
},
BookCallbackData::Sequence { id, .. } => BookCallbackData::Sequence {
id: id.clone(),
page: target_page,
},
}
.to_string()
}
}
async fn send_book_handler<T, Fut>(
message: Message,
bot: AutoSend<Bot>,
command: BookCommand,
books_getter: fn(id: u32, page: u32, allowed_langs: Vec<String>) -> Fut,
) -> crate::bots::BotHandlerInternal
where
T: Format + Clone,
Fut: std::future::Future<Output = Result<Page<T>, Box<dyn std::error::Error + Send + Sync>>>,
{
let id = match command {
BookCommand::Author { id } => id,
BookCommand::Translator { id } => id,
BookCommand::Sequence { id } => id,
};
let chat_id = message.chat.id;
let user_id = message.from().map(|from| from.id);
let user_id = match user_id {
Some(v) => v,
None => {
return match bot
.send_message(chat_id, "Повторите запрос сначала")
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
};
let allowed_langs = get_user_or_default_lang_codes(user_id).await;
let items_page = match books_getter(id, 1, allowed_langs.clone()).await {
Ok(v) => v,
Err(err) => {
match bot
.send_message(chat_id, "Ошибка! Попробуйте позже :(")
.send()
.await
{
Ok(_) => (),
Err(err) => log::error!("{:?}", err),
}
return Err(err);
}
};
if items_page.total_pages == 0 {
match bot.send_message(chat_id, "Книги не найдены!").send().await {
Ok(_) => (),
Err(err) => return Err(Box::new(err)),
};
};
let formated_items = items_page.format_items();
let total_pages = items_page.total_pages;
let footer = format!("\n\nСтраница 1/{total_pages}");
let message_text = format!("{formated_items}{footer}");
let callback_data = match command {
BookCommand::Author { id } => BookCallbackData::Author { id, page: 1 },
BookCommand::Translator { id } => BookCallbackData::Translator { id, page: 1 },
BookCommand::Sequence { id } => BookCallbackData::Sequence { id, page: 1 },
};
let keyboard = generic_get_pagination_keyboard(1, total_pages, callback_data, true);
match bot
.send_message(chat_id, message_text)
.reply_markup(keyboard)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
async fn send_pagination_book_handler<T, Fut>(
cq: CallbackQuery,
bot: AutoSend<Bot>,
callback_data: BookCallbackData,
books_getter: fn(id: u32, page: u32, allowed_langs: Vec<String>) -> Fut,
) -> crate::bots::BotHandlerInternal
where
T: Format + Clone,
Fut: std::future::Future<Output = Result<Page<T>, Box<dyn std::error::Error + Send + Sync>>>,
{
let (id, page) = match callback_data {
BookCallbackData::Author { id, page } => (id, page),
BookCallbackData::Translator { id, page } => (id, page),
BookCallbackData::Sequence { id, page } => (id, page),
};
let chat_id = cq.message.as_ref().map(|message| message.chat.id);
let user_id = cq
.message
.as_ref()
.map(|message| message.from().map(|from| from.id))
.unwrap_or(None);
let message_id = cq.message.as_ref().map(|message| message.id);
let (chat_id, user_id, message_id) = match (chat_id, user_id, message_id) {
(Some(chat_id), Some(user_id), Some(message_id)) => (chat_id, user_id, message_id),
_ => {
return match chat_id {
Some(v) => match bot.send_message(v, "Повторите поиск сначала").send().await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
},
None => return Ok(()),
}
}
};
let allowed_langs = get_user_or_default_lang_codes(user_id).await;
let mut items_page = match books_getter(id, page, allowed_langs.clone()).await {
Ok(v) => v,
Err(err) => {
match bot
.send_message(chat_id, "Ошибка! Попробуйте позже :(")
.send()
.await
{
Ok(_) => (),
Err(err) => log::error!("{:?}", err),
}
return Err(err);
}
};
if items_page.total_pages == 0 {
match bot.send_message(chat_id, "Книги не найдены!").send().await {
Ok(_) => (),
Err(err) => return Err(Box::new(err)),
};
};
if page > items_page.total_pages {
items_page = match books_getter(id, items_page.total_pages, allowed_langs.clone()).await {
Ok(v) => v,
Err(err) => {
match bot
.send_message(chat_id, "Ошибка! Попробуйте позже :(")
.send()
.await
{
Ok(_) => (),
Err(err) => log::error!("{:?}", err),
}
return Err(err);
}
};
}
let formated_items = items_page.format_items();
let total_pages = items_page.total_pages;
let footer = format!("\n\nСтраница {page}/{total_pages}");
let message_text = format!("{formated_items}{footer}");
let keyboard = generic_get_pagination_keyboard(page, total_pages, callback_data, true);
match bot
.edit_message_text(chat_id, message_id, message_text)
.reply_markup(keyboard)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
pub fn get_book_handler() -> crate::bots::BotHandler {
dptree::entry()
.branch(
Update::filter_message()
.chain(filter_command::<BookCommand>())
.endpoint(
|message: Message, bot: AutoSend<Bot>, command: BookCommand| async move {
match command {
BookCommand::Author { .. } => {
send_book_handler(
message,
bot,
command,
get_author_books,
)
.await
}
BookCommand::Translator { .. } => {
send_book_handler(
message,
bot,
command,
get_translator_books,
)
.await
}
BookCommand::Sequence { .. } => {
send_book_handler(
message,
bot,
command,
get_sequence_books,
)
.await
}
}
},
),
)
.branch(
Update::filter_callback_query()
.chain(filter_callback_query::<BookCallbackData>())
.endpoint(|cq: CallbackQuery, bot: AutoSend<Bot>, callback_data: BookCallbackData| async move {
match callback_data {
BookCallbackData::Author { .. } => send_pagination_book_handler(cq, bot, callback_data, get_author_books).await,
BookCallbackData::Translator { .. } => send_pagination_book_handler(cq, bot, callback_data, get_translator_books).await,
BookCallbackData::Sequence { .. } => send_pagination_book_handler(cq, bot, callback_data, get_sequence_books).await,
}
}),
)
}

View File

@@ -0,0 +1,153 @@
use futures::TryStreamExt;
use regex::Regex;
use teloxide::{dispatching::UpdateFilterExt, dptree, prelude::*, types::*};
use tokio_util::compat::FuturesAsyncReadCompatExt;
use crate::{
bots::{
approved_bot::services::book_cache::{
clear_book_cache, download_file, get_cached_message,
types::{CachedMessage, DownloadFile},
},
BotHandlerInternal,
},
bots_manager::BotCache,
};
use super::utils::{filter_command, CommandParse};
#[derive(Clone)]
pub struct DownloadData {
pub format: String,
pub id: u32,
}
impl CommandParse<Self> for DownloadData {
fn parse(s: &str, bot_name: &str) -> Result<Self, strum::ParseError> {
let re = Regex::new(r"^/d_(?P<file_format>[a-zA-Z0-9]+)_(?P<book_id>\d+)$").unwrap();
let full_bot_name = format!("@{bot_name}");
let after_replace = s.replace(&full_bot_name, "");
let caps = re.captures(&after_replace);
let caps = match caps {
Some(v) => v,
None => return Err(strum::ParseError::VariantNotFound),
};
let file_format = &caps["file_format"];
let book_id: u32 = caps["book_id"].parse().unwrap();
Ok(DownloadData {
format: file_format.to_string(),
id: book_id,
})
}
}
async fn _send_cached(
message: Message,
bot: AutoSend<Bot>,
cached_message: CachedMessage,
) -> BotHandlerInternal {
match bot
.copy_message(
message.chat.id,
Recipient::Id(ChatId(cached_message.chat_id)),
cached_message.message_id,
)
.send()
.await
{
Ok(_) => todo!(),
Err(err) => Err(Box::new(err)),
}
}
async fn send_cached_message(
message: Message,
bot: AutoSend<Bot>,
download_data: DownloadData,
) -> BotHandlerInternal {
let cached_message = get_cached_message(&download_data).await;
match cached_message {
Ok(v) => match _send_cached(message.clone(), bot.clone(), v).await {
Ok(_) => return Ok(()),
Err(err) => log::info!("{:?}", err),
},
Err(err) => return Err(err),
};
match clear_book_cache(&download_data).await {
Ok(_) => (),
Err(err) => log::error!("{:?}", err),
};
let cached_message = get_cached_message(&download_data).await;
match cached_message {
Ok(v) => _send_cached(message, bot, v).await,
Err(err) => return Err(err),
}
}
async fn send_with_download_from_channel(
message: Message,
bot: AutoSend<Bot>,
download_data: DownloadData,
) -> BotHandlerInternal {
let downloaded_file = match download_file(&download_data).await {
Ok(v) => v,
Err(err) => return Err(err),
};
let DownloadFile {
response,
filename,
caption,
} = downloaded_file;
let data = response
.bytes_stream()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
.into_async_read()
.compat();
let document: InputFile = InputFile::read(data).file_name(filename);
match bot
.send_document(message.chat.id, document)
.caption(caption)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
async fn download_handler(
message: Message,
bot: AutoSend<Bot>,
cache: BotCache,
download_data: DownloadData,
) -> BotHandlerInternal {
match cache {
BotCache::Original => send_cached_message(message, bot, download_data).await,
BotCache::NoCache => send_with_download_from_channel(message, bot, download_data).await,
}
}
pub fn get_download_hander() -> crate::bots::BotHandler {
dptree::entry().branch(
Update::filter_message()
.chain(filter_command::<DownloadData>())
.endpoint(
|message: Message,
bot: AutoSend<Bot>,
cache: BotCache,
download_data: DownloadData| async move {
download_handler(message, bot, cache, download_data).await
},
),
)
}

View File

@@ -0,0 +1,45 @@
use crate::bots::BotHandlerInternal;
use teloxide::{prelude::*, utils::command::BotCommands};
#[derive(BotCommands, Clone)]
#[command(rename = "lowercase")]
enum HelpCommand {
Start,
Help,
}
pub async fn help_handler(message: Message, bot: AutoSend<Bot>) -> BotHandlerInternal {
let name = message
.from()
.map(|user| user.first_name.clone())
.unwrap_or("пользователь".to_string());
match bot
.send_message(
message.chat.id,
format!(
"
Привет, {name}! \n
Этот бот поможет тебе загружать книги.\n
Настройки языков для поиска /settings.\n
"
),
)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
pub fn get_help_handler() -> crate::bots::BotHandler {
dptree::entry().branch(
Update::filter_message().branch(
dptree::entry()
.filter_command::<HelpCommand>()
.endpoint(|message, bot| async move { help_handler(message, bot).await }),
),
)
}

View File

@@ -0,0 +1,10 @@
pub mod annotations;
pub mod book;
pub mod download;
pub mod help;
pub mod random;
pub mod search;
pub mod settings;
pub mod support;
pub mod update_history;
pub mod utils;

View File

@@ -0,0 +1,364 @@
use strum_macros::{Display, EnumIter};
use teloxide::{
prelude::*,
types::{InlineKeyboardButton, InlineKeyboardMarkup},
utils::command::BotCommands,
};
use crate::bots::{
approved_bot::{
services::{
book_library::{self, formaters::Format},
user_settings::get_user_or_default_lang_codes,
},
tools::filter_callback_query,
},
BotHandlerInternal,
};
#[derive(BotCommands, Clone)]
#[command(rename = "lowercase")]
enum RandomCommand {
Random,
}
#[derive(Clone, Display, EnumIter)]
#[strum(serialize_all = "snake_case")]
enum RandomCallbackData {
RandomBook,
RandomAuthor,
RandomSequence,
RandomBookByGenreRequest,
Genres { index: u32 },
RandomBookByGenre { id: u32 },
}
impl std::str::FromStr for RandomCallbackData {
type Err = strum::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let value = s.to_string();
for callback_data in <RandomCallbackData as strum::IntoEnumIterator>::iter() {
match callback_data {
RandomCallbackData::Genres { .. }
| RandomCallbackData::RandomBookByGenre { .. } => {
let callback_prefix = callback_data.to_string();
if value.starts_with(&callback_prefix) {
let data: u32 = value
.strip_prefix(&format!("{}_", &callback_prefix).to_string())
.unwrap()
.parse()
.unwrap();
match callback_data {
RandomCallbackData::Genres { .. } => {
return Ok(RandomCallbackData::Genres { index: data })
}
RandomCallbackData::RandomBookByGenre { .. } => {
return Ok(RandomCallbackData::RandomBookByGenre { id: data })
}
_ => (),
}
}
}
_ => {
if value == callback_data.to_string() {
return Ok(callback_data);
}
}
}
}
return Err(strum::ParseError::VariantNotFound);
}
}
async fn random_handler(message: Message, bot: AutoSend<Bot>) -> crate::bots::BotHandlerInternal {
const MESSAGE_TEXT: &str = "Что хотим получить?";
let keyboard = InlineKeyboardMarkup {
inline_keyboard: vec![
vec![InlineKeyboardButton {
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
RandomCallbackData::RandomBook.to_string(),
),
text: String::from("Книгу"),
}],
vec![InlineKeyboardButton {
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
RandomCallbackData::RandomBookByGenreRequest.to_string(),
),
text: String::from("Книгу по жанру"),
}],
vec![InlineKeyboardButton {
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
RandomCallbackData::RandomAuthor.to_string(),
),
text: String::from("Автора"),
}],
vec![InlineKeyboardButton {
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
RandomCallbackData::RandomSequence.to_string(),
),
text: String::from("Серию"),
}],
],
};
let res = bot
.send_message(message.chat.id, MESSAGE_TEXT)
.reply_to_message_id(message.id)
.reply_markup(keyboard)
.send()
.await;
match res {
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
async fn get_random_item_handler_internal<T>(
cq: CallbackQuery,
bot: AutoSend<Bot>,
item: Result<T, Box<dyn std::error::Error + Send + Sync>>,
) -> BotHandlerInternal
where
T: Format,
{
match item {
Ok(item) => {
let item_message = item.format();
let send_item_handler = tokio::spawn(
bot.send_message(cq.from.id, item_message)
.reply_markup(InlineKeyboardMarkup {
inline_keyboard: vec![vec![InlineKeyboardButton {
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
cq.data.unwrap(),
),
text: String::from("Повторить?"),
}]],
})
.send(),
);
cq.message.map(|message| async move {
bot.edit_message_reply_markup(message.chat.id, message.id)
.reply_markup(InlineKeyboardMarkup {
inline_keyboard: vec![],
})
.send()
.await
});
match send_item_handler.await {
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
Err(err) => {
match bot
.send_message(cq.from.id, "Ошибка! Попробуйте позже :(")
.send()
.await
{
Ok(_) => (),
Err(int_error) => return Err(Box::new(int_error)),
}
return Err(err);
}
}
}
async fn get_random_item_handler<T, Fut>(
cq: CallbackQuery,
bot: AutoSend<Bot>,
item_getter: fn(allowed_langs: Vec<String>) -> Fut,
) -> BotHandlerInternal
where
T: Format,
Fut: std::future::Future<Output = Result<T, Box<dyn std::error::Error + Send + Sync>>>,
{
let allowed_langs = get_user_or_default_lang_codes(cq.from.id).await;
let item = item_getter(allowed_langs).await;
get_random_item_handler_internal(cq, bot, item).await
}
async fn get_genre_metas_handler(cq: CallbackQuery, bot: AutoSend<Bot>) -> BotHandlerInternal {
let genre_metas = match book_library::get_genre_metas().await {
Ok(v) => v,
Err(err) => return Err(err),
};
match cq.message {
Some(message) => {
let keyboard = InlineKeyboardMarkup {
inline_keyboard: genre_metas
.clone()
.into_iter()
.enumerate()
.map(|(index, genre_meta)| {
vec![InlineKeyboardButton {
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(format!(
"{}_{index}",
RandomCallbackData::Genres {
index: index as u32
}
.to_string()
)),
text: genre_meta,
}]
})
.collect(),
};
match bot
.edit_message_reply_markup(message.chat.id, message.id)
.reply_markup(keyboard)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
None => {
match bot
.send_message(cq.from.id, "Ошибка! Начните заново :(")
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
}
}
async fn get_genres_by_meta_handler(
cq: CallbackQuery,
bot: AutoSend<Bot>,
genre_index: u32,
) -> BotHandlerInternal {
let genre_metas = match book_library::get_genre_metas().await {
Ok(v) => v,
Err(err) => return Err(err),
};
let meta = match genre_metas.get(genre_index as usize) {
Some(v) => v,
None => {
return match bot
.send_message(cq.from.id, "Ошибка! Попробуйте позже :(")
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
};
let genres = match book_library::get_genres(meta.to_string()).await {
Ok(v) => v.items,
Err(err) => return Err(err),
};
let mut buttons: Vec<Vec<InlineKeyboardButton>> = genres
.clone()
.into_iter()
.map(|genre| {
vec![InlineKeyboardButton {
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(format!(
"{}_{}",
RandomCallbackData::RandomBookByGenre { id: genre.id }.to_string(),
genre.id
)),
text: genre.description,
}]
})
.collect();
buttons.push(vec![InlineKeyboardButton {
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
RandomCallbackData::RandomBookByGenreRequest.to_string(),
),
text: "< Назад >".to_string(),
}]);
let keyboard = InlineKeyboardMarkup {
inline_keyboard: buttons,
};
match cq.message {
Some(message) => {
match bot
.edit_message_reply_markup(message.chat.id, message.id)
.reply_markup(keyboard)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
None => {
match bot
.send_message(cq.from.id, "Ошибка! Начните заново :(")
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
}
}
async fn get_random_book_by_genre(
cq: CallbackQuery,
bot: AutoSend<Bot>,
genre_id: u32,
) -> BotHandlerInternal {
let allowed_langs = get_user_or_default_lang_codes(cq.from.id).await;
let item = book_library::get_random_book_by_genre(allowed_langs, Some(genre_id)).await;
get_random_item_handler_internal(cq, bot, item).await
}
pub fn get_random_hander() -> crate::bots::BotHandler {
dptree::entry()
.branch(
Update::filter_message()
.branch(
dptree::entry()
.filter_command::<RandomCommand>()
.endpoint(|message, command, bot| async {
match command {
RandomCommand::Random => random_handler(message, bot).await,
}
})
)
)
.branch(
Update::filter_callback_query()
.chain(filter_callback_query::<RandomCallbackData>())
.endpoint(|cq: CallbackQuery, callback_data: RandomCallbackData, bot: AutoSend<Bot>| async move {
match callback_data {
RandomCallbackData::RandomBook => get_random_item_handler(cq, bot, book_library::get_random_book).await,
RandomCallbackData::RandomAuthor => get_random_item_handler(cq, bot, book_library::get_random_author).await,
RandomCallbackData::RandomSequence => get_random_item_handler(cq, bot, book_library::get_random_sequence).await,
RandomCallbackData::RandomBookByGenreRequest => get_genre_metas_handler(cq, bot).await,
RandomCallbackData::Genres { index } => get_genres_by_meta_handler(cq, bot, index).await,
RandomCallbackData::RandomBookByGenre { id } => get_random_book_by_genre(cq, bot, id).await,
}
})
)
}

View File

@@ -0,0 +1,281 @@
use std::str::FromStr;
use regex::Regex;
use strum_macros::EnumIter;
use teloxide::{
prelude::*,
types::{InlineKeyboardButton, InlineKeyboardMarkup},
};
use crate::bots::{
approved_bot::{
services::{
book_library::{
formaters::Format, search_author, search_book, search_sequence, search_translator,
types::Page,
},
user_settings::get_user_or_default_lang_codes,
},
tools::filter_callback_query,
},
BotHandlerInternal,
};
use super::utils::{generic_get_pagination_keyboard, GetPaginationCallbackData};
#[derive(Clone, EnumIter)]
pub enum SearchCallbackData {
SearchBook { page: u32 },
SearchAuthors { page: u32 },
SearchSequences { page: u32 },
SearchTranslators { page: u32 },
}
impl ToString for SearchCallbackData {
fn to_string(&self) -> String {
match self {
SearchCallbackData::SearchBook { page } => format!("sb_{page}"),
SearchCallbackData::SearchAuthors { page } => format!("sa_{page}"),
SearchCallbackData::SearchSequences { page } => format!("ss_{page}"),
SearchCallbackData::SearchTranslators { page } => format!("st_{page}"),
}
}
}
impl FromStr for SearchCallbackData {
type Err = strum::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let re = Regex::new(r"^(?P<search_type>s[a|b|s|t])_(?P<page>\d+)$").unwrap();
let caps = re.captures(s);
let caps = match caps {
Some(v) => v,
None => return Err(strum::ParseError::VariantNotFound),
};
let search_type = &caps["search_type"];
let page: u32 = caps["page"].parse::<u32>().unwrap();
match search_type {
"sb" => Ok(SearchCallbackData::SearchBook { page }),
"sa" => Ok(SearchCallbackData::SearchAuthors { page }),
"ss" => Ok(SearchCallbackData::SearchSequences { page }),
"st" => Ok(SearchCallbackData::SearchTranslators { page }),
_ => Err(strum::ParseError::VariantNotFound),
}
}
}
impl GetPaginationCallbackData for SearchCallbackData {
fn get_pagination_callback_data(&self, target_page: u32) -> String {
match self {
SearchCallbackData::SearchBook { .. } => {
SearchCallbackData::SearchBook { page: target_page }
}
SearchCallbackData::SearchAuthors { .. } => {
SearchCallbackData::SearchAuthors { page: target_page }
}
SearchCallbackData::SearchSequences { .. } => {
SearchCallbackData::SearchSequences { page: target_page }
}
SearchCallbackData::SearchTranslators { .. } => {
SearchCallbackData::SearchTranslators { page: target_page }
}
}
.to_string()
}
}
fn get_query(cq: CallbackQuery) -> Option<String> {
cq.message
.map(|message| {
message
.reply_to_message()
.map(|reply_to_message| {
reply_to_message
.text()
.map(|text| text.replace('/', "").replace('&', "").replace('?', ""))
})
.unwrap_or(None)
})
.unwrap_or(None)
}
async fn generic_search_pagination_handler<T, Fut>(
cq: CallbackQuery,
bot: AutoSend<Bot>,
search_data: SearchCallbackData,
items_getter: fn(query: String, page: u32, allowed_langs: Vec<String>) -> Fut,
) -> BotHandlerInternal
where
T: Format + Clone,
Fut: std::future::Future<Output = Result<Page<T>, Box<dyn std::error::Error + Send + Sync>>>,
{
let chat_id = cq.message.as_ref().map(|message| message.chat.id);
let user_id = cq
.message
.as_ref()
.map(|message| message.from().map(|from| from.id))
.unwrap_or(None);
let message_id = cq.message.as_ref().map(|message| message.id);
let query = get_query(cq);
let (chat_id, user_id, query, message_id) = match (chat_id, user_id, query, message_id) {
(Some(chat_id), Some(user_id), Some(query), Some(message_id)) => {
(chat_id, user_id, query, message_id)
}
_ => {
return match chat_id {
Some(v) => match bot.send_message(v, "Повторите поиск сначала").send().await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
},
None => return Ok(()),
}
}
};
let allowed_langs = get_user_or_default_lang_codes(user_id).await;
let page = match search_data {
SearchCallbackData::SearchBook { page } => page,
SearchCallbackData::SearchAuthors { page } => page,
SearchCallbackData::SearchSequences { page } => page,
SearchCallbackData::SearchTranslators { page } => page,
};
let mut items_page = match items_getter(query.clone(), page, allowed_langs.clone()).await {
Ok(v) => v,
Err(err) => {
match bot
.send_message(chat_id, "Ошибка! Попробуйте позже :(")
.send()
.await
{
Ok(_) => (),
Err(err) => log::error!("{:?}", err),
}
return Err(err);
}
};
if items_page.total_pages == 0 {
let message_text = match search_data {
SearchCallbackData::SearchBook { .. } => "Книги не найдены!",
SearchCallbackData::SearchAuthors { .. } => "Авторы не найдены!",
SearchCallbackData::SearchSequences { .. } => "Серии не найдены!",
SearchCallbackData::SearchTranslators { .. } => "Переводчики не найдены!",
};
match bot.send_message(chat_id, message_text).send().await {
Ok(_) => (),
Err(err) => return Err(Box::new(err)),
};
};
if page > items_page.total_pages {
items_page = match items_getter(
query.clone(),
items_page.total_pages,
allowed_langs.clone(),
)
.await
{
Ok(v) => v,
Err(err) => {
match bot
.send_message(chat_id, "Ошибка! Попробуйте позже :(")
.send()
.await
{
Ok(_) => (),
Err(err) => log::error!("{:?}", err),
}
return Err(err);
}
};
}
let formated_items = items_page.format_items();
let total_pages = items_page.total_pages;
let footer = format!("\n\nСтраница {page}/{total_pages}");
let message_text = format!("{formated_items}{footer}");
let keyboard = generic_get_pagination_keyboard(page, total_pages, search_data, true);
match bot
.edit_message_text(chat_id, message_id, message_text)
.reply_markup(keyboard)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
pub async fn message_handler(message: Message, bot: AutoSend<Bot>) -> BotHandlerInternal {
let message_text = "Что ищем?";
let keyboard = InlineKeyboardMarkup {
inline_keyboard: vec![
vec![InlineKeyboardButton {
text: "Книгу".to_string(),
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
(SearchCallbackData::SearchBook { page: 1 }).to_string(),
),
}],
vec![InlineKeyboardButton {
text: "Автора".to_string(),
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
(SearchCallbackData::SearchAuthors { page: 1 }).to_string(),
),
}],
vec![InlineKeyboardButton {
text: "Серию".to_string(),
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
(SearchCallbackData::SearchSequences { page: 1 }).to_string(),
),
}],
vec![InlineKeyboardButton {
text: "Переводчика".to_string(),
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
(SearchCallbackData::SearchTranslators { page: 1 }).to_string(),
),
}],
],
};
match bot
.send_message(message.chat.id, message_text)
.reply_to_message_id(message.id)
.reply_markup(keyboard)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
pub fn get_search_hanlder() -> crate::bots::BotHandler {
dptree::entry().branch(
Update::filter_message()
.endpoint(|message, bot| async move { message_handler(message, bot).await }),
).branch(
Update::filter_callback_query()
.chain(filter_callback_query::<SearchCallbackData>())
.endpoint(|cq: CallbackQuery, callback_data: SearchCallbackData, bot: AutoSend<Bot>| async move {
match callback_data {
SearchCallbackData::SearchBook { .. } => generic_search_pagination_handler(cq, bot, callback_data, search_book).await,
SearchCallbackData::SearchAuthors { .. } => generic_search_pagination_handler(cq, bot, callback_data, search_author).await,
SearchCallbackData::SearchSequences { .. } => generic_search_pagination_handler(cq, bot, callback_data, search_sequence).await,
SearchCallbackData::SearchTranslators { .. } => generic_search_pagination_handler(cq, bot, callback_data, search_translator).await,
}
})
)
}

View File

@@ -0,0 +1,217 @@
use std::{collections::HashSet, str::FromStr, vec};
use crate::bots::{
approved_bot::{
services::user_settings::{
create_or_update_user_settings, get_langs, get_user_or_default_lang_codes, Lang,
},
tools::filter_callback_query,
},
BotHandlerInternal,
};
use regex::Regex;
use teloxide::{
prelude::*,
types::{InlineKeyboardButton, InlineKeyboardMarkup, Me},
utils::command::BotCommands,
};
#[derive(BotCommands, Clone)]
#[command(rename = "lowercase")]
enum SettingsCommand {
Settings,
}
#[derive(Clone)]
enum SettingsCallbackData {
LangSettings,
LangOn { code: String },
LangOff { code: String },
}
impl FromStr for SettingsCallbackData {
type Err = strum::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == SettingsCallbackData::LangSettings.to_string().as_str() {
return Ok(SettingsCallbackData::LangSettings);
}
let re = Regex::new(r"^lang_(?P<action>(off)|(on))_(?P<code>[a-zA-z]+)$").unwrap();
let caps = re.captures(s);
let caps = match caps {
Some(v) => v,
None => return Err(strum::ParseError::VariantNotFound),
};
let action = &caps["action"];
let code = caps["code"].to_string();
match action {
"on" => Ok(SettingsCallbackData::LangOn { code }),
"off" => Ok(SettingsCallbackData::LangOff { code }),
_ => Err(strum::ParseError::VariantNotFound),
}
}
}
impl ToString for SettingsCallbackData {
fn to_string(&self) -> String {
match self {
SettingsCallbackData::LangSettings => "lang_settings".to_string(),
SettingsCallbackData::LangOn { code } => format!("lang_on_{code}"),
SettingsCallbackData::LangOff { code } => format!("lang_off_{code}"),
}
}
}
async fn settings_handler(message: Message, bot: AutoSend<Bot>) -> BotHandlerInternal {
let keyboard = InlineKeyboardMarkup {
inline_keyboard: vec![vec![InlineKeyboardButton {
text: "Языки".to_string(),
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
SettingsCallbackData::LangSettings.to_string(),
),
}]],
};
match bot
.send_message(message.chat.id, "Настройки")
.reply_markup(keyboard)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
fn get_lang_keyboard(all_langs: Vec<Lang>, allowed_langs: HashSet<String>) -> InlineKeyboardMarkup {
let buttons = all_langs
.into_iter()
.map(|lang| {
let (emoji, callback_data) = match allowed_langs.contains(&lang.code) {
true => (
"🟢".to_string(),
SettingsCallbackData::LangOff { code: lang.code }.to_string(),
),
false => (
"🔴".to_string(),
SettingsCallbackData::LangOn { code: lang.code }.to_string(),
),
};
vec![InlineKeyboardButton {
text: format!("{emoji} {}", lang.label),
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(callback_data),
}]
})
.collect();
InlineKeyboardMarkup {
inline_keyboard: buttons,
}
}
async fn settings_callback_handler(
cq: CallbackQuery,
bot: AutoSend<Bot>,
callback_data: SettingsCallbackData,
me: Me,
) -> BotHandlerInternal {
let message = match cq.message {
Some(v) => v,
None => return Ok(()), // TODO: alert
};
let user = match message.from() {
Some(v) => v,
None => return Ok(()), // TODO: alert
};
let allowed_langs = get_user_or_default_lang_codes(user.id).await;
let mut allowed_langs_set: HashSet<String> = HashSet::new();
allowed_langs.clone().into_iter().for_each(|v| {
allowed_langs_set.insert(v);
});
match callback_data {
SettingsCallbackData::LangSettings => (),
SettingsCallbackData::LangOn { code } => {
allowed_langs_set.insert(code);
}
SettingsCallbackData::LangOff { code } => {
allowed_langs_set.remove(&code);
}
};
if allowed_langs_set.len() == 0 {
return match bot
.answer_callback_query(cq.id)
.text("Должен быть активен, хотя бы один язык!")
.show_alert(true)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
};
}
match create_or_update_user_settings(
user.id,
user.last_name.clone().unwrap_or("".to_string()),
user.first_name.clone(),
user.username.clone().unwrap_or("".to_string()),
me.username.clone().unwrap(),
allowed_langs_set.clone().into_iter().collect(),
)
.await
{
Ok(_) => (),
Err(err) => return Err(err), // TODO: err
};
let all_langs = match get_langs().await {
Ok(v) => v,
Err(err) => return Err(err),
};
let keyboard = get_lang_keyboard(all_langs, allowed_langs_set);
match bot
.edit_message_reply_markup(message.chat.id, message.id)
.reply_markup(keyboard)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
pub fn get_settings_handler() -> crate::bots::BotHandler {
dptree::entry()
.branch(
Update::filter_message().branch(
dptree::entry()
.filter_command::<SettingsCommand>()
.endpoint(|message, bot| async move { settings_handler(message, bot).await }),
),
)
.branch(
Update::filter_callback_query()
.chain(filter_callback_query::<SettingsCallbackData>())
.endpoint(
|cq: CallbackQuery,
bot: AutoSend<Bot>,
callback_data: SettingsCallbackData,
me: Me| async move {
settings_callback_handler(cq, bot, callback_data, me).await
},
),
)
}

View File

@@ -0,0 +1,50 @@
use crate::bots::BotHandlerInternal;
use teloxide::{
prelude::*,
types::{InlineKeyboardButton, InlineKeyboardMarkup},
utils::command::BotCommands,
};
#[derive(BotCommands, Clone)]
#[command(rename = "lowercase")]
enum SupportCommand {
Support,
}
pub async fn support_command_handler(message: Message, bot: AutoSend<Bot>) -> BotHandlerInternal {
const MESSAGE_TEXT: &str = "
[Лицензии](https://github.com/flibusta-apps/book_bot/blob/main/LICENSE.md)
[Исходный код](https://github.com/flibusta-apps)
";
let keyboard = InlineKeyboardMarkup {
inline_keyboard: vec![vec![InlineKeyboardButton {
kind: teloxide::types::InlineKeyboardButtonKind::Url(
url::Url::parse("https://kurbezz.github.io/Kurbezz/").unwrap(),
),
text: String::from("☕️ Поддержать разработчика"),
}]],
};
match bot
.send_message(message.chat.id, MESSAGE_TEXT)
.parse_mode(teloxide::types::ParseMode::MarkdownV2)
.reply_markup(keyboard)
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
pub fn get_support_handler() -> crate::bots::BotHandler {
dptree::entry().branch(
Update::filter_message().branch(
dptree::entry().filter_command::<SupportCommand>().endpoint(
|message, bot| async move { support_command_handler(message, bot).await },
),
),
)
}

View File

@@ -0,0 +1,227 @@
use chrono::{prelude::*, Duration};
use dateparser::parse;
use std::{str::FromStr, vec};
use crate::bots::{
approved_bot::{services::book_library::get_uploaded_books, tools::filter_callback_query},
BotHandlerInternal,
};
use regex::Regex;
use teloxide::{
prelude::*,
types::{InlineKeyboardButton, InlineKeyboardMarkup},
utils::command::BotCommands,
};
use super::utils::{generic_get_pagination_keyboard, GetPaginationCallbackData};
#[derive(BotCommands, Clone)]
#[command(rename = "snake_case")]
enum UpdateLogCommand {
UpdateLog,
}
#[derive(Clone, Copy)]
struct UpdateLogCallbackData {
from: Date<Utc>,
to: Date<Utc>,
page: u32,
}
impl FromStr for UpdateLogCallbackData {
type Err = strum::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let re = Regex::new(
r"^update_log_(?P<from>\d{4}-\d{2}-\d{2})_(?P<to>\d{4}-\d{2}-\d{2})_(?P<page>\d+)$",
)
.unwrap();
let caps = re.captures(s);
let caps = match caps {
Some(v) => v,
None => return Err(strum::ParseError::VariantNotFound),
};
let from: Date<Utc> = parse(&caps["from"]).unwrap().date();
let to: Date<Utc> = parse(&caps["to"]).unwrap().date();
let page: u32 = caps["page"].parse().unwrap();
Ok(UpdateLogCallbackData { from, to, page })
}
}
impl ToString for UpdateLogCallbackData {
fn to_string(&self) -> String {
let date_format = "%Y-%m-%d";
let from = self.from.format(date_format);
let to = self.to.format(date_format);
let page = self.page;
format!("update_log_{from}_{to}_{page}")
}
}
impl GetPaginationCallbackData for UpdateLogCallbackData {
fn get_pagination_callback_data(&self, target_page: u32) -> String {
let UpdateLogCallbackData { from, to, .. } = self;
UpdateLogCallbackData {
from: from.clone(),
to: to.clone(),
page: target_page,
}
.to_string()
}
}
async fn update_log_command(message: Message, bot: AutoSend<Bot>) -> BotHandlerInternal {
let now = Utc::today();
let d3 = now - Duration::days(3);
let d7 = now - Duration::days(7);
let d30 = now - Duration::days(30);
let keyboard = InlineKeyboardMarkup {
inline_keyboard: vec![
vec![InlineKeyboardButton {
text: "За 3 дня".to_string(),
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
UpdateLogCallbackData {
from: d3,
to: now,
page: 1,
}
.to_string(),
),
}],
vec![InlineKeyboardButton {
text: "За 7 дней".to_string(),
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
UpdateLogCallbackData {
from: d7,
to: now,
page: 1,
}
.to_string(),
),
}],
vec![InlineKeyboardButton {
text: "За 30 дней".to_string(),
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
UpdateLogCallbackData {
from: d30,
to: now,
page: 1,
}
.to_string(),
),
}],
],
};
match bot
.send_message(message.chat.id, "Обновление каталога:")
.reply_markup(keyboard)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
async fn update_log_pagination_handler(
cq: CallbackQuery,
bot: AutoSend<Bot>,
update_callback_data: UpdateLogCallbackData,
) -> BotHandlerInternal {
let message = match cq.message {
Some(v) => v,
None => return Ok(()), // TODO: send notification
};
let from = update_callback_data.from.format("%d.%m.%Y");
let to = update_callback_data.to.format("%d.%m.%Y");
let header = format!("Обновление каталога ({from} - {to}):\n\n");
let mut items_page = match get_uploaded_books(
update_callback_data.page,
update_callback_data.from.format("%Y-%m-%d").to_string(),
update_callback_data.to.format("%Y-%m-%d").to_string(),
)
.await
{
Ok(v) => v,
Err(err) => return Err(err),
};
if items_page.total_pages == 0 {
return match bot
.send_message(message.chat.id, "Нет новых книг за этот период.")
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
};
}
if update_callback_data.page > items_page.total_pages {
items_page = match get_uploaded_books(
items_page.total_pages,
update_callback_data.from.format("YYYY-MM-DD").to_string(),
update_callback_data.to.format("YYYY-MM-DD").to_string(),
)
.await
{
Ok(v) => v,
Err(err) => return Err(err),
};
}
let formated_items = items_page.format_items();
let page = update_callback_data.page;
let total_pages = items_page.total_pages;
let footer = format!("\n\nСтраница {page}/{total_pages}");
let message_text = format!("{header}{formated_items}{footer}");
let keyboard = generic_get_pagination_keyboard(1, total_pages, update_callback_data, true);
match bot
.edit_message_text(message.chat.id, message.id, message_text)
.reply_markup(keyboard)
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
pub fn get_update_log_handler() -> crate::bots::BotHandler {
dptree::entry()
.branch(
Update::filter_message().branch(
dptree::entry()
.filter_command::<UpdateLogCommand>()
.endpoint(|message, bot| async move { update_log_command(message, bot).await }),
),
)
.branch(
Update::filter_callback_query().branch(
dptree::entry()
.chain(filter_callback_query::<UpdateLogCallbackData>())
.endpoint(
|cq: CallbackQuery,
bot: AutoSend<Bot>,
update_log_data: UpdateLogCallbackData| async move {
update_log_pagination_handler(cq, bot, update_log_data).await
},
),
),
)
}

View File

@@ -0,0 +1,114 @@
use teloxide::{dptree, prelude::*, types::*};
pub trait CommandParse<T> {
fn parse(s: &str, bot_name: &str) -> Result<T, strum::ParseError>;
}
pub fn filter_command<Output>() -> crate::bots::BotHandler
where
Output: CommandParse<Output> + Send + Sync + 'static,
{
dptree::entry().chain(dptree::filter_map(move |message: Message, me: Me| {
let bot_name = me.user.username.expect("Bots must have a username");
message
.text()
.and_then(|text| Output::parse(text, &bot_name).ok())
}))
}
pub enum PaginationDelta {
OneMinus,
OnePlus,
FiveMinus,
FivePlus,
}
pub trait GetPaginationCallbackData {
fn get_pagination_callback_data(&self, target_page: u32) -> String;
}
pub fn generic_get_pagination_button<T>(
target_page: u32,
delta: PaginationDelta,
callback_data: &T,
) -> InlineKeyboardButton
where
T: GetPaginationCallbackData,
{
let text = match delta {
PaginationDelta::OneMinus => "<",
PaginationDelta::OnePlus => ">",
PaginationDelta::FiveMinus => "< 5 <",
PaginationDelta::FivePlus => "> 5 >",
};
let callback_data = callback_data.get_pagination_callback_data(target_page);
InlineKeyboardButton {
text: text.to_string(),
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(callback_data),
}
}
pub fn generic_get_pagination_keyboard<T>(
page: u32,
total_pages: u32,
search_data: T,
with_five: bool,
) -> InlineKeyboardMarkup
where
T: GetPaginationCallbackData,
{
let buttons: Vec<Vec<InlineKeyboardButton>> = {
let t_page: i64 = page.into();
let mut result: Vec<Vec<InlineKeyboardButton>> = vec![];
let mut one_page_row: Vec<InlineKeyboardButton> = vec![];
if t_page - 1 > 0 {
one_page_row.push(generic_get_pagination_button(
page - 1,
PaginationDelta::OneMinus,
&search_data,
))
}
if t_page + 1 <= total_pages.into() {
one_page_row.push(generic_get_pagination_button(
page + 1,
PaginationDelta::OnePlus,
&search_data,
))
}
if one_page_row.len() != 0 {
result.push(one_page_row);
}
if with_five {
let mut five_page_row: Vec<InlineKeyboardButton> = vec![];
if t_page - 5 > 0 {
five_page_row.push(generic_get_pagination_button(
page - 5,
PaginationDelta::FiveMinus,
&search_data,
))
}
if t_page + 1 <= total_pages.into() {
five_page_row.push(generic_get_pagination_button(
page + 5,
PaginationDelta::FivePlus,
&search_data,
))
}
if five_page_row.len() != 0 {
result.push(five_page_row);
}
}
result
};
InlineKeyboardMarkup {
inline_keyboard: buttons,
}
}

View File

@@ -0,0 +1,112 @@
use crate::{bots::approved_bot::modules::download::DownloadData, config};
use self::types::{CachedMessage, DownloadFile};
pub mod types;
pub async fn get_cached_message(
download_data: &DownloadData,
) -> Result<CachedMessage, Box<dyn std::error::Error + Send + Sync>> {
let DownloadData { format, id } = download_data;
let client = reqwest::Client::new();
let response = client
.get(format!(
"{}/api/v1/{id}/{format}",
&config::CONFIG.cache_server_url
))
.header("Authorization", &config::CONFIG.cache_server_api_key)
.send()
.await;
let response = match response {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
let response = match response.error_for_status() {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
match response.json::<CachedMessage>().await {
Ok(v) => Ok(v),
Err(err) => Err(Box::new(err)),
}
}
pub async fn clear_book_cache(
download_data: &DownloadData,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let DownloadData { format, id } = download_data;
let client = reqwest::Client::new();
let response = client
.delete(format!(
"{}/api/v1/{id}/{format}",
&config::CONFIG.cache_server_url
))
.header("Authorization", &config::CONFIG.cache_server_api_key)
.send()
.await;
let response = match response {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
match response.error_for_status() {
Ok(_) => Ok(()),
Err(err) => return Err(Box::new(err)),
}
}
pub async fn download_file(
download_data: &DownloadData,
) -> Result<DownloadFile, Box<dyn std::error::Error + Send + Sync>> {
let DownloadData { format, id } = download_data;
let client = reqwest::Client::new();
let response = client
.get(format!(
"{}/api/v1/download/{id}/{format}",
&config::CONFIG.cache_server_url
))
.header("Authorization", &config::CONFIG.cache_server_api_key)
.send()
.await;
let response = match response {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
match response.error_for_status() {
Ok(response) => {
let headers = response.headers();
let filename = headers
.get("content-disposition")
.unwrap()
.to_str()
.unwrap()
.replace('"', "")
.split("filename=")
.collect::<Vec<&str>>()
.get(1)
.unwrap()
.to_string();
let caption = std::str::from_utf8(
&base64::decode(headers.get("x-caption-b64").unwrap()).unwrap(),
)
.unwrap()
.to_string();
Ok(DownloadFile {
response,
filename,
caption,
})
}
Err(err) => return Err(Box::new(err)),
}
}

View File

@@ -0,0 +1,13 @@
use serde::Deserialize;
#[derive(Deserialize, Debug, Clone)]
pub struct CachedMessage {
pub message_id: i32,
pub chat_id: i64,
}
pub struct DownloadFile {
pub response: reqwest::Response,
pub filename: String,
pub caption: String,
}

View File

@@ -0,0 +1,299 @@
use super::types::{Author, AuthorBook, Book, SearchBook, Sequence, Translator, TranslatorBook};
pub trait Format {
fn format(&self) -> String;
}
impl Format for Book {
fn format(&self) -> String {
let book_title = {
let Book { title, lang, .. } = self;
format!("📖 {title} | {lang}\n")
};
let pages_count = match self.pages {
Some(1) | None => "".to_string(),
Some(v) => format!("[ {v}с. ]\n\n"),
};
let annotations = match self.annotation_exists {
true => {
let Book { id, .. } = self;
format!("📝 Аннотация: /b_an_{id}\n\n")
}
false => "".to_string(),
};
let authors = match self.authors.len() != 0 {
true => {
let formated_authors = self
.authors
.clone()
.into_iter()
.map(|author| author.format_author())
.collect::<Vec<String>>()
.join("\n");
format!("Авторы:\n{formated_authors}\n\n")
}
false => "".to_string(),
};
let translators = match self.translators.len() != 0 {
true => {
let formated_translators = self
.translators
.clone()
.into_iter()
.map(|translator| translator.format_translator())
.collect::<Vec<String>>()
.join("\n");
format!("Переводчики:\n{formated_translators}\n\n")
}
false => "".to_string(),
};
let sequences = match self.sequences.len() != 0 {
true => {
let formated_sequences: String = self
.sequences
.clone()
.into_iter()
.map(|sequence| sequence.format())
.collect::<Vec<String>>()
.join("\n");
format!("Серии:\n{formated_sequences}\n\n")
}
false => "".to_string(),
};
let genres = match self.genres.len() != 0 {
true => {
let formated_genres: String = self
.genres
.clone()
.into_iter()
.map(|genre| genre.format())
.collect::<Vec<String>>()
.join("\n");
format!("Жанры:\n{formated_genres}\n\n")
}
false => "".to_string(),
};
let links: String = self
.available_types
.clone()
.into_iter()
.map(|a_type| {
let Book { id, .. } = self;
format!("📥 {a_type}: /d_{a_type}_{id}")
})
.collect::<Vec<String>>()
.join("\n");
let download_links = format!("Скачать:\n{links}");
format!("{book_title}{pages_count}{annotations}{authors}{translators}{sequences}{genres}{download_links}")
}
}
impl Format for Author {
fn format(&self) -> String {
let Author {
id,
last_name,
first_name,
middle_name,
..
} = self;
let title = format!("👤 {last_name} {first_name} {middle_name}\n");
let link = format!("/a_{id}\n");
let annotation = match self.annotation_exists {
true => format!("📝 Аннотация: /a_an_{id}"),
false => "".to_string(),
};
format!("{title}{link}{annotation}")
}
}
impl Format for Sequence {
fn format(&self) -> String {
let Sequence { id, name, .. } = self;
let title = format!("📚 {name}\n");
let link = format!("/s_{id}");
format!("{title}{link}")
}
}
impl Format for SearchBook {
fn format(&self) -> String {
let book_title = {
let SearchBook { title, lang, .. } = self;
format!("📖 {title} | {lang}\n")
};
let annotations = match self.annotation_exists {
true => {
let SearchBook { id, .. } = self;
format!("📝 Аннотация: /b_an_{id}\n")
}
false => "".to_string(),
};
let authors = match self.authors.len() != 0 {
true => {
let formated_authors = self
.authors
.clone()
.into_iter()
.map(|author| author.format_author())
.collect::<Vec<String>>()
.join("\n");
format!("Авторы:\n{formated_authors}\n")
}
false => "".to_string(),
};
let translators = match self.translators.len() != 0 {
true => {
let formated_translators = self
.translators
.clone()
.into_iter()
.map(|translator| translator.format_translator())
.collect::<Vec<String>>()
.join("\n");
format!("Переводчики:\n{formated_translators}\n")
}
false => "".to_string(),
};
let links: String = self
.available_types
.clone()
.into_iter()
.map(|a_type| {
let SearchBook { id, .. } = self;
format!("📥 {a_type}: /d_{a_type}_{id}")
})
.collect::<Vec<String>>()
.join("\n");
let download_links = format!("Скачать:\n{links}");
format!("{book_title}{annotations}{authors}{translators}{download_links}")
}
}
impl Format for Translator {
fn format(&self) -> String {
let Translator {
id,
last_name,
first_name,
middle_name,
..
} = self;
let title = format!("👤 {last_name} {first_name} {middle_name}\n");
let link = format!("/t_{id}\n");
let annotation = match self.annotation_exists {
true => format!("📝 Аннотация: /a_an_{id}"),
false => "".to_string(),
};
format!("{title}{link}{annotation}")
}
}
impl Format for AuthorBook {
fn format(&self) -> String {
let book_title = {
let AuthorBook { title, lang, .. } = self;
format!("📖 {title} | {lang}\n")
};
let annotations = match self.annotation_exists {
true => {
let AuthorBook { id, .. } = self;
format!("📝 Аннотация: /b_an_{id}\n")
}
false => "".to_string(),
};
let translators = match self.translators.len() != 0 {
true => {
let formated_translators = self
.translators
.clone()
.into_iter()
.map(|translator| translator.format_translator())
.collect::<Vec<String>>()
.join("\n");
format!("Переводчики:\n{formated_translators}\n")
}
false => "".to_string(),
};
let links: String = self
.available_types
.clone()
.into_iter()
.map(|a_type| {
let AuthorBook { id, .. } = self;
format!("📥 {a_type}: /d_{a_type}_{id}")
})
.collect::<Vec<String>>()
.join("\n");
let download_links = format!("Скачать:\n{links}");
format!("{book_title}{annotations}{translators}{download_links}")
}
}
impl Format for TranslatorBook {
fn format(&self) -> String {
let book_title = {
let TranslatorBook { title, lang, .. } = self;
format!("📖 {title} | {lang}\n")
};
let annotations = match self.annotation_exists {
true => {
let TranslatorBook { id, .. } = self;
format!("📝 Аннотация: /b_an_{id}\n")
}
false => "".to_string(),
};
let authors = match self.authors.len() != 0 {
true => {
let formated_authors = self
.authors
.clone()
.into_iter()
.map(|author| author.format_author())
.collect::<Vec<String>>()
.join("\n");
format!("Авторы:\n{formated_authors}\n")
}
false => "".to_string(),
};
let links: String = self
.available_types
.clone()
.into_iter()
.map(|a_type| {
let TranslatorBook { id, .. } = self;
format!("📥 {a_type}: /d_{a_type}_{id}")
})
.collect::<Vec<String>>()
.join("\n");
let download_links = format!("Скачать:\n{links}");
format!("{book_title}{annotations}{authors}{download_links}")
}
}

View File

@@ -0,0 +1,217 @@
pub mod formaters;
pub mod types;
use serde::de::DeserializeOwned;
use crate::config;
fn get_allowed_langs_params(allowed_langs: Vec<String>) -> Vec<(&'static str, String)> {
allowed_langs
.into_iter()
.map(|lang| ("allowed_langs", lang))
.collect()
}
async fn _make_request<T>(
url: &str,
params: Vec<(&str, String)>,
) -> Result<T, Box<dyn std::error::Error + Send + Sync>>
where
T: DeserializeOwned,
{
let client = reqwest::Client::new();
let response = client
.get(format!("{}{}", &config::CONFIG.book_server_url, url))
.query(&params)
.header("Authorization", &config::CONFIG.book_server_api_key)
.send()
.await;
let response = match response {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
let response = match response.error_for_status() {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
match response.json::<T>().await {
Ok(v) => Ok(v),
Err(err) => Err(Box::new(err)),
}
}
pub async fn get_random_book_by_genre(
allowed_langs: Vec<String>,
genre: Option<u32>,
) -> Result<types::Book, Box<dyn std::error::Error + Send + Sync>> {
let mut params: Vec<(&str, String)> = get_allowed_langs_params(allowed_langs);
match genre {
Some(v) => params.push(("genre", v.to_string())),
None => (),
}
_make_request("/api/v1/books/random", params).await
}
pub async fn get_random_book(
allowed_langs: Vec<String>,
) -> Result<types::Book, Box<dyn std::error::Error + Send + Sync>> {
get_random_book_by_genre(allowed_langs, None).await
}
pub async fn get_random_author(
allowed_langs: Vec<String>,
) -> Result<types::Author, Box<dyn std::error::Error + Send + Sync>> {
let params: Vec<(&str, String)> = get_allowed_langs_params(allowed_langs);
_make_request("/api/v1/authors/random", params).await
}
pub async fn get_random_sequence(
allowed_langs: Vec<String>,
) -> Result<types::Sequence, Box<dyn std::error::Error + Send + Sync>> {
let params = get_allowed_langs_params(allowed_langs);
_make_request("/api/v1/sequences/random", params).await
}
pub async fn get_genre_metas() -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
_make_request("/api/v1/genres/metas", vec![]).await
}
pub async fn get_genres(
meta: String,
) -> Result<types::Page<types::Genre>, Box<dyn std::error::Error + Send + Sync>> {
let params = vec![("meta", meta)];
_make_request("/api/v1/genres/", params).await
}
const PAGE_SIZE: &str = "7";
pub async fn search_book(
query: String,
page: u32,
allowed_langs: Vec<String>,
) -> Result<types::Page<types::SearchBook>, Box<dyn std::error::Error + Send + Sync>> {
let mut params = get_allowed_langs_params(allowed_langs);
params.push(("page", page.to_string()));
params.push(("size", PAGE_SIZE.to_string()));
_make_request(format!("/api/v1/books/search/{query}").as_str(), params).await
}
pub async fn search_author(
query: String,
page: u32,
allowed_langs: Vec<String>,
) -> Result<types::Page<types::Author>, Box<dyn std::error::Error + Send + Sync>> {
let mut params = get_allowed_langs_params(allowed_langs);
params.push(("page", page.to_string()));
params.push(("size", PAGE_SIZE.to_string()));
_make_request(format!("/api/v1/authors/search/{query}").as_str(), params).await
}
pub async fn search_sequence(
query: String,
page: u32,
allowed_langs: Vec<String>,
) -> Result<types::Page<types::Sequence>, Box<dyn std::error::Error + Send + Sync>> {
let mut params = get_allowed_langs_params(allowed_langs);
params.push(("page", page.to_string()));
params.push(("size", PAGE_SIZE.to_string()));
_make_request(format!("/api/v1/sequences/search/{query}").as_str(), params).await
}
pub async fn search_translator(
query: String,
page: u32,
allowed_langs: Vec<String>,
) -> Result<types::Page<types::Translator>, Box<dyn std::error::Error + Send + Sync>> {
let mut params = get_allowed_langs_params(allowed_langs);
params.push(("page", page.to_string()));
params.push(("size", PAGE_SIZE.to_string()));
_make_request(
format!("/api/v1/translators/search/{query}").as_str(),
params,
)
.await
}
pub async fn get_book_annotation(
id: u32,
) -> Result<types::BookAnnotation, Box<dyn std::error::Error + Send + Sync>> {
_make_request(format!("/api/v1/books/{id}/annotation").as_str(), vec![]).await
}
pub async fn get_author_annotation(
id: u32,
) -> Result<types::AuthorAnnotation, Box<dyn std::error::Error + Send + Sync>> {
_make_request(format!("/api/v1/authors/{id}/annotation").as_str(), vec![]).await
}
pub async fn get_author_books(
id: u32,
page: u32,
allowed_langs: Vec<String>,
) -> Result<types::Page<types::AuthorBook>, Box<dyn std::error::Error + Send + Sync>> {
let mut params = get_allowed_langs_params(allowed_langs);
params.push(("page", page.to_string()));
params.push(("size", PAGE_SIZE.to_string()));
_make_request(format!("/api/v1/authors/{id}/books").as_str(), params).await
}
pub async fn get_translator_books(
id: u32,
page: u32,
allowed_langs: Vec<String>,
) -> Result<types::Page<types::TranslatorBook>, Box<dyn std::error::Error + Send + Sync>> {
let mut params = get_allowed_langs_params(allowed_langs);
params.push(("page", page.to_string()));
params.push(("size", PAGE_SIZE.to_string()));
_make_request(format!("/api/v1/translators/{id}/books").as_str(), params).await
}
pub async fn get_sequence_books(
id: u32,
page: u32,
allowed_langs: Vec<String>,
) -> Result<types::Page<types::SearchBook>, Box<dyn std::error::Error + Send + Sync>> {
let mut params = get_allowed_langs_params(allowed_langs);
params.push(("page", page.to_string()));
params.push(("size", PAGE_SIZE.to_string()));
_make_request(format!("/api/v1/sequences/{id}/books").as_str(), params).await
}
pub async fn get_uploaded_books(
page: u32,
uploaded_gte: String,
uploaded_lte: String,
) -> Result<types::Page<types::SearchBook>, Box<dyn std::error::Error + Send + Sync>> {
let params = vec![
("page", page.to_string()),
("size", PAGE_SIZE.to_string()),
("uploaded_gte", uploaded_gte),
("uploaded_lte", uploaded_lte),
("is_deleted", "false".to_string()),
];
_make_request("/api/v1/books/", params).await
}

View File

@@ -0,0 +1,182 @@
use serde::Deserialize;
use super::formaters::Format;
#[derive(Deserialize, Debug, Clone)]
pub struct BookAuthor {
id: u32,
first_name: String,
last_name: String,
middle_name: String,
}
impl BookAuthor {
pub fn format_author(&self) -> String {
let BookAuthor {
id,
last_name,
first_name,
middle_name,
} = self;
format!("👤 {last_name} {first_name} {middle_name} /a_{id}")
}
pub fn format_translator(&self) -> String {
let BookAuthor {
id,
first_name,
last_name,
middle_name,
} = self;
format!("👤 {last_name} {first_name} {middle_name} /t_{id}")
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct BookGenre {
pub id: u32,
pub description: String,
}
impl BookGenre {
pub fn format(&self) -> String {
format!("🗂 {}", self.description)
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct Source {
// id: u32,
// name: String
}
#[derive(Deserialize, Debug, Clone)]
pub struct Book {
pub id: u32,
pub title: String,
pub lang: String,
// file_type: String,
pub available_types: Vec<String>,
// uploaded: String,
pub annotation_exists: bool,
pub authors: Vec<BookAuthor>,
pub translators: Vec<BookAuthor>,
pub sequences: Vec<Sequence>,
pub genres: Vec<BookGenre>,
// source: Source,
// remote_id: u32,
// id_deleted: bool,
pub pages: Option<u32>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Author {
pub id: u32,
pub last_name: String,
pub first_name: String,
pub middle_name: String,
pub annotation_exists: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Translator {
pub id: u32,
pub last_name: String,
pub first_name: String,
pub middle_name: String,
pub annotation_exists: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Sequence {
pub id: u32,
pub name: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Genre {
pub id: u32,
pub source: Source,
pub remote_id: u32,
pub code: String,
pub description: String,
pub meta: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Page<T> {
pub items: Vec<T>,
pub total: u32,
pub page: u32,
pub size: u32,
pub total_pages: u32,
}
impl<T> Page<T>
where
T: Format + Clone,
{
pub fn format_items(&self) -> String {
self.items
.clone()
.into_iter()
.map(|book| book.format())
.collect::<Vec<String>>()
.join("\n\n\n")
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct SearchBook {
pub id: u32,
pub title: String,
pub lang: String,
// file_type: String,
pub available_types: Vec<String>,
// uploaded: String,
pub annotation_exists: bool,
pub authors: Vec<BookAuthor>,
pub translators: Vec<BookAuthor>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct BookAnnotation {
pub id: u32,
pub title: String,
pub text: String,
pub file: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct AuthorAnnotation {
pub id: u32,
pub title: String,
pub text: String,
pub file: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct AuthorBook {
pub id: u32,
pub title: String,
pub lang: String,
// file_type: String,
pub available_types: Vec<String>,
// uploaded: String,
pub annotation_exists: bool,
pub translators: Vec<BookAuthor>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct TranslatorBook {
pub id: u32,
pub title: String,
pub lang: String,
// file_type: String,
pub available_types: Vec<String>,
// uploaded: String,
pub annotation_exists: bool,
pub authors: Vec<BookAuthor>,
}

View File

@@ -0,0 +1,3 @@
pub mod book_cache;
pub mod book_library;
pub mod user_settings;

View File

@@ -0,0 +1,126 @@
use serde::Deserialize;
use serde_json::json;
use teloxide::types::UserId;
use crate::config;
#[derive(Deserialize, Debug, Clone)]
pub struct Lang {
// pub id: u32,
pub label: String,
pub code: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct UserSettings {
pub user_id: u64,
pub last_name: String,
pub first_name: String,
pub username: String,
pub source: String,
pub allowed_langs: Vec<Lang>,
}
pub async fn get_user_settings(
user_id: UserId,
) -> Result<UserSettings, Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
let response = client
.get(format!(
"{}/users/{}",
&config::CONFIG.user_settings_url,
user_id
))
.header("Authorization", &config::CONFIG.user_settings_api_key)
.send()
.await;
let response = match response {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
let response = match response.error_for_status() {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
match response.json::<UserSettings>().await {
Ok(v) => Ok(v),
Err(err) => Err(Box::new(err)),
}
}
pub async fn get_user_or_default_lang_codes(user_id: UserId) -> Vec<String> {
let default_lang_codes = vec![String::from("ru"), String::from("be"), String::from("uk")];
match get_user_settings(user_id).await {
Ok(v) => v.allowed_langs.into_iter().map(|lang| lang.code).collect(),
Err(_) => default_lang_codes,
}
}
pub async fn create_or_update_user_settings(
user_id: UserId,
last_name: String,
first_name: String,
username: String,
source: String,
allowed_langs: Vec<String>,
) -> Result<UserSettings, Box<dyn std::error::Error + Send + Sync>> {
let body = json!({
"user_id": user_id,
"last_name": last_name,
"first_name": first_name,
"username": username,
"source": source,
"allowed_langs": allowed_langs
});
let client = reqwest::Client::new();
let response = client
.post(format!("{}/users/", &config::CONFIG.user_settings_url))
.body(body.to_string())
.header("Authorization", &config::CONFIG.user_settings_api_key)
.send()
.await;
let response = match response {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
let response = match response.error_for_status() {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
match response.json::<UserSettings>().await {
Ok(v) => Ok(v),
Err(err) => Err(Box::new(err)),
}
}
pub async fn get_langs() -> Result<Vec<Lang>, Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
let response = client
.get(format!("{}/languages/", &config::CONFIG.user_settings_url))
.header("Authorization", &config::CONFIG.user_settings_api_key)
.send()
.await;
let response = match response {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
let response = match response.error_for_status() {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
match response.json::<Vec<Lang>>().await {
Ok(v) => Ok(v),
Err(err) => Err(Box::new(err)),
}
}

View File

@@ -0,0 +1,10 @@
use teloxide::{dptree, types::CallbackQuery};
pub fn filter_callback_query<T>() -> crate::bots::BotHandler
where
T: std::str::FromStr + Send + Sync + 'static,
{
dptree::entry().chain(dptree::filter_map(move |cq: CallbackQuery| {
cq.data.and_then(|data| T::from_str(data.as_str()).ok())
}))
}

View File

@@ -1,59 +0,0 @@
import { Context } from "telegraf";
import { AuthorAnnotation, BookAnnotation } from "./services/book_library";
import { isNormalText } from "./utils";
import { getTextPaginationData } from './keyboard';
import Sentry from '@/sentry';
import { downloadImage } from "./services/downloader";
export function getAnnotationHandler<T extends BookAnnotation | AuthorAnnotation>(
annotationGetter: (id: number) => Promise<T>,
callbackData: string
): (ctx: Context) => Promise<void> {
return async (ctx: Context) => {
if (!ctx.message || !('text' in ctx.message)) {
return;
}
const objId = ctx.message.text.split("@")[0].split('_')[2];
const annotation = await annotationGetter(parseInt(objId));
if (!annotation.file && !isNormalText(annotation.text)) {
await ctx.reply("Аннотация недоступна :(");
return;
}
if (annotation.file) {
const imageData = await downloadImage(annotation.file);
if (imageData !== null) {
try {
await ctx.telegram.sendPhoto(ctx.message.chat.id, { source: imageData });
} catch (e) {
Sentry.captureException(e);
}
}
}
if (!isNormalText(annotation.text)) return;
const data = getTextPaginationData(`${callbackData}${objId}`, annotation.text, 0);
try {
await ctx.reply(data.current, {
parse_mode: "HTML",
reply_markup: data.keyboard.reply_markup,
});
} catch (e) {
Sentry.captureException(e, {
extra: {
message: data.current,
annotation,
objId
}
})
}
}
}

View File

@@ -1,31 +0,0 @@
export const SETTINGS_LANGUAGES = 'settings.languages';
export const SEARCH_BOOK_PREFIX = 'sb_';
export const SEARCH_AUTHORS_PREFIX = 'sa_';
export const SEARCH_SERIES_PREFIX = 'ss_';
export const SEARCH_TRANSLATORS_PREFIX = 'st_';
export const AUTHOR_BOOKS_PREFIX = 'ba_';
export const TRANSLATOR_BOOKS_PREFIX = 'bt_';
export const SEQUENCE_BOOKS_PREFIX = 'bs_';
export const BOOK_ANNOTATION_PREFIX = 'a_an_';
export const AUTHOR_ANNOTATION_PREFIX = 'b_an_';
export const BOOK_INFO_PREFIX = 'b_i_';
export const RANDOM_BOOK = 'random_book';
export const RANDOM_BOOK_BY_GENRE_REQUEST = 'random_book_by_genre_request';
export const RANDOM_BOOK_BY_GENRE = 'random_book_by_genre_';
export const RANDOM_AUTHOR = 'random_author';
export const RANDOM_SEQUENCE = 'random_sequence';
export const GENRES_PREFIX = 'genres_';
export const LANG_SETTINGS = 'lang_settings';
export const ENABLE_LANG_PREFIX = 'lang_on_';
export const DISABLE_LANG_PREFIX = 'lang_off_';
export const UPDATE_LOG_PREFIX = 'update_log_';
export const RATE_PREFIX = 'r_';

View File

@@ -1,14 +0,0 @@
import { TelegramError } from 'telegraf';
export function isNotModifiedMessage(e: any): boolean {
if (!(e instanceof TelegramError)) return false;
return e.description === 'Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message';
}
export function isReplyMessageNotFound(e: any): boolean {
if (!(e instanceof TelegramError)) return false;
return e.description === 'Bad Request: replied message not found';
}

View File

@@ -1,164 +0,0 @@
import { AuthorBook, TranslatorBook, Book, Author, Sequence, BookAuthor, DetailBook, Genre } from './services/book_library';
type AllBookTypes = Book | AuthorBook | TranslatorBook;
function isAuthorBook(item: AllBookTypes): item is AuthorBook {
return 'translator' in item;
}
function isTranslatorBook(item: AllBookTypes): item is TranslatorBook {
return 'authors' in item;
}
export function formatBook(book: AllBookTypes, short: boolean = false): string {
let response: string[] = [];
response.push(`📖 ${book.title} | ${book.lang}`);
response.push(`Информация: /b_i_${book.id}`);
const pushAuthorOrTranslator = (author: BookAuthor) => response.push(
`͏👤 ${author.last_name} ${author.first_name} ${author.middle_name}`
);
if (isTranslatorBook(book) && book.authors.length > 0) {
response.push('Авторы:')
if (short && book.authors.length >= 5) {
book.authors.slice(0, 5).forEach(pushAuthorOrTranslator);
response.push(" и другие.");
} else {
book.authors.forEach(pushAuthorOrTranslator);
}
}
if (isAuthorBook(book) && book.translators.length > 0) {
response.push('Переводчики:');
if (short && book.translators.length >= 5) {
book.translators.slice(0, 5).forEach(pushAuthorOrTranslator);
response.push(" и другие.")
} else {
book.translators.forEach(pushAuthorOrTranslator);
}
}
book.available_types.forEach(a_type => response.push(`📥 ${a_type}: /d_${a_type}_${book.id}`));
return response.join('\n');
}
export function formatDetailBook(book: DetailBook): string {
let response: string[] = [];
const addEmptyLine = () => response.push("");
response.push(`📖 ${book.title} | ${book.lang}`);
if (book.pages !== null)
response.push(`[ ${book.pages} с. ]`);
addEmptyLine();
if (book.annotation_exists) {
response.push(`📝 Аннотация: /b_an_${book.id}`)
addEmptyLine();
}
if (book.authors.length > 0) {
response.push('Авторы:')
const pushAuthor = (author: BookAuthor) => response.push(
`͏👤 ${author.last_name} ${author.first_name} ${author.middle_name} /a_${author.id}`
);
book.authors.forEach(pushAuthor);
addEmptyLine();
}
if (book.translators.length > 0) {
response.push('Переводчики:');
const pushTranslator = (author: BookAuthor) => response.push(
`͏👤 ${author.last_name} ${author.first_name} ${author.middle_name} /t_${author.id}`
);
book.translators.forEach(pushTranslator);
addEmptyLine();
}
if (book.sequences.length > 0) {
response.push('Серии:');
const pushSequence = (sequence: Sequence) => response.push(
`📚 ${sequence.name} /s_${sequence.id}`
);
book.sequences.forEach(pushSequence);
addEmptyLine();
}
if (book.genres.length > 0) {
response.push('Жанры:');
const pushGenre = (genre: Genre) => response.push(
`🗂 ${genre.description}`
);
book.genres.forEach(pushGenre);
addEmptyLine();
}
response.push("Скачать: ")
book.available_types.forEach(a_type => response.push(`📥 ${a_type}: /d_${a_type}_${book.id}`));
return response.join('\n');
}
export function formatDetailBookWithRating(book: DetailBook): string {
return formatDetailBook(book) + '\n\n\nОценка:';
}
export function formatBookShort(book: AllBookTypes): string {
return formatBook(book, true);
}
export function formatAuthor(author: Author): string {
let response = [];
response.push(`👤 ${author.last_name} ${author.first_name} ${author.middle_name}`);
response.push(`/a_${author.id}`);
if (author.annotation_exists) {
response.push(`📝 Аннотация: /a_an_${author.id}`);
}
return response.join('\n');
}
export function formatTranslator(author: Author): string {
let response = [];
response.push(`👤 ${author.last_name} ${author.first_name} ${author.middle_name}`);
response.push(`/t_${author.id}`);
if (author.annotation_exists) {
response.push(`📝 Аннотация: /a_an_${author.id}`);
}
return response.join('\n');
}
export function formatSequence(sequence: Sequence): string {
let response = [];
response.push(`📚 ${sequence.name}`);
response.push(`/s_${sequence.id}`);
return response.join('\n');
}

View File

@@ -1,72 +0,0 @@
import { Context } from 'telegraf';
import { clearBookCache, getBookCache, downloadFromCache } from '../services/book_cache';
import { getBookCacheBuffer } from '../services/book_cache_buffer';
import { BotState, Cache } from '@/bots/manager/types';
async function _sendFile(ctx: Context, state: BotState, chatId: number, id: number, format: string) {
const sendWithDownloadFromChannel = async () => {
const data = await downloadFromCache(id, format);
if (data === null) {
await ctx.reply("Ошибка скачивания книги. Попробуйте позже");
return
}
await ctx.telegram.sendDocument(chatId, { source: data.source, filename: data.filename }, { caption: data.caption });
}
const getCachedMessage = async () => {
if (state.cache === Cache.ORIGINAL) {
return getBookCache(id, format);
}
return getBookCacheBuffer(id, format);
};
const sendCached = async () => {
const cache = await getCachedMessage();
await ctx.telegram.copyMessage(chatId, cache.chat_id, cache.message_id, {
allow_sending_without_reply: true,
});
};
if (state.cache === Cache.NO_CACHE) {
return sendWithDownloadFromChannel();
}
try {
return await sendCached();
} catch (e) {
await clearBookCache(id, format);
return sendCached();
}
}
export async function sendFile(ctx: Context, state: BotState) {
if (!ctx.message || !('text' in ctx.message)) {
return;
}
const [_, format, id] = ctx.message.text.split('@')[0].split('_');
const chatId = ctx.message.chat.id;
const sendSendingAction = async () => {
await ctx.telegram.sendChatAction(chatId, "upload_document");
}
const action = setInterval(() => sendSendingAction(), 5000);
try {
sendSendingAction();
return await _sendFile(ctx, state, chatId, parseInt(id), format);
} catch (e) {
await ctx.reply("Ошибка! Попробуйте позже :(", {
reply_to_message_id: ctx.message.message_id,
});
throw e;
} finally {
clearInterval(action);
}
}

View File

@@ -1,22 +0,0 @@
import { Telegraf, TelegramError } from "telegraf";
export async function setCommands(bot: Telegraf) {
async function setMyCommands() {
await bot.telegram.setMyCommands([
{command: "random", description: "Попытать удачу"},
{command: "update_log", description: "Обновления каталога"},
{command: "settings", description: "Настройки"},
{command: "support", description: "Поддержать разработчика"},
// {command: "help", description: "Помощь"},
]);
}
try {
await setMyCommands();
} catch (e: unknown) {
if (e instanceof TelegramError && e.response.error_code === 429) {
setTimeout(() => setMyCommands(), 1000 * (e.response.parameters?.retry_after || 5));
}
}
}

View File

@@ -1,476 +0,0 @@
import { Context, Telegraf, Markup, TelegramError } from 'telegraf';
import moment from 'moment';
import debug from 'debug';
import { BotState } from '@/bots/manager/types';
import env from '@/config';
import * as Messages from "./messages";
import * as CallbackData from "./callback_data";
import * as BookLibrary from "./services/book_library";
import * as Rating from "./services/book_ratings";
import UsersCounter from '@/analytics/users_counter';
import Limiter from '@/bots/limiter';
import { createOrUpdateUserSettings, getUserOrDefaultLangCodes } from './services/user_settings';
import { formatBook, formatBookShort, formatAuthor, formatSequence, formatTranslator, formatDetailBook, formatDetailBookWithRating } from './format';
import { getCallbackArgs, getPaginatedMessage, getPrefixWithQueryCreator, getSearchArgs, registerLanguageSettingsCallback, registerPaginationCommand, registerRandomItemCallback } from './utils';
import { getRandomKeyboard, getRatingKeyboard, getTextPaginationData, getUpdateLogKeyboard, getUserAllowedLangsKeyboard } from './keyboard';
import { sendFile } from './hooks/downloading';
import { setCommands } from './hooks/setCommands';
import { isNotModifiedMessage, isReplyMessageNotFound } from './errors_utils';
import { getAnnotationHandler } from './annotations';
import Sentry from '@/sentry';
const log = debug("approvedBot");
export async function createApprovedBot(token: string, state: BotState): Promise<Telegraf> {
const bot = new Telegraf(token, {
telegram: {
apiRoot: env.TELEGRAM_BOT_API_ROOT,
},
handlerTimeout: 300_000,
});
const me = await bot.telegram.getMe();
setCommands(bot);
bot.use(async (ctx: Context, next) => {
if (ctx.from) {
const user = ctx.from;
createOrUpdateUserSettings({
user_id: user.id,
last_name: user.last_name || '',
first_name: user.first_name,
username: user.username || '',
source: me.username,
}).catch((e) => {
Sentry.captureException(e);
});
UsersCounter.take(user.id, me.username);
}
await next();
});
bot.use(async (ctx: Context, next) => {
if (await Limiter.isLimited(ctx.update.update_id)) return;
await next();
});
bot.command(["start", `start@${me.username}`], async (ctx: Context) => {
if (!ctx.message) {
return;
}
const name = ctx.message.from.first_name || ctx.message.from.username || 'пользователь';
try {
await ctx.telegram.sendMessage(ctx.message.chat.id,
Messages.START_MESSAGE.replace('{name}', name), {
reply_to_message_id: ctx.message.message_id,
}
);
} catch (e) {
if (e instanceof TelegramError) {
if (e.code !== 403) throw e;
}
}
});
bot.command(["help", `help@${me.username}`], async (ctx: Context) => ctx.reply(Messages.HELP_MESSAGE));
bot.command(["support", `support@{me.username}`], async (ctx: Context) => {
const keyboard = Markup.inlineKeyboard([
Markup.button.url("☕️ Поддержать разработчика", "https://kurbezz.github.io/Kurbezz/")
]);
ctx.reply(Messages.SUPPORT_MESSAGE, {parse_mode: 'Markdown', reply_markup: keyboard.reply_markup});
});
registerPaginationCommand(
bot, CallbackData.SEARCH_BOOK_PREFIX, getSearchArgs, null, BookLibrary.searchByBookName, formatBookShort, undefined, Messages.BOOKS_NOT_FOUND
);
registerPaginationCommand(
bot, CallbackData.SEARCH_TRANSLATORS_PREFIX, getSearchArgs, null, BookLibrary.searchTranslators, formatTranslator,
undefined, Messages.TRANSLATORS_NOT_FOUND
);
registerPaginationCommand(
bot, CallbackData.SEARCH_AUTHORS_PREFIX, getSearchArgs, null, BookLibrary.searchAuthors, formatAuthor, undefined, Messages.AUTHORS_NOT_FOUND
);
registerPaginationCommand(
bot, CallbackData.SEARCH_SERIES_PREFIX, getSearchArgs, null, BookLibrary.searchSequences, formatSequence, undefined, Messages.SEQUENCES_NOT_FOUND
);
registerPaginationCommand(
bot, CallbackData.AUTHOR_BOOKS_PREFIX, getCallbackArgs, getPrefixWithQueryCreator(CallbackData.AUTHOR_BOOKS_PREFIX),
BookLibrary.getAuthorBooks, formatBookShort, undefined, Messages.BOOKS_NOT_FOUND,
);
registerPaginationCommand(
bot, CallbackData.TRANSLATOR_BOOKS_PREFIX, getCallbackArgs, getPrefixWithQueryCreator(CallbackData.TRANSLATOR_BOOKS_PREFIX),
BookLibrary.getTranslatorBooks, formatBookShort, undefined, Messages.BOOKS_NOT_FOUND,
);
registerPaginationCommand(
bot, CallbackData.SEQUENCE_BOOKS_PREFIX, getCallbackArgs, getPrefixWithQueryCreator(CallbackData.SEQUENCE_BOOKS_PREFIX),
BookLibrary.getSequenceBooks, formatBookShort, undefined, Messages.BOOKS_NOT_FOUND,
);
bot.command(["random", `random@${me.username}`], async (ctx: Context) => {
ctx.reply("Что хотим получить?", {
reply_markup: getRandomKeyboard().reply_markup,
})
});
registerRandomItemCallback(bot, CallbackData.RANDOM_BOOK, BookLibrary.getRandomBook, formatDetailBook);
registerRandomItemCallback(bot, CallbackData.RANDOM_AUTHOR, BookLibrary.getRandomAuthor, formatAuthor);
registerRandomItemCallback(bot, CallbackData.RANDOM_SEQUENCE, BookLibrary.getRandomSequence, formatSequence);
bot.action(CallbackData.RANDOM_BOOK_BY_GENRE_REQUEST, async (ctx: Context) => {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) return;
const metaGenres = await BookLibrary.getGenreMetas();
const keyboard = Markup.inlineKeyboard(
metaGenres.map((meta, index) => {
return [Markup.button.callback(meta, `${CallbackData.GENRES_PREFIX}${index}`)];
})
);
await ctx.editMessageReplyMarkup(keyboard.reply_markup);
});
bot.action(new RegExp(CallbackData.GENRES_PREFIX), async (ctx: Context) => {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) return;
const queryData = ctx.callbackQuery.data.split("_");
const metaIndex = parseInt(queryData[1]);
const metaGenres = await BookLibrary.getGenreMetas();
const meta = metaGenres[metaIndex];
const genres = await BookLibrary.getGenres(meta);
const buttons = genres.items.map((genre) => {
return [Markup.button.callback(genre.description, `${CallbackData.RANDOM_BOOK_BY_GENRE}${genre.id}`)]
});
buttons.push(
[Markup.button.callback("< Назад >", CallbackData.RANDOM_BOOK_BY_GENRE_REQUEST)]
);
const keyboard = Markup.inlineKeyboard(buttons);
await ctx.editMessageReplyMarkup(keyboard.reply_markup);
});
bot.action(new RegExp(CallbackData.RANDOM_BOOK_BY_GENRE), async (ctx: Context) => {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) return;
const allowedLangs = await getUserOrDefaultLangCodes(ctx.callbackQuery.from.id);
const queryData = ctx.callbackQuery.data.split("_");
const genreId = parseInt(queryData[4]);
const item = await BookLibrary.getRandomBook(allowedLangs, genreId);
const keyboard = Markup.inlineKeyboard([
[Markup.button.callback("Повторить?", ctx.callbackQuery.data)]
]);
try {
await ctx.editMessageReplyMarkup(Markup.inlineKeyboard([]).reply_markup);
} catch (e) {}
ctx.reply(formatDetailBook(item), {
reply_markup: keyboard.reply_markup,
});
});
bot.command(["update_log", `update_log@${me.username}`], async (ctx: Context) => {
ctx.reply("Обновление каталога: ", {
reply_markup: getUpdateLogKeyboard().reply_markup,
});
});
bot.action(new RegExp(CallbackData.UPDATE_LOG_PREFIX), async (ctx: Context) => {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) return;
const allowedLangs = await getUserOrDefaultLangCodes(ctx.callbackQuery.from.id);
const data = ctx.callbackQuery.data.split("_");
const page = parseInt(data[4]);
const arg = `${data[2]}_${data[3]}`;
const header = `Обновление каталога (${moment(data[2]).format("DD.MM.YYYY")} - ${moment(data[3]).format("DD.MM.YYYY")}):\n\n`;
const noItemsMessage = 'Нет новых книг за этот период.';
const pMessage = await getPaginatedMessage(
`${CallbackData.UPDATE_LOG_PREFIX}${arg}_`, arg, page, allowedLangs, BookLibrary.getBooks, formatBook, header, noItemsMessage,
);
try {
await ctx.editMessageText(pMessage.message, {
reply_markup: pMessage.keyboard?.reply_markup
});
} catch (e) {
if (!isNotModifiedMessage(e)) {
Sentry.captureException(e);
}
}
});
bot.command(["settings", `settings@${me.username}`], async (ctx: Context) => {
const keyboard = Markup.inlineKeyboard([
[Markup.button.callback("Языки", CallbackData.LANG_SETTINGS)]
]);
ctx.reply("Настройки:", {
reply_markup: keyboard.reply_markup
});
});
bot.action(CallbackData.LANG_SETTINGS, async (ctx: Context) => {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) return;
const keyboard = await getUserAllowedLangsKeyboard(ctx.callbackQuery.from.id);
try {
await ctx.editMessageText("Настройки языков:", {
reply_markup: keyboard.reply_markup,
});
} catch (e) {
if (!isNotModifiedMessage(e)) {
Sentry.captureException(e);
}
}
});
registerLanguageSettingsCallback(bot, 'on', CallbackData.ENABLE_LANG_PREFIX);
registerLanguageSettingsCallback(bot, 'off', CallbackData.DISABLE_LANG_PREFIX);
bot.hears(new RegExp(`^/d_[a-zA-Z0-9]+_[\\d]+(@${me.username})*$`), async (ctx) => {
try {
await sendFile(ctx, state)
} catch (e) {
Sentry.captureException(e, {
extra: {
action: "sendFile",
message: ctx.message.text,
}
})
}
});
bot.hears(
new RegExp(`^/b_an_[\\d]+(@${me.username})*$`),
getAnnotationHandler(BookLibrary.getBookAnnotation, CallbackData.BOOK_ANNOTATION_PREFIX)
);
bot.action(new RegExp(CallbackData.BOOK_ANNOTATION_PREFIX), async (ctx: Context) => {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) return;
const queryData = ctx.callbackQuery.data.split("_");
const bookId = queryData[2];
const page = queryData[3];
const annotation = await BookLibrary.getBookAnnotation(parseInt(bookId));
const data = getTextPaginationData(`${CallbackData.BOOK_ANNOTATION_PREFIX}${bookId}`, annotation.text, parseInt(page));
try {
await ctx.editMessageText(
data.current, {
parse_mode: "HTML",
reply_markup: data.keyboard.reply_markup,
}
);
} catch (e) {
if (!isNotModifiedMessage(e)) {
Sentry.captureException(e, {
extra: {
message: data.current,
bookId,
page,
}
});
}
}
});
bot.hears(
new RegExp(`^/a_an_[\\d]+(@${me.username})*$`),
getAnnotationHandler(BookLibrary.getAuthorAnnotation, CallbackData.AUTHOR_ANNOTATION_PREFIX)
);
bot.action(new RegExp(CallbackData.AUTHOR_ANNOTATION_PREFIX), async (ctx: Context) => {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) return;
const queryData = ctx.callbackQuery.data.split("_");
const authorId = queryData[2];
const page = queryData[3];
const annotation = await BookLibrary.getAuthorAnnotation(parseInt(authorId));
const data = getTextPaginationData(`${CallbackData.AUTHOR_ANNOTATION_PREFIX}${authorId}`, annotation.text, parseInt(page));
try {
await ctx.editMessageText(
data.current, {
parse_mode: "HTML",
reply_markup: data.keyboard.reply_markup,
}
);
} catch (e) {
if (!isNotModifiedMessage(e)) {
Sentry.captureException(e, {
extra: {
message: data.current,
authorId,
page,
}
});
}
}
});
bot.hears(new RegExp(`^/a_[\\d]+(@${me.username})*$`), async (ctx: Context) => {
if (!ctx.message || !('text' in ctx.message)) {
return;
}
const authorId = ctx.message.text.split('@')[0].split('_')[1];
const allowedLangs = await getUserOrDefaultLangCodes(ctx.message.from.id);
const pMessage = await getPaginatedMessage(
`${CallbackData.AUTHOR_BOOKS_PREFIX}${authorId}_`, parseInt(authorId), 1,
allowedLangs, BookLibrary.getAuthorBooks, formatBook, undefined, Messages.BOOKS_NOT_FOUND
);
await ctx.reply(pMessage.message, {
reply_markup: pMessage.keyboard?.reply_markup
});
});
bot.hears(new RegExp(`^/t_[\\d]+(@${me.username})*$`), async (ctx: Context) => {
if (!ctx.message || !('text' in ctx.message)) {
return;
}
const translatorId = ctx.message.text.split("@")[0].split('_')[1];
const allowedLangs = await getUserOrDefaultLangCodes(ctx.message.from.id);
const pMessage = await getPaginatedMessage(
`${CallbackData.TRANSLATOR_BOOKS_PREFIX}${translatorId}_`, parseInt(translatorId), 1,
allowedLangs, BookLibrary.getTranslatorBooks, formatBook, undefined, Messages.BOOKS_NOT_FOUND
);
await ctx.reply(pMessage.message, {
reply_markup: pMessage.keyboard?.reply_markup
});
});
bot.hears(new RegExp(`^/s_[\\d]+(@${me.username})*$`), async (ctx: Context) => {
if (!ctx.message || !('text' in ctx.message)) {
return;
}
const sequenceId = ctx.message.text.split("@")[0].split('_')[1];
const allowedLangs = await getUserOrDefaultLangCodes(ctx.message.from.id);
const pMessage = await getPaginatedMessage(
`${CallbackData.SEQUENCE_BOOKS_PREFIX}${sequenceId}_`, parseInt(sequenceId), 1, allowedLangs,
BookLibrary.getSequenceBooks, formatBook, undefined, Messages.BOOKS_NOT_FOUND,
);
await ctx.reply(pMessage.message, {
reply_markup: pMessage.keyboard?.reply_markup
});
});
bot.hears(new RegExp(`^/b_i_[\\d]+(@${me.username})*$`), async (ctx: Context) => {
if (!ctx.message || !('text' in ctx.message)) {
return;
}
const bookIdString = ctx.message.text.split("@")[0].split('_')[2];
const bookId = parseInt(bookIdString);
const book = await BookLibrary.getBookById(bookId);
const keyboard = await getRatingKeyboard(ctx.message.from.id, bookId, null);
await ctx.reply(formatDetailBookWithRating(book), {
reply_to_message_id: ctx.message.message_id,
reply_markup: keyboard.reply_markup,
});
});
bot.action(new RegExp(CallbackData.RATE_PREFIX), async (ctx: Context) => {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) return;
const queryData = ctx.callbackQuery.data.split("_");
const userId = parseInt(queryData[1]);
const bookId = parseInt(queryData[2]);
const rate = parseInt(queryData[3]);
const rating = await Rating.set(userId, bookId, rate);
const keyboard = await getRatingKeyboard(userId, bookId, rating);
try {
await ctx.editMessageReplyMarkup(
keyboard.reply_markup
);
} catch (e) {}
});
bot.on("message", async (ctx: Context) => {
if (!ctx.message || !('text' in ctx.message)) {
return;
}
let keyboard = Markup.inlineKeyboard([
[
Markup.button.callback('Книгу', `${CallbackData.SEARCH_BOOK_PREFIX}1`)
],
[
Markup.button.callback('Автора', `${CallbackData.SEARCH_AUTHORS_PREFIX}1`),
],
[
Markup.button.callback('Серию', `${CallbackData.SEARCH_SERIES_PREFIX}1`),
],
[
Markup.button.callback('Переводчика', `${CallbackData.SEARCH_TRANSLATORS_PREFIX}1`),
]
]);
try {
await ctx.telegram.sendMessage(ctx.message.chat.id, Messages.SEARCH_MESSAGE, {
reply_to_message_id: ctx.message.message_id,
reply_markup: keyboard.reply_markup,
});
} catch (e) {
if (!isReplyMessageNotFound(e)) {
Sentry.captureException(e);
}
}
});
bot.catch((err, ctx: Context) => {
log({err, ctx});
Sentry.captureException(err);
});
return bot;
}

View File

@@ -1,135 +0,0 @@
import { Markup } from 'telegraf';
import { InlineKeyboardMarkup } from 'typegram';
import moment from 'moment';
import chunkText from 'chunk-text';
import { RANDOM_BOOK, RANDOM_AUTHOR, RANDOM_SEQUENCE, ENABLE_LANG_PREFIX, DISABLE_LANG_PREFIX, UPDATE_LOG_PREFIX, RATE_PREFIX, RANDOM_BOOK_BY_GENRE, RANDOM_BOOK_BY_GENRE_REQUEST } from './callback_data';
import { getLanguages, getUserOrDefaultLangCodes } from './services/user_settings';
import * as BookRating from "./services/book_ratings";
function getButtonLabel(delta: number, direction: 'left' | 'right'): string {
if (delta == 1) {
return direction === 'left' ? "<" : ">";
}
return direction === 'left' ? `< ${delta} <` : `> ${delta} >`;
}
export function getPaginationKeyboard(prefix: string, query: string | number, page: number, totalPages: number): Markup.Markup<InlineKeyboardMarkup> {
function getRow(delta: number) {
const row = [];
if (page - delta > 0) {
row.push(Markup.button.callback(getButtonLabel(delta, 'left'), `${prefix}${page - delta}`));
}
if (page + delta <= totalPages) {
row.push(Markup.button.callback(getButtonLabel(delta, 'right'), `${prefix}${page + delta}`));
}
return row;
}
const rows = [];
const row1 = getRow(1);
if (row1) {
rows.push(row1);
}
const row5 = getRow(5);
if (row5) {
rows.push(row5);
}
return Markup.inlineKeyboard(rows);
}
export function getTextPaginationData(prefix: string, text: string, currentPage: number): {current: string, keyboard: Markup.Markup<InlineKeyboardMarkup>} {
const chunks = chunkText(text, 512).filter((chunk) => chunk.length !== 0);
const current = chunks[currentPage];
const row = [];
if (currentPage - 1 >= 0) {
row.push(Markup.button.callback("<", `${prefix}_${currentPage - 1}`));
}
if (currentPage + 1 < chunks.length) {
row.push(Markup.button.callback(">", `${prefix}_${currentPage + 1}`));
}
const keyboard = Markup.inlineKeyboard([row]);
return {
current,
keyboard,
}
}
export function getRandomKeyboard(): Markup.Markup<InlineKeyboardMarkup> {
return Markup.inlineKeyboard([
[Markup.button.callback('Книгу', RANDOM_BOOK)],
[Markup.button.callback('Книгу по жанру', RANDOM_BOOK_BY_GENRE_REQUEST)],
[Markup.button.callback('Автора', RANDOM_AUTHOR)],
[Markup.button.callback('Серию', RANDOM_SEQUENCE)],
]);
}
export function getUpdateLogKeyboard(): Markup.Markup<InlineKeyboardMarkup> {
const format = "YYYY-MM-DD";
const now = moment().format(format);
const d3 = moment().subtract(3, 'days').format(format);
const d7 = moment().subtract(7, 'days').format(format);
const d30 = moment().subtract(30, 'days').format(format);
return Markup.inlineKeyboard([
[Markup.button.callback('За 3 дня', `${UPDATE_LOG_PREFIX}${d3}_${now}_1`)],
[Markup.button.callback('За 7 дней', `${UPDATE_LOG_PREFIX}${d7}_${now}_1`)],
[Markup.button.callback('За 30 дней', `${UPDATE_LOG_PREFIX}${d30}_${now}_1`)],
]);
}
export async function getUserAllowedLangsKeyboard(userId: number): Promise<Markup.Markup<InlineKeyboardMarkup>> {
const allLangs = await getLanguages();
const userAllowedLangsCodes = await getUserOrDefaultLangCodes(userId);
const onEmoji = '🟢';
const offEmoji = '🔴';
return Markup.inlineKeyboard([
...allLangs.map((lang) => {
let titlePrefix: string;
let callbackDataPrefix: string;
if (userAllowedLangsCodes.includes(lang.code)) {
titlePrefix = onEmoji;
callbackDataPrefix = DISABLE_LANG_PREFIX;
} else {
titlePrefix = offEmoji;
callbackDataPrefix = ENABLE_LANG_PREFIX;
}
const title = `${titlePrefix} ${lang.label}`;
return [Markup.button.callback(title, `${callbackDataPrefix}${lang.code}`)];
})
]);
}
export async function getRatingKeyboard(userId: number, bookId: number, rating: BookRating.Rating | null): Promise<Markup.Markup<InlineKeyboardMarkup>> {
const bookRating = rating ? rating : await BookRating.get(userId, bookId);
const rate = bookRating ? bookRating.rate : null;
return Markup.inlineKeyboard([
[1, 2, 3, 4, 5].map((bRate) => {
const title = bRate === rate ? `⭐️ ${bRate}` : bRate.toString();
return Markup.button.callback(title, `${RATE_PREFIX}${userId}_${bookId}_${bRate}`);
})
]);
}

View File

@@ -1,22 +0,0 @@
export const START_MESSAGE = 'Привет, {name}! \n' +
'Этот бот поможет тебе загружать книги.\n' +
// 'Узнать, как со мной работать /help.\n' +
'Настройки языков для поиска /settings.\n';
export const HELP_MESSAGE = 'Пока пусто :(';
export const SETTINGS_MESSAGE = 'Настройки:';
export const SEARCH_MESSAGE = 'Что ищем?';
export const BOOKS_NOT_FOUND = "Книги не найдены.";
export const AUTHORS_NOT_FOUND = "Авторы не найдены.";
export const TRANSLATORS_NOT_FOUND = "Переводчики не найдены.";
export const SEQUENCES_NOT_FOUND = "Серии не найдены.";
export const SUPPORT_MESSAGE =
'[Лицензии](https://github.com/flibusta-apps/book_bot/blob/main/LICENSE.md) \n\n' +
'[Исходный код](https://github.com/flibusta-apps)\n';

View File

@@ -1,99 +0,0 @@
import got, { Response } from 'got';
import { decode } from 'js-base64';
import env from '@/config';
export interface CachedMessage {
message_id: number,
chat_id: string | number,
}
interface BookCache {
id: number;
object_id: number;
object_type: string;
data: CachedMessage & {
file_token: string | null,
}
}
async function _makeRequest<T>(url: string, searchParams?: string | Record<string, string | number | boolean | null | undefined> | URLSearchParams | undefined): Promise<T> {
const response = await got<T>(`${env.CACHE_SERVER_URL}${url}`, {
searchParams,
headers: {
'Authorization': env.CACHE_SERVER_API_KEY,
},
responseType: 'json',
});
return response.body;
}
async function _makeDeleteRequest<T>(url: string, searchParams?: string | Record<string, string | number | boolean | null | undefined> | URLSearchParams | undefined): Promise<T> {
const response = await got.delete<T>(`${env.CACHE_SERVER_URL}${url}`, {
searchParams,
headers: {
'Authorization': env.CACHE_SERVER_API_KEY,
},
responseType: 'json',
});
return response.body;
}
export async function getBookCache(bookId: number, fileType: string): Promise<CachedMessage> {
return (await _makeRequest<BookCache>(`/api/v1/${bookId}/${fileType}`)).data;
}
export async function clearBookCache(bookId: number, fileType: string): Promise<CachedMessage> {
return (await _makeDeleteRequest<BookCache>(`/api/v1/${bookId}/${fileType}`)).data;
}
export interface DownloadedFile {
source: NodeJS.ReadableStream;
filename: string;
caption: string;
}
export async function downloadFromCache(bookId: number, fileType: string): Promise<DownloadedFile | null> {
const readStream = got.stream.get(`${env.CACHE_SERVER_URL}/api/v1/download/${bookId}/${fileType}`, {
headers: {
'Authorization': env.CACHE_SERVER_API_KEY,
},
});
return new Promise<DownloadedFile | null>((resolve, reject) => {
let timeout: NodeJS.Timeout | null = null;
const resolver = async (response: Response) => {
if (response.statusCode !== 200) {
resolve(null);
if (timeout) clearTimeout(timeout);
return
}
const captionData = response.headers['x-caption-b64'];
if (captionData === undefined || Array.isArray(captionData)) throw Error('No caption?');
if (timeout) clearTimeout(timeout);
return resolve({
source: readStream,
filename: (response.headers['content-disposition'] || '').replaceAll('"', "").split('filename=')[1],
caption: decode(captionData),
})
}
timeout = setTimeout(() => {
readStream.off("response", resolver);
resolve(null);
}, 60_000);
readStream.on("response", resolver);
});
}

View File

@@ -1,22 +0,0 @@
import got from 'got';
import env from '@/config';
import { CachedMessage } from './book_cache';
async function _makeRequest<T>(url: string, searchParams?: string | Record<string, string | number | boolean | null | undefined> | URLSearchParams | undefined): Promise<T> {
const response = await got<T>(`${env.BUFFER_SERVER_URL}${url}`, {
searchParams,
headers: {
'Authorization': env.BUFFER_SERVER_API_KEY,
},
responseType: 'json',
});
return response.body;
}
export async function getBookCacheBuffer(bookId: number, fileType: string): Promise<CachedMessage> {
return _makeRequest<CachedMessage>(`/api/v1/${bookId}/${fileType}`);
}

View File

@@ -1,235 +0,0 @@
import got from 'got';
import env from '@/config';
import { getAllowedLangsSearchParams } from '../utils';
const PAGE_SIZE = 7;
export interface Page<T> {
items: T[];
page: number;
size: number;
total: number;
total_pages: number;
}
export interface BookAuthor {
id: number;
first_name: string;
last_name: string;
middle_name: string;
}
export interface BaseBook {
id: number;
title: string;
lang: string;
file_type: string;
available_types: string[];
uploaded: string;
annotation_exists: boolean;
}
export interface AuthorBook extends BaseBook {
translators: BookAuthor[];
}
export interface TranslatorBook extends BaseBook {
authors: BookAuthor[];
}
export interface Book extends BaseBook {
authors: BookAuthor[];
translators: BookAuthor[];
}
export interface Genre {
id: number;
description: string;
}
export interface Source {
id: number;
name: string;
}
export interface DetailBook extends Book {
sequences: Sequence[];
genres: Genre[];
source: Source;
remote_id: number;
is_deleted: boolean;
pages: number | null;
}
export interface Author {
id: number;
last_name: string;
first_name: string;
middle_name: string;
annotation_exists: boolean;
}
export interface Sequence {
id: number;
name: string;
}
export interface AuthorAnnotation {
id: number;
title: string;
text: string;
file: string | null;
}
export interface BookAnnotation {
id: number;
title: string;
text: string;
file: string | null;
}
async function _makeRequest<T>(url: string, searchParams?: string | Record<string, string | number | boolean | null | undefined> | URLSearchParams | undefined): Promise<T> {
const response = await got<T>(`${env.BOOK_SERVER_URL}${url}`, {
searchParams,
headers: {
'Authorization': env.BOOK_SERVER_API_KEY,
},
responseType: 'json',
});
return response.body;
}
export async function getBooks(query: string, page: number, allowedLangs: string[]): Promise<Page<Book>> {
const queryDates = query.split("_");
const searchParams = getAllowedLangsSearchParams(allowedLangs);
searchParams.append('page', page.toString());
searchParams.append('size', PAGE_SIZE.toString());
searchParams.append('uploaded_gte', queryDates[0]);
searchParams.append('uploaded_lte', queryDates[1]);
searchParams.append('is_deleted', 'false');
return _makeRequest<Page<Book>>(`/api/v1/books/`, searchParams);
}
export async function getBookById(book_id: number): Promise<DetailBook> {
return _makeRequest<DetailBook>(`/api/v1/books/${book_id}`);
}
export async function searchByBookName(query: string, page: number, allowedLangs: string[]): Promise<Page<Book>> {
const searchParams = getAllowedLangsSearchParams(allowedLangs);
searchParams.append('page', page.toString());
searchParams.append('size', PAGE_SIZE.toString());
return _makeRequest<Page<Book>>(`/api/v1/books/search/${query}`, searchParams);
}
export async function searchAuthors(query: string, page: number, allowedLangs: string[]): Promise<Page<Author>> {
const searchParams = getAllowedLangsSearchParams(allowedLangs);
searchParams.append('page', page.toString());
searchParams.append('size', PAGE_SIZE.toString());
return _makeRequest<Page<Author>>(`/api/v1/authors/search/${query}`, searchParams);
}
export async function searchTranslators(query: string, page: number, allowedLangs: string[]): Promise<Page<Author>> {
const searchParams = getAllowedLangsSearchParams(allowedLangs);
searchParams.append('page', page.toString());
searchParams.append('size', PAGE_SIZE.toString());
return _makeRequest<Page<Author>>(`/api/v1/translators/search/${query}`, searchParams);
}
export async function searchSequences(query: string, page: number, allowedLangs: string[]): Promise<Page<Sequence>> {
const searchParams = getAllowedLangsSearchParams(allowedLangs);
searchParams.append('page', page.toString());
searchParams.append('size', PAGE_SIZE.toString());
return _makeRequest<Page<Sequence>>(`/api/v1/sequences/search/${query}`, searchParams);
}
export async function getBookAnnotation(bookId: number): Promise<BookAnnotation> {
return _makeRequest<BookAnnotation>(`/api/v1/books/${bookId}/annotation`);
}
export async function getAuthorAnnotation(authorId: number): Promise<AuthorAnnnotation> {
return _makeRequest<AuthorAnnnotation>(`/api/v1/authors/${authorId}/annotation`);
}
export async function getAuthorBooks(authorId: number | string, page: number, allowedLangs: string[]): Promise<Page<AuthorBook>> {
const searchParams = getAllowedLangsSearchParams(allowedLangs);
searchParams.append('page', page.toString());
searchParams.append('size', PAGE_SIZE.toString());
return _makeRequest<Page<AuthorBook>>(`/api/v1/authors/${authorId}/books`, searchParams);
}
export async function getTranslatorBooks(translatorId: number | string, page: number, allowedLangs: string[]): Promise<Page<AuthorBook>> {
const searchParams = getAllowedLangsSearchParams(allowedLangs);
searchParams.append('page', page.toString());
searchParams.append('size', PAGE_SIZE.toString());
return _makeRequest<Page<AuthorBook>>(`/api/v1/translators/${translatorId}/books`, searchParams);
}
export async function getSequenceBooks(sequenceId: number | string, page: number, allowedLangs: string[]): Promise<Page<Book>> {
const searchParams = getAllowedLangsSearchParams(allowedLangs);
searchParams.append('page', page.toString());
searchParams.append('size', PAGE_SIZE.toString());
return _makeRequest<Page<Book>>(`/api/v1/sequences/${sequenceId}/books`, searchParams);
}
export async function getRandomBook(allowedLangs: string[], genre: number | null = null): Promise<DetailBook> {
const params = getAllowedLangsSearchParams(allowedLangs);
if (genre) params.append("genre", genre.toString());
return _makeRequest<DetailBook>(
'/api/v1/books/random',
params,
);
}
export async function getRandomAuthor(allowedLangs: string[]): Promise<Author> {
return _makeRequest<Author>('/api/v1/authors/random', getAllowedLangsSearchParams(allowedLangs));
}
export async function getRandomSequence(allowedLangs: string[]): Promise<Sequence> {
return _makeRequest<Sequence>('/api/v1/sequences/random', getAllowedLangsSearchParams(allowedLangs));
}
export async function getGenreMetas(): Promise<string[]> {
return _makeRequest<string[]>('/api/v1/genres/metas');
}
export async function getGenres(meta: string): Promise<Page<Genre>> {
return _makeRequest<Page<Genre>>('/api/v1/genres', {meta});
}

View File

@@ -1,46 +0,0 @@
import got from 'got';
import env from '@/config';
export interface Rating {
id: number;
user_id: number;
book_id: number;
rate: number;
updated: string;
}
export async function get(userId: number, bookId: number): Promise<Rating | null> {
try {
const response = await got<Rating>(`${env.RATINGS_URL}/api/v1/ratings/${userId}/${bookId}`, {
headers: {
'Authorization': env.RATINGS_API_KEY,
},
responseType: 'json',
});
return response.body;
} catch {
return null;
}
}
export async function set(userId: number, bookId: number, rate: number): Promise<Rating> {
const response = await got.post<Rating>(`${env.RATINGS_URL}/api/v1/ratings`, {
json: {
"user_id": userId,
"book_id": bookId,
"rate": rate,
},
headers: {
'Authorization': env.RATINGS_API_KEY,
'Content-Type': 'application/json',
},
responseType: 'json'
});
return response.body;
}

View File

@@ -1,46 +0,0 @@
import got from 'got';
import env from '@/config';
export interface DownloadedFile {
source: NodeJS.ReadableStream;
filename: string;
}
export async function download(source_id: number, remote_id: number, file_type: string): Promise<DownloadedFile> {
const readStream = got.stream.get(`${env.DOWNLOADER_URL}/download/${source_id}/${remote_id}/${file_type}`, {
headers: {
'Authorization': env.DOWNLOADER_API_KEY,
},
});
return new Promise<DownloadedFile>((resolve, reject) => {
readStream.on("response", async response => {
resolve({
source: readStream,
filename: (response.headers['content-disposition'] || '').split('filename=')[1]
});
});
});
}
export async function downloadImage(path: string): Promise<NodeJS.ReadableStream | null> {
const readStream = got.stream.get(path, {throwHttpErrors: false});
return new Promise<NodeJS.ReadableStream | null>((resolve, reject) => {
readStream.on("response", async response => {
if (response.statusCode === 200) {
resolve(readStream);
} else {
resolve(null);
}
});
readStream.once("error", error => {
resolve(null);
})
});
}

View File

@@ -1,75 +0,0 @@
import got from 'got';
import env from '@/config';
interface Language {
id: number;
label: string;
code: string;
}
interface UserSettings {
user_id: number;
last_name: string;
first_name: string;
source: string;
allowed_langs: Language[];
}
export interface UserSettingsUpdateData {
user_id: number;
last_name: string;
first_name: string;
username: string;
source: string;
allowed_langs?: string[];
}
async function _makeGetRequest<T>(url: string, searchParams?: string | Record<string, string | number | boolean | null | undefined> | URLSearchParams | undefined): Promise<T> {
const response = await got<T>(`${env.USER_SETTINGS_URL}${url}`, {
searchParams,
headers: {
'Authorization': env.USER_SETTINGS_API_KEY,
},
responseType: 'json'
});
return response.body;
}
export async function getLanguages(): Promise<Language[]> {
return _makeGetRequest<Language[]>('/languages');
}
export async function getUserSettings(userId: number): Promise<UserSettings> {
return _makeGetRequest<UserSettings>(`/users/${userId}`);
}
export async function getUserOrDefaultLangCodes(userId: number): Promise<string[]> {
try {
return (await getUserSettings(userId)).allowed_langs.map((lang) => lang.code);
} catch {
return ["ru", "be", "uk"];
}
}
export async function createOrUpdateUserSettings(data: UserSettingsUpdateData): Promise<UserSettings> {
const response = await got.post<UserSettings>(`${env.USER_SETTINGS_URL}/users/`, {
json: data,
headers: {
'Authorization': env.USER_SETTINGS_API_KEY,
'Content-Type': 'application/json',
},
responseType: 'json'
});
return response.body;
}

View File

@@ -1,228 +0,0 @@
import { Context, Markup, Telegraf } from 'telegraf';
import { InlineKeyboardMarkup } from 'typegram';
import { URLSearchParams } from 'url';
import { isNotModifiedMessage } from './errors_utils';
import { getPaginationKeyboard, getUserAllowedLangsKeyboard } from './keyboard';
import * as BookLibrary from "./services/book_library";
import { createOrUpdateUserSettings, getUserOrDefaultLangCodes } from './services/user_settings';
import Sentry from '@/sentry';
interface PreparedMessage {
message: string;
keyboard?: Markup.Markup<InlineKeyboardMarkup>;
}
export async function getPaginatedMessage<T, D extends string | number>(
prefix: string,
data: D,
page: number,
allowedLangs: string[],
itemsGetter: (data: D, page: number, allowedLangs: string[]) => Promise<BookLibrary.Page<T>>,
itemFormater: (item: T) => string,
header: string = "",
noItemsMessage: string = "",
): Promise<PreparedMessage> {
const itemsPage = await itemsGetter(data, page, allowedLangs);
if (itemsPage.total_pages === 0) {
return {
message: noItemsMessage,
}
}
if (page > itemsPage.total_pages) {
return getPaginatedMessage(prefix, data, itemsPage.total_pages, allowedLangs, itemsGetter, itemFormater, header, noItemsMessage);
}
const formatedItems = itemsPage.items.map(itemFormater).join('\n\n\n');
const message = header + formatedItems + `\n\nСтраница ${page}/${itemsPage.total_pages}`;
const keyboard = getPaginationKeyboard(prefix, data, page, itemsPage.total_pages);
return {
message,
keyboard
};
}
export function registerPaginationCommand<T, Q extends string | number>(
bot: Telegraf,
prefix: string,
argsGetter: (ctx: Context) => { query: Q, page: number } | null,
prefixCreator: ((query: Q) => string) | null,
itemsGetter: (data: Q, page: number, allowedLangs: string[]) => Promise<BookLibrary.Page<T>>,
itemFormater: (item: T) => string,
headers?: string,
noItemsMessage?: string,
) {
bot.action(new RegExp(prefix), async (ctx: Context) => {
if (!ctx.callbackQuery) return;
const args = argsGetter(ctx);
if (args === null) return;
const { query, page } = args;
const allowedLangs = await getUserOrDefaultLangCodes(ctx.callbackQuery.from.id);
const tPrefix = prefixCreator ? prefixCreator(query) : prefix;
const pMessage = await getPaginatedMessage(
tPrefix, query, page, allowedLangs, itemsGetter, itemFormater, headers, noItemsMessage,
);
try {
await ctx.editMessageText(pMessage.message, {
reply_markup: pMessage.keyboard?.reply_markup
});
} catch (e) {
if (!isNotModifiedMessage(e)) {
Sentry.captureException(e);
}
}
})
}
export function registerRandomItemCallback<T>(
bot: Telegraf,
callback_data: string,
itemGetter: (allowedLangs: string[]) => Promise<T>,
itemFormatter: (item: T) => string,
) {
bot.action(callback_data, async (ctx: Context) => {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) return;
const item = await itemGetter(
await getUserOrDefaultLangCodes(ctx.callbackQuery.from.id),
);
const keyboard = Markup.inlineKeyboard([
[Markup.button.callback("Повторить?", callback_data)]
]);
try {
await ctx.editMessageReplyMarkup(Markup.inlineKeyboard([]).reply_markup);
} catch (e) {}
ctx.reply(itemFormatter(item), {
reply_markup: keyboard.reply_markup,
});
});
}
export function registerLanguageSettingsCallback(
bot: Telegraf,
action: 'on' | 'off',
prefix: string,
) {
bot.action(new RegExp(prefix), async (ctx: Context) => {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) return;
let allowedLangsCodes = await getUserOrDefaultLangCodes(ctx.callbackQuery.from.id);
const tLang = ctx.callbackQuery.data.split("_")[2];
if (action === 'on') {
allowedLangsCodes.push(tLang);
} else {
allowedLangsCodes = allowedLangsCodes.filter((item) => item !== tLang);
}
if (allowedLangsCodes.length === 0) {
ctx.answerCbQuery("Должен быть активен, хотя бы один язык!", {
show_alert: true,
});
return;
}
const user = ctx.callbackQuery.from;
await createOrUpdateUserSettings({
user_id: user.id,
last_name: user.last_name || '',
first_name: user.first_name,
username: user.username || '',
source: ctx.botInfo.username,
allowed_langs: allowedLangsCodes,
});
const keyboard = await getUserAllowedLangsKeyboard(user.id);
try {
await ctx.editMessageReplyMarkup(keyboard.reply_markup);
} catch {}
});
}
export function getAllowedLangsSearchParams(allowedLangs: string[]): URLSearchParams {
const sp = new URLSearchParams();
allowedLangs.forEach((lang) => sp.append('allowed_langs', lang));
return sp;
}
const fail = (ctx: Context) => ctx.reply("Ошибка! Повторите поиск :(");
export function getSearchArgs(ctx: Context): { query: string, page: number } | null {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) {
fail(ctx)
return null;
}
if (!ctx.callbackQuery.message || !('reply_to_message' in ctx.callbackQuery.message)) {
fail(ctx);
return null;
}
if (!ctx.callbackQuery.message.reply_to_message || !('text' in ctx.callbackQuery.message.reply_to_message)) {
fail(ctx)
return null;
}
const page = parseInt(ctx.callbackQuery.data.split('_')[1]);
if (isNaN(page)) {
fail(ctx)
return null;
}
const query = ctx.callbackQuery.message.reply_to_message.text
.replaceAll("/", "");
return { query, page };
}
export function getCallbackArgs(ctx: Context): { query: string, page: number} | null {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) {
fail(ctx)
return null;
}
const [ _, query, sPage ] = ctx.callbackQuery.data.split('_');
const page = parseInt(sPage);
if (isNaN(page)) {
fail(ctx)
return null;
}
return { query, page };
}
export function getPrefixWithQueryCreator(prefix: string) {
return (query: string) => `${prefix}${query}_`;
}
export function isNormalText(value: string | null): boolean {
if (value === null) return false;
if (value.length === 0) return false;
if (value.replaceAll("\n", "").replaceAll(" ", "").length === 0) return false;
return true;
}

View File

@@ -1,22 +0,0 @@
import { Telegraf, Context } from 'telegraf';
import { BotState } from '@/bots/manager/types';
import env from '@/config';
export async function createBlockedBot(token: string, state: BotState): Promise<Telegraf> {
const bot = new Telegraf(token, {
telegram: {
apiRoot: env.TELEGRAM_BOT_API_ROOT,
}
});
await bot.telegram.deleteMyCommands();
bot.on("message", async (ctx: Context) => {
await ctx.reply('Бот заблокирован!');
});
return bot;
}

View File

@@ -1,25 +0,0 @@
import { Telegraf, Context } from 'telegraf';
import { BotState } from '@/bots/manager/types';
import env from '@/config';
export async function createPendingBot(token: string, state: BotState): Promise<Telegraf> {
const bot = new Telegraf(token, {
telegram: {
apiRoot: env.TELEGRAM_BOT_API_ROOT,
}
});
await bot.telegram.deleteMyCommands();
bot.on("message", async (ctx: Context) => {
await ctx.reply(
'Бот зарегистрирован, но не подтвержден администратором! \n' +
'Подтверждение занимает примерно 12 часов.'
);
});
return bot;
}

View File

@@ -1,25 +0,0 @@
import { Telegraf } from "telegraf";
import { BotState } from '@/bots/manager/types';
import { createPendingBot } from './bots/pending';
import { createBlockedBot } from './bots/blocked';
import { createApprovedBot } from './bots/approved/index';
export enum BotStatuses {
PENDING = 'pending',
APPROVED = 'approved',
BLOCKED = 'blocked',
}
export default async function getBot(token: string, state: BotState): Promise<Telegraf> {
const handlers = {
[BotStatuses.PENDING]: createPendingBot,
[BotStatuses.BLOCKED]: createBlockedBot,
[BotStatuses.APPROVED]: createApprovedBot,
};
return handlers[state.status](token, state);
}

View File

@@ -1,51 +0,0 @@
import { createClient, RedisClientType } from 'redis';
import env from '@/config';
import debug from 'debug';
import Sentry from '@/sentry';
export default class Limiter {
static debugger = debug("limiter");
static MAX_PROCESSING_COUNT: number = 3;
static _redisClient: RedisClientType | null = null;
static async _getClient() {
if (this._redisClient === null) {
this._redisClient = createClient({
url: `redis://${env.REDIS_HOST}:${env.REDIS_PORT}/${env.REDIS_DB}`
});
this._redisClient.on('error', (err) => {
console.log(err);
Sentry.captureException(err);
});
await this._redisClient.connect();
}
return this._redisClient;
}
static _getKey(updateId: number) {
return `update_${updateId}`;
}
static async _getCount(updateId: number): Promise<number> {
const key = this._getKey(updateId);
const client = await this._getClient();
await client.set(key, 0, {EX: 5 * 60, NX: true});
return client.incr(key);
}
static async isLimited(updateId: number): Promise<boolean> {
const count = await this._getCount(updateId);
this.debugger(`${updateId}: ${count}`);
return count > this.MAX_PROCESSING_COUNT;
}
}

View File

@@ -1,175 +0,0 @@
import express, { Response, Request, NextFunction } from 'express';
import { Server } from 'http';
import * as dockerIpTools from "docker-ip-get";
import { Telegraf } from 'telegraf';
import env from '@/config';
import getBot from '../factory/index';
import UsersCounter from '@/analytics/users_counter';
import { makeSyncRequest } from "./utils";
import { BotState } from "./types";
import Sentry from '@/sentry';
export default class BotsManager {
static server: Server | null = null;
// Bots
static bots: {[key: number]: Telegraf} = {};
static botsStates: {[key: number]: BotState} = {};
static botsPendingUpdatesCount: {[key: number]: number} = {};
// Intervals
static syncInterval: NodeJS.Timer | null = null;
static async start() {
this.launch();
await this.sync();
if (this.syncInterval === null) {
this.syncInterval = setInterval(() => this.sync(), 300_000);
}
process.once('SIGINT', () => this.stop());
process.once('SIGTERM', () => this.stop());
}
static async sync() {
const botsData = await makeSyncRequest();
if (botsData === null) return;
await Promise.all(botsData.map((state) => this._updateBotState(state)));
await Promise.all(
Object.values(this.botsStates).map(
(value: BotState) => this._checkPendingUpdates(this.bots[value.id], value)
)
);
}
static async _updateBotState(state: BotState) {
const isExists = this.bots[state.id] !== undefined;
if (isExists &&
this.botsStates[state.id].status === state.status &&
this.botsStates[state.id].cache === state.cache
) {
return;
}
try {
const oldBot = new Telegraf(state.token);
await oldBot.telegram.deleteWebhook();
await oldBot.telegram.logOut();
} catch (e) {}
let bot: Telegraf;
try {
bot = await getBot(state.token, state);
} catch (e) {
return;
}
if (!(await this._setWebhook(bot, state))) return;
this.bots[state.id] = bot;
this.botsStates[state.id] = state;
this.restartApplication();
}
static async _checkPendingUpdates(bot: Telegraf, state: BotState) {
try {
const webhookInfo = await bot.telegram.getWebhookInfo();
const previousPendingUpdateCount = this.botsPendingUpdatesCount[state.id] || 0;
if (previousPendingUpdateCount !== 0 && webhookInfo.pending_update_count !== 0) {
this._setWebhook(bot, state);
}
this.botsPendingUpdatesCount[state.id] = webhookInfo.pending_update_count;
} catch (e) {
Sentry.captureException(e, {
extra: {
method: "_checkPendingUpdate",
state_id: state.id,
}
});
}
}
static async _setWebhook(bot: Telegraf, state: BotState): Promise<boolean> {
const dockerIps = (await dockerIpTools.getContainerIp()).split(" ");
const filteredIp = dockerIps.filter((ip) => ip.startsWith(env.NETWORK_IP_PREFIX));
const ips = filteredIp.length !== 0 ? filteredIp : dockerIps;
for (const ip of ips) {
try {
await bot.telegram.setWebhook(
`${env.WEBHOOK_BASE_URL}:${env.WEBHOOK_PORT}/${state.id}/${bot.telegram.token}`, {
ip_address: ip,
}
);
return true;
} catch (e) {}
}
return false;
}
static getBotHandlers() {
return Object.keys(this.bots).map((index) => {
const bot = this.bots[parseInt(index)];
return bot.webhookCallback(`/${index}/${bot.telegram.token}`);
});
}
private static async launch() {
const application = express();
application.get("/healthcheck", (req, res) => {
res.send("Ok!");
});
application.get("/metrics", (req, res) => {
UsersCounter.getMetrics().then((response) => {
res.send(response);
});
});
const handlers = this.getBotHandlers();
if (handlers.length !== 0) application.use(handlers);
this.server = application.listen(env.WEBHOOK_PORT);
console.log("Server started!");
}
static stop() {
Object.keys(this.bots).forEach(key => this.bots[parseInt(key)].telegram.deleteWebhook());
if (this.syncInterval) {
clearInterval(this.syncInterval);
this.syncInterval = null;
}
this.server?.close();
this.server = null;
console.log("Server stopped!")
}
static restartApplication() {
this.server?.close();
this.server = null;
this.launch();
console.log("Server restarted!");
}
}

View File

@@ -1,16 +0,0 @@
import { BotStatuses } from '../factory/index';
export enum Cache {
ORIGINAL = "original",
BUFFER = "buffer",
NO_CACHE = "no_cache"
}
export interface BotState {
id: number;
token: string;
status: BotStatuses;
cache: Cache;
created_time: string;
}

View File

@@ -1,21 +0,0 @@
import got from 'got';
import env from '@/config';
import { BotState } from "./types";
export async function makeSyncRequest(): Promise<BotState[] | null> {
try {
const response = await got<BotState[]>(env.MANAGER_URL, {
headers: {
'Authorization': env.MANAGER_API_KEY
},
responseType: 'json',
});
return response.body;
} catch (err) {
return null;
}
}

49
src/bots/mod.rs Normal file
View File

@@ -0,0 +1,49 @@
mod approved_bot;
use std::error::Error;
use teloxide::prelude::*;
pub type BotHandlerInternal = Result<(), Box<dyn Error + Send + Sync>>;
type BotHandler = Handler<
'static,
dptree::di::DependencyMap,
BotHandlerInternal,
teloxide::dispatching::DpHandlerDescription,
>;
type BotCommands = Option<Vec<teloxide::types::BotCommand>>;
fn get_pending_handler() -> BotHandler {
let handler = |msg: Message, bot: AutoSend<Bot>| async move {
let message_text = "
Бот зарегистрирован, но не подтвержден администратором! \
Подтверждение занимает примерно 12 часов.
";
bot.send_message(msg.chat.id, message_text).await?;
Ok(())
};
Update::filter_message().chain(dptree::endpoint(handler))
}
fn get_blocked_handler() -> BotHandler {
let handler = |msg: Message, bot: AutoSend<Bot>| async move {
let message_text = "Бот заблокирован!";
bot.send_message(msg.chat.id, message_text).await?;
Ok(())
};
Update::filter_message().chain(dptree::endpoint(handler))
}
pub fn get_bot_handler(status: crate::bots_manager::BotStatus) -> (BotHandler, BotCommands) {
match status {
crate::bots_manager::BotStatus::Pending => (get_pending_handler(), None),
crate::bots_manager::BotStatus::Approved => approved_bot::get_approved_handler(),
crate::bots_manager::BotStatus::Blocked => (get_blocked_handler(), None),
}
}

208
src/bots_manager.rs Normal file
View File

@@ -0,0 +1,208 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use teloxide::types::BotCommand;
use tokio::time::{sleep, Duration};
use teloxide::{
dispatching::{update_listeners::webhooks, ShutdownToken},
prelude::*,
};
use url::Url;
use serde::Deserialize;
use crate::config;
#[derive(Deserialize, Debug, PartialEq, Clone, Copy)]
pub enum BotStatus {
#[serde(rename = "pending")]
Pending,
#[serde(rename = "approved")]
Approved,
#[serde(rename = "blocked")]
Blocked,
}
#[derive(Deserialize, Debug, PartialEq, Clone, Copy)]
pub enum BotCache {
#[serde(rename = "original")]
Original,
#[serde(rename = "no_cache")]
NoCache,
}
#[derive(Deserialize, Debug)]
struct BotData {
id: u32,
token: String,
status: BotStatus,
cache: BotCache,
}
async fn get_bots() -> Result<Vec<BotData>, reqwest::Error> {
let client = reqwest::Client::new();
let response = client
.get(format!("{}/api", &config::CONFIG.manager_url))
.header("Authorization", &config::CONFIG.manager_api_key)
.send()
.await;
match response {
Ok(v) => v.json::<Vec<BotData>>().await,
Err(err) => Err(err),
}
}
pub struct BotsManager {
next_port: u16,
bot_port_map: HashMap<u32, u16>,
bot_status_and_cache_map: HashMap<u32, (BotStatus, BotCache)>,
bot_shutdown_token_map: HashMap<u32, ShutdownToken>,
}
impl BotsManager {
pub fn create() -> Self {
BotsManager {
next_port: 8000,
bot_port_map: HashMap::new(),
bot_status_and_cache_map: HashMap::new(),
bot_shutdown_token_map: HashMap::new(),
}
}
async fn start_bot(&mut self, bot_data: &BotData) {
let bot = Bot::new(bot_data.token.clone())
.set_api_url(config::CONFIG.telegram_bot_api.clone())
.auto_send();
let token = bot.inner().token();
let port = self.bot_port_map.get(&bot_data.id).unwrap();
let addr = ([0, 0, 0, 0], *port).into();
let host = format!("{}:{}", &config::CONFIG.webhook_base_url, port);
let url = Url::parse(&format!("{host}/{token}")).unwrap();
log::info!(
"Start bot(id={}) with {:?} handler, port {}",
bot_data.id,
bot_data.status,
port
);
let listener = webhooks::axum(bot.clone(), webhooks::Options::new(addr, url))
.await
.expect("Couldn't setup webhook");
let (handler, commands) = crate::bots::get_bot_handler(bot_data.status);
let set_command_result = match commands {
Some(v) => bot.set_my_commands::<Vec<BotCommand>>(v).send().await,
None => bot.delete_my_commands().send().await,
};
match set_command_result {
Ok(_) => (),
Err(err) => log::error!("{:?}", err),
}
let mut dispatcher = Dispatcher::builder(bot, handler)
.dependencies(dptree::deps![bot_data.cache])
.build();
let shutdown_token = dispatcher.shutdown_token();
self.bot_shutdown_token_map
.insert(bot_data.id, shutdown_token);
tokio::spawn(async move {
dispatcher
.dispatch_with_listener(
listener,
LoggingErrorHandler::with_custom_text("An error from the update listener"),
)
.await;
});
}
async fn stop_bot(&mut self, bot_id: u32) {
let shutdown_token = match self.bot_shutdown_token_map.remove(&bot_id) {
Some(v) => v,
None => return,
};
for _ in 1..100 {
match shutdown_token.clone().shutdown() {
Ok(v) => return v.await,
Err(_) => (),
};
sleep(Duration::from_millis(100)).await;
}
}
async fn update_data(&mut self, bots_data: Vec<BotData>) {
for bot_data in bots_data.iter() {
if !self.bot_port_map.contains_key(&bot_data.id) {
self.bot_port_map.insert(bot_data.id, self.next_port);
self.next_port += 1;
}
match self.bot_status_and_cache_map.get(&bot_data.id) {
Some(v) => {
if *v != (bot_data.status, bot_data.cache) {
self.bot_status_and_cache_map
.insert(bot_data.id, (bot_data.status, bot_data.cache));
self.stop_bot(bot_data.id).await;
self.start_bot(bot_data).await;
}
}
None => {
self.bot_status_and_cache_map
.insert(bot_data.id, (bot_data.status, bot_data.cache));
self.start_bot(bot_data).await;
}
}
}
}
async fn check(&mut self) {
let bots_data = get_bots().await;
match bots_data {
Ok(v) => self.update_data(v).await,
Err(err) => log::info!("{:?}", err),
}
}
async fn stop_all(&mut self) {
for token in self.bot_shutdown_token_map.values() {
for _ in 1..100 {
match token.clone().shutdown() {
Ok(v) => {
v.await;
break;
}
Err(_) => (),
}
}
}
}
pub async fn start(running: Arc<AtomicBool>) {
let mut manager = BotsManager::create();
loop {
manager.check().await;
for _ in 1..30 {
sleep(Duration::from_secs(1)).await;
if !running.load(Ordering::SeqCst) {
manager.stop_all().await;
return;
}
}
}
}
}

54
src/config.rs Normal file
View File

@@ -0,0 +1,54 @@
pub struct Config {
pub telegram_bot_api: reqwest::Url,
pub webhook_base_url: String,
pub manager_url: String,
pub manager_api_key: String,
pub user_settings_url: String,
pub user_settings_api_key: String,
pub book_server_url: String,
pub book_server_api_key: String,
pub cache_server_url: String,
pub cache_server_api_key: String,
pub sentry_dsn: String,
}
fn get_env(env: &'static str) -> String {
std::env::var(env).unwrap_or_else(|_| panic!("Cannot get the {} env variable", env))
}
impl Config {
pub fn load() -> Config {
Config {
telegram_bot_api: reqwest::Url::parse(&get_env("TELEGRAM_BOT_API_ROOT"))
.unwrap_or_else(|_| {
panic!("Cannot parse url from TELEGRAM_BOT_API_ROOT env variable")
}),
webhook_base_url: get_env("WEBHOOK_BASE_URL"),
manager_url: get_env("MANAGER_URL"),
manager_api_key: get_env("MANAGER_API_KEY"),
user_settings_url: get_env("USER_SETTINGS_URL"),
user_settings_api_key: get_env("USER_SETTINGS_API_KEY"),
book_server_url: get_env("BOOK_SERVER_URL"),
book_server_api_key: get_env("BOOK_SERVER_API_KEY"),
cache_server_url: get_env("CACHE_SERVER_URL"),
cache_server_api_key: get_env("CACHE_SERVER_API_KEY"),
sentry_dsn: get_env("SENTRY_DSN"),
}
}
}
lazy_static! {
pub static ref CONFIG: Config = Config::load();
}

View File

@@ -1,38 +0,0 @@
import { cleanEnv, str, num } from 'envalid';
export default cleanEnv(process.env, {
SENTRY_DSN: str(),
WEBHOOK_BASE_URL: str(),
WEBHOOK_PORT: num(),
TELEGRAM_BOT_API_ROOT: str({ default: "https://api.telegram.org" }),
MANAGER_URL: str(),
MANAGER_API_KEY: str(),
BOOK_SERVER_URL: str(),
BOOK_SERVER_API_KEY: str(),
CACHE_SERVER_URL: str(),
CACHE_SERVER_API_KEY: str(),
BUFFER_SERVER_URL: str(),
BUFFER_SERVER_API_KEY: str(),
DOWNLOADER_URL: str(),
DOWNLOADER_API_KEY: str(),
USER_SETTINGS_URL: str(),
USER_SETTINGS_API_KEY: str(),
RATINGS_URL: str(),
RATINGS_API_KEY: str(),
NETWORK_IP_PREFIX: str(),
REDIS_HOST: str(),
REDIS_PORT: num(),
REDIS_DB: num(),
});

29
src/main.rs Normal file
View File

@@ -0,0 +1,29 @@
#[macro_use]
extern crate lazy_static;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
mod bots;
mod bots_manager;
mod config;
#[tokio::main]
async fn main() {
let _guard = sentry::init(config::CONFIG.sentry_dsn.clone());
env_logger::init();
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
})
.expect("Error setting Ctrl-C handler");
tokio::spawn(async move {
bots_manager::BotsManager::start(running).await;
})
.await
.unwrap();
}

View File

@@ -1,3 +0,0 @@
import BotsManager from './bots/manager';
BotsManager.start();

View File

@@ -1,10 +0,0 @@
import * as Sentry from '@sentry/node';
import env from '@/config';
Sentry.init({
dsn: env.SENTRY_DSN,
});
export default Sentry;

View File

@@ -1,104 +0,0 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ESNEXT", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "ESNEXT", /* Specify what module code is generated. */
// "rootDir": "./src", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
"baseUrl": "./src", /* Specify the base directory to resolve non-relative module names. */
"paths": {
"@/*": ["./*"]
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [
// "./src",
// ], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
"outDir": "./build/", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

1354
yarn.lock

File diff suppressed because it is too large Load Diff