@ykurilov/agent-chat-messengers 0.0.0-stage → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +891 -0
  2. package/LICENSE +21 -0
  3. package/README.md +381 -2
  4. package/dist/chunk-4QQ62ZKT.js +197 -0
  5. package/dist/chunk-4QQ62ZKT.js.map +1 -0
  6. package/dist/chunk-C6IGB3R2.js +9 -0
  7. package/dist/chunk-C6IGB3R2.js.map +1 -0
  8. package/dist/index.js +2748 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/max.js +308 -0
  11. package/dist/max.js.map +1 -0
  12. package/dist/postgres.js +367 -0
  13. package/dist/postgres.js.map +1 -0
  14. package/dist/react.js +184 -0
  15. package/dist/react.js.map +1 -0
  16. package/dist/sqlite.js +326 -0
  17. package/dist/sqlite.js.map +1 -0
  18. package/dist/telegram.js +297 -0
  19. package/dist/telegram.js.map +1 -0
  20. package/dist/types/account-linking.d.ts +35 -0
  21. package/dist/types/answer-delivery.d.ts +67 -0
  22. package/dist/types/bot-commands.d.ts +11 -0
  23. package/dist/types/bounded-body.d.ts +18 -0
  24. package/dist/types/button-actions.d.ts +139 -0
  25. package/dist/types/chat-notices.d.ts +8 -0
  26. package/dist/types/chat-port.d.ts +152 -0
  27. package/dist/types/conversation-policy.d.ts +60 -0
  28. package/dist/types/create-messengers.d.ts +20 -0
  29. package/dist/types/group-chats.d.ts +46 -0
  30. package/dist/types/inbound-media.d.ts +37 -0
  31. package/dist/types/index.d.ts +10 -0
  32. package/dist/types/markdown-to-messenger.d.ts +28 -0
  33. package/dist/types/max/index.d.ts +27 -0
  34. package/dist/types/max/max-adapter.d.ts +10 -0
  35. package/dist/types/max/max-client.d.ts +22 -0
  36. package/dist/types/max/max-inbound.d.ts +55 -0
  37. package/dist/types/max.d.ts +2 -0
  38. package/dist/types/messenger-adapter.d.ts +128 -0
  39. package/dist/types/messenger-api.d.ts +51 -0
  40. package/dist/types/messenger-files.d.ts +27 -0
  41. package/dist/types/messenger-gateway.d.ts +55 -0
  42. package/dist/types/messenger-texts.d.ts +118 -0
  43. package/dist/types/outbound-queue.d.ts +48 -0
  44. package/dist/types/pending-deliveries.d.ts +30 -0
  45. package/dist/types/polling-lease.d.ts +25 -0
  46. package/dist/types/postgres.d.ts +17 -0
  47. package/dist/types/react/messengers-labels.d.ts +31 -0
  48. package/dist/types/react/messengers-skin.d.ts +41 -0
  49. package/dist/types/react/use-messenger-links.d.ts +21 -0
  50. package/dist/types/react.d.ts +5 -0
  51. package/dist/types/sqlite.d.ts +11 -0
  52. package/dist/types/store/memory-store.d.ts +3 -0
  53. package/dist/types/store/migration-settings.d.ts +4 -0
  54. package/dist/types/store/postgres-schema.d.ts +1053 -0
  55. package/dist/types/store/postgres-store.d.ts +9 -0
  56. package/dist/types/store/sqlite-schema.d.ts +1110 -0
  57. package/dist/types/store/sqlite-store.d.ts +4 -0
  58. package/dist/types/task-cards.d.ts +25 -0
  59. package/dist/types/task-ready-ping.d.ts +27 -0
  60. package/dist/types/telegram/index.d.ts +26 -0
  61. package/dist/types/telegram/telegram-adapter.d.ts +9 -0
  62. package/dist/types/telegram/telegram-client.d.ts +19 -0
  63. package/dist/types/telegram/telegram-inbound.d.ts +82 -0
  64. package/dist/types/telegram.d.ts +2 -0
  65. package/dist/types/types.d.ts +375 -0
  66. package/dist/types/vk/index.d.ts +28 -0
  67. package/dist/types/vk/vk-adapter.d.ts +18 -0
  68. package/dist/types/vk/vk-client.d.ts +22 -0
  69. package/dist/types/vk/vk-inbound.d.ts +15 -0
  70. package/dist/types/vk.d.ts +2 -0
  71. package/dist/types/voice-replies.d.ts +32 -0
  72. package/dist/vk.js +364 -0
  73. package/dist/vk.js.map +1 -0
  74. package/drizzle/postgres/0000_init.sql +72 -0
  75. package/drizzle/postgres/0001_pending_deliveries.sql +14 -0
  76. package/drizzle/postgres/0002_conversations_current_chat_idx.sql +1 -0
  77. package/drizzle/postgres/0003_inbound_updates_recovery.sql +7 -0
  78. package/drizzle/postgres/meta/0000_snapshot.json +514 -0
  79. package/drizzle/postgres/meta/0001_snapshot.json +624 -0
  80. package/drizzle/postgres/meta/0002_snapshot.json +639 -0
  81. package/drizzle/postgres/meta/0003_snapshot.json +703 -0
  82. package/drizzle/postgres/meta/_journal.json +34 -0
  83. package/drizzle/sqlite/0000_init.sql +72 -0
  84. package/drizzle/sqlite/0001_pending_deliveries.sql +13 -0
  85. package/drizzle/sqlite/0002_conversations_current_chat_idx.sql +1 -0
  86. package/drizzle/sqlite/0003_inbound_updates_recovery.sql +7 -0
  87. package/drizzle/sqlite/meta/0000_snapshot.json +462 -0
  88. package/drizzle/sqlite/meta/0001_snapshot.json +545 -0
  89. package/drizzle/sqlite/meta/0002_snapshot.json +552 -0
  90. package/drizzle/sqlite/meta/0003_snapshot.json +604 -0
  91. package/drizzle/sqlite/meta/_journal.json +34 -0
  92. package/package.json +110 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yury Kurilov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,382 @@
