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.3.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");

UI-движок: меню, экраны и пошаговые диалоги

Высокоуровневый слой поверх примитивов. Приложение объявляет своё меню (экраны, кнопки, шаги диалога) как данные и передаёт свои обработчики; библиотека выполняет всю механику взаимодействия одинаково в любом приложении. Доменной (например, торговой) логики библиотека не содержит — всё специфичное приходит параметрами.

Канонические операции (одно поведение везде)

| Операция | Что делает | Чем вызывается | |---|---|---| | Заменить экран / навигация | удалить прошлые сообщения + отправить новое (текст / фото / клавиатура) | menuReplacer.replaceMenu(ctx, { text, replyMarkup }) | | Закрыть меню | удалить все отслеживаемые сообщения, ничего не слать | menuReplacer.replaceMenu(ctx, { shouldCloseOnly: true }) | | Снять клавиатуру (оставить сообщение) | убрать кнопки, текст оставить как историю | dismissKeyboard(ctx) | | Загрузка (callback-кнопка) | мгновенный отклик: убрать клавиатуру либо показать , затем результат | loadingController.startCallbackLoading(ctx, mode) | | Загрузка (reply-кнопка) | удалить прошлое меню → → результат | loadingController.startHearsLoading(ctx) | | Назад | родитель экрана по дереву → заменить экран | menuTree.getParent(screen) |

Шаг 1. Поверхность (один или несколько ботов)

resolveSurface(ctx) сообщает движку, в каком боте/чате работать и какой трекер сообщений использовать. Для одного бота — простая функция; для нескольких — выбор по токену бота из ctx.

import {
  Context,
  createBot,
  createMessageTracker,
  createMenuReplacer,
  createLoadingController,
  type MenuSurface,
} from "@solncebro/telegram-engine";

const bot = createBot({ botToken: "YOUR_TOKEN", botName: "DemoBot" });
const chatId = "123456789";
const tracker = createMessageTracker();

const resolveSurface = (): MenuSurface | null => ({
  telegram: bot.bot.telegram,
  chatId,
  tracker,
});

const onLog = (level: "warn" | "error", message: string, meta?: Record<string, unknown>) =>
  console[level](`[Demo] ${message}`, meta ?? {});

const menuReplacer = createMenuReplacer({ resolveSurface, onLog });
const loadingController = createLoadingController({
  menuReplacer,
  resolveSurface,
  defaultLoadingText: "⏳ Загрузка...",
  onLog,
});

Шаг 2. Дерево экранов

Приложение описывает дерево как карту «экран → родитель». Библиотека резолвит родителя, проверяет валидность экрана и строит нижний ряд [Назад][Закрыть]. Что показывать на каждом экране — задаёт приложение (функции render).

import {
  createMenuTree,
  buildDismissReplyMarkup,
  type RawInlineButton,
} from "@solncebro/telegram-engine";

type Screen = "root" | "settings" | "notifications" | "about";

const menuTree = createMenuTree<Screen>({
  parentByScreen: {
    root: null,
    settings: "root",
    notifications: "settings",
    about: "root",
  },
});

const FOOTER_LABELS = {
  backText: "⬅️ Назад",
  backCallbackData: "nav_back",
  closeText: "✖️ Закрыть",
  closeCallbackData: "nav_close",
};

// render каждого экрана — это прикладные данные: заголовок + кнопки.
function renderScreen(screen: Screen): { text: string; buttons: RawInlineButton[][] } {
  const footer = menuTree.buildFooterRow(screen, FOOTER_LABELS);

  switch (screen) {
    case "root":
      return {
        text: "Главное меню",
        buttons: [
          [{ text: "⚙️ Настройки", callback_data: "open:settings" }],
          [{ text: "ℹ️ О боте", callback_data: "open:about" }],
          footer,
        ],
      };
    case "settings":
      return {
        text: "Настройки",
        buttons: [[{ text: "🔔 Уведомления", callback_data: "open:notifications" }], footer],
      };
    case "notifications":
      return { text: "Уведомления: включены", buttons: [footer] };
    case "about":
      return { text: "Демо-бот на @solncebro/telegram-engine", buttons: [footer] };
  }
}

