This commit is contained in:
2021-12-13 02:06:36 +03:00
commit 7725f47f2a
22 changed files with 893 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
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 AUTHOR_BOOKS_PREFIX = 'ba_';
export const SEQUENCE_BOOKS_PREFIX = 'bs_';

View File

@@ -0,0 +1,55 @@
import { AuthorBook, Book, Author, Sequence } from './services/book_library';
function isBook(item: AuthorBook | Book): item is Book {
return 'authors' in item;
}
export function formatBook(book: AuthorBook | Book): string {
let response: string[] = [];
response.push(`📖 ${book.title} | ${book.lang}`);
if (book.annotation_exists) {
response.push(`📝 Аннотация: /b_info_${book.id}`)
}
if (isBook(book) && book.authors.length > 0) {
response.push('Авторы:')
book.authors.forEach(author => response.push(`͏👤 ${author.last_name} ${author.first_name} ${author.middle_name}`));
}
if (book.translators.length > 0) {
response.push('Переводчики:');
book.translators.forEach(author => response.push(`͏👤 ${author.last_name} ${author.first_name} ${author.middle_name}`));
}
book.available_types.forEach(a_type => response.push(`📥 ${a_type}: /d_${a_type}_${book.id}`));
return response.join('\n');
}
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_info_${author.id}`);
}
return response.join('\n');
}
export function formatSequence(sequence: Sequence): string {
let response = [];
response.push(`📚 ${sequence.name}`);
response.push(`/s_${sequence.id}`);
return response.join('\n');
}

View File

@@ -0,0 +1,142 @@
import { Context, Telegraf, Markup } from 'telegraf';
import { BotState } from '@/bots/manager';
import env from '@/config';
import * as Messages from "./messages";
import * as CallbackData from "./callback_data";
import * as BookLibrary from "./services/book_library";
import { CachedMessage, getBookCache } from './services/book_cache';
import { getBookCacheBuffer } from './services/book_cache_buffer';
import { formatBook, formatAuthor, formatSequence } from './format';
import { getPaginatedMessage, registerPaginationCommand } from './utils';
export async function createApprovedBot(token: string, state: BotState): Promise<Telegraf> {
const bot = new Telegraf(token, {
telegram: {
apiRoot: env.TELEGRAM_BOT_API_ROOT,
}
});
await bot.telegram.setMyCommands([
{command: "random_book", description: "Случайная книга"},
{command: "update_log", description: "Информация об обновлении каталога"},
{command: "settings", description: "Настройки"},
{command: "help", description: "Помощь"},
]);
bot.help((ctx: Context) => ctx.reply(Messages.HELP_MESSAGE));
bot.start((ctx: Context) => {
if (!ctx.message) {
return;
}
const name = ctx.message.from.first_name || ctx.message.from.username || 'пользователь';
ctx.telegram.sendMessage(ctx.message.chat.id,
Messages.START_MESSAGE.replace('{name}', name), {
reply_to_message_id: ctx.message.message_id,
}
);
});
registerPaginationCommand(bot, CallbackData.SEARCH_BOOK_PREFIX, BookLibrary.searchByBookName, formatBook);
registerPaginationCommand(bot, CallbackData.SEARCH_AUTHORS_PREFIX, BookLibrary.searchAuthors, formatAuthor);
registerPaginationCommand(bot, CallbackData.SEARCH_SERIES_PREFIX, BookLibrary.searchSequences, formatSequence);
registerPaginationCommand(bot, CallbackData.AUTHOR_BOOKS_PREFIX, BookLibrary.getAuthorBooks, formatBook);
registerPaginationCommand(bot, CallbackData.SEQUENCE_BOOKS_PREFIX, BookLibrary.getSequenceBooks, formatBook);
bot.hears(/^\/d_[a-zA-Z0-9]+_[\d]+$/gm, async (ctx: Context) => {
if (!ctx.message || !('text' in ctx.message)) {
return;
}
const [_, format, id] = ctx.message.text.split('_');
let cache: CachedMessage;
if (state.privileged) {
cache = await getBookCache(parseInt(id), format);
} else {
cache = await getBookCacheBuffer(parseInt(id), format);
}
ctx.telegram.copyMessage(ctx.message.chat.id, cache.chat_id, cache.message_id, {
allow_sending_without_reply: true,
})
});
bot.hears(/^\/b_info_[\d]+$/gm, async (ctx: Context) => {
if (!ctx.message || !('text' in ctx.message)) {
return;
}
const bookId = ctx.message.text.split('_')[2];
const annotation = await BookLibrary.getBookAnnotation(parseInt(bookId));
ctx.reply(annotation.text);
});
bot.hears(/^\/a_[\d]+$/gm, async (ctx: Context) => {
if (!ctx.message || !('text' in ctx.message)) {
return;
}
const authorId = ctx.message.text.split('_')[1];
const pMessage = await getPaginatedMessage(CallbackData.AUTHOR_BOOKS_PREFIX, authorId, 1, BookLibrary.getAuthorBooks, formatBook);
await ctx.reply(pMessage.message, {
reply_markup: pMessage.keyboard.reply_markup
});
});
bot.hears(/^\/s_[\d]+$/gm, async (ctx: Context) => {
if (!ctx.message || !('text' in ctx.message)) {
return;
}
const sequenceId = ctx.message.text.split('_')[1];
const pMessage = await getPaginatedMessage(CallbackData.SEQUENCE_BOOKS_PREFIX, sequenceId, 1, BookLibrary.getSequenceBooks, formatBook);
await ctx.reply(pMessage.message, {
reply_markup: pMessage.keyboard.reply_markup
});
});
bot.on("message", async (ctx: Context) => {
if (!ctx.message || !('text' in ctx.message)) {
return;
}
const query = ctx.message.text.substring(0, 64 - 7);
let keyboard = Markup.inlineKeyboard([
[
Markup.button.callback('Книгу', `${CallbackData.SEARCH_BOOK_PREFIX}${query}_1`)
],
[
Markup.button.callback('Автора', `${CallbackData.SEARCH_AUTHORS_PREFIX}${query}_1`),
],
[
Markup.button.callback('Серию', `${CallbackData.SEARCH_SERIES_PREFIX}${query}_1`)
],
[
Markup.button.callback('Переводчика', '# ToDO'),
]
]);
await ctx.telegram.sendMessage(ctx.message.chat.id, Messages.SEARCH_MESSAGE, {
reply_to_message_id: ctx.message.message_id,
reply_markup: keyboard.reply_markup,
});
});
return bot;
}

View File

@@ -0,0 +1,32 @@
import { Markup } from 'telegraf';
import { InlineKeyboardMarkup } from 'typegram';
export function getPaginationKeyboard(prefix: string, query: string, page: number, totalPages: number): Markup.Markup<InlineKeyboardMarkup> {
function getRow(delta: number) {
const row = [];
if (page - delta > 0) {
row.push(Markup.button.callback(`-${delta}`, `${prefix}${query}_${page - delta}`));
}
if (page + delta <= totalPages) {
row.push(Markup.button.callback(`+${delta}`, `${prefix}${query}_${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);
}

