mirror of
https://github.com/flibusta-apps/book_bot.git
synced 2025-12-06 07:25:36 +01:00
Add rust implementation
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
.vscode
|
||||
|
||||
build
|
||||
|
||||
node_modules
|
||||
|
||||
package-lock.json
|
||||
20
.github/workflows/build_docker_image.yml
vendored
20
.github/workflows/build_docker_image.yml
vendored
@@ -23,6 +23,14 @@ jobs:
|
||||
name: Set up Docker Buildx
|
||||
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
|
||||
uses: ASzc/change-string-case-action@v2
|
||||
with:
|
||||
@@ -48,6 +56,18 @@ jobs:
|
||||
tags: ghcr.io/${{ env.IMAGE }}:latest
|
||||
context: .
|
||||
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
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,7 +1,6 @@
|
||||
.vscode
|
||||
|
||||
build
|
||||
target
|
||||
|
||||
node_modules
|
||||
|
||||
package-lock.json
|
||||
test_env
|
||||
.DS_Store
|
||||
|
||||
2018
Cargo.lock
generated
Normal file
2018
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
Cargo.toml
Normal file
28
Cargo.toml
Normal 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"
|
||||
@@ -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 ./
|
||||
COPY ./yarn.lock ./
|
||||
COPY ./tsconfig.json ./
|
||||
COPY ./src ./src
|
||||
WORKDIR /usr/src/myapp
|
||||
COPY . .
|
||||
|
||||
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 ./
|
||||
COPY ./scripts/healthcheck.js ./
|
||||
RUN update-ca-certificates
|
||||
|
||||
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
|
||||
40
package.json
40
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
47
src/bots/approved_bot/mod.rs
Normal file
47
src/bots/approved_bot/mod.rs
Normal 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("Поддержать разработчика"),
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
354
src/bots/approved_bot/modules/annotations.rs
Normal file
354
src/bots/approved_bot/modules/annotations.rs
Normal 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
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
346
src/bots/approved_bot/modules/book.rs
Normal file
346
src/bots/approved_bot/modules/book.rs
Normal 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,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
153
src/bots/approved_bot/modules/download.rs
Normal file
153
src/bots/approved_bot/modules/download.rs
Normal 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
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
45
src/bots/approved_bot/modules/help.rs
Normal file
45
src/bots/approved_bot/modules/help.rs
Normal 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 }),
|
||||
),
|
||||
)
|
||||
}
|
||||
10
src/bots/approved_bot/modules/mod.rs
Normal file
10
src/bots/approved_bot/modules/mod.rs
Normal 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;
|
||||
364
src/bots/approved_bot/modules/random.rs
Normal file
364
src/bots/approved_bot/modules/random.rs
Normal 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,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
281
src/bots/approved_bot/modules/search.rs
Normal file
281
src/bots/approved_bot/modules/search.rs
Normal 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,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
217
src/bots/approved_bot/modules/settings.rs
Normal file
217
src/bots/approved_bot/modules/settings.rs
Normal 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
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
50
src/bots/approved_bot/modules/support.rs
Normal file
50
src/bots/approved_bot/modules/support.rs
Normal 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 },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
227
src/bots/approved_bot/modules/update_history.rs
Normal file
227
src/bots/approved_bot/modules/update_history.rs
Normal 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
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
114
src/bots/approved_bot/modules/utils.rs
Normal file
114
src/bots/approved_bot/modules/utils.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
112
src/bots/approved_bot/services/book_cache/mod.rs
Normal file
112
src/bots/approved_bot/services/book_cache/mod.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
13
src/bots/approved_bot/services/book_cache/types.rs
Normal file
13
src/bots/approved_bot/services/book_cache/types.rs
Normal 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,
|
||||
}
|
||||
299
src/bots/approved_bot/services/book_library/formaters.rs
Normal file
299
src/bots/approved_bot/services/book_library/formaters.rs
Normal 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}")
|
||||
}
|
||||
}
|
||||
217
src/bots/approved_bot/services/book_library/mod.rs
Normal file
217
src/bots/approved_bot/services/book_library/mod.rs
Normal 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(¶ms)
|
||||
.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
|
||||
}
|
||||
182
src/bots/approved_bot/services/book_library/types.rs
Normal file
182
src/bots/approved_bot/services/book_library/types.rs
Normal 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>,
|
||||
}
|
||||
3
src/bots/approved_bot/services/mod.rs
Normal file
3
src/bots/approved_bot/services/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod book_cache;
|
||||
pub mod book_library;
|
||||
pub mod user_settings;
|
||||
126
src/bots/approved_bot/services/user_settings/mod.rs
Normal file
126
src/bots/approved_bot/services/user_settings/mod.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
10
src/bots/approved_bot/tools.rs
Normal file
10
src/bots/approved_bot/tools.rs
Normal 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())
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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_';
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
})
|
||||
]);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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!");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
49
src/bots/mod.rs
Normal 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
208
src/bots_manager.rs
Normal 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
54
src/config.rs
Normal 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();
|
||||
}
|
||||
@@ -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
29
src/main.rs
Normal 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();
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import BotsManager from './bots/manager';
|
||||
|
||||
BotsManager.start();
|
||||
@@ -1,10 +0,0 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
||||
import env from '@/config';
|
||||
|
||||
|
||||
Sentry.init({
|
||||
dsn: env.SENTRY_DSN,
|
||||
});
|
||||
|
||||
export default Sentry;
|
||||
104
tsconfig.json
104
tsconfig.json
@@ -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. */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user