@verse-bot/tg-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/bot-factory.d.ts +8 -0
  2. package/dist/bot-factory.js +19 -0
  3. package/dist/index.d.ts +6 -0
  4. package/dist/index.js +22 -0
  5. package/dist/keyboards/index.d.ts +3 -0
  6. package/dist/keyboards/index.js +19 -0
  7. package/dist/keyboards/inline.d.ts +7 -0
  8. package/dist/keyboards/inline.js +19 -0
  9. package/dist/keyboards/reply-universal.d.ts +3 -0
  10. package/dist/keyboards/reply-universal.js +18 -0
  11. package/dist/keyboards/reply.d.ts +10 -0
  12. package/dist/keyboards/reply.js +26 -0
  13. package/dist/middleware/db.d.ts +3 -0
  14. package/dist/middleware/db.js +9 -0
  15. package/dist/middleware/error-handler.d.ts +3 -0
  16. package/dist/middleware/error-handler.js +9 -0
  17. package/dist/middleware/index.d.ts +3 -0
  18. package/dist/middleware/index.js +19 -0
  19. package/dist/middleware/logger.d.ts +3 -0
  20. package/dist/middleware/logger.js +12 -0
  21. package/dist/types/bot.d.ts +7 -0
  22. package/dist/types/bot.js +2 -0
  23. package/dist/types/index.d.ts +1 -0
  24. package/dist/types/index.js +17 -0
  25. package/dist/universal-bot.d.ts +24 -0
  26. package/dist/universal-bot.js +158 -0
  27. package/dist/utils/command.d.ts +7 -0
  28. package/dist/utils/command.js +6 -0
  29. package/dist/utils/index.d.ts +1 -0
  30. package/dist/utils/index.js +17 -0
  31. package/package.json +24 -0
  32. package/src/bot-factory.ts +31 -0
  33. package/src/index.ts +6 -0
  34. package/src/keyboards/index.ts +3 -0
  35. package/src/keyboards/inline.ts +24 -0
  36. package/src/keyboards/reply-universal.ts +22 -0
  37. package/src/keyboards/reply.ts +32 -0
  38. package/src/middleware/db.ts +8 -0
  39. package/src/middleware/error-handler.ts +8 -0
  40. package/src/middleware/index.ts +3 -0
  41. package/src/middleware/logger.ts +14 -0
  42. package/src/types/bot.ts +14 -0
  43. package/src/types/index.ts +1 -0
  44. package/src/universal-bot.ts +188 -0
  45. package/src/utils/command.ts +11 -0
  46. package/src/utils/index.ts +1 -0
  47. package/tsconfig.json +11 -0