async function showScreen(ctx: Context, screen: Screen): Promise<void> {
  const { text, buttons } = renderScreen(screen);
  await menuReplacer.replaceMenu(ctx, {
    text,
    replyMarkup: { inline_keyboard: buttons },
  });
}

Шаг 3. Привязка кнопок (это делает приложение)

Регистрация кнопок и сами обработчики — прикладной код. Они «передаются внутрь»: дергают канонические операции движка.

// Открыть экран
bot.bot.action(/^open:(.+)/, async (ctx) => {
  await loadingController.startCallbackLoading(ctx, "strip-keyboard");
  const target = ctx.match[1];

  if (menuTree.isValidScreen(target)) {
    await showScreen(ctx, target);
  }
});

// Назад
bot.bot.action("nav_back", async (ctx) => {
  await loadingController.startCallbackLoading(ctx, "strip-keyboard");
  // приложение хранит текущий экран как ему удобно; здесь для примера — из callback
  const current = "notifications" as Screen;
  const parent = menuTree.getParent(current) ?? "root";
  await showScreen(ctx, parent);
});

// Закрыть меню
bot.bot.action("nav_close", async (ctx) => {
  await ctx.answerCbQuery();
  await menuReplacer.replaceMenu(ctx, { shouldCloseOnly: true });
});

Шаг 4. Кнопки-заготовки с памятью значений

Готовый ряд кнопок из дефолтов + недавно введённых значений (история не дублируется).

import {
  buildPresetKeyboard,
  buildPresetDisplayList,
  promoteToFront,
} from "@solncebro/telegram-engine";

let recentAmountList: number[] = [];

function buildAmountKeyboard() {
  const displayList = buildPresetDisplayList({
    recentList: recentAmountList,
    defaultList: [100, 500, 1000],
    maxCount: 5,
  });

  return buildPresetKeyboard({
    valueList: displayList,
    callbackPrefix: "amount",
    formatText: (value) => `$${value}`,
  });
}

// при выборе значения — продвинуть его в начало истории
function rememberAmount(value: number): void {
  recentAmountList = promoteToFront(recentAmountList, value, 5);
}

Шаг 5. Пошаговый диалог (движок шагов)

Движок хранит текущий шаг и собранные данные и переключает шаги. Сами шаги, тексты и проверки — целиком в приложении. Тип состояния (OrderState) и набор шагов объявляет приложение.

import { createWizard } from "@solncebro/telegram-engine";

interface OrderState {
  step: "name" | "amount" | "confirm";
  name?: string;
  amount?: number;
}

const wizard = createWizard<OrderState>();
const KEY = "order"; // один диалог на чат; для нескольких чатов — ключ = chatId

// Старт диалога (например, по reply-кнопке "Создать")
async function startCreate(ctx: Context): Promise<void> {
  wizard.start(KEY, { step: "name" });
  await menuReplacer.replaceMenu(ctx, { text: "Введите название:", isPlainText: true });
}

// Маршрутизация текстового ввода по текущему шагу (это решает приложение)
bot.bot.on("text", async (ctx) => {
  const state = wizard.get(KEY);

  if (state === undefined) {
    return; // диалог не активен
  }

  const value = ctx.message.text.trim();

  if (state.step === "name") {
    state.name = value; // движок хранит объект по ссылке — мутация сохраняется
    state.step = "amount";
    await menuReplacer.replaceMenu(ctx, {
      text: "Сумма:",
      isPlainText: true,
      replyMarkup: { inline_keyboard: buildAmountKeyboard() },
    });
    return;
  }

  if (state.step === "amount") {
    const amount = Number(value);
    if (!Number.isFinite(amount) || amount <= 0) {
      await menuReplacer.replaceMenu(ctx, { text: "Нужно положительное число. Сумма:", isPlainText: true });
      return; // остаёмся на том же шаге
    }
    rememberAmount(amount);
    wizard.patch(KEY, { step: "confirm", amount });
    await menuReplacer.replaceMenu(ctx, {
      text: `Создать «${state.name}» на $${amount}?`,
      replyMarkup: { inline_keyboard: [[
        { text: "✅ Да", callback_data: "order_confirm" },
        { text: "✖️ Отмена", callback_data: "order_cancel" },
      ]] },
    });
  }
});

