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.
Files changed (96) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +602 -0
  3. package/README.ru.md +602 -0
  4. package/dist/bot.d.ts +232 -0
  5. package/dist/bot.d.ts.map +1 -0
  6. package/dist/bot.js +558 -0
  7. package/dist/bot.js.map +1 -0
  8. package/dist/crypto/channel-cipher.d.ts +32 -0
  9. package/dist/crypto/channel-cipher.d.ts.map +1 -0
  10. package/dist/crypto/channel-cipher.js +77 -0
  11. package/dist/crypto/channel-cipher.js.map +1 -0
  12. package/dist/crypto/channel-key-store.d.ts +37 -0
  13. package/dist/crypto/channel-key-store.d.ts.map +1 -0
  14. package/dist/crypto/channel-key-store.js +149 -0
  15. package/dist/crypto/channel-key-store.js.map +1 -0
  16. package/dist/crypto/cross-signing.d.ts +57 -0
  17. package/dist/crypto/cross-signing.d.ts.map +1 -0
  18. package/dist/crypto/cross-signing.js +111 -0
  19. package/dist/crypto/cross-signing.js.map +1 -0
  20. package/dist/crypto/file-cipher.d.ts +36 -0
  21. package/dist/crypto/file-cipher.d.ts.map +1 -0
  22. package/dist/crypto/file-cipher.js +61 -0
  23. package/dist/crypto/file-cipher.js.map +1 -0
  24. package/dist/crypto/group-secret-cipher.d.ts +49 -0
  25. package/dist/crypto/group-secret-cipher.d.ts.map +1 -0
  26. package/dist/crypto/group-secret-cipher.js +69 -0
  27. package/dist/crypto/group-secret-cipher.js.map +1 -0
  28. package/dist/crypto/group-secret-store.d.ts +35 -0
  29. package/dist/crypto/group-secret-store.d.ts.map +1 -0
  30. package/dist/crypto/group-secret-store.js +149 -0
  31. package/dist/crypto/group-secret-store.js.map +1 -0
  32. package/dist/crypto/signal.d.ts +81 -0
  33. package/dist/crypto/signal.d.ts.map +1 -0
  34. package/dist/crypto/signal.js +125 -0
  35. package/dist/crypto/signal.js.map +1 -0
  36. package/dist/crypto/stores.d.ts +130 -0
  37. package/dist/crypto/stores.d.ts.map +1 -0
  38. package/dist/crypto/stores.js +314 -0
  39. package/dist/crypto/stores.js.map +1 -0
  40. package/dist/flow/attachments.d.ts +110 -0
  41. package/dist/flow/attachments.d.ts.map +1 -0
  42. package/dist/flow/attachments.js +409 -0
  43. package/dist/flow/attachments.js.map +1 -0
  44. package/dist/flow/conv-cache.d.ts +36 -0
  45. package/dist/flow/conv-cache.d.ts.map +1 -0
  46. package/dist/flow/conv-cache.js +84 -0
  47. package/dist/flow/conv-cache.js.map +1 -0
  48. package/dist/flow/direct.d.ts +109 -0
  49. package/dist/flow/direct.d.ts.map +1 -0
  50. package/dist/flow/direct.js +346 -0
  51. package/dist/flow/direct.js.map +1 -0
  52. package/dist/flow/groups.d.ts +146 -0
  53. package/dist/flow/groups.d.ts.map +1 -0
  54. package/dist/flow/groups.js +768 -0
  55. package/dist/flow/groups.js.map +1 -0
  56. package/dist/flow/prekeys.d.ts +45 -0
  57. package/dist/flow/prekeys.d.ts.map +1 -0
  58. package/dist/flow/prekeys.js +111 -0
  59. package/dist/flow/prekeys.js.map +1 -0
  60. package/dist/flow/receive.d.ts +125 -0
  61. package/dist/flow/receive.d.ts.map +1 -0
  62. package/dist/flow/receive.js +773 -0
  63. package/dist/flow/receive.js.map +1 -0
  64. package/dist/index.d.ts +15 -0
  65. package/dist/index.d.ts.map +1 -0
  66. package/dist/index.js +6 -0
  67. package/dist/index.js.map +1 -0
  68. package/dist/morokbot-file.d.ts +14 -0
  69. package/dist/morokbot-file.d.ts.map +1 -0
  70. package/dist/morokbot-file.js +88 -0
  71. package/dist/morokbot-file.js.map +1 -0
  72. package/dist/ratelimit.d.ts +40 -0
  73. package/dist/ratelimit.d.ts.map +1 -0
  74. package/dist/ratelimit.js +76 -0
  75. package/dist/ratelimit.js.map +1 -0
  76. package/dist/sessions.d.ts +34 -0
  77. package/dist/sessions.d.ts.map +1 -0
  78. package/dist/sessions.js +69 -0
  79. package/dist/sessions.js.map +1 -0
  80. package/dist/state-lock.d.ts +17 -0
  81. package/dist/state-lock.d.ts.map +1 -0
  82. package/dist/state-lock.js +66 -0
  83. package/dist/state-lock.js.map +1 -0
  84. package/dist/transport/http.d.ts +48 -0
  85. package/dist/transport/http.d.ts.map +1 -0
  86. package/dist/transport/http.js +112 -0
  87. package/dist/transport/http.js.map +1 -0
  88. package/dist/transport/ws.d.ts +65 -0
  89. package/dist/transport/ws.d.ts.map +1 -0
  90. package/dist/transport/ws.js +219 -0
  91. package/dist/transport/ws.js.map +1 -0
  92. package/dist/types.d.ts +254 -0
  93. package/dist/types.d.ts.map +1 -0
  94. package/dist/types.js +2 -0
  95. package/dist/types.js.map +1 -0
  96. package/package.json +59 -0
