mirror of
https://github.com/flibusta-apps/book_bot.git
synced 2025-12-06 07:25:36 +01:00
Init
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.vscode
|
||||
|
||||
build
|
||||
|
||||
node_modules
|
||||
|
||||
package-lock.json
|
||||
49
.github/workflows/build_docker_image.yml
vendored
Normal file
49
.github/workflows/build_docker_image.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Build docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
Build-Docker-Image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- id: repository_name
|
||||
uses: ASzc/change-string-case-action@v1
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
|
||||
-
|
||||
name: Login to ghcr.io
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
-
|
||||
name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
env:
|
||||
IMAGE: ${{ steps.repository_name.outputs.lowercase }}
|
||||
with:
|
||||
push: true
|
||||
tags: ghcr.io/${{ env.IMAGE }}:latest
|
||||
context: .
|
||||
file: ./docker/build.dockerfile
|
||||
|
||||
-
|
||||
name: Invoke deployment hook
|
||||
uses: joelwmale/webhook-action@master
|
||||
with:
|
||||
url: ${{ secrets.WEBHOOK_URL }}
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.vscode
|
||||
|
||||
build
|
||||
|
||||
node_modules
|
||||
|
||||
package-lock.json
|
||||
22
docker/build.dockerfile
Normal file
22
docker/build.dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:lts-alpine as build-image
|
||||
|
||||
WORKDIR /root/app
|
||||
|
||||
COPY ./package.json ./
|
||||
COPY ./tsconfig.json ./
|
||||
COPY ./src ./src
|
||||
|
||||
RUN npm i && npm run build
|
||||
|
||||
|
||||
FROM node:lts-alpine as runtime-image
|
||||
|
||||
WORKDIR /root/app
|
||||
|
||||
COPY ./package.json ./
|
||||
|
||||
RUN npm i --only=production
|
||||
|
||||
COPY --from=build-image /root/app/build ./build
|
||||
|
||||
CMD npm run run
|
||||
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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-watch": "npm run build -- --watch",
|
||||
"run": "node ./build/main.cjs",
|
||||
"run-watch": "nodemon build/main.cjs"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"envalid": "^7.2.2",
|
||||
"express": "^4.17.1",
|
||||
"got": "^11.8.3",
|
||||
"safe-compare": "^1.1.4",
|
||||
"telegraf": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/node": "^16.11.9",
|
||||
"@types/safe-compare": "^1.1.0",
|
||||
"esbuild": "^0.14.2",
|
||||
"nodemon": "^2.0.15",
|
||||
"typescript": "^4.5.2"
|
||||
}
|
||||
}
|
||||
8
src/bots/factory/bots/approved/callback_data.ts
Normal file
8
src/bots/factory/bots/approved/callback_data.ts
Normal 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_';
|
||||
55
src/bots/factory/bots/approved/format.ts
Normal file
55
src/bots/factory/bots/approved/format.ts
Normal 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');
|
||||
}
|
||||
142
src/bots/factory/bots/approved/index.ts
Normal file
142
src/bots/factory/bots/approved/index.ts
Normal 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;
|
||||
}
|
||||
32
src/bots/factory/bots/approved/keyboard.ts
Normal file
32
src/bots/factory/bots/approved/keyboard.ts
Normal 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);
|
||||
}
|
||||
11
src/bots/factory/bots/approved/messages.ts
Normal file
11
src/bots/factory/bots/approved/messages.ts
Normal 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 = 'Что ищем?';
|
||||
37
src/bots/factory/bots/approved/services/book_cache.ts
Normal file
37
src/bots/factory/bots/approved/services/book_cache.ts
Normal 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;
|
||||
}
|
||||
22
src/bots/factory/bots/approved/services/book_cache_buffer.ts
Normal file
22
src/bots/factory/bots/approved/services/book_cache_buffer.ts
Normal 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}`);
|
||||
}
|
||||
121
src/bots/factory/bots/approved/services/book_library.ts
Normal file
121
src/bots/factory/bots/approved/services/book_library.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
55
src/bots/factory/bots/approved/utils.ts
Normal file
55
src/bots/factory/bots/approved/utils.ts
Normal 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);
|
||||
}
|
||||
})
|
||||
}
|
||||
16
src/bots/factory/bots/blocked.ts
Normal file
16
src/bots/factory/bots/blocked.ts
Normal 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;
|
||||
}
|
||||
16
src/bots/factory/bots/pending.ts
Normal file
16
src/bots/factory/bots/pending.ts
Normal 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
25
src/bots/factory/index.ts
Normal 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
114
src/bots/manager.ts
Normal 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
16
src/config.ts
Normal 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
4
src/main.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import BotsManager from './bots/manager';
|
||||
|
||||
|
||||
BotsManager.start();
|
||||
104
tsconfig.json
Normal file
104
tsconfig.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"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