// Кнопка из заготовок суммы
bot.bot.action(/^amount:(\d+)/, async (ctx) => {
  const state = wizard.get(KEY);
  if (state?.step !== "amount") {
    await ctx.answerCbQuery("Сессия истекла");
    return;
  }
  await loadingController.startCallbackLoading(ctx, "strip-keyboard");
  const amount = Number(ctx.match[1]);
  rememberAmount(amount);
  wizard.patch(KEY, { step: "confirm", amount });
  await menuReplacer.replaceMenu(ctx, {
    text: `Создать «${state.name}» на $${amount}?`,
    replyMarkup: { inline_keyboard: [[
      { text: "✅ Да", callback_data: "order_confirm" },
      { text: "✖️ Отмена", callback_data: "order_cancel" },
    ]] },
  });
});

bot.bot.action("order_confirm", async (ctx) => {
  const loading = await loadingController.startCallbackLoading(ctx, "replace-text");
  const state = wizard.get(KEY);
  // ...прикладное действие (сохранить, вызвать API и т.д.)...
  wizard.reset(KEY);
  await loading.finalize(`Создано: ${state?.name} ($${state?.amount})`);
});

bot.bot.action("order_cancel", async (ctx) => {
  await ctx.answerCbQuery();
  wizard.reset(KEY);
  await menuReplacer.replaceMenu(ctx, { shouldCloseOnly: true });
});

Этого набора достаточно, чтобы собрать любое меню и любой пошаговый диалог: приложение описывает экраны, кнопки и шаги как данные, а механика взаимодействия (появление/замена/закрытие сообщений, загрузка, навигация, хранение шагов) приходит из библиотеки в едином виде.


API Reference

Экспорты

| Функция | Модуль | Описание | |---------|--------|----------| | createBotRegistry | core | Реестр N ботов с общим access control | | createBot | core | Одиночный Telegraf-инстанс (с crash guard) | | applyBotCrashGuard | core | Защита long polling от падения одного handler'а | | createSender | core | Примитивы отправки, привязанные к боту | | createAccessControl | core | Белый список peer ID | | createCallbackEncoder | menu | Кодирование callback_data (64-байтный лимит) | | createKeyboardBuilder | menu | Построение inline-клавиатур | | createNavigationSchema | menu | Граф навигации с валидацией | | createMenuRouter | menu | Роутер шагов меню | | createActionRouter | menu | Роутер действий | | createMenuTree | menu | Дерево экранов: родитель / валидация / ряд [Назад][Закрыть] | | createMenuReplacer | menu | Жизненный цикл сообщений: заменить экран / закрыть / удалить отслеживаемые | | createLoadingController | menu | Индикатор загрузки для callback- и reply-кнопок (LoadingHandle) | | buildDismissReplyMarkup | menu | Клавиатура из одной кнопки «Закрыть» | | dismissKeyboard | menu | Снять inline-клавиатуру, сообщение оставить | | createWizard | menu | Движок шагов: хранит шаг + данные, переключает шаги | | buildPresetKeyboard | menu | Ряд кнопок-заготовок из списка значений | | buildPresetDisplayList | menu | Слияние «недавние + дефолты» без дублей | | promoteToFront | menu | Продвинуть значение в начало истории | | buildMessageIdListToDelete | menu | Список ID на удаление (трекинг + callback-сообщение) | | isBenignTelegramEditError | message | Распознать безвредную ошибку правки/удаления | | editMessageWithFallback | message | Правка caption → откат на правку текста | | 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 |