1
- # Temporary Holding Version
1
+ # @ykurilov/agent-chat-messengers
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Чат приложения в Telegram, VK и MAX: человек пишет боту, отвечает тот же агент, что и в вебе. Пакет сам принимает
4
+ обновления (вебхук или опрос), привязывает аккаунты, шлёт ответы с учётом ограничений мессенджера и карточки задач.
5
+
6
+ ```ts
7
+ import { createMessengers } from "@ykurilov/agent-chat-messengers";
8
+ import { createSqliteMessengerStore } from "@ykurilov/agent-chat-messengers/sqlite";
9
+ import { telegramBot } from "@ykurilov/agent-chat-messengers/telegram";
10
+
11
+ const store = await createSqliteMessengerStore({ filePath: "./data/messengers.db" });
12
+ const messengers = createMessengers({
13
+ store,
14
+ bots: [telegramBot({ token, username: "my_school_bot", receive: { polling: true } })],
15
+ service, // служба чата из createChatServer
16
+ resolveUser: async (request) => currentUser(request), // кто сейчас в приложении (для привязки)
17
+ basePath: "/api/messengers",
18
+ attachments: ingestMessengerFile, // необязательно, см. ниже
19
+ transcribe: transcribeVoice, // необязательно, см. ниже
20
+ });
21
+ app.all("/api/messengers/*", (c) => messengers.handler.fetch(c.req.raw));
22
+ await messengers.start(); // меню команд, недоставленное, опрос
23
+ // при остановке сервера: await messengers.stop(); await store.close();
24
+ ```
25
+
26
+ ## Боты
27
+
28
+ Один бот на мессенджер. Токены — из окружения сервера, в код и журналы они не попадают.
29
+
30
+ ```ts
31
+ import { telegramBot } from "@ykurilov/agent-chat-messengers/telegram";
32
+ import { vkBot } from "@ykurilov/agent-chat-messengers/vk";
33
+ import { maxBot } from "@ykurilov/agent-chat-messengers/max";
34
+
35
+ telegramBot({ token, username: "my_school_bot", receive: { webhook: { secret } } }); // или { polling: true }
36
+ vkBot({ token, groupId: 123456, groupScreenName: "my_school", receive: { callback: { secret, confirmation } } }); // или { longPoll: true }
37
+ maxBot({ token, botName: "my_school_bot", receive: { webhook: { secret } } }); // или { polling: true }
38
+ ```
39
+
40
+ | Мессенджер | Вебхук | Опрос |
41
+ |---|---|---|
42
+ | Telegram | `receive: { webhook: { secret } }` | `receive: { polling: true }` |
43
+ | VK | `receive: { callback: { secret, confirmation } }` — секретный ключ (обязателен) и строка подтверждения из настроек сервера в сообществе | `receive: { longPoll: true }` |
44
+ | MAX | `receive: { webhook: { secret } }`, секрет 5–256 знаков из `A–Z a–z 0–9 _ -` | `receive: { polling: true }` |
45
+
46
+ - **Вебхук** нужен публичный HTTPS-адрес приложения. Telegram и MAX надо сказать, куда присылать:
47
+ `await bot.registerWebhook("https://my.app/api/messengers/telegram/webhook")` при запуске (у VK адрес
48
+ вписывается в настройках сообщества).
49
+ - **Опрос** адреса не требует: подходит для локальной разработки и серверов без внешнего адреса. Одним опросом
50
+ одного бота занимается только одна копия сервера.
51
+ - **Из России** `api.telegram.org` может не открываться. Telegram-боту тогда дают `api` с адресом обратного прокси:
52
+ `api: { baseUrl: "https://tg-proxy.example", headers: { "x-key": "…" } }` или свою функцию `api: { fetch }`.
53
+ Тот же `api` есть у `vkBot` и `maxBot`. Адреса по умолчанию: `https://api.telegram.org`, `https://api.vk.com`,
54
+ `https://platform-api.max.ru`. Значения заголовков из журналов прячутся.
55
+ - **Группы Telegram.** Бот в группе по умолчанию видит только команды и ответы на свои сообщения («режим
56
+ приватности»). Чтобы он читал упоминания, надо отключить этот режим в BotFather (`/setprivacy` → Disable) и
57
+ заново добавить бота в группу.
58
+
59
+ ## Настройки `createMessengers`
60
+
61
+ | Настройка | Что | По умолчанию |
62
+ |---|---|---|
63
+ | `store`, `bots`, `basePath`, `resolveUser` | Обязательные | — |
64
+ | `service` | Служба чата из `createChatServer`. Нет — бот только привязывает аккаунты, см. «Рецепт: бот только для привязки и уведомлений» | бот без чата |
65
+ | `conversations` | `"single"` — одна переписка, без `/new` и `/chats`; `"many"` — несколько чатов | `"single"` |
66
+ | `resolveAccount` | Кто может писать: `{ userId }` или `{ refuse: "текст" }` | только привязанные аккаунты |
67
+ | `chatForConversation` | Какой чат открыть для разговора (`first`, `new`, `chat_gone`, `start_payload`); можно дать агенту `agentStarts` — он напишет первым | новый пустой чат |
68
+ | `groups` | Включает группы; нет поля — боты в группах молчат | выключены |
69
+ | `groups.answerTo` | `"mentions"` — упоминание и ответ на сообщение бота; `"all"` — на всё | `"mentions"` |
70
+ | `groups.allowedTools` | Какие инструменты приложения доступны в группе | ни одного |
71
+ | `groups.payer` | Чей лимит тратится; `null` — не отвечать | тот, кто подключил |
72
+ | `groups.perMemberHourly` | Обращений одного участника в час | 20 |
73
+ | `voiceReplies` | Голосовые ответы, см. ниже | текст |
74
+ | `onLinked(link)` | Человек привязал аккаунт: приложение узнаёт об этом (например, чтобы выдать награду), см. «Привязка аккаунта» | нет сигнала |
75
+ | `onUnlinked(link)` | Привязка снята: приложение узнаёт об этом (например, чтобы убрать человека из рассылки), см. «Привязка аккаунта» | нет сигнала |
76
+ | `allowedOrigins` | Чужие страницы, которым можно выдавать и снимать привязку (`POST`, `DELETE` на `/links`) с куками человека, например `["https://school.getcourse.ru"]`. С других чужих страниц — 403 `forbidden_origin` | только свой сайт |
77
+ | `commandsMenu` | `false` — не ставить ботам меню команд и снять поставленное раньше. У бота есть своя `commandsMenu`, она главнее | `true` |
78
+ | `taskReadyPing` | `true`: «Задача готова» приходит в мессенджер, когда вкладка закрыта | выключено |
79
+ | `attachments`, `transcribe` | Файлы и голос, см. «Два мостика» | «не принимаю» |
80
+ | `webChatUrl(chatId)` | Кнопка «Открыть в приложении» | без кнопки |
81
+ | `texts` | Свои фразы бота для всех ботов; у бота есть свои `texts`, они главнее. См. «Свои фразы бота» | `defaultMessengerTexts` |
82
+ | `onAnswerDelivery(report)` | Исход доставки каждого ответа агента: `delivered`, `blocked`, `window_closed`, `retry`, `failed`. Сценарию — `(report) => flow.reportDelivery(report)`. См. «Доставка ответов» | нет сигнала |
83
+
84
+ Команды: `/start <код>` и `/link <код>` — привязка, `/stop` — остановить ответ, при `conversations: "many"` ещё
85
+ `/new` и `/chats`. В группе `/stop` принимается от автора текущего хода, плательщика и того, кто подключил бота.
86
+ Код привязки гасится только в своём мессенджере: код Telegram, введённый в VK, не сгорает.
87
+
88
+ Права и закрытый чат приложения работают и здесь. Голосовое при `voice: false` и файлы при `attachments: false` не
89
+ скачиваются и не распознаются: человек получает «недоступно, напишите текстом». `/chats` и выбор чата смотрят на `chatList`
90
+ текущего чата, закрытый чат в списке помечен «закрыт». Отказ `beforeCreateChat` на `/new`, `/start` с меткой и первый чат
91
+ приходит человеку текстом. Своя служба вместо ядра может реализовать необязательный `assertCapability`.
92
+
93
+ ## Привязка аккаунта
94
+
95
+ Человек в приложении нажимает «Подключить» — получает ссылку на бота с кодом (живёт 15 минут, подходит один раз),
96
+ открывает её в мессенджере, нажимает «Начать» — аккаунт привязан. Адреса внутри `basePath`: `GET /links` — список,
97
+ `POST /links` — новый код, `DELETE /links/<мессенджер>` — отвязать. Готовая панель — `MessengerConnect` из
98
+ `@ykurilov/agent-chat-messengers/react`; свой вид — через `MessengersSkin` (см. `docs/messengers.md`).
99
+
100
+ Узнать, что человек привязал аккаунт, можно через `onLinked`. Её вызывают после того, как привязка сохранена, и ждут
101
+ до ответа «Готово: аккаунт подключён». Неверный, просроченный и уже использованный код сигнала не дают. Ошибка внутри
102
+ `onLinked` уходит в `logError` и привязку не отменяет.
103
+
104
+ ```ts
105
+ createMessengers({
106
+ // …
107
+ onLinked: async (link) => {
108
+ // link: { messenger, botId, externalUserId, userId, name, username?, linkedAt }
109
+ await grantLimitReset(link.userId, `${link.messenger}:${link.externalUserId}`);
110
+ },
111
+ });
112
+ ```
113
+
114
+ Сигнал может прийти не один раз для одного аккаунта мессенджера: человек отвязал и привязал снова. Награду за
115
+ привязку выдавайте идемпотентно, например по паре `messenger` + `externalUserId`.
116
+
117
+ Аккаунт мессенджера, уже привязанный к одному пользователю, код другого пользователя не переносит: человек получает
118
+ фразу `linkTaken`, привязка остаётся прежней, сигналов нет. Чтобы перенести аккаунт, его сначала отключают в
119
+ приложении у прежнего пользователя.
120
+
121
+ Узнать, что привязку сняли, можно через `onUnlinked`. Её вызывают, когда привязки в базе уже нет. Привязка снимается
122
+ одним путём: человек отвязал аккаунт в приложении (`DELETE /links/<мессенджер>`, кнопка «Отключить» в
123
+ `MessengerConnect`) — сигнал на каждый его аккаунт этого мессенджера. Ответ 204 ждёт, пока обработчик закончит: долгий
124
+ `onUnlinked` задерживает кнопку «Отключить», поэтому тяжёлую работу лучше ставить в очередь.
125
+
126
+ Других путей нет: блокировка бота человеком привязку не снимает, а `/disconnect` в группе отключает бота от группы,
127
+ а не аккаунт. Ошибка внутри `onUnlinked` уходит в `logError` и отвязку не отменяет.
128
+
129
+ Сигнал может повториться: два одновременных запроса отвязки дадут его дважды. Поэтому обработчик должен быть
130
+ идемпотентным: «убрать, если есть». Бывает и наоборот: сервер упал сразу после удаления — сигнала не будет. Перед
131
+ рассылкой надёжнее сверяться с `store.listLinks(userId)`.
132
+
133
+ ```ts
134
+ createMessengers({
135
+ // …
136
+ onUnlinked: async (link) => {
137
+ // Убрать человека из рассылки этого мессенджера. Повторный вызов ничего не ломает.
138
+ await newsletter.unsubscribe(link.userId, `${link.messenger}:${link.externalUserId}`);
139
+ },
140
+ });
141
+ ```
142
+
143
+ ## Свои фразы бота
144
+
145
+ Все фразы бота лежат в `defaultMessengerTexts` (тип `MessengerTexts`). Любую можно заменить в `texts` у
146
+ `createMessengers` (для всех ботов) или у самого бота: `telegramBot({ …, texts })`, `vkBot`, `maxBot`. Фраза бота
147
+ главнее общей, общая главнее фразы по умолчанию. Незаданные фразы остаются по умолчанию.
148
+
149
+ - **Пустая строка — молчание.** `""` или фраза-функция, вернувшая `""`, значит «ничего не отправлять». Ответ агента
150
+ фразой не считается и уходит как обычно.
151
+ - **Нажатие своей кнопки подтверждается всегда.** Иначе у человека вечно крутится значок загрузки. При пустой фразе
152
+ (`accepted`, `taskCanceled` и т. п.) бот подтверждает нажатие без текста. Совсем не отвечает он только на чужую или
153
+ неизвестную кнопку, на кнопку от того, кому писать нельзя, и на кнопку в группе не от плательщика.
154
+ - **Пустой итог убирает «Работаю…».** Если ответ состоял только из строки статуса, а `done`, `stopped` или
155
+ `answerFailed` пустые, бот удаляет сообщение статуса. Telegram, VK и MAX это умеют. Свой адаптер без
156
+ `deleteMessage` оставит строку статуса в чате как есть.
157
+ - **Сообщения с кнопками не глушатся.** Список `/chats` (`chatsTitle`), вопрос о разрешении (`approvalQuestion`) и
158
+ вопрос задачи (`taskApprovalQuestion`) без текста не отправить. Пустая фраза здесь — фраза по умолчанию.
159
+ - **Фразы-функции** получают данные: `chatSelected(title)`, `limitReached({ resetsAt, timeZone, unit })`,
160
+ `fileTooBig(limitMb)`, `fileRefused(reason)` и другие — см. `defaultMessengerTexts`.
161
+ - **Команды подчиняются тем же правилам, что сообщение.** `/start`, `/help`, `/new`, `/chats`, `/stop` и кнопки от
162
+ того, кому писать нельзя, получают отказ `resolveAccount` (а без него — фразу `howToConnect`). Пустой отказ — тишина.
163
+
164
+ Что непривязанный человек может получить и чем это выключить:
165
+
166
+ | Когда | Фраза |
167
+ |---|---|
168
+ | Любое сообщение, команда, голос, файл, кнопка в личке | отказ `resolveAccount`, без него — `howToConnect` |
169
+ | Неверный или просроченный код в `/start <код>`, `/link <код>` | `linkFailed` |
170
+ | Код другого пользователя, а аккаунт уже привязан | `linkTaken` |
171
+ | Удачная привязка | `linked` |
172
+ | Группа, группы выключены (один раз на группу) | `writeInPrivate` |
173
+ | Группа, группы включены | `connectHint`, `helpGroup`, `groupNeedsLink`, `groupOnlyConnector`, `groupRefused`, `memberLimit`, `onlyPayer`, `stopNotYours` |
174
+
175
+ ### Рецепт: бот только для привязки и уведомлений
176
+
177
+ Приложению мессенджер нужен только для привязки аккаунта и своих уведомлений, без разговора с агентом. Тогда
178
+ `service` не передают — это «бот без чата»:
179
+
180
+ - привязка (`/start <код>`, `/link <код>`), отвязка, `onLinked` и `onUnlinked` работают как обычно;
181
+ - привязанный человек на любое сообщение, команду, голосовое, файл и кнопку получает фразу `notifyOnly` (пустая —
182
+ тишина). Ход агента не начинается, файлы и голосовые не скачиваются. Нажатие кнопки бот подтверждает без текста;
183
+ - непривязанный получает `howToConnect`, в группе — один раз `writeInPrivate`. Если задан `resolveAccount`, его отказ
184
+ главнее: это та же проверка «кто пишет», что и с чатом;
185
+ - фразы по умолчанию не обещают разговор: `linked` — «Готово: аккаунт подключён.», `howToConnect` зовёт подключить
186
+ аккаунт ради уведомлений (обе в `defaultMessengerTextsWithoutChat`). Порядок тот же: фраза бота → общая `texts` →
187
+ умолчание бота без чата → обычное умолчание;
188
+ - меню команд снимается: команд, которые бот выполняет, нет. В Telegram снимаются меню личных и групповых чатов;
189
+ общее меню, заданное в BotFather (и меню для отдельных языков), нужно очистить там. MAX снимает меню пустым
190
+ списком `commands` — так сказано в его документации (dev.max.ru); живым ботом это не проверено;
191
+ - настройки разговора (`groups`, `conversations`, `chatForConversation`, `attachments`, `transcribe`, `voiceReplies`,
192
+ `taskReadyPing`, `webChatUrl`) без `service` задать нельзя — `typecheck` не пропустит.
193
+
194
+ Уведомления приложение шлёт само; кому — видно по `store.listLinks(userId)`.
195
+
196
+ **ВК-бот, который молчит.** Сообщество ВК обслуживает другой сервер, поэтому бот не пишет ничего, кроме фразы после
197
+ удачной привязки.
198
+
199
+ ```ts
200
+ const vk = vkBot({
201
+ token: process.env.VK_TOKEN!,
202
+ groupId: 123456,
203
+ groupScreenName: "my_app",
204
+ receive: { callback: { secret, confirmation } },
205
+ texts: { linked: "Аккаунт привязан. Уведомления придут сюда.", linkFailed: "", howToConnect: "", notifyOnly: "", writeInPrivate: "" },
206
+ });
207
+ ```
208
+
209
+ **Telegram-бот с одной фразой.** На всё, кроме привязки, бот отвечает одной фразой — и привязанным, и непривязанным,
210
+ и в группе.
211
+
212
+ ```ts
213
+ const NOTIFY_ONLY = "Это бот уведомлений AppStudio. Писать сюда не нужно — откройте приложение.";
214
+
215
+ const telegram = telegramBot({
216
+ token: process.env.TELEGRAM_TOKEN!,
217
+ username: "appstudio_bot",
218
+ receive: { webhook: { secret } },
219
+ texts: { linked: "Готово: уведомления AppStudio будут приходить сюда.", howToConnect: NOTIFY_ONLY, notifyOnly: NOTIFY_ONLY, writeInPrivate: NOTIFY_ONLY },
220
+ });
221
+
222
+ const store = await createPostgresMessengerStore({ connectionString: process.env.DATABASE_URL! });
223
+ const messengers = createMessengers({
224
+ store,
225
+ bots: [vk, telegram],
226
+ basePath: "/api/messengers",
227
+ resolveUser,
228
+ // service нет: разговора с агентом нет, меню команд снимается.
229
+ onLinked: (link) => notifications.subscribe(link.userId, `${link.messenger}:${link.externalUserId}`),
230
+ onUnlinked: (link) => notifications.unsubscribe(link.userId, `${link.messenger}:${link.externalUserId}`),
231
+ });
232
+ await messengers.start();
233
+
234
+ // Остановка сервера: сначала мессенджеры, потом хранилище (оно закрывает свой пул соединений).
235
+ process.once("SIGTERM", async () => {
236
+ await messengers.stop();
237
+ await store.close();
238
+ });
239
+ ```
240
+
241
+ Почему у Telegram одна фраза стоит в трёх местах. `howToConnect` — ответ непривязанному, `notifyOnly` — привязанному,
242
+ `writeInPrivate` — один раз в группе. `resolveAccount` здесь не нужен: без `service` бот и так никого не пускает к
243
+ агенту. Если он задан и отказывает, звучит его отказ.
244
+
245
+ Бот с чатом, которому не нужно меню, — `commandsMenu: false` в `createMessengers` или у бота (`telegramBot`,
246
+ `maxBot`). У VK меню команд нет, ставить и снимать там нечего.
247
+
248
+ ## Голосовые ответы
249
+
250
+ Бот может отвечать голосовым сообщением. Озвучку делает `createVoiceReplies` из
251
+ `@ykurilov/agent-chat-voice-dialog/server` (ElevenLabs; цена и настройки — в README того пакета):
252
+
253
+ ```ts
254
+ import { createVoiceReplies } from "@ykurilov/agent-chat-voice-dialog/server";
255
+
256
+ voiceReplies: createVoiceReplies({
257
+ apiKey: process.env.ELEVENLABS_API_KEY!,
258
+ voiceId: "JBFqnCBsd6RMkjVDRZzb",
259
+ model: "eleven_v4_turbo",
260
+ language: "ru",
261
+ logError: console.error,
262
+ storage, // тот же, что у чата: туда пишется расход
263
+ pricePerThousandChars: { eleven_v4_turbo: 0.11 },
264
+ }),
265
+ ```
266
+
267
+ Лимит расходов исчерпан или сервис недоступен — человек получает обычный текст. Код, таблицы и схемы вслух не
268
+ читаются («код на экране»). Длинный ответ озвучивается до 1500 знаков (`maxChars`), дальше — фраза «Дальше на
269
+ экране.».
270
+
271
+ ## Два мостика приложения
272
+
273
+ Пакет не тянет вложения и распознавание речи в зависимости. Приложение подаёт их сюда двумя короткими функциями.
274
+ Нет функции — человек получает понятный ответ: «Файлы здесь не принимаю», «Голосовые здесь не распознаю».
275
+
276
+ **Файлы** — в вложения чата через `ingestFile` пакета `@ykurilov/agent-chat-attachments`. Владелец файла — человек,
277
+ привязанный к аккаунту мессенджера (`ownerId`):
278
+
279
+ ```ts
280
+ import type { AttachmentIngest } from "@ykurilov/agent-chat-messengers";
281
+
282
+ const ingestMessengerFile: AttachmentIngest = ({ ownerId, chatId, bytes, name }) =>
283
+ attachments.ingestFile({ userId: ownerId, chatId, bytes, name });
284
+ ```
285
+
286
+ Отказ вложений (`{ ok: false, reason }`: не тот тип, слишком большой, занято) человек получает словами, агент не
287
+ запускается. Тип файла вложения определяют по содержимому, поэтому `mediaType` из мессенджера не нужен.
288
+
289
+ **Голос** — обёртка над `transcribeAudioFile` пакета `@ykurilov/agent-chat-voice/server`:
290
+
291
+ ```ts
292
+ import type { AudioTranscriber } from "@ykurilov/agent-chat-messengers";
293
+ import { transcribeAudioFile } from "@ykurilov/agent-chat-voice/server";
294
+
295
+ const transcribeVoice: AudioTranscriber = async ({ ownerId, audio }) => {
296
+ if (await voiceLimitReached(ownerId)) return { skipped: "limit" };
297
+ return {
298
+ text: await transcribeAudioFile({
299
+ bytes: audio,
300
+ apiKey: process.env.SONIOX_API_KEY!,
301
+ model: "stt-rt-preview",
302
+ languageHints: ["ru"],
303
+ websocketUrl: sonioxUrl, // с российского сервера — адрес прокси
304
+ logError: console.error,
305
+ }),
306
+ };
307
+ };
308
+ ```
309
+
310
+ `{ skipped: "limit" }` — человек узнаёт, что лимит исчерпан. Ошибка распознавания — «Не получилось распознать голосовое».
311
+ Голосовые MAX, которые мессенджер распознал сам, сюда не попадают. Голосовое длиннее 10 минут или больше 20 МБ
312
+ не распознаётся.
313
+
314
+ ## Вебхук: один бот на мессенджер
315
+
316
+ У каждого мессенджера один адрес вебхука внутри `basePath`: `/telegram/webhook`, `/vk/callback`, `/max/webhook`.
317
+ Его принимает первый бот этого мессенджера с `receive: "webhook"`. Второй бот того же мессенджера работает опросом
318
+ (`receive: "polling"`). Это решение, а не недоделка: одному приложению нужен один бот в мессенджере, а адрес без id
319
+ бота проще настроить.
320
+
321
+ ## Приём сообщений
322
+
323
+ - Обновление от мессенджера записывается в хранилище вместе с содержимым, потом обрабатывается. Повтор того же
324
+ обновления от мессенджера не обрабатывается второй раз.
325
+ - База не приняла запись — вебхук отвечает 503, и мессенджер присылает обновление снова. При опросе то же обновление
326
+ пробуется снова, пауза растёт от секунды до 30 секунд, следующие его не обгоняют. Обновление, которое не
327
+ записывается десять минут подряд, пропускается с ошибкой в `logError`: одно битое обновление не держит бота.
328
+ - Цена этого порога: база недоступна дольше 10 минут — сообщения людей, которые не удалось записать за это время,
329
+ пропускаются. От них остаётся только запись в журнале ошибок, агент их не увидит. Вебхук после порога отвечает
330
+ мессенджеру 200, и мессенджер их больше не присылает.
331
+ - Postgres не хранит символ NUL: хранилище убирает его из строк от мессенджера и модели перед записью.
332
+ - Сервер упал посреди обработки — после срока аренды (2 минуты) обновление подбирает живая копия или эта же после
333
+ `start()`. Обработка сорвалась с ошибкой — повтор через полминуты. Не вышло за три повтора или прошли сутки —
334
+ обновление брошено, ошибка в `logError`.
335
+ - Двойного ответа нет: сообщение агенту получает номер из ключа обновления. Повтор сверяется с историей чата. На
336
+ сообщение уже ответили — второго хода нет. Сообщение записано, а ответа нет — служба чата доводит ответ на него же,
337
+ файлы второй раз не скачиваются.
338
+ - Приём — «хотя бы раз», не «ровно раз». Защита от двойного ответа агента есть, у остального её нет. Сервер упал
339
+ после обработки, но до записи «готово» — команда (`/new`, `/connect`) выполнится второй раз. Повторное нажатие
340
+ кнопки получит «Уже отвечено.».
341
+ - Порядок при повторе не гарантирован. Повтор встаёт в очередь разговора за сообщениями, которые пришли позже и уже
342
+ обработаны: после падения сервера повтор идёт через 2 минуты, после ошибки — через полминуты.
343
+
344
+ ## Доставка ответов
345
+
346
+ - Итог ответа, карточка и «Задача готова» записываются в хранилище до отправки. Сервер упал или перезапустился —
347
+ после старта (`start()`) недоставленное уходит снова, с теми же кнопками.
348
+ - Копия сервера держит свои записи 2 минуты и продлевает раз в 30 секунд. Запись упавшей копии забирает другая копия
349
+ после этого срока. Две копии одну запись не шлют.
350
+ - Доставка «хотя бы раз»: падение между отправкой и удалением записи даст повтор того же сообщения.
351
+ - Мессенджер недоступен дольше повторов очереди — запись остаётся и повторяется позже: пауза растёт от 30 секунд
352
+ до 30 минут, после суток ожидания запись удаляется. Постоянная ошибка (отказ, блокировка) удаляет запись сразу.
353
+ - Не переживают перезапуск: голосовые ответы и промежуточные правки живого сообщения. Итог их перекрывает.
354
+ - `onAnswerDelivery` получает исход каждого ответа агента: чей ответ (`ownerId`, `chatId`, `messageId`), как начат ход
355
+ (`trigger`: `human` или `notice` — агент пишет первым) и `outcome`. Один ответ может дать несколько исходов: `retry`,
356
+ потом итог повтора. Разбирать по `messageId`, последний главнее. Ошибка в обработчике уходит в `logError`.
357
+ - ВК не пускает писать первым, пока человек не напишет сам (ошибка 950): это `window_closed` и
358
+ `MessengerWindowClosedError`, а не сбой.
359
+ - С проверками ответа (`replyChecks` службы чата) мессенджер не шлёт ответ по словам: уходит только проверенный текст.
360
+
361
+ ## Карточки задач
362
+
363
+ - Задача ждёт подтверждения — карточка «Разрешить / Отклонить». Кнопки несут номер вопроса: нажатие на старой
364
+ карточке после нового вопроса отвечает «Уже отвечено.».
365
+ - Фоновая задача идёт — карточка «Задача … выполняется» с кнопкой «Отменить задачу».
366
+ - Задача закончена — «Задача готова» в личный чат того, кто её запустил.
367
+
368
+ ## Хранилище
369
+
370
+ Память (`createMemoryMessengerStore`, для тестов), SQLite (`/sqlite`) и Postgres (`/postgres`). Миграции лежат в
371
+ `drizzle/` и применяются при создании хранилища.
372
+
373
+ После `start()` записи старше 30 дней убираются при запуске и потом раз в час: коды привязки, принятые обновления,
374
+ кнопки под ответами, обращения участников групп. Кнопка под ответом старше месяца отвечает «Уже отвечено.».
375
+ Привязки, разговоры и недоставленные итоги не убираются, сколько бы им ни было. Необработанное входящее обновление
376
+ остаётся, пока его кто-то держит или ждёт повтора. Если его никто не трогал 30 дней (например, бота убрали из
377
+ настроек), оно убирается вместе с текстом.
378
+
379
+ SQLite и Postgres открывают своё соединение: файл и пул. Закрывает его `store.close()` — зовите при остановке сервера
380
+ после `messengers.stop()`. Повторный вызов ничего не делает. Если хранилище не удалось подготовить (база недоступна,
381
+ миграция упала), соединение закрывается до ошибки. Чужой пул или файл хранилище не принимает, поэтому закрывает только
382
+ своё.
@@ -0,0 +1,197 @@
1
+ // src/messenger-api.ts
2
+ import { scrubSecrets } from "@ykurilov/agent-chat-core";
3
+ var MIN_SECRET_LENGTH = 8;
4
+ var MessengerApiError = class extends Error {
5
+ constructor(message, status) {
6
+ super(message);
7
+ this.status = status;
8
+ this.name = "MessengerApiError";
9
+ }
10
+ status;
11
+ };
12
+ var MessengerRateLimitError = class extends MessengerApiError {
13
+ constructor(message, retryAfterMs) {
14
+ super(message, 429);
15
+ this.retryAfterMs = retryAfterMs;
16
+ this.name = "MessengerRateLimitError";
17
+ }
18
+ retryAfterMs;
19
+ };
20
+ var MessengerBlockedError = class extends MessengerApiError {
21
+ constructor(message) {
22
+ super(message, 403);
23
+ this.name = "MessengerBlockedError";
24
+ }
25
+ };
26
+ var MessengerWindowClosedError = class extends MessengerApiError {
27
+ constructor(message) {
28
+ super(message, 403);
29
+ this.name = "MessengerWindowClosedError";
30
+ }
31
+ };
32
+ var MessengerMarkupError = class extends MessengerApiError {
33
+ constructor(message) {
34
+ super(message, 400);
35
+ this.name = "MessengerMarkupError";
36
+ }
37
+ };
38
+ function createMessengerApi(input) {
39
+ const settings = input.api ?? {};
40
+ const ownFetch = "fetch" in settings ? settings.fetch : void 0;
41
+ const headers = "fetch" in settings ? {} : settings.headers ?? {};
42
+ for (const [name, value] of Object.entries(headers)) {
43
+ if (value.length < MIN_SECRET_LENGTH) {
44
+ throw new Error(`${input.messenger}: \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u0437\u0430\u0433\u043E\u043B\u043E\u0432\u043A\u0430 \xAB${name}\xBB \u043A\u043E\u0440\u043E\u0447\u0435 ${MIN_SECRET_LENGTH} \u0437\u043D\u0430\u043A\u043E\u0432 \u2014 \u0442\u0430\u043A\u043E\u0439 \u0442\u043E\u043A\u0435\u043D \u043D\u0435 \u0441\u043F\u0440\u044F\u0442\u0430\u0442\u044C \u0432 \u0436\u0443\u0440\u043D\u0430\u043B\u0430\u0445; \u043D\u0443\u0436\u0435\u043D \u0434\u043B\u0438\u043D\u043D\u0435\u0435`);
45
+ }
46
+ }
47
+ const baseUrl = (("fetch" in settings ? void 0 : settings.baseUrl) ?? input.defaultBaseUrl).replace(/\/+$/, "");
48
+ const secrets = [input.token, ...Object.values(headers)].filter((value) => value.length >= MIN_SECRET_LENGTH);
49
+ const hideSecrets = (text) => scrubSecrets(text, secrets);
50
+ return {
51
+ baseUrl,
52
+ hideSecrets,
53
+ error: (status, description) => new MessengerApiError(`${input.messenger}: ${hideSecrets(description)}`, status),
54
+ async fetch(path, init = {}) {
55
+ const merged = new Headers(init.headers);
56
+ for (const [name, value] of Object.entries(headers)) merged.set(name, value);
57
+ try {
58
+ return await (ownFetch ?? globalThis.fetch)(`${baseUrl}${path}`, { ...init, headers: merged });
59
+ } catch (error) {
60
+ if (init.signal?.aborted) throw error;
61
+ const reason = error instanceof Error ? error.message : String(error);
62
+ throw new MessengerApiError(`${input.messenger}: \u0441\u0431\u043E\u0439 \u0437\u0430\u043F\u0440\u043E\u0441\u0430 \u043A API: ${hideSecrets(reason)}`, 0);
63
+ }
64
+ }
65
+ };
66
+ }
67
+ function buttonsRejected(error) {
68
+ return error instanceof MessengerApiError && !(error instanceof MessengerBlockedError) && !(error instanceof MessengerWindowClosedError) && !(error instanceof MessengerRateLimitError) && !(error instanceof MessengerMarkupError) && error.status >= 400 && error.status < 500;
69
+ }
70
+
71
+ // src/bot-commands.ts
72
+ function parseCommand(text) {
73
+ const match = /^\/([A-Za-z0-9_]+)(?:@\S+)?(?:\s+([\s\S]*))?$/.exec((text ?? "").trim());
74
+ return match ? { name: (match[1] ?? "").toLowerCase(), args: (match[2] ?? "").trim() } : null;
75
+ }
76
+ function commandOf(update) {
77
+ if (update.kind === "start") return { name: "start", args: update.startPayload ?? "" };
78
+ return update.kind === "command" ? parseCommand(update.text) : null;
79
+ }
80
+
81
+ // src/bounded-body.ts
82
+ var MAX_JSON_BODY_BYTES = 1024 * 1024;
83
+ var BodyTooLargeError = class extends Error {
84
+ constructor(maxBytes) {
85
+ super(`\u0442\u0435\u043B\u043E \u0431\u043E\u043B\u044C\u0448\u0435 ${maxBytes} \u0431\u0430\u0439\u0442`);
86
+ this.maxBytes = maxBytes;
87
+ this.name = "BodyTooLargeError";
88
+ }
89
+ maxBytes;
90
+ };
91
+ async function readAtMost(source, maxBytes, tooLarge = () => new BodyTooLargeError(maxBytes)) {
92
+ if (Number(source.headers.get("content-length")) > maxBytes) {
93
+ void source.body?.cancel().catch(() => void 0);
94
+ throw tooLarge();
95
+ }
96
+ if (!source.body) return new Uint8Array(0);
97
+ const reader = source.body.getReader();
98
+ const chunks = [];
99
+ let size = 0;
100
+ for (let read = await reader.read(); !read.done; read = await reader.read()) {
101
+ size += read.value.byteLength;
102
+ if (size > maxBytes) {
103
+ void reader.cancel().catch(() => void 0);
104
+ throw tooLarge();
105
+ }
106
+ chunks.push(read.value);
107
+ }
108
+ const bytes = new Uint8Array(size);
109
+ let offset = 0;
110
+ for (const chunk of chunks) {
111
+ bytes.set(chunk, offset);
112
+ offset += chunk.byteLength;
113
+ }
114
+ return bytes;
115
+ }
116
+ async function readJsonBody(request) {
117
+ let bytes;
118
+ try {
119
+ bytes = await readAtMost(request, MAX_JSON_BODY_BYTES);
120
+ } catch (error) {
121
+ return { error: error instanceof BodyTooLargeError ? "too_large" : "bad" };
122
+ }
123
+ try {
124
+ return { json: JSON.parse(new TextDecoder().decode(bytes)) };
125
+ } catch {
126
+ return { error: "bad" };
127
+ }
128
+ }
129
+
130
+ // src/messenger-files.ts
131
+ function uploadForm(fields) {
132
+ const form = new FormData();
133
+ for (const [name, value] of Object.entries(fields)) {
134
+ if (value === void 0) continue;
135
+ if (typeof value === "string") form.set(name, value);
136
+ else form.set(name, new Blob([value.bytes], value.mediaType ? { type: value.mediaType } : {}), value.name);
137
+ }
138
+ return form;
139
+ }
140
+ var MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024;
141
+ var DownloadTooLargeError = class extends Error {
142
+ constructor(maxBytes) {
143
+ super(`\u0444\u0430\u0439\u043B \u0431\u043E\u043B\u044C\u0448\u0435 ${maxBytes} \u0431\u0430\u0439\u0442`);
144
+ this.maxBytes = maxBytes;
145
+ this.name = "DownloadTooLargeError";
146
+ }
147
+ maxBytes;
148
+ };
149
+ async function downloadedFile(response, maxBytes = MAX_DOWNLOAD_BYTES) {
150
+ const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim();
151
+ const bytes = await readAtMost(response, maxBytes, () => new DownloadTooLargeError(maxBytes));
152
+ return { bytes, ...mediaType && { mediaType } };
153
+ }
154
+ var MEDIA_TYPES = {
155
+ pdf: "application/pdf",
156
+ txt: "text/plain",
157
+ csv: "text/csv",
158
+ md: "text/markdown",
159
+ json: "application/json",
160
+ png: "image/png",
161
+ jpg: "image/jpeg",
162
+ jpeg: "image/jpeg",
163
+ gif: "image/gif",
164
+ zip: "application/zip",
165
+ mp3: "audio/mpeg",
166
+ ogg: "audio/ogg",
167
+ doc: "application/msword",
168
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
169
+ xls: "application/vnd.ms-excel",
170
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
171
+ };
172
+ function mediaTypeOfExtension(ext) {
173
+ return ext ? MEDIA_TYPES[ext.toLowerCase().replace(/^\./, "")] : void 0;
174
+ }
175
+ function mediaTypeOfFileName(name) {
176
+ return name?.includes(".") ? mediaTypeOfExtension(name.split(".").pop()) : void 0;
177
+ }
178
+
179
+ export {
180
+ parseCommand,
181
+ commandOf,
182
+ readJsonBody,
183
+ uploadForm,
184
+ MAX_DOWNLOAD_BYTES,
185
+ DownloadTooLargeError,
186
+ downloadedFile,
187
+ mediaTypeOfExtension,
188
+ mediaTypeOfFileName,
189
+ MessengerApiError,
190
+ MessengerRateLimitError,
191
+ MessengerBlockedError,
192
+ MessengerWindowClosedError,
193
+ MessengerMarkupError,
194
+ createMessengerApi,
195
+ buttonsRejected
196
+ };
197
+ //# sourceMappingURL=chunk-4QQ62ZKT.js.map