morok-bot-sdk 1.0.1
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.
- package/LICENSE +201 -0
- package/README.md +602 -0
- package/README.ru.md +602 -0
- package/dist/bot.d.ts +232 -0
- package/dist/bot.d.ts.map +1 -0
- package/dist/bot.js +558 -0
- package/dist/bot.js.map +1 -0
- package/dist/crypto/channel-cipher.d.ts +32 -0
- package/dist/crypto/channel-cipher.d.ts.map +1 -0
- package/dist/crypto/channel-cipher.js +77 -0
- package/dist/crypto/channel-cipher.js.map +1 -0
- package/dist/crypto/channel-key-store.d.ts +37 -0
- package/dist/crypto/channel-key-store.d.ts.map +1 -0
- package/dist/crypto/channel-key-store.js +149 -0
- package/dist/crypto/channel-key-store.js.map +1 -0
- package/dist/crypto/cross-signing.d.ts +57 -0
- package/dist/crypto/cross-signing.d.ts.map +1 -0
- package/dist/crypto/cross-signing.js +111 -0
- package/dist/crypto/cross-signing.js.map +1 -0
- package/dist/crypto/file-cipher.d.ts +36 -0
- package/dist/crypto/file-cipher.d.ts.map +1 -0
- package/dist/crypto/file-cipher.js +61 -0
- package/dist/crypto/file-cipher.js.map +1 -0
- package/dist/crypto/group-secret-cipher.d.ts +49 -0
- package/dist/crypto/group-secret-cipher.d.ts.map +1 -0
- package/dist/crypto/group-secret-cipher.js +69 -0
- package/dist/crypto/group-secret-cipher.js.map +1 -0
- package/dist/crypto/group-secret-store.d.ts +35 -0
- package/dist/crypto/group-secret-store.d.ts.map +1 -0
- package/dist/crypto/group-secret-store.js +149 -0
- package/dist/crypto/group-secret-store.js.map +1 -0
- package/dist/crypto/signal.d.ts +81 -0
- package/dist/crypto/signal.d.ts.map +1 -0
- package/dist/crypto/signal.js +125 -0
- package/dist/crypto/signal.js.map +1 -0
- package/dist/crypto/stores.d.ts +130 -0
- package/dist/crypto/stores.d.ts.map +1 -0
- package/dist/crypto/stores.js +314 -0
- package/dist/crypto/stores.js.map +1 -0
- package/dist/flow/attachments.d.ts +110 -0
- package/dist/flow/attachments.d.ts.map +1 -0
- package/dist/flow/attachments.js +409 -0
- package/dist/flow/attachments.js.map +1 -0
- package/dist/flow/conv-cache.d.ts +36 -0
- package/dist/flow/conv-cache.d.ts.map +1 -0
- package/dist/flow/conv-cache.js +84 -0
- package/dist/flow/conv-cache.js.map +1 -0
- package/dist/flow/direct.d.ts +109 -0
- package/dist/flow/direct.d.ts.map +1 -0
- package/dist/flow/direct.js +346 -0
- package/dist/flow/direct.js.map +1 -0
- package/dist/flow/groups.d.ts +146 -0
- package/dist/flow/groups.d.ts.map +1 -0
- package/dist/flow/groups.js +768 -0
- package/dist/flow/groups.js.map +1 -0
- package/dist/flow/prekeys.d.ts +45 -0
- package/dist/flow/prekeys.d.ts.map +1 -0
- package/dist/flow/prekeys.js +111 -0
- package/dist/flow/prekeys.js.map +1 -0
- package/dist/flow/receive.d.ts +125 -0
- package/dist/flow/receive.d.ts.map +1 -0
- package/dist/flow/receive.js +773 -0
- package/dist/flow/receive.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/morokbot-file.d.ts +14 -0
- package/dist/morokbot-file.d.ts.map +1 -0
- package/dist/morokbot-file.js +88 -0
- package/dist/morokbot-file.js.map +1 -0
- package/dist/ratelimit.d.ts +40 -0
- package/dist/ratelimit.d.ts.map +1 -0
- package/dist/ratelimit.js +76 -0
- package/dist/ratelimit.js.map +1 -0
- package/dist/sessions.d.ts +34 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +69 -0
- package/dist/sessions.js.map +1 -0
- package/dist/state-lock.d.ts +17 -0
- package/dist/state-lock.d.ts.map +1 -0
- package/dist/state-lock.js +66 -0
- package/dist/state-lock.js.map +1 -0
- package/dist/transport/http.d.ts +48 -0
- package/dist/transport/http.d.ts.map +1 -0
- package/dist/transport/http.js +112 -0
- package/dist/transport/http.js.map +1 -0
- package/dist/transport/ws.d.ts +65 -0
- package/dist/transport/ws.d.ts.map +1 -0
- package/dist/transport/ws.js +219 -0
- package/dist/transport/ws.js.map +1 -0
- package/dist/types.d.ts +254 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +59 -0
package/README.ru.md
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
# morok-bot-sdk
|
|
2
|
+
|
|
3
|
+
Node.js / TypeScript SDK для разработки ботов на платформе сквозного шифрования [Морок](https://morok.me).
|
|
4
|
+
|
|
5
|
+
SDK берет на себя установку сессии Signal Protocol, рассылку под channel-key в беседах и каналах, пополнение запаса одноразовых ключей, обновление JWT, переподключение WebSocket и шифрование вложений. Вы пишете обработчики событий.
|
|
6
|
+
|
|
7
|
+
- English version: [README.md](./README.md).
|
|
8
|
+
- Пошаговое руководство со скриншотами панели разработчика: [docs/getting-started.ru.md](./docs/getting-started.ru.md).
|
|
9
|
+
- Полный справочник HTTP, WebSocket и формата обмена: [api.md](https://morok.me/api).
|
|
10
|
+
- Развертывание (systemd, Docker, бэкапы, мониторинг): [docs/deployment.ru.md](./docs/deployment.ru.md).
|
|
11
|
+
|
|
12
|
+
## Установка
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install morok-bot-sdk
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Требования:
|
|
19
|
+
|
|
20
|
+
- Node **22 или новее** (SDK использует `globalThis.crypto`, нативный `Buffer.from(..., 'base64url')` и семантику `fs.rename`, которой нет гарантий в более старых версиях)
|
|
21
|
+
- Файл токена `.morokbot`, сгенерированный при создании бота в приложении Морок
|
|
22
|
+
|
|
23
|
+
Приватные ключи бота должны лежать на сервере, который вы контролируете.
|
|
24
|
+
|
|
25
|
+
## Быстрый старт
|
|
26
|
+
|
|
27
|
+
1. В приложении Морок откройте **Настройки -> О Мороке** и включите переключатель **Режим разработчика**. В настройках появится одноименный раздел.
|
|
28
|
+
2. **Настройки -> Режим разработчика -> Создать бота**. Заполните три шага окна создания (описание, внешний вид, управление) и нажмите **Создать**. После этого под токеном появляется кнопка **Скачать .morokbot**. Она сохраняет JSON-файл с токеном и Signal-ключевым материалом бота.
|
|
29
|
+
3. Положите файл рядом с кодом бота как `bot.morokbot` либо передайте абсолютный путь в `tokenFile`.
|
|
30
|
+
4. Напишите обработчики:
|
|
31
|
+
|
|
32
|
+
> **Не коммитьте `.morokbot` и `bot-state/` в git.** Файл `.morokbot` содержит signing-ключ бота: любой, у кого он есть, может выдавать себя за вашего бота. В `bot-state/` после первого запуска лежит Signal-идентичность и запас prekey. Готовый `.gitignore` с обоими паттернами лежит в репозитории SDK (`sdk/.gitignore`), скопируйте его в свой проект до первого коммита. После первого импорта: `chmod 0600 bot.morokbot && chmod 0700 bot-state/`.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { MorokBot } from 'morok-bot-sdk'
|
|
37
|
+
|
|
38
|
+
const bot = await MorokBot.fromFile({ tokenFile: './bot.morokbot' })
|
|
39
|
+
|
|
40
|
+
bot.on('start', (e) => console.log(`новый пользователь: @${e.peer.username}`))
|
|
41
|
+
bot.on('stop', (e) => console.log(`пользователь отписался: @${e.peer.username}`))
|
|
42
|
+
|
|
43
|
+
bot.on('command', async (c) => {
|
|
44
|
+
if (c.command === 'help') await bot.reply(c, { text: 'Я эхо-бот.' })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
bot.on('message', async (m) => {
|
|
48
|
+
if (m.text) await bot.reply(m, { text: `эхо: ${m.text}` })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
bot.on('disconnect', (d) => console.log('сокет отвалился, willReconnect:', d.willReconnect))
|
|
52
|
+
bot.on('error', (e) => console.error('ошибка бота:', e.message))
|
|
53
|
+
|
|
54
|
+
await bot.start()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
SDK закрывает:
|
|
58
|
+
|
|
59
|
+
- выпуск сессии через `/auth/bot-session` и обновление JWT по коду WS 4001 либо HTTP 401
|
|
60
|
+
- подключение WebSocket с экспоненциальной задержкой повторов
|
|
61
|
+
- X3DH-handshake при первом контакте и Double Ratchet через libsignal
|
|
62
|
+
- пополнение одноразовых prekey (стартовый top-up, реактивный серверный сигнал prekeys_low и подстраховочный фоновый тик раз в 5 минут)
|
|
63
|
+
- ротация подписанного prekey по серверному маркеру в 7 дней
|
|
64
|
+
- доставка исходящего сообщения на все устройства собеседника
|
|
65
|
+
- сопоставление собственных эхо-копий по `fanoutId`, чтобы `bot.send()` возвращал реальный `messageId`
|
|
66
|
+
|
|
67
|
+
## Конфигурация
|
|
68
|
+
|
|
69
|
+
`MorokBot.fromFile` принимает `BotConfig & { tokenFile }`:
|
|
70
|
+
|
|
71
|
+
| Поле | По умолчанию | Примечания |
|
|
72
|
+
|------------------------|--------------------------|---------------------------------------------------------------------|
|
|
73
|
+
| `tokenFile` | (обязательно) | Путь к файлу `.morokbot` |
|
|
74
|
+
| `stateDir` | `./bot-state/` | Свой каталог на каждого бота. Хранит приватные ключи. chmod 0700 |
|
|
75
|
+
| `apiBaseUrl` | `https://app.morok.me` | Переопределите для self-hosted или dev-окружения |
|
|
76
|
+
| `wsUrl` | вычисляется | http -> ws, https -> wss, добавляется `/ws` |
|
|
77
|
+
| `replenishThreshold` | `100` | Порог пополнения одноразовых ключей. Совпадает с серверной нижней отметкой, поэтому порог и реактивный сигнал `prekeys_low` согласованы |
|
|
78
|
+
| `replenishTarget` | `200` | Целевой размер запаса после пополнения (равен серверному потолку на один вызов) |
|
|
79
|
+
| `backgroundIntervalMs` | `300_000` (5 минут) | Подстраховочный тик за реактивным серверным сигналом `prekeys_low`, который пополняет по требованию. 0 отключает цикл (только для тестов) |
|
|
80
|
+
| `autoBackfillOnJoin` | `false` | Автоматически отдавать локальные эпохи новым участникам |
|
|
81
|
+
| `logger` | молчит | В стиле pino: `info`, `warn`, `error`, `debug` |
|
|
82
|
+
|
|
83
|
+
## Хранилище
|
|
84
|
+
|
|
85
|
+
Данные бота лежат в двух местах:
|
|
86
|
+
|
|
87
|
+
- `stateDir` на вашей машине: identity-ключ, prekeys, сессии Double Ratchet, локальные копии channel-key и group-secret. Десятки МБ максимум. Морок этот объем не квотирует, это ваш диск.
|
|
88
|
+
- Сервер Морока хранит шифротекст сообщений в пути и все файлы бота, в том числе пока их не забрал офлайн-получатель. Разница только в том, на чью квоту они идут. **Обычный файл** сразу занимает квоту того, кому ушел: в ЛС квоту получателя (который запустил бота), в беседе или канале квоту владельца беседы (который добавил бота). Бот может занять все свободное место этой стороны, а когда оно кончится, отправка падает с `SendRejectedError`, код `recipient_storage_full`. **Голосовые и видеосообщения иначе: квоту они не занимают.** Сервер все равно держит их у себя, на локальном медиа-диске, и удаляет через 30 дней после отправки (и раньше, если диск переполняется, начиная со старых по всем пользователям). У получателей остается то, что они успели скачать или сохранить себе в Заметки.
|
|
89
|
+
|
|
90
|
+
Дальше в этом разделе описана структура каталога `stateDir` (по умолчанию `./bot-state/`).
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
bot-state/
|
|
94
|
+
identity.json пара ключей, accountSigningKey, registrationId
|
|
95
|
+
state.json счетчики (id следующего одноразового ключа, время последней ротации SPK)
|
|
96
|
+
state.lock pid-блокировка, один процесс на stateDir
|
|
97
|
+
sessions/<peer>.<dev>.json запись Double Ratchet на устройство собеседника
|
|
98
|
+
prekeys/signed-<id>.json подписанные prekey (подпись сохраняется между раундами)
|
|
99
|
+
prekeys/onetime-<id>.json одноразовые prekey
|
|
100
|
+
identity-cache/<addr>.json identity_key собеседника для TOFU
|
|
101
|
+
channel-keys/<conv>.json история channel-key по разговору
|
|
102
|
+
group-secrets/<conv>.json история group-secret по разговору
|
|
103
|
+
quarantine/ fsck перемещает сюда битые файлы при старте
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
После первого запуска в `stateDir` лежит приватный ключевой материал бота. Не выгружайте его в S3, не пересылайте по почте, не коммитьте в git. Храните резервную копию так же, как SSH-ключ.
|
|
107
|
+
|
|
108
|
+
Два процесса на одном `stateDir` портят Signal-сессии. pid-lock это ловит, но на всякий случай давайте каждому боту свой каталог.
|
|
109
|
+
|
|
110
|
+
## Публичный API
|
|
111
|
+
|
|
112
|
+
### Конструктор
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
MorokBot.fromFile(config: BotConfig & { tokenFile: string }): Promise<MorokBot>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Читает и валидирует файл токена, собирает бота. Сетевых обращений до `start()` не делает.
|
|
119
|
+
|
|
120
|
+
### Жизненный цикл
|
|
121
|
+
|
|
122
|
+
| Вызов | Эффект |
|
|
123
|
+
|---------------------|-----------------------------------------------------------------------------------------|
|
|
124
|
+
| `await bot.start()` | Импорт ключей, fsck, выпуск сессии, открытие WebSocket, первичное пополнение запаса. Повторный вызов безопасен. |
|
|
125
|
+
| `await bot.stop()` | Останавливает фоновые циклы, закрывает сокет, отвечает ошибкой на висящие отправки, снимает блокировку. Повторный вызов безопасен. |
|
|
126
|
+
| `bot.isConnected` | `true` после успешной аутентификации в WebSocket. |
|
|
127
|
+
| `bot.userId` | Числовой userId. До завершения `start()` бросает исключение. |
|
|
128
|
+
|
|
129
|
+
`start()` можно вызвать конкурентно: второй вызов сразу возвращается, бот окажется в одном состоянии. Если позвать `stop()` пока `start()` еще в полете, инициализация прерывается на ближайшей внутренней контрольной точке без утечек.
|
|
130
|
+
|
|
131
|
+
### События
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
bot.on('message', (m: IncomingMessage) => …)
|
|
135
|
+
bot.on('command', (c: CommandInvocation) => …)
|
|
136
|
+
bot.on('start', (e: BotStartEvent) => …) // пользователь нажал "Запустить"
|
|
137
|
+
bot.on('stop', (e: BotStopEvent) => …) // пользователь нажал "Стоп"
|
|
138
|
+
bot.on('reaction', (e: ReactionEvent) => …)
|
|
139
|
+
bot.on('conversation_added', (e: ConversationAddedEvent) => …)
|
|
140
|
+
bot.on('conversation_kicked', (e: ConversationKickedEvent) => …)
|
|
141
|
+
bot.on('disconnect', (d: DisconnectInfo) => …)
|
|
142
|
+
bot.on('error', (err: Error) => …)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`IncomingMessage`:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
{
|
|
149
|
+
messageId: number
|
|
150
|
+
conversationId: number
|
|
151
|
+
conversationType: 'DIRECT' | 'GROUP' | 'CHANNEL'
|
|
152
|
+
sender: Peer // { userId, username, displayName }
|
|
153
|
+
senderDeviceId: number
|
|
154
|
+
text: string // тело сообщения или подпись к вложению ('' если нет ни того, ни другого)
|
|
155
|
+
attachment?: IncomingAttachment
|
|
156
|
+
gallery?: IncomingGallery // 2-10 элементов
|
|
157
|
+
clientMsgId: string | null
|
|
158
|
+
replyToId: number | null
|
|
159
|
+
threadRootId: number | null
|
|
160
|
+
createdAt: Date
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`CommandInvocation` дополняет его полями `{ command, args, argv }` для сообщений, начинающихся с `/cmd`.
|
|
165
|
+
|
|
166
|
+
`DisconnectInfo.reason`:
|
|
167
|
+
- `'transport'`: сетевой обрыв, SDK переподключается
|
|
168
|
+
- `'auth'`: код WebSocket 4001, запись сессии в Redis удалена, SDK обновляет JWT и переподключается
|
|
169
|
+
- `'shutdown'`: вы сами вызвали `stop()`, переподключения не будет
|
|
170
|
+
|
|
171
|
+
### Отправка
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
// текстовое личное сообщение
|
|
175
|
+
await bot.send({ peer: 12345, text: 'привет' })
|
|
176
|
+
await bot.send({ peer: 'alice', text: 'привет' }) // псевдоним резолвится через REST
|
|
177
|
+
|
|
178
|
+
// файл (подпись необязательна)
|
|
179
|
+
await bot.send({
|
|
180
|
+
peer,
|
|
181
|
+
text: 'фото кота',
|
|
182
|
+
attachment: {
|
|
183
|
+
kind: 'file',
|
|
184
|
+
data: fs.readFileSync('./cat.jpg'),
|
|
185
|
+
name: 'cat.jpg',
|
|
186
|
+
mime: 'image/jpeg',
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// голосовое (подписи у голосовых нет)
|
|
191
|
+
await bot.send({
|
|
192
|
+
peer,
|
|
193
|
+
attachment: {
|
|
194
|
+
kind: 'voice',
|
|
195
|
+
data: oggBytes,
|
|
196
|
+
duration: 4.2,
|
|
197
|
+
waveform: [10, 30, 80, 100, 70, 30, 10],
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// видеосообщение
|
|
202
|
+
await bot.send({
|
|
203
|
+
peer,
|
|
204
|
+
attachment: {
|
|
205
|
+
kind: 'video_note',
|
|
206
|
+
data: webmBytes,
|
|
207
|
+
duration: 6,
|
|
208
|
+
shape: 'circle', // см. выноску "Формы видеосообщения" ниже
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// галерея: от 2 до 10 файлов в одном пузыре
|
|
213
|
+
await bot.send({
|
|
214
|
+
peer,
|
|
215
|
+
text: 'фото с прогулки',
|
|
216
|
+
attachments: [
|
|
217
|
+
{ kind: 'file', data: a, name: '1.jpg', mime: 'image/jpeg' },
|
|
218
|
+
{ kind: 'file', data: b, name: '2.jpg', mime: 'image/jpeg' },
|
|
219
|
+
],
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// сообщение в беседу или канал
|
|
223
|
+
await bot.send({ conversation: 42, text: 'объявление' })
|
|
224
|
+
|
|
225
|
+
// ответ на входящее сообщение (треды выставляются и для личных сообщений, и для бесед)
|
|
226
|
+
await bot.reply(msg, { text: 'спасибо' })
|
|
227
|
+
|
|
228
|
+
// поставить реакцию любым юникод-символом (не только эмодзи) либо снять реакцию
|
|
229
|
+
await bot.react(msg, '𔙃') // кабан
|
|
230
|
+
await bot.unreact(msg)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`bot.send()` резолвится в `{ messageId, clientMsgId, conversationId }`. Ровно одно из `peer` или `conversation` обязательно.
|
|
234
|
+
|
|
235
|
+
`bot.react(msg, unicode)` / `bot.unreact(msg)` принимают входящее сообщение или команду. Реакцией может быть любой юникод-символ, не только эмодзи. Она шифруется под разговор: в личных по устройствам собеседника, в беседах и каналах под channel-key. У бота может быть только одна реакция на сообщение, новая заменяет предыдущую. Свою реакцию бот обратно не получает, поэтому `react` завершается сразу после отправки. Чужие реакции приходят в `bot.on('reaction', ...)`.
|
|
236
|
+
|
|
237
|
+
`peer` принимает числовой `userId` (предпочтительно при ответе на входящее, значение уже под рукой) либо строковый псевдоним (SDK резолвит его через `GET /users/:username` на каждый вызов, без кеша).
|
|
238
|
+
|
|
239
|
+
`expiresInSeconds` в `send` или `reply` делает сообщение исчезающим, сервер удаляет его у всех через столько секунд после доставки
|
|
240
|
+
|
|
241
|
+
Серверные лимиты:
|
|
242
|
+
|
|
243
|
+
- Файлы до **5 ГБ** в открытом виде. До 50 МБ отправляются одним запросом, крупнее загружаются частями. SDK переключается между режимами сам.
|
|
244
|
+
- Голосовые: длительность `[0.1, 600]` секунд, до 64 пиков waveform.
|
|
245
|
+
- Видеосообщения: длительность `[0.5, 300]` секунд. `shape` это произвольная строка, актуальный список см. в выноске ниже.
|
|
246
|
+
- Галереи: от 2 до 10 элементов, все типа `kind: 'file'`. Голосовые и видеосообщения внутри галереи не поддерживаются (фронтенд их там не рендерит).
|
|
247
|
+
- **Обычные файлы** бота списываются с того, кто их получает (получатель в ЛС или владелец беседы/канала), в пределах его квоты, см. [Хранилище](#хранилище). **Голосовые и видеосообщения квоту не занимают**. Длинное видеосообщение все равно может превышать 50 МБ (5-минутный кружок около 58 МБ), SDK загружает его по chunked-пути прозрачно.
|
|
248
|
+
|
|
249
|
+
> **Формы видеосообщения**: `shape` это строка, SDK передает ее как есть, приложение получателя рисует известные ему имена и подставляет `circle` для всего остального
|
|
250
|
+
>
|
|
251
|
+
> Имена, которые рисует получатель: `circle`, `square`, `slanted`, `pill`, `oval`, `arch`, `diamond`, `pentagon`, `gem`, `clamShell`, `sunny`, `cookie1`, `cookie2`, `cookie3`, `cookie4`, `clover1`, `clover2`, `burst`, `softBurst`, `puffyDiamond`, `pixelCircle`, `heart`
|
|
252
|
+
|
|
253
|
+
### Команды и управление
|
|
254
|
+
|
|
255
|
+
`bot.setMyCommands([{ command, description, sortOrder? }])` задает каталог слэш-команд, которые приложение подсказывает при вводе `/`. `bot.setMyControls([...])` задает дерево кнопок в меню бота, где контрол это `{ id, label, icon?, command?, children? }`. Кнопка с `children` открывает подменю на месте. Кнопка с `command` подставляет `/command ` в поле ввода. Кнопка без того и другого работает колбэком, боту приходит событие `control`, и он отвечает или перестраивает меню новым `setMyControls`. Слушайте через `bot.on('control', e => ...)`, где `e.controlId` это кнопка, а `e.sender` это кто нажал. Вызывайте после `start()`, каждый вызов заменяет дерево целиком. Бот объявляет до 32 команд и дерево кнопок до 64 узлов и до 4 уровней. `icon` берется из Material Symbols.
|
|
256
|
+
|
|
257
|
+
`setMyControls` глобальный, одно меню для всех чатов. Чтобы у каждого пользователя были свои кнопки, вызывайте `bot.setControlsFor(userId, [...])` - она задает кнопки только для чата с одним пользователем, не затрагивая остальных. Так удобно показывать результаты поиска кнопками под конкретного человека или вести его по шагам. `userId` берется из события `control` (`e.sender.userId`) или из сообщения (`msg.sender.userId`). Переопределение живет недолго и переживает перезагрузку у этого пользователя. `bot.clearControlsFor(userId)` убирает его и возвращает пользователю глобальное меню. Границы те же, до 64 узлов и до 4 уровней. Корневое меню держите на `setMyControls`, а динамику ведите через `setControlsFor`.
|
|
258
|
+
|
|
259
|
+
### Прием вложений
|
|
260
|
+
|
|
261
|
+
`m.attachment` стоит на сообщениях с одним вложением, `m.gallery` на галереях. Байты не качаются, пока вы не вызовете `.download()`:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
bot.on('message', async (m) => {
|
|
265
|
+
if (m.attachment) {
|
|
266
|
+
const a = m.attachment
|
|
267
|
+
console.log(`пришло ${a.kind} ${a.size}B (${a.mime})`)
|
|
268
|
+
if (a.kind === 'voice') console.log(`длительность: ${a.duration}s`)
|
|
269
|
+
if (a.kind === 'video_note') console.log(`форма: ${a.shape}`)
|
|
270
|
+
const bytes = await a.download()
|
|
271
|
+
fs.writeFileSync(`./inbox/${a.name ?? a.fileId}`, bytes)
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
if (m.gallery) {
|
|
275
|
+
for (const item of m.gallery.items) {
|
|
276
|
+
if (item.kind === 'file') {
|
|
277
|
+
const bytes = await item.attachment.download()
|
|
278
|
+
fs.writeFileSync(`./inbox/${item.attachment.name ?? item.attachment.fileId}`, bytes)
|
|
279
|
+
}
|
|
280
|
+
if (item.kind === 'contact') console.log(`контакт: @${item.username ?? item.userId}`)
|
|
281
|
+
if (item.kind === 'location') console.log(`координаты: ${item.lat},${item.lng}`)
|
|
282
|
+
}
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
console.log('текст:', m.text)
|
|
286
|
+
})
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
`download()` возвращает `Buffer` с расшифрованным содержимым. Reject на 404 либо 410 (файл удален или вытеснен по квоте), а также на ошибке расшифровки (порча формата обмена или неверный ключ). SDK не валидирует mime по белому списку: процесс это Node, а не браузер, поэтому считайте `mime` подсказкой от отправителя.
|
|
290
|
+
|
|
291
|
+
`attachment.virusTotalVerdict` несет результат проверки VirusTotal (`'clean'`, `'suspicious'`, `'malware'`) на момент, когда SDK получил файл. Безопасные типы (картинки, медиа) не сканируются и сразу идут как `'clean'`. Потенциально опасные (исполняемые, архивы) уходят в VirusTotal, и пока он не ответил, вердикт `null`. SDK читает вердикт один раз и не следит за обновлениями, так что `null` значит, что проверка тогда еще не завершилась.
|
|
292
|
+
|
|
293
|
+
### Беседы и каналы
|
|
294
|
+
|
|
295
|
+
После добавления бота в беседу или канал (событие `conversation_added`) можно постить через `bot.send({ conversation, ... })` и отвечать через `bot.reply(msg, ...)`. SDK хранит channel-key по разговору в `stateDir/channel-keys/`, а group-secret в `stateDir/group-secrets/`.
|
|
296
|
+
|
|
297
|
+
**Что боту можно и нельзя:**
|
|
298
|
+
|
|
299
|
+
- Добавляет бота в беседу или канал и назначает ему роль только владелец (вкладка «Боты» в профиле, без подтверждения от бота).
|
|
300
|
+
- Права бота те же, что у человека с такой же ролью.
|
|
301
|
+
- В **канале** есть посты и комментарии, пост верхнего уровня пишет только бот-администратор или владелец, бот-модератор и бот-участник могут только комментировать.
|
|
302
|
+
- В **беседе** есть обычные сообщения, писать может любой бот-участник, роль меняет только права модерации, такие как удаление чужих сообщений и заглушение участников, для них нужна роль модератора или выше.
|
|
303
|
+
- Не может: менять роли участников, добавлять другого бота, становиться владельцем, создавать беседы и каналы, вызывать /start у другого бота.
|
|
304
|
+
- Запрещенное действие сервер отклоняет с HTTP 403 или `SendRejectedError`.
|
|
305
|
+
|
|
306
|
+
Администрирование беседы или канала:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
// Сминтить свежий channel-key, разослать на устройства всех остальных участников
|
|
310
|
+
await bot.rotateChannelKey(conversationId)
|
|
311
|
+
|
|
312
|
+
// Перевыпустить group-secret (которым обернуты пакеты channel-key) и channel-key
|
|
313
|
+
// одной серверной транзакцией. Делайте после исключения скомпрометированного участника,
|
|
314
|
+
// старые участники не смогут расшифровать будущие пакеты
|
|
315
|
+
await bot.rotateGroupSecret(conversationId)
|
|
316
|
+
|
|
317
|
+
// Раздать локальную историю channel-key другим устройствам участника
|
|
318
|
+
// Полезно, когда бот единственный онлайн-участник и новому подписчику нужно догнать историю
|
|
319
|
+
await bot.backfillChannelKeys(conversationId, { userId: 7777 })
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
`autoBackfillOnJoin: true` в `BotConfig` дергает `backfillChannelKeys` для каждого нового участника без вашей обвязки. Сервер все равно режет по `joined_secret_version`, история до вступления не утечет.
|
|
323
|
+
|
|
324
|
+
### Модель ошибок
|
|
325
|
+
|
|
326
|
+
- **Ошибка расшифровки** -> событие `error`, сообщение отбрасывается, следующий type-3 кадр собеседника пересоберет сессию
|
|
327
|
+
- **Сервер отклонил отправку** -> `send()` падает с экспортируемым `SendRejectedError`, причина в машиночитаемом `.code`: `bot_not_started` (пользователь не нажал "Запустить"), `recipient_storage_full`, `send_blocked`, `too_many_messages`
|
|
328
|
+
- **Сервер отклонил загрузку** -> загрузка файла бросает экспортируемый `UploadRejectedError` с серверным `.code`, например `BOT_STAGING_FULL`, до того как уйдет кадр отправки
|
|
329
|
+
- **Сетевой обрыв** -> событие `disconnect` с `willReconnect: true`, отправка, еще стоящая в очереди WebSocket-слоя, улетает после переподключения, а отправка, уже ушедшая в сеть и ждущая ответа сервера, падает с экспортируемым `SendUncertainError`, чтобы вы повторили или сверились, сообщение могло и дойти, и не дойти
|
|
330
|
+
- **Отозванный JWT** (в панели разработчика обновили токен либо бот удален) -> WebSocket закрывается кодом 4001, SDK снова обращается к `/auth/bot-session` и переподключается, если бот еще жив, перевыпуск токена (кнопка "Перевыпустить токен") деструктивен, старый `.morokbot` теперь бесполезен, перезапустите SDK с новым файлом
|
|
331
|
+
- **Отправка в беседу или канал, откуда вас исключили** -> `send()` падает с HTTP 403, локальное состояние channel-key уже сброшено на событии `conversation_kicked`
|
|
332
|
+
|
|
333
|
+
## Вспомогательные классы
|
|
334
|
+
|
|
335
|
+
Небольшие утилиты в составе `MorokBot`. Использовать не обязательно, они просто убирают типовую логику из обработчиков событий.
|
|
336
|
+
|
|
337
|
+
### RateLimiter
|
|
338
|
+
|
|
339
|
+
Счетчик по схеме token-bucket на каждый ключ. Сдерживает флуд от одного собеседника, не трогая нормальный трафик от других.
|
|
340
|
+
|
|
341
|
+
```ts
|
|
342
|
+
import { MorokBot, RateLimiter } from 'morok-bot-sdk'
|
|
343
|
+
|
|
344
|
+
const limiter = new RateLimiter({
|
|
345
|
+
capacity: 5, // допустимый всплеск
|
|
346
|
+
refillPerSec: 1, // устойчивая скорость
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
bot.on('message', async (m) => {
|
|
350
|
+
if (!limiter.tryAcquire(m.sender.userId)) {
|
|
351
|
+
await bot.reply(m, { text: 'Слишком много сообщений, дайте мне минуту.' })
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
// ... ваш обработчик
|
|
355
|
+
})
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`tryAcquire(key, cost = 1)` возвращает `true`, если `cost` токенов было и они списаны, `false` иначе. `available(key)` подсматривает значение без расхода. `reset(key)` чистит один счетчик, `clear()` все.
|
|
359
|
+
|
|
360
|
+
Доступ за O(1), счетчики сами удаляются после простоя в полном состоянии. Все хранится только в памяти. Если один бот работает в нескольких процессах, заведите общий счетчик в своем хранилище (например, Redis), в SDK он не входит.
|
|
361
|
+
|
|
362
|
+
Чтобы держать темп постов верхнего уровня в канал под серверным (запас 5, дальше один в 30 секунд) задайте счетчик как `new RateLimiter({ capacity: 5, refillPerSec: 1 / 30 })` и вызывайте `tryAcquire` перед каждым постом. Сам SDK не повторяет отказ `too_many_messages` за вас, поймайте `SendRejectedError`, прочитайте `.code` и сбавьте темп, так же и с `SendUncertainError` после обрыва, где вы решаете, повторить или свериться
|
|
363
|
+
|
|
364
|
+
### BotSessions
|
|
365
|
+
|
|
366
|
+
Хранилище состояния на каждого пользователя для пошаговых сценариев ("спросить имя", "спросить email", "подтвердить"). Внутри обычная `Map` по `userId` с необязательным TTL и методом `update()` для частичных изменений.
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
import { MorokBot, BotSessions } from 'morok-bot-sdk'
|
|
370
|
+
|
|
371
|
+
interface RegisterFlow {
|
|
372
|
+
step: 'name' | 'email'
|
|
373
|
+
name?: string
|
|
374
|
+
email?: string
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const flows = new BotSessions<RegisterFlow>({ ttlMs: 5 * 60_000 }) // 5 минут простоя = бросить
|
|
378
|
+
|
|
379
|
+
bot.on('command', async (c) => {
|
|
380
|
+
if (c.command === 'register') {
|
|
381
|
+
flows.set(c.sender.userId, { step: 'name' })
|
|
382
|
+
await bot.reply(c, { text: 'Как вас зовут?' })
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
bot.on('message', async (m) => {
|
|
387
|
+
const state = flows.get(m.sender.userId)
|
|
388
|
+
if (!state) return // не в flow
|
|
389
|
+
|
|
390
|
+
if (state.step === 'name' && m.text) {
|
|
391
|
+
flows.update(m.sender.userId, { step: 'email', name: m.text })
|
|
392
|
+
await bot.reply(m, { text: 'А email?' })
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
if (state.step === 'email' && m.text) {
|
|
396
|
+
const final = flows.update(m.sender.userId, { email: m.text })
|
|
397
|
+
flows.delete(m.sender.userId) // flow завершен
|
|
398
|
+
await bot.reply(m, { text: `Принято, ${final.name}. Напишем на ${final.email}.` })
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Только в памяти. Перезапуск процесса все стирает. Если нужно сохранение сценариев между перезапусками, сохраняйте состояние на диск или в Redis из обработчиков самостоятельно.
|
|
404
|
+
|
|
405
|
+
## Восстановление токена
|
|
406
|
+
|
|
407
|
+
Если потеряли файл `.morokbot`:
|
|
408
|
+
|
|
409
|
+
1. Режим разработчика -> ваш бот -> **Редактировать** -> шаг **Управление** -> **Перевыпустить**.
|
|
410
|
+
2. Сервер отзывает старый токен И закрывает все живые сессии.
|
|
411
|
+
3. Окно создания один раз показывает новый токен. Замените поле `token` в файле `.morokbot` (все остальное остается прежним, identity, SPK, OTPK не меняются).
|
|
412
|
+
4. Перезапустите SDK.
|
|
413
|
+
|
|
414
|
+
Если потерян и ключевой материал, бота придется удалить и создать заново. Удаление убирает аккаунт бота целиком: у собеседников он превращается в удаленный аккаунт и пропадает из поиска. Новый бот будет отдельным аккаунтом, даже под тем же псевдонимом, согласие на него не переносится, поэтому каждому собеседнику придется заново найти бота и нажать "Запустить".
|
|
415
|
+
|
|
416
|
+
## Безопасность
|
|
417
|
+
|
|
418
|
+
- Сервер Морока никогда не расшифровывает содержимое сообщений. SDK использует ключи сессии Signal Protocol для личных сообщений и channel-key (AES-256-GCM) для каждой беседы или канала.
|
|
419
|
+
- При полной утечке БД наружу уходят шифротекст, публичные Signal-ключи, HMAC от телефонных номеров, граф общения по псевдонимам (кто с кем и когда), размеры файлов. Содержимого сообщений и приватных ключей в БД нет. Сырые IP в логи не пишутся.
|
|
420
|
+
- В `.morokbot` и в `stateDir` лежат приватные ключи бота. Если они попадут в чужие руки, бота можно только пересоздать заново.
|
|
421
|
+
- При подключении бот перекрестно подписывает свое устройство, публикуя сертификат устройства, и проверяет сертификаты собеседников при первом контакте, поэтому переименованный собеседник или собеседник на новом устройстве по-прежнему дает согласованный номер безопасности. Сертификат устройства задается один раз. Сервер отклоняет тихую смену ключа (отправка другого сертификата на уже подписанное устройство возвращает HTTP 409), поэтому ни враждебный сервер, ни перехваченная сессия не подменят проверочный материал бота незаметно.
|
|
422
|
+
|
|
423
|
+
> **Отчеты об уязвимостях**: пишите на `security@morok.me`. Пожалуйста, **не оформляйте публичные GitHub issues с описанием уязвимостей**: это раскроет проблему каждому встречному до того, как выйдет фикс. Машинно-читаемая политика раскрытия лежит в [`/.well-known/security.txt`](https://morok.me/.well-known/security.txt) (RFC 9116).
|
|
424
|
+
|
|
425
|
+
## Что делать, если
|
|
426
|
+
|
|
427
|
+
Типичные симптомы и решения. Подробности по кодам ошибок в [справочнике API](https://morok.me/api).
|
|
428
|
+
|
|
429
|
+
### Подключение и аутентификация
|
|
430
|
+
|
|
431
|
+
| Симптом | Что значит | Решение |
|
|
432
|
+
|----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
|
|
433
|
+
| `disconnect` событие каждые несколько секунд, `reason: 'transport'` | Нестабильная сеть, NAT, сервер ребутится или прокси режет идл-соединения. | Проверьте `curl https://app.morok.me/health`. Если сервер ок, SDK переподключится сам. Включите `logger` для деталей. |
|
|
434
|
+
| WebSocket закрывается кодом 4001 (`reason: 'auth'`) | Запись сессии в Redis удалена, либо в панели разработчика обновили токен, либо `/auth/bot-session` отклонил ваш токен. | SDK обновит JWT автоматически. Если зациклилось, токен мертв: нажмите **Перевыпустить токен** в панели и подмените поле `token` в `.morokbot`. |
|
|
435
|
+
| `MorokBot.start: ... 401 INVALID_CREDENTIALS` | В `.morokbot` устарел `token`. | Подмените поле `token` в `.morokbot` свежим из панели. Остальное (identity, prekeys) не трогайте. |
|
|
436
|
+
| `MorokBot.start: refused state-dir lock, another process ...` | Два SDK-процесса смотрят на один и тот же `stateDir`. Поймал pid-lock. | Убейте старый процесс или дайте каждому свой `stateDir`. Stale `state.lock` чистится автоматически, если pid уже мертв. |
|
|
437
|
+
|
|
438
|
+
### Отправка
|
|
439
|
+
|
|
440
|
+
Отказы отправки бросают экспортируемый `SendRejectedError` с машиночитаемым `.code` (`bot_not_started`, `recipient_storage_full`, `send_blocked`, `too_many_messages`). Бот может отправить **5 сообщений подряд** в одном разговоре (ЛС, беседа, комментарии), счетчик сбрасывается, как только там пишет не-бот. Посты верхнего уровня в канал лимитируются иначе: 5 в запасе, дальше не чаще одного в 30 секунд. Галерея это одно сообщение (до 10 элементов), а реакции, правки и удаления не считаются. Отклоненная загрузка файла (больше 5 ГБ в открытом виде или у бота больше 10 ГБ неотправленного запаса) бросает экспортируемый `UploadRejectedError` с `.code` вроде `BOT_STAGING_FULL`, а обрыв во время ожидания ответа сервера бросает экспортируемый `SendUncertainError`, где сообщение могло и дойти, и не дойти, поэтому вы повторяете или сверяетесь
|
|
441
|
+
|
|
442
|
+
| Симптом | Что значит | Решение |
|
|
443
|
+
|----------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
444
|
+
| `send()` reject с `bot_not_started` | Собеседник не нажал "Запустить" в профиле бота. Серверная защита, боты не могут писать в ЛС первыми. | Ждите события `start` от пользователя, потом ему можно писать. Чтобы пользователи нажимали, попросите в описании профиля бота. |
|
|
445
|
+
| `send()` reject с HTTP 403 в беседе или канале | Бота исключили или разговор удалили. Событие `conversation_kicked` уже пришло, SDK сбросил локальное состояние channel-key. | Не повторяйте запрос. Если ждете возврата, подпишитесь на `conversation_added`. |
|
|
446
|
+
| `send()` бросает `UploadRejectedError` | Файл больше 5 ГБ в открытом виде, либо у бота накопилось больше 10 ГБ загруженных, но еще не отправленных файлов (`.code` `BOT_STAGING_FULL`) | Уменьшите файл либо отправьте уже загруженное (после отправки файл перестает занимать этот лимит) |
|
|
447
|
+
| `send()` падает с `SendUncertainError` после `disconnect` | Соединение оборвалось, пока отправка ждала ответа сервера, сообщение могло и дойти, и не дойти | Поймайте `SendUncertainError`, затем повторите или сверьтесь с историей (он несет `clientMsgId` и `conversationId`), слепой повтор может задвоить, если первое дошло |
|
|
448
|
+
| `send()` reject с `SendRejectedError` (`code: 'recipient_storage_full'`) | Обычный файл списан на сторону, которая его несет: получателя ЛС, запустившего бота, или владельца беседы/канала, добавившего бота. Его хранилище заполнено. | Эта сторона освобождает место или переходит на свое облако с большим объемом, либо посылайте меньше. |
|
|
449
|
+
| `send()` reject с `SendRejectedError` (`code: 'too_many_messages'`) | Бот превысил лимит частоты: 5 подряд в ЛС/беседе/комментариях (сброс сообщением не-бота) или, для постов верхнего уровня в канал, темп выше одного в 30 секунд после первых 5. | В ЛС/беседе/комментариях дождитесь сообщения не-бота (открывает новые 5). В канале сбавьте темп до одного поста в 30 секунд. Либо упакуйте больше в меньшее число сообщений: галерея это одно сообщение до 10 элементов. Реакции, правки и удаления не считаются. |
|
|
450
|
+
| `bot.send({ peer: 'alice' })` тормозит на резолве псевдонима | Разрешение псевдонима в `userId` дергает `GET /users/:username` на каждый вызов (без кеша в SDK). | Если часто пишете одному собеседнику, закешируйте `userId` у себя. Числовое значение неизменно. |
|
|
451
|
+
|
|
452
|
+
### Прием
|
|
453
|
+
|
|
454
|
+
| Симптом | Что значит | Решение |
|
|
455
|
+
|----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
|
|
456
|
+
| `error` событие с "decrypt failed" / "no matching session" | Состояние Double Ratchet у собеседника разошлось с ботом. Часто после переустановки приложения. | SDK дропает сообщение и ждет следующий type-3 фрейм от собеседника, тот пересоберет сессию. Действий не требуется. |
|
|
457
|
+
| `warn` лог `[signal] PEER IDENTITY CHANGED` на type-3 фрейме | Известный собеседник предъявил новый identity-ключ, и SDK перепиннил его, обычно из-за переустановки. SDK принимает это и продолжает, не блокирует. | Для обычной переустановки действий не нужно. Если вы отслеживаете identity собеседников отдельно, считайте это поводом перепроверить собеседника. |
|
|
458
|
+
| `attachment.download()` reject с HTTP 404 / 410 | Файл удален, истек либо вытеснен квотным cron. | Считайте файл потерянным. Отправитель может перезалить. |
|
|
459
|
+
| Во фронтенде собеседника предупреждение «Изменился номер безопасности» | У того же аккаунта сменился identity-ключ (на аккаунт импортировали другой `.morokbot` с новым ключевым материалом). В обычном потоке это не случается: перевыпуск токена identity не трогает, очистка `stateDir` реимпортирует тот же ключ из `.morokbot`, а пересоздание бота дает отдельный аккаунт (это новый бот, собеседники заново находят его и жмут «Запустить»). | Клиент собеседника сам перепиннит новый ключ (TOFU), предупреждение покажется один раз, без блокировки. Перепроверить номер безопасности вручную можно по желанию. |
|
|
460
|
+
|
|
461
|
+
### Каталог состояния
|
|
462
|
+
|
|
463
|
+
| Симптом | Что значит | Решение |
|
|
464
|
+
|----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
|
|
465
|
+
| Boot-лог: `[bot] fsck quarantined N session files` | Один или несколько файлов в `stateDir/sessions/` не распарсились. SDK переместил битые в `stateDir/quarantine/`, остальное состояние работает. | Затронутые сессии пересоберутся при следующем сообщении. Загляните в `quarantine/` хотя бы раз, чтобы понять, что их повредило (отказ диска, kill -9 во время записи). |
|
|
466
|
+
| Размер `stateDir` медленно растет месяцами | Накапливаются записи сессий: один файл на пару собеседник-устройство. | Это нормально, даже на тысячах собеседников каталог занимает десятки МБ. Не чистите его вручную: удаление файлов сессий ломает Signal-сессии. |
|
|
467
|
+
| Залипший `state.lock` после `kill -9` | В pid-файле мертвый PID, SDK отказывается стартовать. | SDK проверяет, что записанный PID жив, и снимает блокировку, если нет. Если все равно "refused", PID переиспользован системой. Удалите `state.lock` вручную. |
|
|
468
|
+
|
|
469
|
+
### Создание и управление ботом
|
|
470
|
+
|
|
471
|
+
| Симптом | Что значит | Решение |
|
|
472
|
+
|----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
|
|
473
|
+
| `BOT_USERNAME_TAKEN` при создании | Псевдоним с суффиксом `-bot` уже занят. | Выберите другой псевдоним. |
|
|
474
|
+
| `BOT_LIMIT_REACHED` (409) | У вас уже 10 ботов. | Удалите ненужного бота во вкладке Режим разработчика либо через `DELETE /developer/bots/:id`. |
|
|
475
|
+
| Новый `.morokbot` после "Перевыпустить токен" не импортируется | Если оставили только новый `token` и подменили его в старом файле, все правильно. Если перевыкачали весь `.morokbot` и identity бота сменился, собеседники увидят «Изменился номер безопасности». | По умолчанию «Перевыпустить» в панели меняет только токен. Если сменилась и identity, у собеседников один раз покажется «Изменился номер безопасности», клиент перепиннит новый ключ сам (TOFU). Прежнюю идентичность не восстановить. |
|
|
476
|
+
|
|
477
|
+
### Отладка
|
|
478
|
+
|
|
479
|
+
Подайте логгер, и будете видеть, что SDK делает. Pino работает напрямую, `console`-shape тоже:
|
|
480
|
+
|
|
481
|
+
```ts
|
|
482
|
+
const bot = await MorokBot.fromFile({
|
|
483
|
+
tokenFile: './bot.morokbot',
|
|
484
|
+
logger: {
|
|
485
|
+
info: (o, m) => console.log (m, o),
|
|
486
|
+
warn: (o, m) => console.warn (m, o),
|
|
487
|
+
error: (o, m) => console.error(m, o),
|
|
488
|
+
debug: (o, m) => console.debug(m, o),
|
|
489
|
+
},
|
|
490
|
+
})
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Уровень `debug` пишет каждый WS-фрейм и каждый цикл пополнения prekey, логи быстро разрастаются. `info` оставляет загрузочные сообщения и редкие события. На боевом сервере разумно держать `warn` и `error`, направленные в файл через systemd или journald.
|
|
494
|
+
|
|
495
|
+
Если подозреваете проблему на стороне сервера, соберите:
|
|
496
|
+
- `curl https://app.morok.me/health` и `/version`
|
|
497
|
+
- Точный `error.message` из события `error`
|
|
498
|
+
- Окружающие лог-строки на `debug` уровне
|
|
499
|
+
|
|
500
|
+
и откройте [GitHub issue](https://github.com/geloid/morok-bot-sdk/issues) с этим.
|
|
501
|
+
|
|
502
|
+
## Разработка
|
|
503
|
+
|
|
504
|
+
```bash
|
|
505
|
+
git clone https://github.com/geloid/morok-bot-sdk.git
|
|
506
|
+
cd morok-bot-sdk
|
|
507
|
+
npm install
|
|
508
|
+
npm run build # tsc -> dist/
|
|
509
|
+
npm run typecheck
|
|
510
|
+
npm test # vitest unit-тесты
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Unit-тесты покрывают парсер `.morokbot`, файловые хранилища Signal (с fsck и защитой от выхода за пределы каталога), форматы обмена channel и group-secret, файловый шифр (один запрос и chunked AAD), envelope галереи. Интеграционного набора нет: запускайте пример из `examples/echo-bot/` на своем тестовом окружении.
|
|
514
|
+
|
|
515
|
+
`npm run lint` тоже нет, проект полагается на `tsc --strict` и тесты.
|
|
516
|
+
|
|
517
|
+
### Генерация API reference
|
|
518
|
+
|
|
519
|
+
```bash
|
|
520
|
+
npm run docs:api # typedoc -> docs-api/
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
`docs-api/` это статический HTML-сайт публичной поверхности SDK (все, что экспортируется из `src/index.ts`), генерируемый [TypeDoc](https://typedoc.org/) из TypeScript-типов и JSDoc. Опубликованная копия лежит на [morok.me/sdk-api/](https://morok.me/sdk-api/). Каталог в gitignore: это артефакт сборки, перегенерируется на каждый релиз.
|
|
524
|
+
|
|
525
|
+
## Глоссарий
|
|
526
|
+
|
|
527
|
+
Криптотермины, встречающиеся в SDK и [справочнике API](https://morok.me/api).
|
|
528
|
+
|
|
529
|
+
| Термин | Значение |
|
|
530
|
+
|-------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
531
|
+
| **Signal Protocol** | Набор криптопримитивов, который Морок унаследовал от [Open Whisper Systems](https://signal.org/docs/). E2E-ратчет в личных сообщениях + симметричный channel-key для бесед и каналов + асинхронный handshake. |
|
|
532
|
+
| **X3DH** | Extended Triple Diffie-Hellman: асинхронный handshake, позволяющий начать зашифрованную сессию с получателем, который сейчас офлайн. Смешивает identity-ключ + подписанный + одноразовый. |
|
|
533
|
+
| **Double Ratchet** | Алгоритм эволюции ключей внутри сессии после X3DH. Каждое сообщение продвигает ключ, поэтому компрометация одного ключа раскрывает максимум одно сообщение в каждом направлении. |
|
|
534
|
+
| **prekey** | Публичный Curve25519-ключ, который получатель публикует заранее. Инициатор X3DH забирает один и с ним собирает сессию. Бывают двух типов ниже. |
|
|
535
|
+
| **подписанный prekey (SPK)** | Долгоживущий prekey, подписанный identity-ключом. Сервер требует ротации раз в 7 дней. Используется, когда одноразового prekey нет в запасе. |
|
|
536
|
+
| **одноразовый prekey (OTPK)** | Короткоживущий prekey, удаляется после первого использования. Запас поддерживается на уровне `replenishTarget`, SDK дополняет автоматически. |
|
|
537
|
+
| **identity-ключ** | Долгоживущая пара Curve25519 у бота. Определяет криптоидентичность бота для собеседников. Лежит в `stateDir/identity.json`. |
|
|
538
|
+
| **TOFU** | "Trust On First Use": собеседник принимает identity-ключ при первом контакте и проверяет, что он не меняется. Смена identity-ключа вызывает предупреждение в приложении собеседника. |
|
|
539
|
+
| **стирание sender_id** | Ретроактивная минимизация метаданных. На личных сообщениях сервер держит sender_id лишь в горячем окне (по умолчанию 7 дней), чтобы вернуть квитанции о доставке и прочтении, после чего обнуляет его. В момент маршрутизации сервер отправителя видит, поэтому это не sealed sender (такая модель в сервисе не задействована). Содержимое бесед и каналов шифруется под channel-key. |
|
|
540
|
+
| **channel-key** | Симметричный AES-256 ключ на разговор. Шифрует сообщения в беседе или канале. Ротация через `rotateChannelKey()`. |
|
|
541
|
+
| **group-secret** | Симметричный ключ на разговор, которым оборачиваются пакеты channel-key при раздаче новым участникам. Ротация через `rotateGroupSecret()` (также ротирует channel-key). |
|
|
542
|
+
| **эпоха** | Монотонный счетчик у channel-key. Каждая ротация увеличивает его. SDK хранит историю, поэтому старые сообщения остаются читаемыми после ротации. |
|
|
543
|
+
| **формат обмена** | Точный байтовый формат конверта шифротекста. Channel-шифр использует `"MOK1" \| epoch_BE32 \| iv12 \| ct+tag` (см. `src/crypto/channel-cipher.ts`). |
|
|
544
|
+
| **fanoutId** | Идентификатор, который сервер присваивает каждой копии исходящего (по копии на устройство получателя). SDK матчит свое эхо по нему, чтобы `bot.send()` резолвился в реальный `messageId`. |
|
|
545
|
+
| **clientMsgId** | Стабильный идентификатор от отправителя, общий для всех веерных копий одной логической отправки. `bot.reply()` пробрасывает его, чтобы ответ попал на логическое сообщение, а не на копию конкретного устройства. |
|
|
546
|
+
| **AAD** | Additional Authenticated Data: байты, передаваемые в AES-GCM, не шифруются, но должны совпасть при расшифровке. Морок использует строки вида `morok-channel-<convId>` для привязки шифротекста к контексту. |
|
|
547
|
+
|
|
548
|
+
Дополнительное чтение: [Signal Protocol whitepaper](https://signal.org/docs/), [X3DH спецификация](https://signal.org/docs/specifications/x3dh/), [Double Ratchet спецификация](https://signal.org/docs/specifications/doubleratchet/).
|
|
549
|
+
|
|
550
|
+
## Производительность и масштабирование
|
|
551
|
+
|
|
552
|
+
Один процесс SDK обслуживает одного бота. Расход ресурсов на бота:
|
|
553
|
+
|
|
554
|
+
| Метрика | Значение |
|
|
555
|
+
|---------------------------------|-------------------------------------------------------------------------------------|
|
|
556
|
+
| RAM (резидентная) | 80-150 МБ. Большая часть приходится на libsignal. |
|
|
557
|
+
| CPU | < 5% одного ядра в покое, всплески на X3DH handshake и AES-GCM при загрузках. |
|
|
558
|
+
| Пропускная способность | Один процесс тянет сотни сообщений в секунду. Узкое место почти всегда в вашем обработчике или в сети. |
|
|
559
|
+
| Рост `stateDir` | ~ 1 КБ на пару активных собеседник-устройство. Десятки МБ даже у бота с тысячами собеседников. |
|
|
560
|
+
|
|
561
|
+
Один и тот же `stateDir` между процессами портит Signal-сессии, pid-lock не дает это сделать случайно. Чтобы запустить несколько ботов на одном хосте, выделите каждому свой процесс и свой `stateDir`. Память растет примерно линейно.
|
|
562
|
+
|
|
563
|
+
Веерная рассылка исходящего сообщения по устройствам собеседника происходит на стороне сервера: один `bot.send()` производит один envelope у вас, сервер сам доставляет его на каждое устройство.
|
|
564
|
+
|
|
565
|
+
Когда одному боту не хватает пропускной способности одного процесса, причина почти всегда в обработчике: запросы в БД, внешние API, обработка изображений. WebSocket и libsignal выдерживают значительно больше, чем типичный код обработчика.
|
|
566
|
+
|
|
567
|
+
Вспомогательные классы `RateLimiter` и `BotSessions` из поставки живут в памяти и не пересекают границу процесса. Если один бот обслуживается несколькими процессами, напишите аналог поверх своего общего хранилища (например, Redis).
|
|
568
|
+
|
|
569
|
+
## Переход с Telegram Bot API
|
|
570
|
+
|
|
571
|
+
Если строили бота под Telegram, грубые эквиваленты:
|
|
572
|
+
|
|
573
|
+
| Концепт | Telegram Bot API | Морок |
|
|
574
|
+
|--------------------------------|---------------------------------------------|------------------------------------------------------------------------------------|
|
|
575
|
+
| Транспорт | HTTPS long-poll или webhook | Долгоживущий WebSocket (переподключение внутри SDK) |
|
|
576
|
+
| Формат токена | `<int>:<base64>` | `bot:<int>:<base64url>` |
|
|
577
|
+
| Доставка серверу -> боту | `getUpdates` long-poll или HTTP POST на URL | WS-фрейм -> `bot.on('message')` |
|
|
578
|
+
| Идентичность | Один бот на токен, ключевого материала нет | Signal identity-ключ + подписанный prekey + одноразовые prekey, генерация на клиенте |
|
|
579
|
+
| Несколько устройств | Не применимо: бот в одном экземпляре | Из коробки: одно исходящее сообщение сервер сам доставляет на все устройства собеседника |
|
|
580
|
+
| Беседы | Нативно (`message.chat.id`) | Та же форма (`bot.send({ conversation })`). Админ добавляет бота, SDK подтягивает channel-key |
|
|
581
|
+
| Каналы | Нативно (broadcast) | Та же форма. Комментарии собираются в треды через `threadRootId` |
|
|
582
|
+
| Шифрование | Plaintext на серверах Telegram | E2E (Signal Protocol для личных сообщений, channel-key для бесед и каналов). Сервер хранит только шифротекст. |
|
|
583
|
+
| Слэш-команды | Слэш-команды | Через интерфейс редактирования бота либо `POST /developer/bots/:id/commands` |
|
|
584
|
+
| Кнопки (управление) | Кнопки (управление) | Меню-дерево кнопок в боте (ПК и мобильный), задается `setMyControls`, нажатия в `bot.on('control')` |
|
|
585
|
+
| Webhooks | Из коробки | Не поддерживается |
|
|
586
|
+
| Inline-режим | Из коробки | Не поддерживается |
|
|
587
|
+
| Платежи | Из коробки | Не поддерживается |
|
|
588
|
+
| Создание sticker pack | `createNewStickerSet` и т.п. | Не поддерживается |
|
|
589
|
+
| Лимит размера файла | Зависит от тарифа (от 20 МБ до 2 ГБ) | 5 ГБ на файл. Списывается с получателя (ЛС) или владельца беседы или канала |
|
|
590
|
+
| Rate limits | ~ 30 сообщений/с глобально | Бот: 5 сообщений подряд в ЛС/беседе/комментариях (сброс сообщением не-бота). Посты в канал: 5, дальше не чаще одного в 30 секунд. Плюс общий лимит 300 сообщений в минуту на пользователя |
|
|
591
|
+
| Модель согласия | Пользователь начинает чат сообщением | Пользователь нажимает **Запустить**, бот не может писать первым. Событие `bot.on('start')`. |
|
|
592
|
+
| Смена идентичности | Регенерация токена не меняет identity | Смена identity-ключа вызывает TOFU-warning у собеседника |
|
|
593
|
+
|
|
594
|
+
Если механически портируете бота из Telegram, основная часть кода обработки сообщений переезжает напрямую. Модель согласия та же, что в Telegram: пока пользователь не нажмет кнопку, бот не может ему писать. Переосмыслить нужно только шифрование вложений: SDK сам шифрует каждый файл отдельным AES-ключом, но клиентская проверка mime-типов на Node-бота не распространяется, поэтому `mime` от собеседника считайте подсказкой, а не гарантией.
|
|
595
|
+
|
|
596
|
+
## Версионирование
|
|
597
|
+
|
|
598
|
+
Пакет соблюдает [semver](https://semver.org/). Все, что экспортируется из `morok-bot-sdk` (класс `MorokBot`, `BotConfig`, `IncomingMessage`, нагрузки событий, типы вложений), это публичный API. Изменения формата обмена идут по правилам совместимости сервера Морока, описанным в [api.md](https://morok.me/api).
|
|
599
|
+
|
|
600
|
+
## Лицензия
|
|
601
|
+
|
|
602
|
+
Apache License 2.0. См. [LICENSE](./LICENSE).
|