View File

@@ -0,0 +1,11 @@
export const START_MESSAGE = 'Привет, {name}! \n' +
'Этот бот поможет тебе загружать книги.\n' +
'Узнать, как со мной работать /help.\n' +
'Настройки языков для поиска /settings.\n';
export const HELP_MESSAGE = 'Лучше один раз увидеть, чем сто раз услышать.\n' +
'https://youtu.be/HV6Wm87D6_A';
export const SETTINGS_MESSAGE = 'Настройки:';
export const SEARCH_MESSAGE = 'Что ищем?';

View File

@@ -0,0 +1,37 @@
import got from 'got';
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;
}
export async function getBookCache(bookId: number, fileType: string): Promise<CachedMessage> {
return (await _makeRequest<BookCache>(`/api/v1/${bookId}/${fileType}`)).data;
}

View File

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

View File

@@ -0,0 +1,121 @@
import got from 'got';
import env from '@/config';
const PAGE_SIZE = 7;
export interface Page<T> {
items: T[];
page: number;
size: number;
total: number;
total_pages: number;
}
interface BookAuthor {
id: number;
first_name: string;
last_name: string;
middle_name: string;
}
export interface AuthorBook {
id: number;
title: string;
lang: string;
file_type: string;
available_types: string[];
uploaded: string;
annotation_exists: boolean;
translators: BookAuthor[];
}
export interface Book extends AuthorBook {
authors: BookAuthor[];
}
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 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 searchByBookName(query: string, page: number): Promise<Page<Book>> {
return _makeRequest<Page<Book>>(`/api/v1/books/search/${query}`, {
page: page,
size: PAGE_SIZE,
})
}
export async function searchAuthors(query: string, page: number): Promise<Page<Author>> {
return _makeRequest<Page<Author>>(`/api/v1/authors/search/${query}`, {
page: page,
size: PAGE_SIZE,
});
}
export async function searchSequences(query: string, page: number): Promise<Page<Sequence>> {
return _makeRequest<Page<Sequence>>(`/api/v1/sequences/search/${query}`, {
page: page,
size: PAGE_SIZE,
});
}
export async function getBookAnnotation(bookId: number): Promise<BookAnnotation> {
return _makeRequest<BookAnnotation>(`/api/v1/books/${bookId}/annotation`);
}
export async function getAuthorBooks(authorId: number, page: number): Promise<Page<AuthorBook>> {
return _makeRequest<Page<AuthorBook>>(`/api/v1/authors/${authorId}/books`, {
page: page,
size: PAGE_SIZE,
});
}
export async function getSequenceBooks(sequenceId: number, page: number): Promise<Page<Book>> {
return _makeRequest<Page<Book>>(`/api/v1/sequences/${sequenceId}/books`, {
page: page,
size: PAGE_SIZE,
});
}