package/README.md ADDED
@@ -0,0 +1,602 @@
1
+ # morok-bot-sdk
2
+
3
+ Node.js / TypeScript SDK for building bots on the [Morok](https://morok.me) end-to-end encrypted messaging platform.
4
+
5
+ The SDK handles Signal Protocol session bootstrap, channel-key fan-out for group chats and channels, prekey replenish, JWT refresh, WebSocket reconnect, and attachment crypto. You write event handlers.
6
+
7
+ - Русская версия: [README.ru.md](./README.ru.md).
8
+ - First-time setup walkthrough with dev-panel screenshots: [docs/getting-started.md](./docs/getting-started.md).
9
+ - Full HTTP / WebSocket / wire-format reference: [api.md](https://morok.me/api).
10
+ - Production deployment recipes (systemd, Docker, backups, monitoring): [docs/deployment.md](./docs/deployment.md).
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install morok-bot-sdk
16
+ ```
17
+
18
+ Requirements:
19
+
20
+ - Node **22 or later** (the SDK uses `globalThis.crypto`, native `Buffer.from(..., 'base64url')`, and `fs.rename` semantics older versions do not guarantee)
21
+ - A `.morokbot` token file generated when you create a bot in the Morok app
22
+
23
+ Bot private keys must stay on a server you control.
24
+
25
+ ## Quickstart
26
+
27
+ 1. In the Morok app: **Settings -> About Morok**, flip the **Developer mode** toggle. A section of the same name appears in Settings.
28
+ 2. **Settings -> Developer mode -> Create bot**. Fill in the three steps (description, appearance, management) and press **Create**. A **Download .morokbot** button then appears under the token. It saves a JSON file with the bot's token and Signal key material.
29
+ 3. Place the file next to your bot code as `bot.morokbot` (or pass an absolute path to `tokenFile`).
30
+ 4. Write the handler:
31
+
32
+ > **Do not commit `.morokbot` or `bot-state/` to git.** The `.morokbot` file contains the bot's signing key, anyone with it can impersonate the bot. `bot-state/` holds the Signal identity and prekey pool after first start. A `.gitignore` with both entries ships in the SDK (`sdk/.gitignore`), copy it into your project before the first commit. On first import: `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(`new user: @${e.peer.username}`))
41
+ bot.on('stop', (e) => console.log(`user left: @${e.peer.username}`))
42
+
43
+ bot.on('command', async (c) => {
44
+ if (c.command === 'help') await bot.reply(c, { text: 'I am an echo bot.' })
45
+ })
46
+
47
+ bot.on('message', async (m) => {
48
+ if (m.text) await bot.reply(m, { text: `echo: ${m.text}` })
49
+ })
50
+
51
+ bot.on('disconnect', (d) => console.log('socket dropped, willReconnect:', d.willReconnect))
52
+ bot.on('error', (e) => console.error('bot error:', e.message))
53
+
54
+ await bot.start()
55
+ ```
56
+
57
+ The SDK handles:
58
+
59
+ - `/auth/bot-session` mint + JWT refresh on WS code 4001 / HTTP 401
60
+ - WebSocket connect with exponential backoff
61
+ - X3DH bootstrap on first contact + Double Ratchet via libsignal
62
+ - One-time prekey replenish (boot top-up, a reactive `prekeys_low` server push, plus a 5-minute background backstop)
63
+ - Signed prekey rotation on the server's 7-day mark
64
+ - Multi-device fan-out so an outbound DM lands on every peer device
65
+ - Own-echo matching by `fanoutId` so `bot.send()` resolves to the real `messageId`
66
+
67
+ ## Configuration
68
+
69
+ `MorokBot.fromFile` accepts `BotConfig & { tokenFile }`:
70
+
71
+ | Field | Default | Notes |
72
+ |------------------------|--------------------------|----------------------------------------------------------------|
73
+ | `tokenFile` | (required) | Path to the `.morokbot` file |
74
+ | `stateDir` | `./bot-state/` | Per-bot, exclusive. Holds private keys after import. chmod 0700 |
75
+ | `apiBaseUrl` | `https://app.morok.me` | Override for self-host / dev |
76
+ | `wsUrl` | derived | http -> ws, https -> wss, append `/ws` |
77
+ | `replenishThreshold` | `100` | Top up OTPKs when the pool drops below this. Matches the server's low-water mark, so this threshold and the reactive `prekeys_low` signal agree |
78
+ | `replenishTarget` | `200` | OTPK count after replenish (equals the server's per-call cap) |
79
+ | `backgroundIntervalMs` | `300_000` (5 min) | Backstop tick behind the reactive `prekeys_low` server signal that tops up on demand. Set to 0 to disable the loop (tests only) |
80
+ | `autoBackfillOnJoin` | `false` | Auto-share local epoch keys when a member joins |
81
+ | `logger` | silent | Pino-shaped: `info` / `warn` / `error` / `debug` |
82
+
83
+ ## Storage
84
+
85
+ Bot data lives in two places:
86
+
87
+ - `stateDir` on your machine: identity key, prekeys, Double Ratchet sessions, local copies of channel-keys and group-secrets. Tens of MB at most. Morok does not quota this, it's your disk.
88
+ - The Morok server keeps the in-flight ciphertext and every file a bot sends, including while an offline recipient has not fetched them yet. The only difference is whose quota they count against. A **regular file** immediately takes up the quota of whoever it went to: in a DM the recipient (who must have started the bot), in a group chat or channel the conversation owner (who added the bot). The bot can fill that party's free space, and once it runs out the send rejects with `SendRejectedError`, code `recipient_storage_full`. **Voice and video messages are different: they do not count against anyone's quota.** The server still keeps them, on a local media disk, and auto-deletes them **30 days after sending** (and sooner if that disk runs low, oldest first across all users). Recipients keep whatever they downloaded or saved to their Notes earlier.
89
+
90
+ The rest of this section is the layout inside `stateDir` (default `./bot-state/`).
91
+
92
+ ```
93
+ bot-state/
94
+ identity.json key pair + accountSigningKey + registrationId
95
+ state.json counters (next OTPK id, last SPK rotation)
96
+ state.lock pid-based lock, one process per stateDir
97
+ sessions/<peer>.<dev>.json per-peer-device Double Ratchet record
98
+ prekeys/signed-<id>.json signed prekeys (signature preserved across rounds)
99
+ prekeys/onetime-<id>.json one-time prekeys
100
+ identity-cache/<addr>.json peer identity_key for TOFU
101
+ channel-keys/<conv>.json per-conversation channel-key history
102
+ group-secrets/<conv>.json per-conversation group-secret history
103
+ quarantine/ fsck moves corrupted files here on start
104
+ ```
105
+
106
+ After the first start `stateDir` holds the bot's private key material. Don't sync it to S3, don't email it, don't commit it. Back it up the way you back up an SSH key.
107
+
108
+ Two processes on one `stateDir` corrupt the Signal sessions. The pid-lock catches it, but give each bot its own directory anyway.
109
+
110
+ ## API surface
111
+
112
+ ### Construction
113
+
114
+ ```ts
115
+ MorokBot.fromFile(config: BotConfig & { tokenFile: string }): Promise<MorokBot>
116
+ ```
117
+
118
+ Reads and validates the token file, builds the bot. No network IO until `start()`.
119
+
120
+ ### Lifecycle
121
+
122
+ | Call | Effect |
123
+ |---------------------|----------------------------------------------------------------------------------------------|
124
+ | `await bot.start()` | Imports keys (idempotent), fscks state, mints session, opens WS, runs boot prekey replenish. |
125
+ | `await bot.stop()` | Stops background loops, closes WS, rejects pending sends, releases state lock. Idempotent. |
126
+ | `bot.isConnected` | `true` once WS auth is complete. |
127
+ | `bot.userId` | Numeric userId. Throws before `start()` resolves. |
128
+
129
+ `start()` is safe to call concurrently: the second caller returns immediately, the bot ends up in one state. Calling `stop()` while `start()` is mid-flight aborts the boot cleanly at the next internal checkpoint.
130
+
131
+ ### Events
132
+
133
+ ```ts
134
+ bot.on('message', (m: IncomingMessage) => …)
135
+ bot.on('command', (c: CommandInvocation) => …)
136
+ bot.on('start', (e: BotStartEvent) => …) // user pressed "Start"
137
+ bot.on('stop', (e: BotStopEvent) => …) // user pressed "Stop"
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 // body or caption ('' if neither)
155
+ attachment?: IncomingAttachment
156
+ gallery?: IncomingGallery // 2-10 items
157
+ clientMsgId: string | null
158
+ replyToId: number | null
159
+ threadRootId: number | null
160
+ createdAt: Date
161
+ }
162
+ ```
163
+
164
+ `CommandInvocation` extends it with `{ command, args, argv }` for messages that start with `/cmd`.
165
+
166
+ `DisconnectInfo.reason`:
167
+ - `'transport'`: network drop, the SDK is reconnecting
168
+ - `'auth'`: WS code 4001, session ticket revoked, the SDK refreshes the JWT and reconnects
169
+ - `'shutdown'`: your code called `stop()`, no further reconnect
170
+
171
+ ### Sending
172
+
173
+ ```ts
174
+ // text DM
175
+ await bot.send({ peer: 12345, text: 'hi' })
176
+ await bot.send({ peer: 'alice', text: 'hi' }) // pseudonym resolved via REST
177
+
178
+ // file (caption optional)
179
+ await bot.send({
180
+ peer,
181
+ text: 'photo of my cat',
182
+ attachment: {
183
+ kind: 'file',
184
+ data: fs.readFileSync('./cat.jpg'),
185
+ name: 'cat.jpg',
186
+ mime: 'image/jpeg',
187
+ },
188
+ })
189
+
190
+ // voice note (voice notes carry no caption)
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
+ // video note
202
+ await bot.send({
203
+ peer,
204
+ attachment: {
205
+ kind: 'video_note',
206
+ data: webmBytes,
207
+ duration: 6,
208
+ shape: 'circle', // see "Video-note shapes" note below
209
+ },
210
+ })
211
+
212
+ // gallery: 2-10 file attachments in one bubble
213
+ await bot.send({
214
+ peer,
215
+ text: 'cat photos',
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
+ // group-chat / channel post
223
+ await bot.send({ conversation: 42, text: 'announcement' })
224
+
225
+ // reply to an incoming message (threads correctly in DMs and group chats)
226
+ await bot.reply(msg, { text: 'thanks' })
227
+
228
+ // react with any unicode symbol (not just emoji), or remove the reaction
229
+ await bot.react(msg, '𔙃')
230
+ await bot.unreact(msg)
231
+ ```
232
+
233
+ `bot.send()` resolves to `{ messageId, clientMsgId, conversationId }`. Exactly one of `peer` or `conversation` is required.
234
+
235
+ `bot.react(msg, unicode)` / `bot.unreact(msg)` take an incoming message or command. A reaction can be any unicode string, not just an emoji. It is encrypted to the conversation: per peer-device in DMs, under the channel-key in group chats and channels. A bot has only one reaction per message, and a new one replaces the previous. The bot is never echoed its own reaction, so `react` resolves once the frame is sent. Other users' reactions arrive via `bot.on('reaction', ...)`.
236
+
237
+ `peer` accepts a numeric `userId` (preferred when replying to an incoming message, the value is already in hand) or a pseudonym string (the SDK resolves it via `GET /users/:username` once per call, no caching).
238
+
239
+ `expiresInSeconds` on `send` or `reply` makes a disappearing message, the server removes it for everyone that many seconds after it is delivered
240
+
241
+ Server caps:
242
+
243
+ - File attachments up to **5 GB** plaintext. Single-shot path for files <= 50 MB, chunked path above it, the SDK picks transparently.
244
+ - Voice notes: duration `[0.1, 600]` seconds, up to 64 waveform peaks.
245
+ - Video notes: duration `[0.5, 300]` seconds. Shape is an opaque string, see the callout below for the canonical list.
246
+ - Galleries: 2-10 items, all `kind: 'file'`. Voice and video notes are not allowed inside a gallery (FE renderer does not support them there).
247
+ - A bot's **regular files** are billed to whoever gets them (a DM recipient, or a group-chat/channel owner), up to that party's quota, see [Storage](#storage). **Voice and video messages are quota-free**. A long video message can still exceed 50 MB (a 5-minute circle is about 58 MB), and the SDK uploads it via the chunked path transparently.
248
+
249
+ > **Video-note shapes**: `shape` is a string the SDK passes through, the receiver renders the names it knows and shows `circle` for anything else
250
+ >
251
+ > Names the receiver renders: `circle`, `square`, `slanted`, `pill`, `oval`, `arch`, `diamond`, `pentagon`, `gem`, `clamShell`, `sunny`, `cookie1`, `cookie2`, `cookie3`, `cookie4`, `clover1`, `clover2`, `burst`, `softBurst`, `puffyDiamond`, `pixelCircle`, `heart`
252
+
253
+ ### Commands and controls
254
+
255
+ `bot.setMyCommands([{ command, description, sortOrder? }])` publishes the slash-command catalogue the composer offers when a user types `/`. `bot.setMyControls([...])` publishes a tree of buttons in the composer's bot-menu, where a control is `{ id, label, icon?, command?, children? }`. A button with `children` opens a submenu in place. A button with `command` drops `/command ` into the input. A button with neither is a callback, the bot gets a `control` event and can answer or rebuild the menu with another `setMyControls`. Handle taps with `bot.on('control', e => ...)`, where `e.controlId` is the button and `e.sender` is who tapped. Call these after `start()`, each call replaces the whole tree. A bot may declare up to 32 commands and a control tree of up to 64 nodes, 4 levels deep. A control `icon` is a Material Symbols name.
256
+
257
+ `setMyControls` is global, the same menu in every chat. For an individual flow, where each user has their own buttons, use `bot.setControlsFor(userId, [...])` to set the buttons for ONE user's chat without touching the others. This is how you render per-user search results as buttons, or walk one user through a wizard. The user id is `e.sender.userId` from a control event or `msg.sender.userId` from a message. The override is short-lived and survives that user's reload. `bot.clearControlsFor(userId)` drops it and reverts the user to the global menu. The same bounds apply (64 nodes, 4 levels). Keep `setMyControls` for the durable root menu and drive the dynamic parts with `setControlsFor`.
258
+
259
+ ### Receiving attachments
260
+
261
+ `m.attachment` is present on single-attachment messages, `m.gallery` on multi-attachment bubbles. Bytes are not fetched until `.download()` is called:
262
+
263
+ ```ts
264
+ bot.on('message', async (m) => {
265
+ if (m.attachment) {
266
+ const a = m.attachment
267
+ console.log(`got ${a.kind} ${a.size}B (${a.mime})`)
268
+ if (a.kind === 'voice') console.log(`duration: ${a.duration}s`)
269
+ if (a.kind === 'video_note') console.log(`shape: ${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(`contact: @${item.username ?? item.userId}`)
281
+ if (item.kind === 'location') console.log(`location: ${item.lat},${item.lng}`)
282
+ }
283
+ return
284
+ }
285
+ console.log('text:', m.text)
286
+ })
287
+ ```
288
+
289
+ `download()` returns a `Buffer` of decrypted plaintext. It rejects on 404 / 410 (file removed or quota-evicted) and on decryption failure (wire tamper or wrong key). The SDK does not enforce a mime allow-list, the consumer is a Node process, treat `mime` as a sender-supplied hint.
290
+
291
+ `attachment.virusTotalVerdict` carries the VirusTotal result (`'clean'`, `'suspicious'`, `'malware'`) as of when the SDK received the file. Safe types (images, media) aren't scanned and come through as `'clean'` right away. Potentially dangerous ones (executables, archives) go to VirusTotal, and until it answers the verdict is `null`. The SDK reads the verdict once and doesn't watch for updates, so `null` means the scan hadn't finished at that point.
292
+
293
+ ### Group chats and channels
294
+
295
+ Once the bot is added to a group chat or channel (`conversation_added` event), it can post with `bot.send({ conversation, ... })` and reply to incoming messages with `bot.reply(msg, ...)`. The SDK keeps per-conversation channel-keys under `stateDir/channel-keys/` and group-secrets under `stateDir/group-secrets/`.
296
+
297
+ **What a bot can and cannot do:**
298
+
299
+ - Only the conversation owner adds a bot and assigns its role (the "Боты" / Bots tab in the profile, no bot-side accept step).
300
+ - A bot has the same permissions as a human with the same role.
301
+ - A **channel** has posts and comments, only an admin or owner bot writes a top-level post, a moderator or member bot can only comment.
302
+ - A **group chat** has plain messages, any member bot can send, the role only changes moderation power such as removing other people's messages or muting, which needs moderator or higher.
303
+ - It **cannot**: change members' roles, add another bot, become owner, create group chats or channels, or `/start` another bot.
304
+ - A disallowed action is rejected server-side with HTTP 403 or `SendRejectedError`.
305
+
306
+ Group-chat / channel administration:
307
+
308
+ ```ts
309
+ // Mint a fresh channel-key, distribute to every other member device
310
+ await bot.rotateChannelKey(conversationId)
311
+
312
+ // Re-key the group_secret (used to seal channel-key bundles) and
313
+ // channel-key in one server transaction. Run after kicking a leaker
314
+ // so old members cannot unseal future bundles
315
+ await bot.rotateGroupSecret(conversationId)
316
+
317
+ // Share local channel-key history with another member's devices
318
+ // Useful when the bot is the only online member and a new joiner needs to catch up
319
+ await bot.backfillChannelKeys(conversationId, { userId: 7777 })
320
+ ```
321
+
322
+ `autoBackfillOnJoin: true` in `BotConfig` runs `backfillChannelKeys` for every new joiner without you wiring it up. The server still filters by `joined_secret_version`, so pre-join history doesn't leak.
323
+
324
+ ### Error model
325
+
326
+ - **Decryption failure** -> `error` event, the message is dropped, the peer's next type-3 frame rebuilds the session
327
+ - **Send rejected by the server** -> `send()` rejects with the exported `SendRejectedError`, the `.code` carries the reason: `bot_not_started` (the user has not pressed Start), `recipient_storage_full`, `send_blocked`, `too_many_messages`
328
+ - **Upload rejected** -> the upload throws the exported `UploadRejectedError` with the server `.code` such as `BOT_STAGING_FULL`, this happens before any send frame leaves
329
+ - **Network drop** -> `disconnect` event with `willReconnect: true`, a send still in the WS queue flushes on reconnect, a send already on the wire and waiting for its echo rejects with the exported `SendUncertainError` so you retry or reconcile (it may or may not have landed)
330
+ - **JWT revoked** (the developer panel rotated the token, or the bot was deleted) -> WS closes with code 4001, the SDK calls `/auth/bot-session` again and reconnects if the bot is still alive, a token re-issue (panel: "Regenerate token") is destructive so restart the SDK with the new `.morokbot`
331
+ - **Send to a kicked group chat or channel** -> `send()` rejects with HTTP 403, the SDK already dropped local channel-key state on the `conversation_kicked` event
332
+
333
+ ## Helpers
334
+
335
+ Small standalone utilities shipped alongside `MorokBot`. None of them are required, they exist to keep typical bot patterns out of your event handlers.
336
+
337
+ ### RateLimiter
338
+
339
+ Token-bucket per key. Use it to throttle floods from one peer without dropping legitimate traffic from others.
340
+
341
+ ```ts
342
+ import { MorokBot, RateLimiter } from 'morok-bot-sdk'
343
+
344
+ const limiter = new RateLimiter({
345
+ capacity: 5, // burst tolerance
346
+ refillPerSec: 1, // sustained rate
347
+ })
348
+
349
+ bot.on('message', async (m) => {
350
+ if (!limiter.tryAcquire(m.sender.userId)) {
351
+ await bot.reply(m, { text: 'Too many messages, give me a moment.' })
352
+ return
353
+ }
354
+ // ... your handler
355
+ })
356
+ ```
357
+
358
+ `tryAcquire(key, cost = 1)` returns `true` if `cost` tokens were available and deducted, `false` otherwise. `available(key)` peeks the count without consuming. `reset(key)` clears one bucket, `clear()` wipes all.
359
+
360
+ Buckets are O(1) on access and prune themselves once idle and full. In-memory only. If you run multiple bot processes against one logical bot, use a shared Redis-backed limiter instead (not shipped).
361
+
362
+ To pace top-level channel posts to the server cadence (a burst of 5 then one per 30 seconds) configure the bucket as `new RateLimiter({ capacity: 5, refillPerSec: 1 / 30 })` and call `tryAcquire` before each post. The SDK never retries a `too_many_messages` rejection for you, catch the `SendRejectedError`, read `.code`, and back off, the same goes for a `SendUncertainError` after a drop where you decide to retry or reconcile
363
+
364
+ ### BotSessions
365
+
366
+ Per-user state store for multi-step flows ("ask name", "ask email", "confirm"). Plain `Map<userId, State>` with optional TTL and a shallow `update()` for partial mutations.
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 min idle = abandon
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: 'What is your name?' })
383
+ }
384
+ })
385
+
386
+ bot.on('message', async (m) => {
387
+ const state = flows.get(m.sender.userId)
388
+ if (!state) return // not in a 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: 'And your 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 complete
398
+ await bot.reply(m, { text: `Got it, ${final.name}. We will email ${final.email}.` })
399
+ }
400
+ })
401
+ ```
402
+
403
+ In-memory only. Process restart wipes everything. If you need durable flows across restarts, persist the state to disk or Redis from your handler.
404
+
405
+ ## Token rotation and recovery
406
+
407
+ If you lose the `.morokbot` file:
408
+
409
+ 1. Developer mode -> your bot -> **Edit** -> the **Management** step -> **Regenerate**.
410
+ 2. The server revokes the old token AND closes any live sessions.
411
+ 3. The wizard shows the fresh token once. Replace the `token` field in your `.morokbot` (everything else stays put, identity / SPK / OTPKs are unchanged).
412
+ 4. Restart the SDK.
413
+
414
+ If you also lose the key material, you have to delete and recreate the bot. Deleting a bot removes its account entirely: to peers it turns into a deleted account and drops out of search. The recreated bot is a separate account even under the same pseudonym, and consent does not carry over, so each peer has to find the bot again and press "Start".
415
+
416
+ ## Security
417
+
418
+ - The Morok server never decrypts message payloads. The SDK uses Signal Protocol session keys for DMs and a per-conversation channel-key (AES-256-GCM) for group chats and channels.
419
+ - A full database leak surfaces ciphertext, public Signal keys, HMAC'd phone numbers, who talked to whom and when, and file sizes. Message contents and private keys aren't in the DB. Raw IPs aren't logged.
420
+ - The `.morokbot` file and `stateDir` are private keys on disk. If they leak, the bot is gone.
421
+ - On connect the bot cross-signs its device by publishing a device certificate, and it verifies peer certificates on first contact, so a renamed contact or a contact on a new device still derives a consistent safety number. A device certificate is **set once**. The server rejects a silent re-key (posting a different certificate to an already-certified device returns HTTP 409), so neither a hostile server nor a hijacked session can quietly swap the bot's verification material.
422
+
423
+ > **Vulnerability reports**: email `security@morok.me`. Please, **do not file public GitHub issues for security reports**, this will expose the bug to everyone who comes across before the fix is released. The machine-readable disclosure policy is at [`/.well-known/security.txt`](https://morok.me/.well-known/security.txt) (RFC 9116).
424
+
425
+ ## Troubleshooting
426
+
427
+ Common symptoms and the fix. For lower-level details on the underlying error codes see the [API reference](https://morok.me/api).
428
+
429
+ ### Connection / auth
430
+
431
+ | Symptom | What it means | Fix |
432
+ |-----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
433
+ | `disconnect` events every few seconds, `reason: 'transport'` | Network or NAT is flapping, server restarting, or load balancer killing idle connections. | Check `curl https://app.morok.me/health`. If the server is healthy, the SDK will reconnect on its own. Enable `logger` for detail. |
434
+ | WS keeps closing with code 4001 (`reason: 'auth'`) | Session ticket was revoked, either the developer panel rotated the token, or `/auth/bot-session` rejected your token. | The SDK refreshes the JWT automatically. If it loops, your token is dead, run **Regenerate token** in the dev panel and patch the `token` field in your `.morokbot`. |
435
+ | `MorokBot.start: ... 401 INVALID_CREDENTIALS` | `.morokbot` file has a stale `token`. | Replace the `token` field in your `.morokbot` with a fresh one from the dev panel. The rest of the file (identity, prekeys) stays put. |
436
+ | `MorokBot.start: refused state-dir lock, another process ...` | Two SDK processes pointed at the same `stateDir`. The pid-based lock caught it. | Kill the other process or give each instance its own `stateDir`. Stale `state.lock` is auto-cleared when the holding pid is gone. |
437
+
438
+ ### Sending
439
+
440
+ Send rejections throw the exported `SendRejectedError`, which carries a machine-readable `.code` (`bot_not_started`, `recipient_storage_full`, `send_blocked`, `too_many_messages`). A bot may send **5 messages in a row** in one conversation (DM, group chat, channel comments), the run resets as soon as a non-bot posts there. Top-level channel posts use a different limit: a burst of 5, then no faster than one per 30 seconds. A gallery counts as one message (up to 10 items), and reactions, edits and deletes don't count. A refused upload (over the 5 GB per-file cap, or the bot over its 10 GB unsent staging) throws the exported `UploadRejectedError` with `.code` like `BOT_STAGING_FULL`, and a drop while a send waits for its echo throws the exported `SendUncertainError` where the message may or may not have landed, so you retry or reconcile
441
+
442
+ | Symptom | What it means | Fix |
443
+ |-----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
444
+ | `send()` rejects with `bot_not_started` | The peer has not pressed "Start" on your bot's profile. Server-side guard, bots can't cold-DM users. | Wait for the `start` event, then it's safe to message them. To prompt users, ship a profile description that asks them to press the button. |
445
+ | `send()` rejects with HTTP 403 on a group chat / channel | The bot was kicked or the conversation was destroyed. The `conversation_kicked` event already fired and the SDK dropped local channel-key state. | Don't retry. Wait for `conversation_added` if you expect to be re-added. |
446
+ | `send()` throws `UploadRejectedError` | The file is over the 5 GB per-file cap, or the bot holds over 10 GB of uploaded-but-unsent files (`.code` `BOT_STAGING_FULL`) | Trim the file, or send what is already uploaded (a sent file no longer counts against the staging limit) |
447
+ | `send()` rejects with `SendRejectedError` (`code: 'recipient_storage_full'`) | A regular file was billed to the party who bears it: the DM recipient who started the bot, or the group-chat/channel owner who added it. Their storage is full. | That party frees space or moves to a personal cloud with more room, or you send less. |
448
+ | `send()` rejects with `SendRejectedError` (`code: 'too_many_messages'`) | The bot hit its send-rate limit: 5 in a row in a DM/group-chat/comments (a non-bot post resets it), or, for top-level channel posts, faster than one per 30 seconds after the first 5. | In a DM/group-chat/comments, wait for a non-bot post (opens a fresh 5). In a channel, slow to one post per 30 seconds. Or pack more into fewer messages: a gallery is one message of up to 10 items. Reactions, edits and deletes don't count. |
449
+ | `send()` rejects with `SendUncertainError` after a `disconnect` | The socket dropped while the send was waiting for its server echo, the message may or may not have been delivered | Catch `SendUncertainError`, then retry or reconcile against history (it carries `clientMsgId` and `conversationId`), a blind retry can double-deliver if the first one landed |
450
+ | `bot.send({ peer: 'alice' })` resolves pseudonyms slowly | Pseudonym -> userId resolution hits `GET /users/:username` on every call (no SDK cache). | If you DM the same peer repeatedly, cache `userId` on your side. The numeric value never changes. |
451
+
452
+ ### Receiving
453
+
454
+ | Symptom | What it means | Fix |
455
+ |-----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
456
+ | `error` event with "decrypt failed" / "no matching session" | The peer's Double Ratchet state diverged from the bot's. Often happens after the peer reinstalls the app. | The SDK drops the message and waits for the next type-3 frame from the peer, which re-bootstraps the session. No action needed. |
457
+ | `warn` log `[signal] PEER IDENTITY CHANGED` on a type-3 frame | A known peer presented a new identity key and the SDK re-pinned it, usually because the peer reinstalled. The SDK accepts it and keeps going, it does not block. | No action for a normal reinstall. If you track peer identities out of band, treat it as a prompt to re-verify that peer. |
458
+ | `attachment.download()` rejects with HTTP 404 / 410 | File was deleted, expired, or evicted by quota cron. | Treat as gone. The sender can re-upload. |
459
+ | Peer's app shows the "Safety number changed" warning | The same account's identity key changed (a different `.morokbot` with new key material was imported). This does not happen in the normal flow: regenerating the token leaves identity alone, wiping `stateDir` re-imports the same key from `.morokbot`, and re-creating the bot yields a separate account (a new bot — peers just find it again and press Start). | Peers' clients re-pin the new key automatically (TOFU); the warning shows once and nothing is blocked, and re-verifying the safety number by hand is optional. |
460
+
461
+ ### State directory
462
+
463
+ | Symptom | What it means | Fix |
464
+ |-----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
465
+ | Boot log: `[bot] fsck quarantined N session files` | One or more files under `stateDir/sessions/` failed to parse. They've been moved to `stateDir/quarantine/` so the rest of the state still works. | Affected peer sessions will rebuild on the next message. Inspect `quarantine/` once to see what corrupted them (disk failure, kill -9 during write, etc.). |
466
+ | `stateDir` size growing slowly over months | Per-peer session records accumulate. One file per `(peer, device)` pair. | This is normal: even with thousands of peers the directory is tens of MB. Don't clean it by hand, deleting session files breaks Signal sessions. |
467
+ | Stale `state.lock` after process kill -9 | Pid file mentions a dead PID. SDK refuses to start. | The SDK actually checks if the recorded PID is still alive and clears the lock if not. If you still see "refused", the recorded PID was recycled, delete `state.lock` manually. |
468
+
469
+ ### Bot creation / management
470
+
471
+ | Symptom | What it means | Fix |
472
+ |-----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
473
+ | `BOT_USERNAME_TAKEN` on bot creation | The pseudonym with the `-bot` suffix is already used. | Pick a different pseudonym. |
474
+ | `BOT_LIMIT_REACHED` (409) | You already own 10 bots. | Delete one you don't need from the Developer mode tab, or via `DELETE /developer/bots/:id`. |
475
+ | `.morokbot` file from "Regenerate token" doesn't import | If you only kept the new `token` and pasted it on top of an old file, that's the correct procedure. If you re-downloaded the whole `.morokbot` and the identity changed, peers will see "Safety number changed". | The dev panel "Regenerate" rotates only the token by default. If the identity changed too, peers get a one-time "Safety number changed" warning and their clients re-pin the new key automatically (TOFU); the old identity can't be restored. |
476
+
477
+ ### Debugging
478
+
479
+ Pass a logger to see what the SDK is doing. Pino works directly, `console`-shaped also fine:
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
+ The `debug` level logs every WebSocket frame and every prekey-replenish tick, which floods the log fast. `info` keeps the boot trace and rare events. In production, route `warn` and `error` to a file via systemd or journald.
494
+
495
+ If you suspect a server issue, capture:
496
+ - `curl https://app.morok.me/health` and `/version`
497
+ - The exact `error.message` from the `error` event
498
+ - Surrounding log lines at `debug` level
499
+
500
+ and open a [GitHub issue](https://github.com/geloid/morok-bot-sdk/issues) with that.
501
+
502
+ ## Development
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 tests
511
+ ```
512
+
513
+ Unit tests cover the `.morokbot` parser, file-backed Signal stores (with fsck and path-traversal guards), the channel / group-secret wire formats, the file cipher (single-shot and chunked AAD), and the gallery payload envelope. There is no integration suite, run the example bot under `examples/echo-bot/` against your own staging instance.
514
+
515
+ There is no `npm run lint` script, the project leans on `tsc --strict` and the test suite.
516
+
517
+ ### Generated API reference
518
+
519
+ ```bash
520
+ npm run docs:api # typedoc -> docs-api/
521
+ ```
522
+
523
+ `docs-api/` is a static HTML site of the SDK's public surface (everything exported from `src/index.ts`), generated by [TypeDoc](https://typedoc.org/) from the TypeScript types and JSDoc. The published live copy is at [morok.me/sdk-api/](https://morok.me/sdk-api/). The directory is gitignored, it's a build artifact, regenerated on each release.
524
+
525
+ ## Glossary
526
+
527
+ Cryptographic terms used throughout the SDK and the [API reference](https://morok.me/api).
528
+
529
+ | Term | Meaning |
530
+ |-------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
531
+ | **Signal Protocol** | The set of cryptographic primitives Morok inherits from [Open Whisper Systems](https://signal.org/docs/). E2E ratcheted DMs + channel-key group chats and channels + asynchronous handshake. |
532
+ | **X3DH** | Extended Triple Diffie-Hellman: the asynchronous handshake that lets a sender start an encrypted session with a recipient who is offline. Mixes identity + signed + one-time keys. |
533
+ | **Double Ratchet** | The per-session key-evolution algorithm that follows X3DH. Every message advances the key, so a single key compromise leaks at most one message in each direction. |
534
+ | **prekey** | A public Curve25519 key the recipient publishes ahead of time, consumed by an X3DH initiator. Two flavours below. |
535
+ | **signed prekey (SPK)** | Long-lived prekey signed by the identity key. Rotated by the server every 7 days. Used when no OTPK is available. |
536
+ | **one-time prekey (OTPK)** | Short-lived prekey consumed on first use. Pool maintained at `replenishTarget` items. SDK refills automatically. |
537
+ | **identity key** | The bot's long-lived Curve25519 keypair. Defines the bot's cryptographic identity to peers. Lives in `stateDir/identity.json`. |
538
+ | **TOFU** | "Trust On First Use": the peer accepts the identity key shown on first contact and verifies it stays the same afterwards. A changed identity key triggers a warning in the FE. |
539
+ | **sender_id scrubbing** | Retroactive metadata minimisation. On DMs the server keeps sender_id only during a hot window (7 days by default) so delivery and read receipts can route back, then nulls it. At routing time the server does see the sender, so this is not sealed sender (that stricter model is not active in the service). Group-chat and channel content is encrypted under the channel-key. |
540
+ | **channel-key** | Per-conversation AES-256 symmetric key used to encrypt messages in a group chat or channel. Rotated by `rotateChannelKey()`. |
541
+ | **group-secret** | Per-conversation symmetric key used to wrap channel-key bundles when distributing them to new members. Rotated by `rotateGroupSecret()` (also rotates the channel-key). |
542
+ | **epoch** | A monotonic counter on the channel-key. Each rotation bumps it. The SDK keeps history so older messages stay readable after a rotation. |
543
+ | **wire format** | The exact byte layout of a ciphertext envelope. The channel cipher uses `"MOK1" \| epoch_BE32 \| iv12 \| ct+tag` (see `src/crypto/channel-cipher.ts`). |
544
+ | **fanoutId** | Per-recipient-copy id the server stamps on each device's copy of an outbound message. The SDK matches own-echo by it so `bot.send()` resolves to the real `messageId`. |
545
+ | **clientMsgId** | Sender-stable id shared by every fan-out copy of one logical send. `bot.reply()` threads through it so replies hit the logical message, not a per-device copy. |
546
+ | **AAD** | Additional Authenticated Data: bytes passed to AES-GCM that aren't encrypted but must match on decrypt. Morok uses scoped strings like `morok-channel-<convId>` to bind ciphertext to context. |
547
+
548
+ Further reading: [Signal Protocol whitepaper](https://signal.org/docs/), [X3DH spec](https://signal.org/docs/specifications/x3dh/), [Double Ratchet spec](https://signal.org/docs/specifications/doubleratchet/).
549
+
550
+ ## Performance and scaling
551
+
552
+ One SDK process serves one bot. Per-bot footprint:
553
+
554
+ | Aspect | Value |
555
+ |---------------------------------|------------------------------------------------------------------------------------|
556
+ | RAM (resident) | 80-150 MB. libsignal accounts for most of it. |
557
+ | CPU | < 5% of one core when idle, spikes during X3DH handshakes and AES-GCM on uploads. |
558
+ | Throughput | A single process handles hundreds of messages per second. The bottleneck is almost always your handler code or the network. |
559
+ | `stateDir` growth | ~ 1 KB per active peer-device pair. Tens of MB even for bots with thousands of peers. |
560
+
561
+ Sharing one `stateDir` between processes corrupts the Signal sessions, the pid-lock prevents it from happening accidentally. To run multiple bots on one host, give each its own process and its own `stateDir`. Memory scales roughly linearly.
562
+
563
+ Outbound fan-out to a multi-device peer happens server-side: one `bot.send()` produces one envelope on your side, the server replicates it to each peer device.
564
+
565
+ When a single bot exceeds the throughput of one process, the handler is almost always the cause: DB lookups, external API calls, image processing. The WebSocket and libsignal layers keep up with much more than typical handler code.
566
+
567
+ The shipped `RateLimiter` and `BotSessions` helpers live in memory and don't cross process boundaries. If multiple processes serve one logical bot, write a thin Redis-backed equivalent.
568
+
569
+ ## Migrating from Telegram Bot API
570
+
571
+ If you've built a Telegram bot before, the rough equivalents are:
572
+
573
+ | Concept | Telegram Bot API | Morok |
574
+ |--------------------------|---------------------------------------------|-----------------------------------------------------------------------------------|
575
+ | Transport | HTTPS long-poll or webhook | Persistent WebSocket (the SDK handles reconnect) |
576
+ | Token format | `<int>:<base64>` | `bot:<int>:<base64url>` |
577
+ | Server -> bot delivery | `getUpdates` long-poll or HTTP POST to URL | WS frame -> `bot.on('message')` |
578
+ | Bot identity | Single bot per token, no key material | Signal identity key + signed prekey + one-time prekeys, generated client-side |
579
+ | Multi-device | Not applicable, bot is a singleton | Built-in: one outbound DM fans out to every peer device on the server side |
580
+ | Group-chat support | Native (`message.chat.id`) | Same shape (`bot.send({ conversation })`). Admin adds the bot, SDK consumes channel-keys |
581
+ | Channel support | Native (broadcast) | Same. Comments thread via `threadRootId` |
582
+ | Encryption | Plaintext on Telegram servers | E2E (Signal Protocol DMs, channel-key group chats and channels). Server stores ciphertext only. |
583
+ | Slash commands | Set via `setMyCommands` | Set in the bot's Edit screen or `POST /developer/bots/:id/commands` |
584
+ | Buttons (controls) | Reply / inline keyboards | Set via `setMyControls` as a tree, taps fire `bot.on('control')` |
585
+ | Webhooks | First-class | Not supported |
586
+ | Inline mode | First-class | Not supported |
587
+ | Payments | First-class | Not supported |
588
+ | Sticker pack creation | `createNewStickerSet` etc. | Not supported |
589
+ | File upload caps | Tier-dependent (20 MB to 2 GB) | 5 GB per file; charged to the recipient (DM) or conversation owner (group chat / channel) |
590
+ | Rate limits | ~ 30 msg/sec global | A bot: 5 in a row in a DM/group-chat/comments (a non-bot post resets it); top-level channel posts: 5, then no faster than one per 30 s. Plus a 300 msg/min per-user cap |
591
+ | Consent model | User starts a chat by message | User presses **Start** first, the bot cannot cold-DM. `bot.on('start')` fires. |
592
+ | Identity changes | Token regen doesn't change identity | Identity key change triggers a TOFU warning on the peer side |
593
+
594
+ If you're porting a Telegram bot mechanically, most of the message-handling code translates directly. The consent model is the same as Telegram: you can't message a user until they press the button. The one thing to rethink is attachment encryption: the SDK encrypts each file under its own AES key, but the client-side mime allow-list does **not** apply to a Node bot, so treat `mime` from peers as a hint, not a guarantee.
595
+
596
+ ## Versioning
597
+
598
+ The package follows [semver](https://semver.org/). Anything exported from `morok-bot-sdk` (the `MorokBot` class, `BotConfig`, `IncomingMessage`, the event payloads, the attachment types) is part of the public API. Wire-format changes follow the Morok server's own compatibility policy described in [api.md](https://morok.me/api).
599
+
600
+ ## License
601
+
602
+ Apache License 2.0. See [LICENSE](./LICENSE).