@solncebro/telegram-engine
v0.2.0
Published
Universal TypeScript library for Telegram bots with menu system, broadcasting, and message management
Maintainers
Readme
@solncebro/telegram-engine
Универсальная TypeScript-библиотека для Telegram ботов на базе Telegraf v4.
Предоставляет готовые механизмы: реестр ботов, inline-меню с навигацией, MarkdownV2, рассылка с pin'ами, управление вводом пользователя, декларативная регистрация команд.
Библиотека не привязана к доменной логике — потребитель определяет свои enum'ы, типы и обработчики через дженерики и конфигурацию.
Установка
yarn add @solncebro/telegram-engineЕдинственная runtime-зависимость — telegraf ^4.16.3 (устанавливается автоматически).
Быстрый старт
Минимальный бот за 20 строк:
import {
createBotRegistry,
registerBotCommands,
} from "@solncebro/telegram-engine";
const registry = createBotRegistry({
allowedPeerList: ["123456789"],
onLog: (message, data) => console.log(message, data),
});
const bot = registry.register({
botToken: "YOUR_BOT_TOKEN",
botName: "MyBot",
});
await registerBotCommands({
bot: bot.bot,
commandConfigList: [
{
command: "start",
description: "Запуск бота",
handler: async (ctx) => {
await ctx.reply("Привет!");
},
},
],
});
await registry.launchAll();Руководство по модулям
1. Core — Управление ботами
createBotRegistry — Реестр ботов
Центральная точка управления N ботами. Создаёт ботов, sender'ов и access control автоматически.
import { createBotRegistry } from "@solncebro/telegram-engine";
const registry = createBotRegistry({
// Список chat ID, которым разрешено взаимодействовать с ботами.
// Пустой массив или отсутствие — разрешено всем.
allowedPeerList: ["111111", "222222"],
// Callback для логирования. Подключи свой логгер.
onLog: (message, data) => logger.info(data, message),
});Регистрация ботов:
const serviceBot = registry.register({
botToken: process.env.SERVICE_BOT_TOKEN!,
botName: "Service",
onError: (error, botName) => {
logger.error({ error }, `Failed to launch ${botName}`);
},
});
const notificationBot = registry.register({
botToken: process.env.NOTIFICATION_BOT_TOKEN!,
botName: "Notification",
});
// Запуск всех ботов
await registry.launchAll();
// Остановка (например, при SIGTERM)
process.on("SIGTERM", async () => {
await registry.stopAll("SIGTERM");
});Получение бота или sender'а по имени:
const bot = registry.getBot("Service"); // Telegraf | undefined
const sender = registry.createSender("Service"); // TelegramSender | undefined
const registered = registry.get("Service"); // { instance, sender } | undefinedcreateBot — Одиночный бот
Если реестр не нужен:
import { createBot } from "@solncebro/telegram-engine";
const instance = createBot({
botToken: "TOKEN",
botName: "MyBot",
onError: (error, name) => console.error(name, error),
});
// Бот не запускается автоматически — ты контролируешь момент запуска
await instance.launch();
// Telegraf-инстанс для регистрации хендлеров
instance.bot.command("ping", (ctx) => ctx.reply("pong"));createAccessControl — Контроль доступа
import { createAccessControl } from "@solncebro/telegram-engine";
const ac = createAccessControl({ allowedPeerList: ["123", "456"] });
ac.isAllowedPeer("123"); // true
ac.isAllowedPeer("789"); // false
// Динамическое управление
ac.addPeer("789");
ac.removePeer("456");
// Пустой список = разрешено всем
const openAc = createAccessControl({ allowedPeerList: [] });
openAc.isAllowedPeer("любой"); // truecreateSender — Отправка сообщений
Привязывает функции отправки к конкретному боту:
import { createSender, createAccessControl } from "@solncebro/telegram-engine";
const sender = createSender({
getBot: () => myTelegrafInstance, // ленивая загрузка — бот может быть создан позже
accessControl: createAccessControl({ allowedPeerList: ["123"] }),
onLog: (msg) => console.log(msg),
});
// Отправка сообщения
await sender.sendMessage({
message: "Привет!",
peer: "123456789",
useMarkdownV2: true, // опционально
isSilentMessage: false, // опционально
returnMessageId: false, // опционально — если true, возвращает number
});
// Отправка + получение message_id
const messageId = await sender.sendMessage({
message: "Важное сообщение",
peer: "123456789",
returnMessageId: true,
});
// Закрепление / открепление
await sender.pinMessage("123456789", messageId as number);
await sender.unpinMessage("123456789", messageId as number);
// Редактирование
await sender.editMessage({
chatId: "123456789",
messageId: 42,
text: "Обновлённый текст",
useMarkdownV2: true,
});
// Удаление
await sender.deleteMessage("123456789", 42);Все методы безопасны: если бот не создан (getBot() вернул undefined) или peer не разрешён — метод завершается без ошибки.
2. Menu — Система inline-меню
Полноценная навигация по inline-клавиатурам с поддержкой Back/Main menu, валидацией параметров и роутингом шагов.
Шаг 1: Определи свои типы
// types/menu.types.ts в твоём проекте
enum MenuStepEnum {
main = "main",
settings = "settings",
selectCategory = "selectCategory",
categoryDetail = "categoryDetail",
}
enum MenuActionEnum {
toggleFeature = "toggleFeature",
changeValue = "changeValue",
back = "back",
closeMenu = "closeMenu",
}
interface MenuCallbackData {
step?: MenuStepEnum;
action?: MenuActionEnum;
categoryId?: string;
page?: number;
}Шаг 2: Настрой callbackEncoder
Telegram ограничивает callback_data до 64 байт. Encoder сжимает данные через short-коды:
import {
createCallbackEncoder,
type FieldConfig,
} from "@solncebro/telegram-engine";
// Short-коды для enum-значений
const stepCodeByValue: Record<MenuStepEnum, string> = {
[MenuStepEnum.main]: "m",
[MenuStepEnum.settings]: "s",
[MenuStepEnum.selectCategory]: "sc",
[MenuStepEnum.categoryDetail]: "cd",
};
const stepValueByCode = Object.fromEntries(
Object.entries(stepCodeByValue).map(([k, v]) => [v, k]),
) as Record<string, MenuStepEnum>;
const actionCodeByValue: Record<MenuActionEnum, string> = {
[MenuActionEnum.toggleFeature]: "tf",
[MenuActionEnum.changeValue]: "cv",
[MenuActionEnum.back]: "b",
[MenuActionEnum.closeMenu]: "cm",
};
const actionValueByCode = Object.fromEntries(
Object.entries(actionCodeByValue).map(([k, v]) => [v, k]),
) as Record<string, MenuActionEnum>;
const fieldConfigList: Array<FieldConfig<MenuCallbackData>> = [
{
key: "step",
shortCode: "s",
encode: (v) => stepCodeByValue[v as MenuStepEnum],
decode: (v) => stepValueByCode[v as string],
},
{
key: "action",
shortCode: "a",
encode: (v) => actionCodeByValue[v as MenuActionEnum],
decode: (v) => actionValueByCode[v as string],
},
{
key: "categoryId",
shortCode: "c",
encode: (v) => String(v),
decode: (v) => String(v),
},
{
key: "page",
shortCode: "p",
encode: (v) => Number(v),
decode: (v) => Number(v),
},
];
const encoder = createCallbackEncoder<MenuCallbackData>(fieldConfigList);
// Использование
const encoded = encoder.encode({
step: MenuStepEnum.categoryDetail,
categoryId: "electronics",
page: 2,
});
// → '{"s":"cd","c":"electronics","p":2}' (компактно, влезет в 64 байта)
const decoded = encoder.decode(encoded);
// → { step: "categoryDetail", categoryId: "electronics", page: 2 }Шаг 3: Построй клавиатуры
import { createKeyboardBuilder } from "@solncebro/telegram-engine";
const kb = createKeyboardBuilder(encoder);
// Клавиатура главного меню
const mainMenuKeyboard = kb.build([
{
text: "⚙️ Настройки",
callbackData: { step: MenuStepEnum.settings },
},
{
text: "📁 Категории",
callbackData: { step: MenuStepEnum.selectCategory },
},
{
text: "❌ Закрыть",
callbackData: { action: MenuActionEnum.closeMenu },
},
]);
// Клавиатура с навигацией (Back + Main menu)
const categoryKeyboard = kb.build(
[
{ text: "📦 Электроника", callbackData: { step: MenuStepEnum.categoryDetail, categoryId: "electronics" } },
{ text: "👕 Одежда", callbackData: { step: MenuStepEnum.categoryDetail, categoryId: "clothes" } },
],
{
backCallbackData: { step: MenuStepEnum.main },
mainMenuCallbackData: { step: MenuStepEnum.main },
backText: "⬅️ Назад", // опционально, по умолчанию "Back"
mainMenuText: "🏠 Главное меню", // опционально, по умолчанию "Main menu"
},
);Шаг 4: Опиши навигацию
import {
createNavigationSchema,
type NavigationStepSchema,
} from "@solncebro/telegram-engine";
const schema: Record<MenuStepEnum, NavigationStepSchema<MenuStepEnum, MenuCallbackData>> = {
[MenuStepEnum.main]: {
requiredParamList: [], // нет обязательных параметров
backTo: null, // некуда возвращаться
backParamList: [],
},
[MenuStepEnum.settings]: {
requiredParamList: [],
backTo: MenuStepEnum.main, // Back → главное меню
backParamList: [],
},
[MenuStepEnum.selectCategory]: {
requiredParamList: [],
backTo: MenuStepEnum.main,
backParamList: [],
},
[MenuStepEnum.categoryDetail]: {
requiredParamList: ["categoryId"], // для этого шага нужен categoryId
backTo: MenuStepEnum.selectCategory,
backParamList: [], // при Back categoryId не передаётся (возвращаемся к списку)
},
};
const navigation = createNavigationSchema<MenuStepEnum, MenuCallbackData>(schema);
// Проверка: можно ли отрисовать шаг с такими параметрами?
navigation.validateStepParams(MenuStepEnum.categoryDetail, { categoryId: "electronics" }); // true
navigation.validateStepParams(MenuStepEnum.categoryDetail, {}); // false
// Куда идти по Back?
navigation.getBackDestination(MenuStepEnum.categoryDetail, { categoryId: "electronics" });
// → { step: "selectCategory", params: {} }Шаг 5: Настрой роутеры
import { createMenuRouter, createActionRouter } from "@solncebro/telegram-engine";
import { Markup } from "telegraf";
const menuRouter = createMenuRouter<MenuStepEnum, MenuCallbackData>({
[MenuStepEnum.main]: () => ({
messageList: ["Главное меню"],
keyboard: mainMenuKeyboard,
}),
[MenuStepEnum.settings]: async () => {
const settings = await fetchSettings(); // твоя бизнес-логика
return {
messageList: [formatSettings(settings)],
keyboard: settingsKeyboard,
};
},
[MenuStepEnum.selectCategory]: () => ({
messageList: ["Выберите категорию:"],
keyboard: categoryKeyboard,
}),
[MenuStepEnum.categoryDetail]: async (data) => {
const category = await fetchCategory(data.categoryId!);
return {
messageList: [formatCategory(category)],
keyboard: categoryDetailKeyboard(data.categoryId!),
};
},
});
const actionRouter = createActionRouter<MenuActionEnum, MenuCallbackData>({
[MenuActionEnum.toggleFeature]: async () => {
await toggleFeature();
return {
messageList: ["Настройка изменена"],
keyboard: settingsKeyboard,
};
},
[MenuActionEnum.changeValue]: () => ({
messageList: ["Введите новое значение:"],
keyboard: inputKeyboard,
}),
[MenuActionEnum.back]: () => ({
messageList: ["Главное меню"],
keyboard: mainMenuKeyboard,
}),
[MenuActionEnum.closeMenu]: () => ({
messageList: ["Меню закрыто"],
keyboard: Markup.inlineKeyboard([]),
}),
});
// Использование в хендлерах
const result = await menuRouter.handleStep(MenuStepEnum.settings, {});
// result.messageList → ["Настройки: ..."]
// result.keyboard → InlineKeyboard
const actionResult = await actionRouter.handleAction(MenuActionEnum.toggleFeature, {});Шаг 6: Собери всё в callback_query handler
const callbackQueryHandler = async (ctx: Context) => {
if (!("data" in ctx.callbackQuery!)) return;
const parsed = encoder.decode(ctx.callbackQuery!.data as string);
if (!parsed) return;
try {
await ctx.answerCbQuery("Обработка...");
} catch {}
if (parsed.action) {
const result = await actionRouter.handleAction(parsed.action, parsed);
await ctx.editMessageText(result.messageList[0], result.keyboard);
return;
}
if (parsed.step) {
if (!navigation.validateStepParams(parsed.step, parsed)) {
const back = navigation.getBackDestination(parsed.step, parsed);
const fallbackStep = back?.step ?? MenuStepEnum.main;
const result = await menuRouter.handleStep(fallbackStep, back?.params ?? {});
await ctx.editMessageText(result.messageList[0], result.keyboard);
return;
}
const result = await menuRouter.handleStep(parsed.step, parsed);
await ctx.editMessageText(result.messageList[0], result.keyboard);
}
};3. Input — Управление вводом пользователя
Когда бот просит пользователя ввести текст (например, новое значение настройки), нужно запомнить что именно он вводит.
inputStateManager
import { createInputStateManager } from "@solncebro/telegram-engine";
const inputState = createInputStateManager<MenuActionEnum, MenuCallbackData>();
// В callback_query handler — когда пользователь нажал "Изменить значение"
inputState.set(chatId, {
action: MenuActionEnum.changeValue,
callbackData: { categoryId: "electronics" },
messageId: ctx.callbackQuery?.message?.message_id,
});
// В message handler — когда пользователь ввёл текст
if (inputState.has(chatId)) {
const state = inputState.get(chatId)!;
if (state.action === MenuActionEnum.changeValue) {
const newValue = parseFloat(ctx.message.text);
await saveValue(state.callbackData?.categoryId, newValue);
await ctx.reply("Значение сохранено!");
inputState.delete(chatId);
}
}Валидаторы
import {
validatePositiveNumber,
validateIntegerAndPositive,
parseCommaSeparatedRange,
} from "@solncebro/telegram-engine";
validatePositiveNumber(5); // true
validatePositiveNumber(-1); // false
validateIntegerAndPositive(5); // { isInteger: true, isPositive: true }
validateIntegerAndPositive(5.5); // { isInteger: false, isPositive: true }
// Парсинг диапазонов "from,to"
parseCommaSeparatedRange("100,200"); // { isValid: true, values: { from: 100, to: 200 } }
parseCommaSeparatedRange("100,"); // { isValid: true, values: { from: 100, to: undefined } }
parseCommaSeparatedRange(",200"); // { isValid: true, values: { from: undefined, to: 200 } }
parseCommaSeparatedRange("-50,50"); // { isValid: true, values: { from: -50, to: 50 } }
parseCommaSeparatedRange("abc,200"); // { isValid: false, errorMessage: "Invalid 'from' value..." }4. Message — Работа с сообщениями
MarkdownV2
Полный набор утилит для безопасного форматирования сообщений в MarkdownV2:
import {
escapeMarkdownV2Text,
escapeMarkdownV2WithFormatting,
formatClickableText,
markdownV2Builder,
} from "@solncebro/telegram-engine";
const price = 1234.56;
const symbol = "BTCUSDT";
// Способ 1: Экранирование + форматирование
const text = `Цена ${formatClickableText(symbol)}: ${escapeMarkdownV2Text(price)} USDT`;
// → "Цена `BTCUSDT`: 1234\.56 USDT"
// Способ 2: Использование markdownV2Builder
const message =
`Символ: ${markdownV2Builder.code(symbol)}\n` +
`Цена: ${markdownV2Builder.bold(price)} USDT\n` +
`Статус: ${markdownV2Builder.italic("актуально")}`;
// → "Символ: `BTCUSDT`\nЦена: *1234\.56* USDT\nСтатус: _актуально_"
// Способ 3: Для сообщений с уже расставленной разметкой
const settingsMessage = "Order Volume: *100 USDT* (was: 50 USDT)";
const escaped = escapeMarkdownV2WithFormatting(settingsMessage);
// → "Order Volume: *100 USDT* \\(was: 50 USDT\\)"
// Bold сохранён, скобки экранированы
await sender.sendMessage({
message,
peer: chatId,
useMarkdownV2: true,
});Функции:
escapeMarkdownV2Text(text | number): string— Экранирует все 18 спецсимволов:_ * [ ] ( ) ~> # + - = | { } . !` Используется когда текст не должен содержать форматирование.escapeMarkdownV2WithFormatting(text): string— Умное экранирование. Распознаёт пары маркеров (*bold*,`code`,||spoiler||,_italic_,~strikethrough~) и сохраняет их, экранируя только содержимое. Идеально для сообщений где разметка уже расставлена (например, Firebase settings, отчёты).formatClickableText(text | number): string— Оборачивает в backticks для кликабельного inline code в Telegram:`BTCUSDT`markdownV2Builderобъект-builder:markdownV2Builder.bold(text)→*text*markdownV2Builder.italic(text)→_text_markdownV2Builder.code(text)→`text`markdownV2Builder.strikethrough(text)→~text~markdownV2Builder.spoiler(text)→||text||markdownV2Builder.link(text, url)→[text](url)markdownV2Builder.escape(text)→ экранирование без маркеров
Разбиение длинных сообщений
import { splitMessageToChunkList } from "@solncebro/telegram-engine";
const longReport = generateReport(); // может быть 10000+ символов
const chunkList = splitMessageToChunkList(longReport);
// chunkList: ["первая часть...", "вторая часть...", ...]
// Разбиение по строкам, не разрывает строки посередине.
// По умолчанию maxLength = 3500 (с запасом от лимита Telegram 4096).
// Можно передать свой лимит:
const smallChunkList = splitMessageToChunkList(longReport, 1000);Трекинг сообщений меню
При навигации по inline-меню нужно удалять старые сообщения и редактировать текущее:
import { createMessageTracker } from "@solncebro/telegram-engine";
const messageTracker = createMessageTracker();
// Запомнили ID отправленных сообщений меню
messageTracker.set(chatId, [msgId1, msgId2, msgId3]);
// При переходе на новый шаг — получаем список для удаления,
// исключая сообщение которое будем редактировать
const toDeleteList = messageTracker.cleanup(chatId, currentMessageId);
// toDeleteList: [msgId1, msgId3] (msgId2 == currentMessageId — исключён)
// Удаляем старые
await deleteMessageListById({
telegram: bot.telegram,
chatId: numericChatId,
messageIdList: toDeleteList,
});Пакетное удаление
import { deleteMessageListById } from "@solncebro/telegram-engine";
await deleteMessageListById({
telegram: bot.telegram,
chatId: 123456789,
messageIdList: [100, 101, 102],
onLog: (msg, data) => logger.warn(data, msg), // логирует ошибки, не бросает
});Удаляет последовательно. Если одно сообщение не удалось (уже удалено, нет прав) — продолжает со следующим.
5. Broadcast — Рассылка
Broadcaster
import { createBroadcaster } from "@solncebro/telegram-engine";
const sender = registry.createSender("Notification")!;
const broadcaster = createBroadcaster({
sender,
recipientList: ["111111", "222222", "333333"],
onLog: (msg, data) => logger.error(data, msg),
});
// Отправить одно сообщение всем
await broadcaster.sendToAll("Сервер перезапущен", true); // true = MarkdownV2
// Отправить длинный отчёт (несколько чанков) всем
const chunkList = splitMessageToChunkList(longReport);
await broadcaster.sendChunkedToAll(chunkList, 300, true);
// 300ms пауза между чанками для одного получателя.
// Получатели обрабатываются параллельно.
// Отправить + закрепить (для дейли-репортов)
const pinnedMessageIdListByChatId = new Map<string, number[]>();
await broadcaster.sendAndPin({
message: dailyReport,
pinnedMessageIdListByChatId, // broadcaster мутирует этот Map
maxPinnedCount: 10, // FIFO: если больше 10 — откреплят самый старый
useMarkdownV2: true,
});
// После вызова pinnedMessageIdListByChatId содержит обновлённые спискиReporter — Retry-обёртка
import { createReporter } from "@solncebro/telegram-engine";
const reporter = createReporter({ broadcaster });
// Отправить событие. При ошибке — одна повторная попытка.
await reporter.reportEvent("Деплой завершён");
await reporter.reportEvent("Баланс: 1000 USDT", true); // MarkdownV2
// Отправить ошибку
await reporter.reportError("Ошибка подключения к API", error);6. Command — Регистрация команд
import { registerBotCommands } from "@solncebro/telegram-engine";
await registerBotCommands({
bot: fundingBot.bot,
// Контроль доступа (опционально)
accessControl: createAccessControl({ allowedPeerList: ["123"] }),
// Команды
commandConfigList: [
{
command: "menu",
description: "Показать меню",
handler: async (ctx) => {
const result = await menuRouter.handleStep(MenuStepEnum.main, {});
await ctx.reply(result.messageList[0], result.keyboard);
},
},
{
command: "status",
description: "Статус системы",
handler: async (ctx) => {
const status = await getSystemStatus();
await ctx.reply(status, { parse_mode: "MarkdownV2" });
},
},
],
// Обработчик inline-кнопок (опционально)
callbackQueryHandler: async (ctx) => {
// Парсинг callback_data, роутинг по шагам/действиям
// (см. раздел "Menu" выше)
},
// Обработчик текстовых сообщений (опционально)
messageHandler: async (ctx) => {
// Обработка пользовательского ввода
// (см. раздел "Input" выше)
},
// Логирование ошибок (опционально)
onError: (msg, data) => logger.error(data, msg),
});registerBotCommands выполняет:
setMyCommands— подсказки в UI Telegram.bot.command(...)— для каждой команды с access check + try-catch.bot.on("callback_query", ...)— если передан.bot.on("message", ...)— если передан.bot.catch(...)— catch-all для необработанных ошибок.
7. Utils
import { pause, TELEGRAM_MESSAGE_MAX_LENGTH } from "@solncebro/telegram-engine";
// Пауза
await pause(1000); // 1 секунда
// Константы
TELEGRAM_MESSAGE_MAX_LENGTH; // 3500Полный пример: бот с меню и рассылкой
import {
createBotRegistry,
createCallbackEncoder,
createKeyboardBuilder,
createMenuRouter,
createNavigationSchema,
createInputStateManager,
createBroadcaster,
createReporter,
registerBotCommands,
escapeMarkdownV2Text,
splitMessageToChunkList,
type FieldConfig,
type NavigationStepSchema,
} from "@solncebro/telegram-engine";
// ─── 1. Типы ──────────────────────────────────
enum StepEnum {
main = "main",
settings = "settings",
}
enum ActionEnum {
toggleNotifications = "toggleNotifications",
close = "close",
}
interface CallbackData {
step?: StepEnum;
action?: ActionEnum;
}
// ─── 2. Encoder + Keyboard ────────────────────
const encoder = createCallbackEncoder<CallbackData>([
{ key: "step", shortCode: "s", encode: (v) => String(v), decode: (v) => String(v) as StepEnum },
{ key: "action", shortCode: "a", encode: (v) => String(v), decode: (v) => String(v) as ActionEnum },
]);
const kb = createKeyboardBuilder(encoder);
// ─── 3. Navigation ───────────────────────────
const navigation = createNavigationSchema<StepEnum, CallbackData>({
[StepEnum.main]: { requiredParamList: [], backTo: null, backParamList: [] },
[StepEnum.settings]: { requiredParamList: [], backTo: StepEnum.main, backParamList: [] },
});
// ─── 4. Router ────────────────────────────────
let notificationsEnabled = true;
const menuRouter = createMenuRouter<StepEnum, CallbackData>({
[StepEnum.main]: () => ({
messageList: ["Главное меню"],
keyboard: kb.build([
{ text: "⚙️ Настройки", callbackData: { step: StepEnum.settings } },
{ text: "❌ Закрыть", callbackData: { action: ActionEnum.close } },
]),
}),
[StepEnum.settings]: () => ({
messageList: [`Уведомления: ${notificationsEnabled ? "ON" : "OFF"}`],
keyboard: kb.build(
[{ text: `${notificationsEnabled ? "🔕" : "🔔"} Переключить`, callbackData: { action: ActionEnum.toggleNotifications } }],
{ backCallbackData: { step: StepEnum.main }, mainMenuCallbackData: { step: StepEnum.main } },
),
}),
});
// ─── 5. Registry + Commands ───────────────────
const registry = createBotRegistry({
allowedPeerList: process.env.ALLOWED_USERS?.split(",") ?? [],
});
const bot = registry.register({
botToken: process.env.BOT_TOKEN!,
botName: "Main",
});
await registerBotCommands({
bot: bot.bot,
commandConfigList: [
{
command: "menu",
description: "Показать меню",
handler: async (ctx) => {
const result = await menuRouter.handleStep(StepEnum.main, {});
await ctx.reply(result.messageList[0], result.keyboard);
},
},
],
callbackQueryHandler: async (ctx) => {
if (!("data" in ctx.callbackQuery!)) return;
const parsed = encoder.decode(ctx.callbackQuery!.data as string);
if (!parsed) return;
try { await ctx.answerCbQuery(); } catch {}
if (parsed.action === ActionEnum.toggleNotifications) {
notificationsEnabled = !notificationsEnabled;
const result = await menuRouter.handleStep(StepEnum.settings, {});
await ctx.editMessageText(result.messageList[0], result.keyboard);
return;
}
if (parsed.action === ActionEnum.close) {
await ctx.deleteMessage();
return;
}
if (parsed.step) {
const result = await menuRouter.handleStep(parsed.step, parsed);
await ctx.editMessageText(result.messageList[0], result.keyboard);
}
},
onError: (msg, data) => console.error(msg, data),
});
// ─── 6. Broadcast ─────────────────────────────
const sender = registry.createSender("Main")!;
const broadcaster = createBroadcaster({
sender,
recipientList: process.env.ALLOWED_USERS?.split(",") ?? [],
});
const reporter = createReporter({ broadcaster });
// Рассылка по расписанию
setInterval(async () => {
await reporter.reportEvent(
`Статус: ${escapeMarkdownV2Text(new Date().toISOString())}`,
true,
);
}, 60_000);
// ─── 7. Запуск ────────────────────────────────
await registry.launchAll();
console.log("Bot started");API Reference
Экспорты
| Функция | Модуль | Описание |
|---------|--------|----------|
| createBotRegistry | core | Реестр N ботов с общим access control |
| createBot | core | Одиночный Telegraf-инстанс |
| createSender | core | Примитивы отправки, привязанные к боту |
| createAccessControl | core | Белый список peer ID |
| createCallbackEncoder | menu | Кодирование callback_data (64-байтный лимит) |
| createKeyboardBuilder | menu | Построение inline-клавиатур |
| createNavigationSchema | menu | Граф навигации с валидацией |
| createMenuRouter | menu | Роутер шагов меню |
| createActionRouter | menu | Роутер действий |
| createInputStateManager | input | Состояние ввода пользователя |
| validatePositiveNumber | input | Проверка > 0 |
| validateIntegerAndPositive | input | Проверка целое + положительное |
| parseCommaSeparatedRange | input | Парсинг "from,to" |
| escapeMarkdownV2Text | message | Экранирование спецсимволов |
| escapeMarkdownV2WithFormatting | message | Экранирование с сохранением разметки |
| formatClickableText | message | Оборачивание в backticks |
| markdownV2Builder | message | Билдер bold/italic/code/spoiler/link/escape |
| splitMessageToChunkList | message | Разбиение по лимиту |
| createMessageTracker | message | Трекинг message ID по чатам |
| deleteMessageListById | message | Пакетное удаление |
| createBroadcaster | broadcast | Рассылка всем получателям |
| createReporter | broadcast | Retry-обёртка для рассылки |
| registerBotCommands | command | Декларативная регистрация команд |
| pause | utils | setTimeout в Promise |
| TELEGRAM_MESSAGE_MAX_LENGTH | utils | 3500 |
| DEFAULT_BROADCAST_PAUSE_MS | utils | 300 |
| DEFAULT_MAX_PINNED_COUNT | utils | 10 |
