@ykurilov/agent-chat-messengers
v0.75.0
Published
Чат приложения в Telegram, VK и MAX: человек пишет боту, отвечает тот же агент, что и в вебе. Пакет сам принимает обновления (вебхук или опрос), привязывает аккаунты, шлёт ответы с учётом ограничений мессенджера и карточки задач.
Readme
@ykurilov/agent-chat-messengers
Чат приложения в Telegram, VK и MAX: человек пишет боту, отвечает тот же агент, что и в вебе. Пакет сам принимает обновления (вебхук или опрос), привязывает аккаунты, шлёт ответы с учётом ограничений мессенджера и карточки задач.
import { createMessengers } from "@ykurilov/agent-chat-messengers";
import { createSqliteMessengerStore } from "@ykurilov/agent-chat-messengers/sqlite";
import { telegramBot } from "@ykurilov/agent-chat-messengers/telegram";
const store = await createSqliteMessengerStore({ filePath: "./data/messengers.db" });
const messengers = createMessengers({
store,
bots: [telegramBot({ token, username: "my_school_bot", receive: { polling: true } })],
service, // служба чата из createChatServer
resolveUser: async (request) => currentUser(request), // кто сейчас в приложении (для привязки)
basePath: "/api/messengers",
attachments: ingestMessengerFile, // необязательно, см. ниже
transcribe: transcribeVoice, // необязательно, см. ниже
});
app.all("/api/messengers/*", (c) => messengers.handler.fetch(c.req.raw));
await messengers.start(); // меню команд, недоставленное, опрос
// при остановке сервера: await messengers.stop(); await store.close();Боты
Один бот на мессенджер. Токены — из окружения сервера, в код и журналы они не попадают.
import { telegramBot } from "@ykurilov/agent-chat-messengers/telegram";
import { vkBot } from "@ykurilov/agent-chat-messengers/vk";
import { maxBot } from "@ykurilov/agent-chat-messengers/max";
telegramBot({ token, username: "my_school_bot", receive: { webhook: { secret } } }); // или { polling: true }
vkBot({ token, groupId: 123456, groupScreenName: "my_school", receive: { callback: { secret, confirmation } } }); // или { longPoll: true }
maxBot({ token, botName: "my_school_bot", receive: { webhook: { secret } } }); // или { polling: true }| Мессенджер | Вебхук | Опрос |
|---|---|---|
| Telegram | receive: { webhook: { secret } } | receive: { polling: true } |
| VK | receive: { callback: { secret, confirmation } } — секретный ключ (обязателен) и строка подтверждения из настроек сервера в сообществе | receive: { longPoll: true } |
| MAX | receive: { webhook: { secret } }, секрет 5–256 знаков из A–Z a–z 0–9 _ - | receive: { polling: true } |
- Вебхук нужен публичный HTTPS-адрес приложения. Telegram и MAX надо сказать, куда присылать:
await bot.registerWebhook("https://my.app/api/messengers/telegram/webhook")при запуске (у VK адрес вписывается в настройках сообщества). - Опрос адреса не требует: подходит для локальной разработки и серверов без внешнего адреса. Одним опросом одного бота занимается только одна копия сервера.
- Из России
api.telegram.orgможет не открываться. Telegram-боту тогда даютapiс адресом обратного прокси:api: { baseUrl: "https://tg-proxy.example", headers: { "x-key": "…" } }или свою функциюapi: { fetch }. Тот жеapiесть уvkBotиmaxBot. Адреса по умолчанию:https://api.telegram.org,https://api.vk.com,https://platform-api.max.ru. Значения заголовков из журналов прячутся. - Группы Telegram. Бот в группе по умолчанию видит только команды и ответы на свои сообщения («режим
приватности»). Чтобы он читал упоминания, надо отключить этот режим в BotFather (
/setprivacy→ Disable) и заново добавить бота в группу.
Настройки createMessengers
| Настройка | Что | По умолчанию |
|---|---|---|
| store, bots, basePath, resolveUser | Обязательные | — |
| service | Служба чата из createChatServer. Нет — бот только привязывает аккаунты, см. «Рецепт: бот только для привязки и уведомлений» | бот без чата |
| conversations | "single" — одна переписка, без /new и /chats; "many" — несколько чатов | "single" |
| resolveAccount | Кто может писать: { userId } или { refuse: "текст" } | только привязанные аккаунты |
| chatForConversation | Какой чат открыть для разговора (first, new, chat_gone, start_payload); можно дать агенту agentStarts — он напишет первым | новый пустой чат |
| groups | Включает группы; нет поля — боты в группах молчат | выключены |
| groups.answerTo | "mentions" — упоминание и ответ на сообщение бота; "all" — на всё | "mentions" |
| groups.allowedTools | Какие инструменты приложения доступны в группе | ни одного |
| groups.payer | Чей лимит тратится; null — не отвечать | тот, кто подключил |
| groups.perMemberHourly | Обращений одного участника в час | 20 |
| voiceReplies | Голосовые ответы, см. ниже | текст |
| onLinked(link) | Человек привязал аккаунт: приложение узнаёт об этом (например, чтобы выдать награду), см. «Привязка аккаунта» | нет сигнала |
| onUnlinked(link) | Привязка снята: приложение узнаёт об этом (например, чтобы убрать человека из рассылки), см. «Привязка аккаунта» | нет сигнала |
| allowedOrigins | Чужие страницы, которым можно выдавать и снимать привязку (POST, DELETE на /links) с куками человека, например ["https://school.getcourse.ru"]. С других чужих страниц — 403 forbidden_origin | только свой сайт |
| commandsMenu | false — не ставить ботам меню команд и снять поставленное раньше. У бота есть своя commandsMenu, она главнее | true |
| taskReadyPing | true: «Задача готова» приходит в мессенджер, когда вкладка закрыта | выключено |
| attachments, transcribe | Файлы и голос, см. «Два мостика» | «не принимаю» |
| webChatUrl(chatId) | Кнопка «Открыть в приложении» | без кнопки |
| texts | Свои фразы бота для всех ботов; у бота есть свои texts, они главнее. См. «Свои фразы бота» | defaultMessengerTexts |
| onAnswerDelivery(report) | Исход доставки каждого ответа агента: delivered, blocked, window_closed, retry, failed. Сценарию — (report) => flow.reportDelivery(report). См. «Доставка ответов» | нет сигнала |
Команды: /start <код> и /link <код> — привязка, /stop — остановить ответ, при conversations: "many" ещё
/new и /chats. В группе /stop принимается от автора текущего хода, плательщика и того, кто подключил бота.
Код привязки гасится только в своём мессенджере: код Telegram, введённый в VK, не сгорает.
Права и закрытый чат приложения работают и здесь. Голосовое при voice: false и файлы при attachments: false не
скачиваются и не распознаются: человек получает «недоступно, напишите текстом». /chats и выбор чата смотрят на chatList
текущего чата, закрытый чат в списке помечен «закрыт». Отказ beforeCreateChat на /new, /start с меткой и первый чат
приходит человеку текстом. Своя служба вместо ядра может реализовать необязательный assertCapability.
Привязка аккаунта
Человек в приложении нажимает «Подключить» — получает ссылку на бота с кодом (живёт 15 минут, подходит один раз),
открывает её в мессенджере, нажимает «Начать» — аккаунт привязан. Адреса внутри basePath: GET /links — список,
POST /links — новый код, DELETE /links/<мессенджер> — отвязать. Готовая панель — MessengerConnect из
@ykurilov/agent-chat-messengers/react; свой вид — через MessengersSkin (см. docs/messengers.md).
Узнать, что человек привязал аккаунт, можно через onLinked. Её вызывают после того, как привязка сохранена, и ждут
до ответа «Готово: аккаунт подключён». Неверный, просроченный и уже использованный код сигнала не дают. Ошибка внутри
onLinked уходит в logError и привязку не отменяет.
createMessengers({
// …
onLinked: async (link) => {
// link: { messenger, botId, externalUserId, userId, name, username?, linkedAt }
await grantLimitReset(link.userId, `${link.messenger}:${link.externalUserId}`);
},
});Сигнал может прийти не один раз для одного аккаунта мессенджера: человек отвязал и привязал снова. Награду за
привязку выдавайте идемпотентно, например по паре messenger + externalUserId.
Аккаунт мессенджера, уже привязанный к одному пользователю, код другого пользователя не переносит: человек получает
фразу linkTaken, привязка остаётся прежней, сигналов нет. Чтобы перенести аккаунт, его сначала отключают в
приложении у прежнего пользователя.
Узнать, что привязку сняли, можно через onUnlinked. Её вызывают, когда привязки в базе уже нет. Привязка снимается
одним путём: человек отвязал аккаунт в приложении (DELETE /links/<мессенджер>, кнопка «Отключить» в
MessengerConnect) — сигнал на каждый его аккаунт этого мессенджера. Ответ 204 ждёт, пока обработчик закончит: долгий
onUnlinked задерживает кнопку «Отключить», поэтому тяжёлую работу лучше ставить в очередь.
Других путей нет: блокировка бота человеком привязку не снимает, а /disconnect в группе отключает бота от группы,
а не аккаунт. Ошибка внутри onUnlinked уходит в logError и отвязку не отменяет.
Сигнал может повториться: два одновременных запроса отвязки дадут его дважды. Поэтому обработчик должен быть
идемпотентным: «убрать, если есть». Бывает и наоборот: сервер упал сразу после удаления — сигнала не будет. Перед
рассылкой надёжнее сверяться с store.listLinks(userId).
createMessengers({
// …
onUnlinked: async (link) => {
// Убрать человека из рассылки этого мессенджера. Повторный вызов ничего не ломает.
await newsletter.unsubscribe(link.userId, `${link.messenger}:${link.externalUserId}`);
},
});Свои фразы бота
Все фразы бота лежат в defaultMessengerTexts (тип MessengerTexts). Любую можно заменить в texts у
createMessengers (для всех ботов) или у самого бота: telegramBot({ …, texts }), vkBot, maxBot. Фраза бота
главнее общей, общая главнее фразы по умолчанию. Незаданные фразы остаются по умолчанию.
- Пустая строка — молчание.
""или фраза-функция, вернувшая"", значит «ничего не отправлять». Ответ агента фразой не считается и уходит как обычно. - Нажатие своей кнопки подтверждается всегда. Иначе у человека вечно крутится значок загрузки. При пустой фразе
(
accepted,taskCanceledи т. п.) бот подтверждает нажатие без текста. Совсем не отвечает он только на чужую или неизвестную кнопку, на кнопку от того, кому писать нельзя, и на кнопку в группе не от плательщика. - Пустой итог убирает «Работаю…». Если ответ состоял только из строки статуса, а
done,stoppedилиanswerFailedпустые, бот удаляет сообщение статуса. Telegram, VK и MAX это умеют. Свой адаптер безdeleteMessageоставит строку статуса в чате как есть. - Сообщения с кнопками не глушатся. Список
/chats(chatsTitle), вопрос о разрешении (approvalQuestion) и вопрос задачи (taskApprovalQuestion) без текста не отправить. Пустая фраза здесь — фраза по умолчанию. - Фразы-функции получают данные:
chatSelected(title),limitReached({ resetsAt, timeZone, unit }),fileTooBig(limitMb),fileRefused(reason)и другие — см.defaultMessengerTexts. - Команды подчиняются тем же правилам, что сообщение.
/start,/help,/new,/chats,/stopи кнопки от того, кому писать нельзя, получают отказresolveAccount(а без него — фразуhowToConnect). Пустой отказ — тишина.
Что непривязанный человек может получить и чем это выключить:
| Когда | Фраза |
|---|---|
| Любое сообщение, команда, голос, файл, кнопка в личке | отказ resolveAccount, без него — howToConnect |
| Неверный или просроченный код в /start <код>, /link <код> | linkFailed |
| Код другого пользователя, а аккаунт уже привязан | linkTaken |
| Удачная привязка | linked |
| Группа, группы выключены (один раз на группу) | writeInPrivate |
| Группа, группы включены | connectHint, helpGroup, groupNeedsLink, groupOnlyConnector, groupRefused, memberLimit, onlyPayer, stopNotYours |
Рецепт: бот только для привязки и уведомлений
Приложению мессенджер нужен только для привязки аккаунта и своих уведомлений, без разговора с агентом. Тогда
service не передают — это «бот без чата»:
- привязка (
/start <код>,/link <код>), отвязка,onLinkedиonUnlinkedработают как обычно; - привязанный человек на любое сообщение, команду, голосовое, файл и кнопку получает фразу
notifyOnly(пустая — тишина). Ход агента не начинается, файлы и голосовые не скачиваются. Нажатие кнопки бот подтверждает без текста; - непривязанный получает
howToConnect, в группе — один разwriteInPrivate. Если заданresolveAccount, его отказ главнее: это та же проверка «кто пишет», что и с чатом; - фразы по умолчанию не обещают разговор:
linked— «Готово: аккаунт подключён.»,howToConnectзовёт подключить аккаунт ради уведомлений (обе вdefaultMessengerTextsWithoutChat). Порядок тот же: фраза бота → общаяtexts→ умолчание бота без чата → обычное умолчание; - меню команд снимается: команд, которые бот выполняет, нет. В Telegram снимаются меню личных и групповых чатов;
общее меню, заданное в BotFather (и меню для отдельных языков), нужно очистить там. MAX снимает меню пустым
списком
commands— так сказано в его документации (dev.max.ru); живым ботом это не проверено; - настройки разговора (
groups,conversations,chatForConversation,attachments,transcribe,voiceReplies,taskReadyPing,webChatUrl) безserviceзадать нельзя —typecheckне пропустит.
Уведомления приложение шлёт само; кому — видно по store.listLinks(userId).
ВК-бот, который молчит. Сообщество ВК обслуживает другой сервер, поэтому бот не пишет ничего, кроме фразы после удачной привязки.
const vk = vkBot({
token: process.env.VK_TOKEN!,
groupId: 123456,
groupScreenName: "my_app",
receive: { callback: { secret, confirmation } },
texts: { linked: "Аккаунт привязан. Уведомления придут сюда.", linkFailed: "", howToConnect: "", notifyOnly: "", writeInPrivate: "" },
});Telegram-бот с одной фразой. На всё, кроме привязки, бот отвечает одной фразой — и привязанным, и непривязанным, и в группе.
const NOTIFY_ONLY = "Это бот уведомлений AppStudio. Писать сюда не нужно — откройте приложение.";
const telegram = telegramBot({
token: process.env.TELEGRAM_TOKEN!,
username: "appstudio_bot",
receive: { webhook: { secret } },
texts: { linked: "Готово: уведомления AppStudio будут приходить сюда.", howToConnect: NOTIFY_ONLY, notifyOnly: NOTIFY_ONLY, writeInPrivate: NOTIFY_ONLY },
});
const store = await createPostgresMessengerStore({ connectionString: process.env.DATABASE_URL! });
const messengers = createMessengers({
store,
bots: [vk, telegram],
basePath: "/api/messengers",
resolveUser,
// service нет: разговора с агентом нет, меню команд снимается.
onLinked: (link) => notifications.subscribe(link.userId, `${link.messenger}:${link.externalUserId}`),
onUnlinked: (link) => notifications.unsubscribe(link.userId, `${link.messenger}:${link.externalUserId}`),
});
await messengers.start();
// Остановка сервера: сначала мессенджеры, потом хранилище (оно закрывает свой пул соединений).
process.once("SIGTERM", async () => {
await messengers.stop();
await store.close();
});Почему у Telegram одна фраза стоит в трёх местах. howToConnect — ответ непривязанному, notifyOnly — привязанному,
writeInPrivate — один раз в группе. resolveAccount здесь не нужен: без service бот и так никого не пускает к
агенту. Если он задан и отказывает, звучит его отказ.
Бот с чатом, которому не нужно меню, — commandsMenu: false в createMessengers или у бота (telegramBot,
maxBot). У VK меню команд нет, ставить и снимать там нечего.
Голосовые ответы
Бот может отвечать голосовым сообщением. Озвучку делает createVoiceReplies из
@ykurilov/agent-chat-voice-dialog/server (ElevenLabs; цена и настройки — в README того пакета):
import { createVoiceReplies } from "@ykurilov/agent-chat-voice-dialog/server";
voiceReplies: createVoiceReplies({
apiKey: process.env.ELEVENLABS_API_KEY!,
voiceId: "JBFqnCBsd6RMkjVDRZzb",
model: "eleven_v4_turbo",
language: "ru",
logError: console.error,
storage, // тот же, что у чата: туда пишется расход
pricePerThousandChars: { eleven_v4_turbo: 0.11 },
}),Лимит расходов исчерпан или сервис недоступен — человек получает обычный текст. Код, таблицы и схемы вслух не
читаются («код на экране»). Длинный ответ озвучивается до 1500 знаков (maxChars), дальше — фраза «Дальше на
экране.».
Два мостика приложения
Пакет не тянет вложения и распознавание речи в зависимости. Приложение подаёт их сюда двумя короткими функциями. Нет функции — человек получает понятный ответ: «Файлы здесь не принимаю», «Голосовые здесь не распознаю».
Файлы — в вложения чата через ingestFile пакета @ykurilov/agent-chat-attachments. Владелец файла — человек,
привязанный к аккаунту мессенджера (ownerId):
import type { AttachmentIngest } from "@ykurilov/agent-chat-messengers";
const ingestMessengerFile: AttachmentIngest = ({ ownerId, chatId, bytes, name }) =>
attachments.ingestFile({ userId: ownerId, chatId, bytes, name });Отказ вложений ({ ok: false, reason }: не тот тип, слишком большой, занято) человек получает словами, агент не
запускается. Тип файла вложения определяют по содержимому, поэтому mediaType из мессенджера не нужен.
Голос — обёртка над transcribeAudioFile пакета @ykurilov/agent-chat-voice/server:
import type { AudioTranscriber } from "@ykurilov/agent-chat-messengers";
import { transcribeAudioFile } from "@ykurilov/agent-chat-voice/server";
const transcribeVoice: AudioTranscriber = async ({ ownerId, audio }) => {
if (await voiceLimitReached(ownerId)) return { skipped: "limit" };
return {
text: await transcribeAudioFile({
bytes: audio,
apiKey: process.env.SONIOX_API_KEY!,
model: "stt-rt-preview",
languageHints: ["ru"],
websocketUrl: sonioxUrl, // с российского сервера — адрес прокси
logError: console.error,
}),
};
};{ skipped: "limit" } — человек узнаёт, что лимит исчерпан. Ошибка распознавания — «Не получилось распознать голосовое».
Голосовые MAX, которые мессенджер распознал сам, сюда не попадают. Голосовое длиннее 10 минут или больше 20 МБ
не распознаётся.
Вебхук: один бот на мессенджер
У каждого мессенджера один адрес вебхука внутри basePath: /telegram/webhook, /vk/callback, /max/webhook.
Его принимает первый бот этого мессенджера с receive: "webhook". Второй бот того же мессенджера работает опросом
(receive: "polling"). Это решение, а не недоделка: одному приложению нужен один бот в мессенджере, а адрес без id
бота проще настроить.
Приём сообщений
- Обновление от мессенджера записывается в хранилище вместе с содержимым, потом обрабатывается. Повтор того же обновления от мессенджера не обрабатывается второй раз.
- База не приняла запись — вебхук отвечает 503, и мессенджер присылает обновление снова. При опросе то же обновление
пробуется снова, пауза растёт от секунды до 30 секунд, следующие его не обгоняют. Обновление, которое не
записывается десять минут подряд, пропускается с ошибкой в
logError: одно битое обновление не держит бота. - Цена этого порога: база недоступна дольше 10 минут — сообщения людей, которые не удалось записать за это время, пропускаются. От них остаётся только запись в журнале ошибок, агент их не увидит. Вебхук после порога отвечает мессенджеру 200, и мессенджер их больше не присылает.
- Postgres не хранит символ NUL: хранилище убирает его из строк от мессенджера и модели перед записью.
- Сервер упал посреди обработки — после срока аренды (2 минуты) обновление подбирает живая копия или эта же после
start(). Обработка сорвалась с ошибкой — повтор через полминуты. Не вышло за три повтора или прошли сутки — обновление брошено, ошибка вlogError. - Двойного ответа нет: сообщение агенту получает номер из ключа обновления. Повтор сверяется с историей чата. На сообщение уже ответили — второго хода нет. Сообщение записано, а ответа нет — служба чата доводит ответ на него же, файлы второй раз не скачиваются.
- Приём — «хотя бы раз», не «ровно раз». Защита от двойного ответа агента есть, у остального её нет. Сервер упал
после обработки, но до записи «готово» — команда (
/new,/connect) выполнится второй раз. Повторное нажатие кнопки получит «Уже отвечено.». - Порядок при повторе не гарантирован. Повтор встаёт в очередь разговора за сообщениями, которые пришли позже и уже обработаны: после падения сервера повтор идёт через 2 минуты, после ошибки — через полминуты.
Доставка ответов
- Итог ответа, карточка и «Задача готова» записываются в хранилище до отправки. Сервер упал или перезапустился —
после старта (
start()) недоставленное уходит снова, с теми же кнопками. - Копия сервера держит свои записи 2 минуты и продлевает раз в 30 секунд. Запись упавшей копии забирает другая копия после этого срока. Две копии одну запись не шлют.
- Доставка «хотя бы раз»: падение между отправкой и удалением записи даст повтор того же сообщения.
- Мессенджер недоступен дольше повторов очереди — запись остаётся и повторяется позже: пауза растёт от 30 секунд до 30 минут, после суток ожидания запись удаляется. Постоянная ошибка (отказ, блокировка) удаляет запись сразу.
- Не переживают перезапуск: голосовые ответы и промежуточные правки живого сообщения. Итог их перекрывает.
onAnswerDeliveryполучает исход каждого ответа агента: чей ответ (ownerId,chatId,messageId), как начат ход (trigger:humanилиnotice— агент пишет первым) иoutcome. Один ответ может дать несколько исходов:retry, потом итог повтора. Разбирать поmessageId, последний главнее. Ошибка в обработчике уходит вlogError.- ВК не пускает писать первым, пока человек не напишет сам (ошибка 950): это
window_closedиMessengerWindowClosedError, а не сбой. - С проверками ответа (
replyChecksслужбы чата) мессенджер не шлёт ответ по словам: уходит только проверенный текст.
Карточки задач
- Задача ждёт подтверждения — карточка «Разрешить / Отклонить». Кнопки несут номер вопроса: нажатие на старой карточке после нового вопроса отвечает «Уже отвечено.».
- Фоновая задача идёт — карточка «Задача … выполняется» с кнопкой «Отменить задачу».
- Задача закончена — «Задача готова» в личный чат того, кто её запустил.
Хранилище
Память (createMemoryMessengerStore, для тестов), SQLite (/sqlite) и Postgres (/postgres). Миграции лежат в
drizzle/ и применяются при создании хранилища.
После start() записи старше 30 дней убираются при запуске и потом раз в час: коды привязки, принятые обновления,
кнопки под ответами, обращения участников групп. Кнопка под ответом старше месяца отвечает «Уже отвечено.».
Привязки, разговоры и недоставленные итоги не убираются, сколько бы им ни было. Необработанное входящее обновление
остаётся, пока его кто-то держит или ждёт повтора. Если его никто не трогал 30 дней (например, бота убрали из
настроек), оно убирается вместе с текстом.
SQLite и Postgres открывают своё соединение: файл и пул. Закрывает его store.close() — зовите при остановке сервера
после messengers.stop(). Повторный вызов ничего не делает. Если хранилище не удалось подготовить (база недоступна,
миграция упала), соединение закрывается до ошибки. Чужой пул или файл хранилище не принимает, поэтому закрывает только
своё.