View File

@@ -0,0 +1,55 @@
import { Context, Markup, Telegraf, TelegramError } from 'telegraf';
import { InlineKeyboardMarkup } from 'typegram';
import { getPaginationKeyboard } from './keyboard';
import * as BookLibrary from "./services/book_library";
interface PreparedMessage {
message: string;
keyboard: Markup.Markup<InlineKeyboardMarkup>;
}
export async function getPaginatedMessage<T>(
prefix: string,
data: any,
page: number,
itemsGetter: (data: any, page: number) => Promise<BookLibrary.Page<T>>,
itemFormater: (item: T) => string,
): Promise<PreparedMessage> {
const itemsPage = await itemsGetter(data, page);
const formatedItems = itemsPage.items.map(itemFormater).join('\n\n\n');
const message = formatedItems + `\n\nСтраница ${page}/${itemsPage.total_pages}`;
const keyboard = getPaginationKeyboard(prefix, data, page, itemsPage.total_pages);
return {
message,
keyboard
};
}
export function registerPaginationCommand<T>(
bot: Telegraf,
prefix: string,
itemsGetter: (data: any, page: number) => Promise<BookLibrary.Page<T>>,
itemFormater: (item: T) => string,
) {
bot.action(new RegExp(prefix), async (ctx: Context) => {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) return;
const [_, query, sPage] = ctx.callbackQuery.data.split('_');
const pMessage = await getPaginatedMessage(prefix, query, parseInt(sPage), itemsGetter, itemFormater);
try {
await ctx.editMessageText(pMessage.message, {
reply_markup: pMessage.keyboard.reply_markup
});
} catch (err) {
// console.log(err);
}
})
}

View File

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

View File

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

25
src/bots/factory/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Telegraf } from "telegraf";
import { BotState } from '@/bots/manager';
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);
}

114
src/bots/manager.ts Normal file
View File

@@ -0,0 +1,114 @@
import express, { Response, Request, NextFunction } from 'express';
import got from 'got';
import { Telegraf } from 'telegraf';
import env from '@/config';
import getBot, { BotStatuses } from './factory/index';
import { Server } from 'http';
export interface BotState {
id: number;
token: string;
status: BotStatuses;
privileged: boolean;
created_time: string;
}
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;
}
}
export default class BotsManager {
static bots: {[key: number]: Telegraf} = {};
static botsStates: {[key: number]: BotStatuses} = {};
static syncInterval: NodeJS.Timer | null = null;
static server: Server | null = null;
static async start() {
await this.sync();
this.launch();
await this.sync();
if (this.syncInterval === null) {
this.syncInterval = setInterval(() => this.sync(), 30_000);
}
}
static async sync() {
const botsData = await _makeSyncRequest();
if (botsData !== null) {
await Promise.all(botsData.map((state) => this.updateBotState(state)));
}
}
static async updateBotState(state: BotState) {
const isExists = this.bots[state.id] !== undefined;
if (isExists && this.botsStates[state.id] === state.status) {
return;
}
const bot = await getBot(state.token, state);
this.bots[state.id] = bot;
this.botsStates[state.id] = state.status;
try {
const oldBot = new Telegraf(bot.telegram.token);
await oldBot.telegram.deleteWebhook();
await oldBot.telegram.logOut();
} catch (e) {
console.log(e);
}
await bot.telegram.setWebhook(
`${env.WEBHOOK_BASE_URL}:${env.WEBHOOK_PORT}/${state.id}/${bot.telegram.token}`
);
}
static async handleUpdate(req: Request, res: Response, next: NextFunction) {
const botIdStr = req.url.split("/")[1];
const bot = this.bots[parseInt(botIdStr)];
await bot.webhookCallback(`/${botIdStr}/${bot.telegram.token}`)(req, res);
}
static async launch() {
const application = express();
application.use((req: Request, res: Response, next: NextFunction) => this.handleUpdate(req, res, next));
this.server = application.listen(env.WEBHOOK_PORT);
console.log("Server started!");
process.once('SIGINT', () => this.stop());
process.once('SIGTERM', () => this.stop());
}
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;
}
}

16
src/config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { cleanEnv, str, num } from 'envalid';
export default cleanEnv(process.env, {
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()
});

4
src/main.ts Normal file
View File

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