@@ -0,0 +1,8 @@
1
+ import { Bot } from 'grammy';
2
+ import type { BotContext } from './types';
3
+ export interface BotFactoryOptions {
4
+ token: string;
5
+ useLogger?: boolean;
6
+ useSession?: boolean;
7
+ }
8
+ export declare function createBot(options: BotFactoryOptions): Bot<BotContext>;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createBot = createBot;
4
+ const grammy_1 = require("grammy");
5
+ const middleware_1 = require("./middleware");
6
+ function createBot(options) {
7
+ const { token, useLogger = true, useSession = false } = options;
8
+ const bot = new grammy_1.Bot(token);
9
+ bot.catch(middleware_1.errorHandler);
10
+ if (useSession) {
11
+ bot.use((0, grammy_1.session)({
12
+ initial: () => ({}),
13
+ }));
14
+ }
15
+ if (useLogger) {
16
+ bot.use(middleware_1.loggerMiddleware);
17
+ }
18
+ return bot;
19
+ }
@@ -0,0 +1,6 @@
1
+ export * from './types';
2
+ export * from './utils';
3
+ export * from './middleware';
4
+ export * from './keyboards';
5
+ export * from './bot-factory.js';
6
+ export * from './universal-bot.js';
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./types"), exports);
18
+ __exportStar(require("./utils"), exports);
19
+ __exportStar(require("./middleware"), exports);
20
+ __exportStar(require("./keyboards"), exports);
21
+ __exportStar(require("./bot-factory.js"), exports);
22
+ __exportStar(require("./universal-bot.js"), exports);
@@ -0,0 +1,3 @@
1
+ export * from './inline.js';
2
+ export * from './reply.js';
3
+ export * from './reply-universal.js';
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./inline.js"), exports);
18
+ __exportStar(require("./reply.js"), exports);
19
+ __exportStar(require("./reply-universal.js"), exports);
@@ -0,0 +1,7 @@
1
+ import { InlineKeyboard } from 'grammy';
2
+ export interface InlineButton {
3
+ text: string;
4
+ data?: string;
5
+ url?: string;
6
+ }
7
+ export declare function createInlineKeyboard(rows: InlineButton[][]): InlineKeyboard;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createInlineKeyboard = createInlineKeyboard;
4
+ const grammy_1 = require("grammy");
5
+ function createInlineKeyboard(rows) {
6
+ const keyboard = new grammy_1.InlineKeyboard();
7
+ for (const row of rows) {
8
+ for (const btn of row) {
9
+ if (btn.url) {
10
+ keyboard.url(btn.text, btn.url);
11
+ }
12
+ else if (btn.data) {
13
+ keyboard.text(btn.text, btn.data);
14
+ }
15
+ }
16
+ keyboard.row();
17
+ }
18
+ return keyboard;
19
+ }
@@ -0,0 +1,3 @@
1
+ import { Keyboard } from 'grammy';
2
+ import type { UniversalKeyboardButton } from '@verse-bot/shared';
3
+ export declare function createTelegramKeyboard(universalKeyboard: UniversalKeyboardButton[][], resize?: boolean, oneTime?: boolean): Keyboard;
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createTelegramKeyboard = createTelegramKeyboard;
4
+ const grammy_1 = require("grammy");
5
+ function createTelegramKeyboard(universalKeyboard, resize = true, oneTime = false) {
6
+ const keyboard = new grammy_1.Keyboard();
7
+ for (const row of universalKeyboard) {
8
+ for (const btn of row) {
9
+ keyboard.text(btn.label);
10
+ }
11
+ keyboard.row();
12
+ }
13
+ if (resize)
14
+ keyboard.resized();
15
+ if (oneTime)
16
+ keyboard.oneTime();
17
+ return keyboard;
18
+ }
@@ -0,0 +1,10 @@
1
+ import { Keyboard } from 'grammy';
2
+ export interface ReplyButton {
3
+ text: string;
4
+ requestContact?: boolean;
5
+ requestLocation?: boolean;
6
+ }
7
+ export declare function createReplyKeyboard(rows: ReplyButton[][], options?: {
8
+ resize?: boolean;
9
+ oneTime?: boolean;
10
+ }): Keyboard;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createReplyKeyboard = createReplyKeyboard;
4
+ const grammy_1 = require("grammy");
5
+ function createReplyKeyboard(rows, options) {
6
+ const keyboard = new grammy_1.Keyboard();
7
+ for (const row of rows) {
8
+ for (const btn of row) {
9
+ if (btn.requestContact) {
10
+ keyboard.requestContact(btn.text);
11
+ }
12
+ else if (btn.requestLocation) {
13
+ keyboard.requestLocation(btn.text);
14
+ }
15
+ else {
16
+ keyboard.text(btn.text);
17
+ }
18
+ }
19
+ keyboard.row();
20
+ }
21
+ if (options?.resize)
22
+ keyboard.resized();
23
+ if (options?.oneTime)
24
+ keyboard.oneTime();
25
+ return keyboard;
26
+ }
@@ -0,0 +1,3 @@
1
+ import type { MiddlewareFn } from 'grammy';
2
+ import type { BotContext } from '../types';
3
+ export declare const dbMiddleware: MiddlewareFn<BotContext>;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.dbMiddleware = void 0;
4
+ const shared_1 = require("@verse-bot/shared");
5
+ const dbMiddleware = async (ctx, next) => {
6
+ ctx.db = (0, shared_1.getPool)();
7
+ await next();
8
+ };
9
+ exports.dbMiddleware = dbMiddleware;
@@ -0,0 +1,3 @@
1
+ import type { ErrorHandler } from 'grammy';
2
+ import type { BotContext } from '../types';
3
+ export declare const errorHandler: ErrorHandler<BotContext>;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.errorHandler = void 0;
4
+ const errorHandler = (err) => {
5
+ const ctx = err.ctx;
6
+ console.error(`[Error] Update ${ctx.update.update_id}:`);
7
+ console.error(err.error);
8
+ };
9
+ exports.errorHandler = errorHandler;
@@ -0,0 +1,3 @@
1
+ export * from './db.js';
2
+ export * from './logger.js';
3
+ export * from './error-handler.js';
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./db.js"), exports);
18
+ __exportStar(require("./logger.js"), exports);
19
+ __exportStar(require("./error-handler.js"), exports);
@@ -0,0 +1,3 @@
1
+ import type { MiddlewareFn } from 'grammy';
2
+ import type { BotContext } from '../types';
3
+ export declare const loggerMiddleware: MiddlewareFn<BotContext>;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loggerMiddleware = void 0;
4
+ const loggerMiddleware = async (ctx, next) => {
5
+ const start = Date.now();
6
+ const from = ctx.from?.username ?? ctx.from?.id ?? 'unknown';
7
+ const text = ctx.message?.text ?? ctx.callbackQuery?.data ?? '—';
8
+ console.log(`[${new Date().toISOString()}] @${from}: ${text}`);
9
+ await next();
10
+ console.log(`[${new Date().toISOString()}] Handled in ${Date.now() - start}ms`);
11
+ };
12
+ exports.loggerMiddleware = loggerMiddleware;
@@ -0,0 +1,7 @@
1
+ import type { Context as GrammyContext, SessionFlavor } from 'grammy';
2
+ import type { Pool } from 'pg';
3
+ export interface SessionData {
4
+ }
5
+ export interface BotContext extends GrammyContext, SessionFlavor<SessionData> {
6
+ db: Pool;
7
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1 @@
1
+ export * from './bot.js';
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./bot.js"), exports);
@@ -0,0 +1,24 @@
1
+ import { Bot } from 'grammy';
2
+ import { type UniversalContext } from '@verse-bot/shared';
3
+ import type { BotContext } from './types';
4
+ export interface TelegramBotConfig {
5
+ token: string;
6
+ adminId?: number;
7
+ /** Обработчики статических команд (без параметров). Ключ – имя команды (без слеша). */
8
+ commands: Record<string, (ctx: UniversalContext) => Promise<void>>;
9
+ /** Определения кнопок (берутся из phrases). Массив объектов с command и button. */
10
+ buttons: {
11
+ command: string;
12
+ label: string;
13
+ }[];
14
+ /** Опционально: обработчик команды /content_<N> */
15
+ contentCommand?: (ctx: UniversalContext, itemNumber: number) => Promise<void>;
16
+ /** Опционально: обработчик команды /userlog_<N> */
17
+ userLogCommand?: (ctx: UniversalContext, userId: number) => Promise<void>;
18
+ /** Опциональный кастомный обработчик отправки фото (используется в replyWithPhoto контекста).
19
+ * Если не задан, используется ctx.replyWithPhoto из GrammY. */
20
+ onReplyWithPhoto?: (photoUrl: string, caption?: string) => Promise<void>;
21
+ /** Путь к папке с контентом (для резервного поиска изображений). */
22
+ contentDir?: string;
23
+ }
24
+ export declare function createUniversalTelegramBot(config: TelegramBotConfig): Bot<BotContext>;
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createUniversalTelegramBot = createUniversalTelegramBot;
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const node_fs_1 = require("node:fs");
9
+ const grammy_1 = require("grammy");
10
+ const shared_1 = require("@verse-bot/shared");
11
+ const bot_factory_js_1 = require("./bot-factory.js");
12
+ const middleware_1 = require("./middleware");
13
+ function createUniversalTelegramBot(config) {
14
+ const bot = (0, bot_factory_js_1.createBot)({ token: config.token });
15
+ // Подключаем пул БД
16
+ bot.use(middleware_1.dbMiddleware);
17
+ // Middleware создания UniversalContext
18
+ bot.use(async (ctx, next) => {
19
+ const defaultPhotoHandler = async (photoUrl, caption) => {
20
+ try {
21
+ await ctx.replyWithPhoto(photoUrl, {
22
+ caption: caption ?? undefined,
23
+ parse_mode: 'MarkdownV2',
24
+ });
25
+ }
26
+ catch {
27
+ if (config.contentDir) {
28
+ const filename = decodeURIComponent(photoUrl.split('/').pop() ?? '');
29
+ const filepath = node_path_1.default.join(config.contentDir, 'content-images', filename);
30
+ if ((0, node_fs_1.existsSync)(filepath)) {
31
+ const buffer = (0, node_fs_1.readFileSync)(filepath);
32
+ await ctx.replyWithPhoto(new grammy_1.InputFile(buffer, filename), {
33
+ caption: caption ?? undefined,
34
+ parse_mode: 'MarkdownV2',
35
+ });
36
+ }
37
+ else {
38
+ await ctx.api.sendMessage(uctx.peerId, caption ?? '');
39
+ }
40
+ }
41
+ else {
42
+ await ctx.api.sendMessage(uctx.peerId, caption ?? '');
43
+ }
44
+ }
45
+ };
46
+ const uctx = {
47
+ platform: 'telegram',
48
+ userId: String(ctx.from?.id ?? 0),
49
+ peerId: ctx.chat?.id ?? 0,
50
+ text: ctx.message?.text ?? '',
51
+ isAdmin: ctx.from?.id === config.adminId,
52
+ db: ctx.db,
53
+ firstName: ctx.from?.first_name,
54
+ lastName: ctx.from?.last_name,
55
+ username: ctx.from?.username,
56
+ chatType: ctx.chat?.type ?? 'unknown',
57
+ format: (0, shared_1.format)('telegram'),
58
+ replySafe: async (text, extra) => uctx.reply(text, { ...(0, shared_1.mdOpts)('telegram'), ...extra }),
59
+ reply: async (text, extra) => {
60
+ await ctx.api.sendMessage(uctx.peerId, text, {
61
+ ...(extra?.parse_mode && { parse_mode: extra.parse_mode }),
62
+ ...(extra?.telegramReplyMarkup && { reply_markup: extra.telegramReplyMarkup }),
63
+ ...(extra?.remove_keyboard && { reply_markup: { remove_keyboard: true } }),
64
+ ...(extra?.link_preview_options && {
65
+ link_preview_options: extra.link_preview_options,
66
+ }),
67
+ });
68
+ },
69
+ replyWithFile: async (buffer, filename, caption) => {
70
+ await ctx.replyWithDocument(new grammy_1.InputFile(buffer, filename), caption ? { caption, parse_mode: 'MarkdownV2' } : { parse_mode: 'MarkdownV2' });
71
+ },
72
+ replyWithPhoto: config.onReplyWithPhoto ?? defaultPhotoHandler,
73
+ tgApi: ctx.api,
74
+ };
75
+ ctx.uctx = uctx;
76
+ await next();
77
+ });
78
+ // Middleware проверки и логирования пользователей
79
+ bot.use(async (ctx, next) => {
80
+ const text = ctx.message?.text ?? '';
81
+ const isStart = text === '/start' || text.startsWith('/start ');
82
+ const uctx = ctx.uctx;
83
+ if (!uctx)
84
+ return next();
85
+ const exists = await (0, shared_1.userExists)(uctx.platform, uctx.userId);
86
+ if (!exists) {
87
+ if (isStart) {
88
+ const dbUser = await (0, shared_1.findOrCreateUser)(uctx.platform, uctx.userId);
89
+ if (!dbUser)
90
+ return;
91
+ uctx.dbUserId = dbUser.id;
92
+ await (0, shared_1.logCommand)(dbUser.id, uctx.platform, '/start');
93
+ return next();
94
+ }
95
+ return;
96
+ }
97
+ const dbUser = await (0, shared_1.findOrCreateUser)(uctx.platform, uctx.userId);
98
+ uctx.dbUserId = dbUser.id;
99
+ if (isStart) {
100
+ await (0, shared_1.logCommand)(dbUser.id, uctx.platform, '/start');
101
+ return next();
102
+ }
103
+ const command = text.split(' ')[0];
104
+ const commandName = command.startsWith('/') ? command : text;
105
+ await (0, shared_1.logCommand)(dbUser.id, uctx.platform, commandName);
106
+ return next();
107
+ });
108
+ // Регистрация статических команд и кнопок
109
+ for (const { command, label } of config.buttons) {
110
+ const handler = config.commands[command];
111
+ if (handler) {
112
+ // Команда вида /start
113
+ bot.command(command, async (ctx) => {
114
+ const uctx = ctx.uctx;
115
+ await handler(uctx);
116
+ });
117
+ // Кнопка с текстом label
118
+ bot.hears(label, async (ctx) => {
119
+ const uctx = ctx.uctx;
120
+ await handler(uctx);
121
+ });
122
+ }
123
+ }
124
+ // Регистрация остальных команд, которые не имеют кнопок (например, /full, /admin)
125
+ for (const [command, handler] of Object.entries(config.commands)) {
126
+ // Проверяем, не была ли уже зарегистрирована через кнопку
127
+ const alreadyRegistered = config.buttons.some((b) => b.command === command);
128
+ if (!alreadyRegistered) {
129
+ bot.command(command, async (ctx) => {
130
+ const uctx = ctx.uctx;
131
+ await handler(uctx);
132
+ });
133
+ }
134
+ }
135
+ // Динамические команды
136
+ if (config.contentCommand) {
137
+ bot.hears(/^\/content_(\d+)$/i, async (ctx) => {
138
+ const itemNumber = parseInt(ctx.match[1], 10);
139
+ if (!isNaN(itemNumber) && itemNumber > 0) {
140
+ const uctx = ctx.uctx;
141
+ await config.contentCommand(uctx, itemNumber);
142
+ }
143
+ else {
144
+ // Сообщение об ошибке? Можно передать фразу из phrases, но пока опустим
145
+ }
146
+ });
147
+ }
148
+ if (config.userLogCommand) {
149
+ bot.hears(/^\/userlog_(\d+)$/i, async (ctx) => {
150
+ const userId = parseInt(ctx.match[1], 10);
151
+ if (!isNaN(userId)) {
152
+ const uctx = ctx.uctx;
153
+ await config.userLogCommand(uctx, userId);
154
+ }
155
+ });
156
+ }
157
+ return bot;
158
+ }
@@ -0,0 +1,7 @@
1
+ import type { BotContext } from '../types';
2
+ export interface CommandDefinition {
3
+ command: string;
4
+ description: string;
5
+ handler: (ctx: BotContext) => Promise<void>;
6
+ }
7
+ export declare function createCommand(def: CommandDefinition): CommandDefinition;
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createCommand = createCommand;
4
+ function createCommand(def) {
5
+ return def;
6
+ }
@@ -0,0 +1 @@
1
+ export * from './command.js';
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./command.js"), exports);
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@verse-bot/tg-core",
3
+ "version": "0.1.0",
4
+ "description": "Core library for building Telegram bots",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "source": "./src/index.ts",
10
+ "import": "./dist/index.js",
11
+ "require": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "dev": "tsc --watch",
18
+ "lint": "tsc --noemit"
19
+ },
20
+ "dependencies": {
21
+ "@verse-bot/shared": "^0.1.0",
22
+ "grammy": "^1.42.0"
23
+ }
24
+ }
@@ -0,0 +1,31 @@
1
+ import { Bot, session } from 'grammy';
2
+ import { errorHandler, loggerMiddleware } from './middleware';
3
+ import type { BotContext, SessionData } from './types';
4
+
5
+ export interface BotFactoryOptions {
6
+ token: string;
7
+ useLogger?: boolean;
8
+ useSession?: boolean;
9
+ }
10
+
11
+ export function createBot(options: BotFactoryOptions): Bot<BotContext> {
12
+ const { token, useLogger = true, useSession = false } = options;
13
+
14
+ const bot = new Bot<BotContext>(token);
15
+
16
+ bot.catch(errorHandler);
17
+
18
+ if (useSession) {
19
+ bot.use(
20
+ session<SessionData, BotContext>({
21
+ initial: (): SessionData => ({}),
22
+ }),
23
+ );
24
+ }
25
+
26
+ if (useLogger) {
27
+ bot.use(loggerMiddleware);
28
+ }
29
+
30
+ return bot;
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './types';
2
+ export * from './utils';
3
+ export * from './middleware';
4
+ export * from './keyboards';
5
+ export * from './bot-factory.js';
6
+ export * from './universal-bot.js';
@@ -0,0 +1,3 @@
1
+ export * from './inline.js';
2
+ export * from './reply.js';
3
+ export * from './reply-universal.js';
@@ -0,0 +1,24 @@
1
+ import { InlineKeyboard } from 'grammy';
2
+
3
+ export interface InlineButton {
4
+ text: string;
5
+ data?: string; // callback_data
6
+ url?: string; // внешняя ссылка
7
+ }
8
+
9
+ export function createInlineKeyboard(rows: InlineButton[][]): InlineKeyboard {
10
+ const keyboard = new InlineKeyboard();
11
+
12
+ for (const row of rows) {
13
+ for (const btn of row) {
14
+ if (btn.url) {
15
+ keyboard.url(btn.text, btn.url);
16
+ } else if (btn.data) {
17
+ keyboard.text(btn.text, btn.data);
18
+ }
19
+ }
20
+ keyboard.row();
21
+ }
22
+
23
+ return keyboard;
24
+ }
@@ -0,0 +1,22 @@
1
+ import { Keyboard } from 'grammy';
2
+ import type { UniversalKeyboardButton } from '@verse-bot/shared';
3
+
4
+ export function createTelegramKeyboard(
5
+ universalKeyboard: UniversalKeyboardButton[][],
6
+ resize: boolean = true,
7
+ oneTime: boolean = false,
8
+ ): Keyboard {
9
+ const keyboard = new Keyboard();
10
+
11
+ for (const row of universalKeyboard) {
12
+ for (const btn of row) {
13
+ keyboard.text(btn.label);
14
+ }
15
+ keyboard.row();
16
+ }
17
+
18
+ if (resize) keyboard.resized();
19
+ if (oneTime) keyboard.oneTime();
20
+
21
+ return keyboard;
22
+ }
@@ -0,0 +1,32 @@
1
+ import { Keyboard } from 'grammy';
2
+
3
+ export interface ReplyButton {
4
+ text: string;
5
+ requestContact?: boolean;
6
+ requestLocation?: boolean;
7
+ }
8
+
9
+ export function createReplyKeyboard(
10
+ rows: ReplyButton[][],
11
+ options?: { resize?: boolean; oneTime?: boolean },
12
+ ): Keyboard {
13
+ const keyboard = new Keyboard();
14
+
15
+ for (const row of rows) {
16
+ for (const btn of row) {
17
+ if (btn.requestContact) {
18
+ keyboard.requestContact(btn.text);
19
+ } else if (btn.requestLocation) {
20
+ keyboard.requestLocation(btn.text);
21
+ } else {
22
+ keyboard.text(btn.text);
23
+ }
24
+ }
25
+ keyboard.row();
26
+ }
27
+
28
+ if (options?.resize) keyboard.resized();
29
+ if (options?.oneTime) keyboard.oneTime();
30
+
31
+ return keyboard;
32
+ }
@@ -0,0 +1,8 @@
1
+ import type { MiddlewareFn } from 'grammy';
2
+ import { getPool } from '@verse-bot/shared';
3
+ import type { BotContext } from '../types';
4
+
5
+ export const dbMiddleware: MiddlewareFn<BotContext> = async (ctx, next) => {
6
+ ctx.db = getPool();
7
+ await next();
8
+ };
@@ -0,0 +1,8 @@
1
+ import type { ErrorHandler } from 'grammy';
2
+ import type { BotContext } from '../types';
3
+
4
+ export const errorHandler: ErrorHandler<BotContext> = (err) => {
5
+ const ctx = err.ctx;
6
+ console.error(`[Error] Update ${ctx.update.update_id}:`);
7
+ console.error(err.error);
8
+ };
@@ -0,0 +1,3 @@
1
+ export * from './db.js';
2
+ export * from './logger.js';
3
+ export * from './error-handler.js';
@@ -0,0 +1,14 @@
1
+ import type { MiddlewareFn } from 'grammy';
2
+ import type { BotContext } from '../types';
3
+
4
+ export const loggerMiddleware: MiddlewareFn<BotContext> = async (ctx, next) => {
5
+ const start = Date.now();
6
+ const from = ctx.from?.username ?? ctx.from?.id ?? 'unknown';
7
+ const text = ctx.message?.text ?? ctx.callbackQuery?.data ?? '—';
8
+
9
+ console.log(`[${new Date().toISOString()}] @${from}: ${text}`);
10
+
11
+ await next();
12
+
13
+ console.log(`[${new Date().toISOString()}] Handled in ${Date.now() - start}ms`);
14
+ };
@@ -0,0 +1,14 @@
1
+ import type { Context as GrammyContext, SessionFlavor } from 'grammy';
2
+ import type { Pool } from 'pg';
3
+
4
+ export interface SessionData {
5
+ // Здесь будут общие данные сессии, например:
6
+ // userId: number;
7
+ // language: 'ru' | 'en';
8
+ }
9
+
10
+ // Расширяем контекст gramgrammy
11
+ export interface BotContext extends GrammyContext, SessionFlavor<SessionData> {
12
+ // Можно добавить свои поля
13
+ db: Pool;
14
+ }
@@ -0,0 +1 @@
1
+ export * from './bot.js';
@@ -0,0 +1,188 @@
1
+ import path from 'node:path';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { Bot, InputFile } from 'grammy';
4
+ import {
5
+ findOrCreateUser,
6
+ format,
7
+ logCommand,
8
+ type UniversalContext,
9
+ userExists,
10
+ mdOpts,
11
+ } from '@verse-bot/shared';
12
+ import { createBot } from './bot-factory.js';
13
+ import { dbMiddleware } from './middleware';
14
+ import type { BotContext } from './types';
15
+
16
+ export interface TelegramBotConfig {
17
+ token: string;
18
+ adminId?: number;
19
+ /** Обработчики статических команд (без параметров). Ключ – имя команды (без слеша). */
20
+ commands: Record<string, (ctx: UniversalContext) => Promise<void>>;
21
+ /** Определения кнопок (берутся из phrases). Массив объектов с command и button. */
22
+ buttons: { command: string; label: string }[];
23
+ /** Опционально: обработчик команды /content_<N> */
24
+ contentCommand?: (ctx: UniversalContext, itemNumber: number) => Promise<void>;
25
+ /** Опционально: обработчик команды /userlog_<N> */
26
+ userLogCommand?: (ctx: UniversalContext, userId: number) => Promise<void>;
27
+ /** Опциональный кастомный обработчик отправки фото (используется в replyWithPhoto контекста).
28
+ * Если не задан, используется ctx.replyWithPhoto из GrammY. */
29
+ onReplyWithPhoto?: (photoUrl: string, caption?: string) => Promise<void>;
30
+ /** Путь к папке с контентом (для резервного поиска изображений). */
31
+ contentDir?: string;
32
+ }
33
+
34
+ export function createUniversalTelegramBot(config: TelegramBotConfig): Bot<BotContext> {
35
+ const bot = createBot({ token: config.token });
36
+
37
+ // Подключаем пул БД
38
+ bot.use(dbMiddleware);
39
+
40
+ // Middleware создания UniversalContext
41
+ bot.use(async (ctx, next) => {
42
+ const defaultPhotoHandler = async (photoUrl: string, caption?: string) => {
43
+ try {
44
+ await ctx.replyWithPhoto(photoUrl, {
45
+ caption: caption ?? undefined,
46
+ parse_mode: 'MarkdownV2',
47
+ });
48
+ } catch {
49
+ if (config.contentDir) {
50
+ const filename = decodeURIComponent(photoUrl.split('/').pop() ?? '');
51
+ const filepath = path.join(config.contentDir, 'content-images', filename);
52
+ if (existsSync(filepath)) {
53
+ const buffer = readFileSync(filepath);
54
+ await ctx.replyWithPhoto(new InputFile(buffer, filename), {
55
+ caption: caption ?? undefined,
56
+ parse_mode: 'MarkdownV2',
57
+ });
58
+ } else {
59
+ await ctx.api.sendMessage(uctx.peerId, caption ?? '');
60
+ }
61
+ } else {
62
+ await ctx.api.sendMessage(uctx.peerId, caption ?? '');
63
+ }
64
+ }
65
+ };
66
+
67
+ const uctx: UniversalContext = {
68
+ platform: 'telegram',
69
+ userId: String(ctx.from?.id ?? 0),
70
+ peerId: ctx.chat?.id ?? 0,
71
+ text: ctx.message?.text ?? '',
72
+ isAdmin: ctx.from?.id === config.adminId,
73
+ db: ctx.db,
74
+ firstName: ctx.from?.first_name,
75
+ lastName: ctx.from?.last_name,
76
+ username: ctx.from?.username,
77
+ chatType: ctx.chat?.type ?? 'unknown',
78
+ format: format('telegram'),
79
+ replySafe: async (text, extra) => uctx.reply(text, { ...mdOpts('telegram'), ...extra }),
80
+ reply: async (text, extra) => {
81
+ await ctx.api.sendMessage(uctx.peerId, text, {
82
+ ...(extra?.parse_mode && { parse_mode: extra.parse_mode }),
83
+ ...(extra?.telegramReplyMarkup && { reply_markup: extra.telegramReplyMarkup }),
84
+ ...(extra?.remove_keyboard && { reply_markup: { remove_keyboard: true } }),
85
+ ...(extra?.link_preview_options && {
86
+ link_preview_options: extra.link_preview_options,
87
+ }),
88
+ });
89
+ },
90
+ replyWithFile: async (buffer, filename, caption) => {
91
+ await ctx.replyWithDocument(
92
+ new InputFile(buffer, filename),
93
+ caption ? { caption, parse_mode: 'MarkdownV2' } : { parse_mode: 'MarkdownV2' },
94
+ );
95
+ },
96
+ replyWithPhoto: config.onReplyWithPhoto ?? defaultPhotoHandler,
97
+ tgApi: ctx.api,
98
+ };
99
+ (ctx as any).uctx = uctx;
100
+ await next();
101
+ });
102
+
103
+ // Middleware проверки и логирования пользователей
104
+ bot.use(async (ctx, next) => {
105
+ const text = ctx.message?.text ?? '';
106
+ const isStart = text === '/start' || text.startsWith('/start ');
107
+ const uctx: UniversalContext = (ctx as any).uctx;
108
+ if (!uctx) return next();
109
+
110
+ const exists = await userExists(uctx.platform, uctx.userId);
111
+ if (!exists) {
112
+ if (isStart) {
113
+ const dbUser = await findOrCreateUser(uctx.platform, uctx.userId);
114
+ if (!dbUser) return;
115
+ uctx.dbUserId = dbUser.id;
116
+ await logCommand(dbUser.id, uctx.platform, '/start');
117
+ return next();
118
+ }
119
+ return;
120
+ }
121
+
122
+ const dbUser = await findOrCreateUser(uctx.platform, uctx.userId);
123
+ uctx.dbUserId = dbUser!.id;
124
+ if (isStart) {
125
+ await logCommand(dbUser!.id, uctx.platform, '/start');
126
+ return next();
127
+ }
128
+
129
+ const command = text.split(' ')[0];
130
+ const commandName = command.startsWith('/') ? command : text;
131
+ await logCommand(dbUser!.id, uctx.platform, commandName);
132
+ return next();
133
+ });
134
+
135
+ // Регистрация статических команд и кнопок
136
+ for (const { command, label } of config.buttons) {
137
+ const handler = config.commands[command];
138
+ if (handler) {
139
+ // Команда вида /start
140
+ bot.command(command, async (ctx) => {
141
+ const uctx = (ctx as any).uctx;
142
+ await handler(uctx);
143
+ });
144
+ // Кнопка с текстом label
145
+ bot.hears(label, async (ctx) => {
146
+ const uctx = (ctx as any).uctx;
147
+ await handler(uctx);
148
+ });
149
+ }
150
+ }
151
+
152
+ // Регистрация остальных команд, которые не имеют кнопок (например, /full, /admin)
153
+ for (const [command, handler] of Object.entries(config.commands)) {
154
+ // Проверяем, не была ли уже зарегистрирована через кнопку
155
+ const alreadyRegistered = config.buttons.some((b) => b.command === command);
156
+ if (!alreadyRegistered) {
157
+ bot.command(command, async (ctx) => {
158
+ const uctx = (ctx as any).uctx;
159
+ await handler(uctx);
160
+ });
161
+ }
162
+ }
163
+
164
+ // Динамические команды
165
+ if (config.contentCommand) {
166
+ bot.hears(/^\/content_(\d+)$/i, async (ctx) => {
167
+ const itemNumber = parseInt(ctx.match[1], 10);
168
+ if (!isNaN(itemNumber) && itemNumber > 0) {
169
+ const uctx = (ctx as any).uctx;
170
+ await config.contentCommand!(uctx, itemNumber);
171
+ } else {
172
+ // Сообщение об ошибке? Можно передать фразу из phrases, но пока опустим
173
+ }
174
+ });
175
+ }
176
+
177
+ if (config.userLogCommand) {
178
+ bot.hears(/^\/userlog_(\d+)$/i, async (ctx) => {
179
+ const userId = parseInt(ctx.match[1], 10);
180
+ if (!isNaN(userId)) {
181
+ const uctx = (ctx as any).uctx;
182
+ await config.userLogCommand!(uctx, userId);
183
+ }
184
+ });
185
+ }
186
+
187
+ return bot;
188
+ }
@@ -0,0 +1,11 @@
1
+ import type { BotContext } from '../types';
2
+
3
+ export interface CommandDefinition {
4
+ command: string;
5
+ description: string;
6
+ handler: (ctx: BotContext) => Promise<void>;
7
+ }
8
+
9
+ export function createCommand(def: CommandDefinition) {
10
+ return def;
11
+ }
@@ -0,0 +1 @@
1
+ export * from './command.js';
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "declaration": true,
7
+ "composite": true
8
+ },
9
+ "include": ["src/**/*.ts"],
10
+ "exclude": ["node_modules", "dist"]
11
+ }