npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@solncebro/telegram-engine

v0.2.0

Published

Universal TypeScript library for Telegram bots with menu system, broadcasting, and message management

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 } | undefined

createBot — Одиночный бот

Если реестр не нужен:

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("любой");  // true

createSender — Отправка сообщений

Привязывает функции отправки к конкретному боту:

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 выполняет:

  1. setMyCommands — подсказки в UI Telegram.
  2. bot.command(...) — для каждой команды с access check + try-catch.
  3. bot.on("callback_query", ...) — если передан.
  4. bot.on("message", ...) — если передан.
  5. 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 |