@graygate/bot-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Parallax AI, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # @graygate/bot-sdk
2
+
3
+ Runtime SDK for [graygate](https://graygate.app) bots. Node, TypeScript.
4
+
5
+ A bot on graygate is an account run by a machine. It holds its own RSA key pair, joins rooms by
6
+ invitation, and reads and writes end-to-end encrypted messages. The server relays ciphertext and
7
+ cannot read any of it — which is why this runtime, and not graygate, holds the keys.
8
+
9
+ Start here: **https://graygate.app/bots/** — what a bot is, what it may and may not do, and the
10
+ five steps from creating one in the app to answering a message.
11
+
12
+ > **Back up your state directory.** `stateDir` holds the bot's RSA private key and the room keys
13
+ > it has been given, as plain files. Neither the server nor the phone of the person who created
14
+ > the bot has a copy. Lose it and the past messages of those rooms stay closed forever — there is
15
+ > no room-key backup, by design. If you lose only the private key you can replace it with
16
+ > `PUT /bot/public-key`, but the room keys come back only when each room admin next rotates.
17
+
18
+ ## Install
19
+
20
+ Until this package is on npm, install it from the repository:
21
+
22
+ ```bash
23
+ npm install github:graygate/bot-sdk
24
+ ```
25
+
26
+ Node 20 or newer.
27
+
28
+ ## Use
29
+
30
+ ```ts
31
+ import { Bot } from '@graygate/bot-sdk';
32
+
33
+ const bot = new Bot({
34
+ token: process.env.GRAYGATE_BOT_TOKEN!, // gg1.<botId>.<secret>, from the app
35
+ stateDir: './state', // private key, room keys, handled ids
36
+ webhook: { port: 8443, path: '/hook', publicUrl: 'https://bot.example.com/hook' },
37
+ autoAcceptInvites: true,
38
+ });
39
+
40
+ bot.command('start', (ctx) => ctx.reply('Hello.'));
41
+ bot.on('message', async (ctx) => { if (ctx.text === 'ping') await ctx.reply('pong'); });
42
+ bot.on('join', async (ctx) => { await ctx.reply('Welcome.'); });
43
+ bot.on('invite', async (invite) => invite.accept());
44
+
45
+ await bot.start();
46
+ ```
47
+
48
+ `start()` activates the bot if this is its first run (generating the key pair locally), serves the
49
+ webhook, and registers it. **It registers on every start on purpose**: there is no polling, so
50
+ re-registering is what makes the server replay everything that piled up while the bot was down,
51
+ oldest first.
52
+
53
+ ### Options
54
+
55
+ | Option | |
56
+ |---|---|
57
+ | `token` | `gg1.<botId>.<secret>`, shown once in the app when the bot is created. Pass it through the environment; never write it to a file |
58
+ | `stateDir` | Private key, room keys by version, and the ids already handled. Back it up |
59
+ | `webhook.publicUrl` | The public https address the server will post to. https and port 443 only; private, loopback and link-local addresses are refused |
60
+ | `webhook.port` / `webhook.host` | Where the SDK listens. Bind to `127.0.0.1` when a reverse proxy terminates TLS |
61
+ | `autoAcceptInvites` | Default true. Set false and decide inside `bot.on('invite')` |
62
+ | `origin` | Server origin. Defaults to `https://server.graygate.app` |
63
+
64
+ ### Handlers
65
+
66
+ - `bot.command(name, ctx)` — a command is an ordinary text message that starts with `/`. In a room
67
+ with more than one bot, people write `/start@your_bot`; a command addressed to another bot is
68
+ ignored for you.
69
+ - `bot.on('message', ctx)` — `ctx.text` is set for text bodies, `ctx.body` always carries the
70
+ decrypted body, `ctx.reply(text)` answers in the same room.
71
+ - `bot.on('join', ctx)` — someone joined a room the bot is in. Useful for a greeting.
72
+ - `bot.on('invite', invite)` — `invite.accept()` or `invite.decline()`.
73
+
74
+ A handler that throws makes the SDK answer the webhook with a non-2xx status, and the server sends
75
+ the batch again after a backoff. That is the intended way to say "I could not handle this yet".
76
+
77
+ ## What you take on
78
+
79
+ - **A public https address is required.** The server checks it when you register and again on every
80
+ send, so an address that later resolves to a private range stops receiving. Redirects are not
81
+ followed. Running on a laptop means running a tunnel.
82
+ - **Your bot reads everything in its rooms.** Room keys are shared, so there is no privacy mode:
83
+ the component that would filter is the server, and the server sees only ciphertext. Members are
84
+ told — the invite raises a confirmation dialog, a warning line appears when the bot joins, and a
85
+ BOT badge follows it. What your runtime logs, stores or forwards is your responsibility, and
86
+ rooms that set an auto-delete period expect you to honour it. The default state keeps no messages.
87
+ - **Media handed to you is unverified.** Phones run every incoming file through a validation
88
+ pipeline before anything renders it. This SDK does not: it decrypts bytes and gives them to you.
89
+ Do not pass them straight to an image decoder, a file path or a system handler.
90
+ - **If the token leaks**, issue a new one in the app and use *Leave all rooms*. Both take effect at
91
+ once, but neither takes back room keys already copied — only a room admin can, by rotating. Tell
92
+ them.
93
+
94
+ ## Webhook contract
95
+
96
+ Every request from the server carries a signature. Verify it before parsing the body; the SDK does
97
+ this for you and answers 401 without reading anything it cannot authenticate.
98
+
99
+ ```
100
+ header: x-graygate-signature: t=<unix seconds>,v1=<hex>
101
+ key: HKDF-SHA256(ikm = sha256(the token's secret part), salt = none,
102
+ info = "graygate:bot-webhook-v1", length = 32)
103
+ v1: HMAC-SHA256(key, "<t>." + <raw request body>)
104
+ verify: constant-time compare, and reject |now - t| > 300 seconds as a replay
105
+ ```
106
+
107
+ The signing key comes from the sha256 of the token's secret part rather than the secret itself
108
+ because the server stores only that hash — it never keeps the secret in the clear. Reissuing the
109
+ token changes the hash, so the signing key changes with it.
110
+
111
+ A 2xx response **is** the acknowledgement: it deletes the server's queued copy. Delivery is
112
+ at-least-once, so drop repeats by item id (the SDK does). Batches carry up to 100 items, requests
113
+ time out after 3 seconds, and after 20 consecutive failures the server switches your webhook off
114
+ and messages only wait in the queue, for 14 days.
115
+
116
+ ## Other languages
117
+
118
+ This is one implementation, not the contract. The REST endpoints, the signature above, and the
119
+ crypto parameters (AES-256-CBC with encrypt-then-HMAC-SHA256, RSA-OAEP room-key envelopes, all via
120
+ [graygate-cypher](https://github.com/joshephan/graygate-cypher)) are enough to write a runtime in
121
+ any language. The wire types in `src/wire/` are the same ones the app and the server use.
122
+
123
+ ## Contributing
124
+
125
+ The sources here are generated from the graygate monorepo, which is where fixes land. Open an issue
126
+ describing the problem — pull requests against generated files cannot be merged directly, but the
127
+ change will be made upstream and land here on the next release.
128
+
129
+ ## License
130
+
131
+ MIT. See [LICENSE](LICENSE).
package/dist/api.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The REST client between a bot runtime and the graygate server.
3
+ *
4
+ * Access tokens last fifteen minutes and are kept **in memory only**; when one is close to expiring
5
+ * it is traded for another using the bot token. There is no refresh token because the bot token is
6
+ * itself the long-lived credential. That token never leaves this object — not into a log, not into
7
+ * the state directory.
8
+ */
9
+ export declare class BotApiError extends Error {
10
+ readonly status: number;
11
+ readonly code: string;
12
+ constructor(status: number, code: string, message?: string);
13
+ }
14
+ export declare class BotApi {
15
+ private readonly origin;
16
+ private readonly token;
17
+ private accessToken;
18
+ private expiresAt;
19
+ private botId;
20
+ private userId;
21
+ constructor(origin: string, token: string);
22
+ get ids(): {
23
+ botId: string | null;
24
+ userId: string | null;
25
+ };
26
+ private ensureAuth;
27
+ private raw;
28
+ call(method: string, path: string, body?: unknown): Promise<Record<string, unknown>>;
29
+ /** For endpoints that answer with an array (`GET /invites`, member lists). */
30
+ callList(method: string, path: string, body?: unknown): Promise<unknown[]>;
31
+ }
package/dist/api.js ADDED
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BotApi = exports.BotApiError = void 0;
4
+ const wire_1 = require("./wire");
5
+ /**
6
+ * The REST client between a bot runtime and the graygate server.
7
+ *
8
+ * Access tokens last fifteen minutes and are kept **in memory only**; when one is close to expiring
9
+ * it is traded for another using the bot token. There is no refresh token because the bot token is
10
+ * itself the long-lived credential. That token never leaves this object — not into a log, not into
11
+ * the state directory.
12
+ */
13
+ class BotApiError extends Error {
14
+ status;
15
+ code;
16
+ constructor(status, code, message) {
17
+ super(message ?? code);
18
+ this.status = status;
19
+ this.code = code;
20
+ this.name = 'BotApiError';
21
+ }
22
+ }
23
+ exports.BotApiError = BotApiError;
24
+ /** Renew this long before expiry (ms), so a token cannot die between the check and the request. */
25
+ const REFRESH_MARGIN_MS = 60_000;
26
+ class BotApi {
27
+ origin;
28
+ token;
29
+ accessToken = null;
30
+ expiresAt = 0;
31
+ botId = null;
32
+ userId = null;
33
+ constructor(origin, token) {
34
+ this.origin = origin;
35
+ this.token = token;
36
+ }
37
+ get ids() {
38
+ return { botId: this.botId, userId: this.userId };
39
+ }
40
+ async ensureAuth() {
41
+ if (this.accessToken && Date.now() < this.expiresAt - REFRESH_MARGIN_MS)
42
+ return this.accessToken;
43
+ const res = await this.raw('POST', '/bot/auth', { token: this.token }, null);
44
+ this.accessToken = res.accessToken;
45
+ this.botId = res.botId;
46
+ this.userId = res.userId ?? null;
47
+ // The lifetime is fixed at BOT_ACCESS_TOKEN_TTL_MIN; there is nothing to recompute here.
48
+ this.expiresAt = Date.now() + 15 * 60_000;
49
+ return this.accessToken;
50
+ }
51
+ async raw(method, path, body, access) {
52
+ const res = await fetch(`${this.origin}${path}`, {
53
+ method,
54
+ headers: {
55
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
56
+ // Client header for the edge in front of the server. Not a secret; see BOT_CLIENT_TOKEN.
57
+ 'x-graygate-client': wire_1.BOT_CLIENT_TOKEN,
58
+ ...(access ? { authorization: `Bearer ${access}` } : {}),
59
+ },
60
+ body: body !== undefined ? JSON.stringify(body) : undefined,
61
+ });
62
+ const text = await res.text();
63
+ const json = text ? JSON.parse(text) : {};
64
+ if (!res.ok) {
65
+ const err = (json.error ?? {});
66
+ throw new BotApiError(res.status, err.code ?? 'error', err.message);
67
+ }
68
+ return json;
69
+ }
70
+ async call(method, path, body) {
71
+ const access = await this.ensureAuth();
72
+ try {
73
+ return await this.raw(method, path, body, access);
74
+ }
75
+ catch (err) {
76
+ // The access token expired early — trade for another one and retry, once.
77
+ if (err instanceof BotApiError && err.status === 401) {
78
+ this.accessToken = null;
79
+ return this.raw(method, path, body, await this.ensureAuth());
80
+ }
81
+ throw err;
82
+ }
83
+ }
84
+ /** For endpoints that answer with an array (`GET /invites`, member lists). */
85
+ async callList(method, path, body) {
86
+ const access = await this.ensureAuth();
87
+ const res = await fetch(`${this.origin}${path}`, {
88
+ method,
89
+ headers: {
90
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
91
+ 'x-graygate-client': wire_1.BOT_CLIENT_TOKEN,
92
+ authorization: `Bearer ${access}`,
93
+ },
94
+ body: body !== undefined ? JSON.stringify(body) : undefined,
95
+ });
96
+ const text = await res.text();
97
+ const json = text ? JSON.parse(text) : [];
98
+ if (!res.ok) {
99
+ const err = (json.error ?? {});
100
+ throw new BotApiError(res.status, err.code ?? 'error', err.message);
101
+ }
102
+ return Array.isArray(json) ? json : [];
103
+ }
104
+ }
105
+ exports.BotApi = BotApi;
@@ -0,0 +1,99 @@
1
+ import { type MessageBody } from './wire';
2
+ export { BotApiError } from './api';
3
+ export { verifyWebhookSignature, webhookSigningKey } from './signature';
4
+ /**
5
+ * The graygate bot runtime.
6
+ *
7
+ * Four things happen here: it authenticates with the bot token and, on a first run, generates a key
8
+ * pair and activates the account; it receives webhooks, **verifies the signature before anything
9
+ * else**, and consumes envelopes to learn room keys; it decrypts messages and hands them to your
10
+ * handlers; and it encrypts replies and posts them back.
11
+ *
12
+ * Every cryptographic operation goes through the wire module — no hand-rolled crypto here.
13
+ *
14
+ * **Media handed to a handler is unverified.** This runtime is not a renderer, and what you do with
15
+ * those bytes is yours to decide: do not pass them straight to an image decoder or a file path.
16
+ */
17
+ export interface BotOptions {
18
+ /** `gg1.<botId>.<secret>`. Read it from the environment; never write it to a file. */
19
+ token: string;
20
+ /** Server origin. Bots do not use the app's fallback-domain logic — you choose the address. */
21
+ origin?: string;
22
+ /** Where the private key, the room keys and the handled ids live. **Back this up.** */
23
+ stateDir: string;
24
+ webhook: {
25
+ port: number;
26
+ /** Defaults to `/hook`. */
27
+ path?: string;
28
+ /** The public https address to register. Private and loopback addresses are refused. */
29
+ publicUrl: string;
30
+ /** Bind to one interface only — `127.0.0.1` when a reverse proxy sits in front. */
31
+ host?: string;
32
+ };
33
+ /** Defaults to true; set false to decide inside `bot.on('invite')`. */
34
+ autoAcceptInvites?: boolean;
35
+ }
36
+ export interface MessageContext {
37
+ roomId: string;
38
+ messageId: string;
39
+ senderId: string;
40
+ body: MessageBody;
41
+ /** The text, when `body.t === 'text'`. */
42
+ text: string | null;
43
+ reply(text: string): Promise<void>;
44
+ }
45
+ export interface JoinContext {
46
+ roomId: string;
47
+ userId: string;
48
+ reply(text: string): Promise<void>;
49
+ }
50
+ export interface InviteContext {
51
+ inviteId: string;
52
+ roomId: string;
53
+ accept(): Promise<void>;
54
+ decline(): Promise<void>;
55
+ }
56
+ export interface CommandContext extends MessageContext {
57
+ command: string;
58
+ args: string;
59
+ }
60
+ type MessageHandler = (ctx: MessageContext) => void | Promise<void>;
61
+ type JoinHandler = (ctx: JoinContext) => void | Promise<void>;
62
+ type InviteHandler = (ctx: InviteContext) => void | Promise<void>;
63
+ type CommandHandler = (ctx: CommandContext) => void | Promise<void>;
64
+ export declare class Bot {
65
+ private readonly options;
66
+ private readonly api;
67
+ private readonly state;
68
+ private readonly secret;
69
+ private readonly path;
70
+ private server;
71
+ private username;
72
+ private messageHandlers;
73
+ private joinHandlers;
74
+ private inviteHandlers;
75
+ private commandHandlers;
76
+ constructor(options: BotOptions);
77
+ on(event: 'message', handler: MessageHandler): void;
78
+ on(event: 'join', handler: JoinHandler): void;
79
+ on(event: 'invite', handler: InviteHandler): void;
80
+ /** A command such as `/start`. Names are ASCII lowercase, digits and underscores. */
81
+ command(name: string, handler: CommandHandler): void;
82
+ /**
83
+ * Starts the bot: activates it (generating a key pair on a first run), serves the webhook, and
84
+ * registers it. **Registering is also the request for a backfill** — there is no polling, so this
85
+ * is when everything that piled up while the bot was down comes in.
86
+ */
87
+ start(): Promise<void>;
88
+ stop(): Promise<void>;
89
+ private ensureActivated;
90
+ private listen;
91
+ private handleRequest;
92
+ private handleItem;
93
+ private dispatchMessage;
94
+ private onInvite;
95
+ /** Sends text. Does nothing when there is no room key — not invited yet, or removed. */
96
+ send(roomId: string, text: string): Promise<void>;
97
+ /** Comments on a post — channels only; carries the parent post's id. */
98
+ replyToPost(roomId: string, parentId: string, text: string): Promise<void>;
99
+ }
package/dist/index.js ADDED
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Bot = exports.webhookSigningKey = exports.verifyWebhookSignature = exports.BotApiError = void 0;
4
+ const node_http_1 = require("node:http");
5
+ const node_crypto_1 = require("node:crypto");
6
+ const wire_1 = require("./wire");
7
+ const wire_2 = require("./wire");
8
+ const api_1 = require("./api");
9
+ const state_1 = require("./state");
10
+ const signature_1 = require("./signature");
11
+ var api_2 = require("./api");
12
+ Object.defineProperty(exports, "BotApiError", { enumerable: true, get: function () { return api_2.BotApiError; } });
13
+ var signature_2 = require("./signature");
14
+ Object.defineProperty(exports, "verifyWebhookSignature", { enumerable: true, get: function () { return signature_2.verifyWebhookSignature; } });
15
+ Object.defineProperty(exports, "webhookSigningKey", { enumerable: true, get: function () { return signature_2.webhookSigningKey; } });
16
+ class Bot {
17
+ options;
18
+ api;
19
+ state;
20
+ secret;
21
+ path;
22
+ server = null;
23
+ username = '';
24
+ messageHandlers = [];
25
+ joinHandlers = [];
26
+ inviteHandlers = [];
27
+ commandHandlers = new Map();
28
+ constructor(options) {
29
+ this.options = options;
30
+ this.api = new api_1.BotApi(options.origin ?? 'https://server.graygate.app', options.token);
31
+ this.state = new state_1.BotState(options.stateDir);
32
+ this.secret = (0, signature_1.tokenSecret)(options.token);
33
+ this.path = options.webhook.path ?? '/hook';
34
+ }
35
+ on(event, handler) {
36
+ if (event === 'message')
37
+ this.messageHandlers.push(handler);
38
+ else if (event === 'join')
39
+ this.joinHandlers.push(handler);
40
+ else
41
+ this.inviteHandlers.push(handler);
42
+ }
43
+ /** A command such as `/start`. Names are ASCII lowercase, digits and underscores. */
44
+ command(name, handler) {
45
+ this.commandHandlers.set(name, handler);
46
+ }
47
+ /**
48
+ * Starts the bot: activates it (generating a key pair on a first run), serves the webhook, and
49
+ * registers it. **Registering is also the request for a backfill** — there is no polling, so this
50
+ * is when everything that piled up while the bot was down comes in.
51
+ */
52
+ async start() {
53
+ await this.ensureActivated();
54
+ await this.listen();
55
+ await this.api.call('PUT', '/bot/webhook', { url: this.options.webhook.publicUrl });
56
+ }
57
+ async stop() {
58
+ const server = this.server;
59
+ this.server = null;
60
+ if (!server)
61
+ return;
62
+ await new Promise((resolve) => server.close(() => resolve()));
63
+ }
64
+ async ensureActivated() {
65
+ const me = await this.api.call('POST', '/bot/auth', undefined).catch(() => null);
66
+ void me; // The client handles authentication; all this decides is whether we are active.
67
+ const profile = await this.api.call('GET', '/bot/me');
68
+ this.username = String(profile.username ?? '');
69
+ if (profile.active === true) {
70
+ if (!this.state.readPrivateKey()) {
71
+ throw new Error('This bot is active but the state directory has no private key, so its old room keys are ' +
72
+ 'gone. Replace the key with PUT /bot/public-key and ask each room admin to invite it again.');
73
+ }
74
+ return;
75
+ }
76
+ const pair = await (0, wire_2.generateRsaKeyPair)();
77
+ this.state.writePrivateKey(pair.privateKeyPem);
78
+ await this.api.call('POST', '/bot/activate', { rsaPublicKey: pair.publicKeyPem });
79
+ }
80
+ listen() {
81
+ return new Promise((resolve, reject) => {
82
+ const server = (0, node_http_1.createServer)((req, res) => {
83
+ void this.handleRequest(req, res).catch(() => {
84
+ // A throwing handler answers non-2xx, and the server sends the batch again.
85
+ if (!res.headersSent)
86
+ res.writeHead(500);
87
+ res.end();
88
+ });
89
+ });
90
+ server.on('error', reject);
91
+ server.listen(this.options.webhook.port, this.options.webhook.host, () => {
92
+ this.server = server;
93
+ resolve();
94
+ });
95
+ });
96
+ }
97
+ async handleRequest(req, res) {
98
+ if (req.method !== 'POST' || (req.url ?? '').split('?')[0] !== this.path) {
99
+ res.writeHead(404);
100
+ res.end();
101
+ return;
102
+ }
103
+ const raw = await readBody(req);
104
+ const signature = req.headers['x-graygate-signature'];
105
+ if (!(0, signature_1.verifyWebhookSignature)(this.secret, Array.isArray(signature) ? signature[0] : signature, raw)) {
106
+ // A request with a bad signature is not even parsed.
107
+ res.writeHead(401);
108
+ res.end();
109
+ return;
110
+ }
111
+ const parsed = wire_1.botWebhookBody.safeParse(JSON.parse(raw));
112
+ if (!parsed.success) {
113
+ res.writeHead(400);
114
+ res.end();
115
+ return;
116
+ }
117
+ /**
118
+ * 2xx goes back only once every handler has finished without throwing. A 2xx **is** the
119
+ * acknowledgement: answer it after a failure and the message is dropped from the server's queue
120
+ * for good. Anything else and the server retries after a backoff.
121
+ */
122
+ for (const item of parsed.data.items)
123
+ await this.handleItem(item);
124
+ res.writeHead(200);
125
+ res.end();
126
+ }
127
+ async handleItem(item) {
128
+ switch (item.type) {
129
+ case 'envelope.new': {
130
+ const privateKey = this.state.readPrivateKey();
131
+ if (!privateKey)
132
+ return;
133
+ const roomKey = await (0, wire_2.unwrapRoomKey)(item.payload.ciphertext, privateKey);
134
+ this.state.putRoomKey(item.payload.roomId, item.payload.keyVersion, roomKey);
135
+ if (item.payload.purpose === 'invite')
136
+ await this.onInvite(item.payload.roomId);
137
+ return;
138
+ }
139
+ case 'msg.new': {
140
+ // At-least-once delivery: the same item can arrive twice.
141
+ if (this.state.markSeen(item.payload.id))
142
+ return;
143
+ const roomKey = this.state.roomKey(item.payload.roomId, item.payload.keyVersion);
144
+ // No key, no way to open it. **Acknowledge anyway** — refusing would replay it for 14 days.
145
+ if (!roomKey)
146
+ return;
147
+ let body;
148
+ try {
149
+ body = await (0, wire_2.decryptMessage)(roomKey, item.payload.ciphertext);
150
+ }
151
+ catch {
152
+ return; // Integrity check failed — dropped quietly, exactly as a phone would.
153
+ }
154
+ const parsed = wire_1.messageBodySchema.safeParse(body);
155
+ if (!parsed.success)
156
+ return;
157
+ await this.dispatchMessage(item.payload.roomId, item.payload.id, item.payload.senderId, parsed.data);
158
+ return;
159
+ }
160
+ case 'member.joined': {
161
+ for (const handler of this.joinHandlers) {
162
+ await handler({
163
+ roomId: item.payload.roomId,
164
+ userId: item.payload.userId,
165
+ reply: (text) => this.send(item.payload.roomId, text),
166
+ });
167
+ }
168
+ return;
169
+ }
170
+ case 'room.deleted': {
171
+ this.state.forgetRoom(item.payload.roomId);
172
+ return;
173
+ }
174
+ case 'member.left':
175
+ case 'member.key_changed':
176
+ return;
177
+ }
178
+ }
179
+ async dispatchMessage(roomId, messageId, senderId, body) {
180
+ const text = body.t === 'text' ? body.text : null;
181
+ const ctx = {
182
+ roomId,
183
+ messageId,
184
+ senderId,
185
+ body,
186
+ text,
187
+ reply: (value) => this.send(roomId, value),
188
+ };
189
+ if (text?.startsWith('/')) {
190
+ const [head, ...rest] = text.slice(1).split(/\s+/);
191
+ // With more than one bot in a room people write `/start@some_bot`; ignore other bots' commands.
192
+ const [name, target] = (head ?? '').split('@');
193
+ if (!target || target === this.username) {
194
+ const handler = this.commandHandlers.get(name ?? '');
195
+ if (handler) {
196
+ await handler({ ...ctx, command: name ?? '', args: rest.join(' ') });
197
+ return;
198
+ }
199
+ }
200
+ }
201
+ for (const handler of this.messageHandlers)
202
+ await handler(ctx);
203
+ }
204
+ async onInvite(roomId) {
205
+ const invites = (await this.api.callList('GET', '/invites'));
206
+ const invite = invites.find((i) => i.roomId === roomId);
207
+ if (!invite)
208
+ return;
209
+ const ctx = {
210
+ inviteId: invite.id,
211
+ roomId,
212
+ accept: async () => {
213
+ await this.api.call('POST', `/invites/${invite.id}/accept`, {});
214
+ },
215
+ decline: async () => {
216
+ await this.api.call('POST', `/invites/${invite.id}/decline`, {});
217
+ },
218
+ };
219
+ if (this.inviteHandlers.length === 0) {
220
+ if (this.options.autoAcceptInvites !== false)
221
+ await ctx.accept();
222
+ return;
223
+ }
224
+ for (const handler of this.inviteHandlers)
225
+ await handler(ctx);
226
+ }
227
+ /** Sends text. Does nothing when there is no room key — not invited yet, or removed. */
228
+ async send(roomId, text) {
229
+ const key = this.state.latestRoomKey(roomId);
230
+ if (!key)
231
+ return;
232
+ // The length limit is enforced here before sending, the same place a phone enforces it.
233
+ const trimmed = text.slice(0, 8000);
234
+ const ciphertext = await (0, wire_2.encryptMessage)(key.roomKey, { t: 'text', text: trimmed });
235
+ try {
236
+ await this.api.call('POST', '/bot/messages', {
237
+ id: (0, node_crypto_1.randomUUID)(),
238
+ roomId,
239
+ keyVersion: key.keyVersion,
240
+ kind: 'text',
241
+ ciphertext,
242
+ });
243
+ }
244
+ catch (err) {
245
+ // The room key moved on; the next envelope arrives by webhook and the next send works.
246
+ if (err instanceof api_1.BotApiError && (err.code === 'stale_key' || err.code === 'not_member'))
247
+ return;
248
+ throw err;
249
+ }
250
+ }
251
+ /** Comments on a post — channels only; carries the parent post's id. */
252
+ async replyToPost(roomId, parentId, text) {
253
+ const key = this.state.latestRoomKey(roomId);
254
+ if (!key)
255
+ return;
256
+ const ciphertext = await (0, wire_2.encryptMessage)(key.roomKey, {
257
+ t: 'text',
258
+ text: text.slice(0, wire_1.COMMENT_TEXT_MAX),
259
+ parentId,
260
+ });
261
+ await this.api.call('POST', '/bot/messages', {
262
+ id: (0, node_crypto_1.randomUUID)(),
263
+ roomId,
264
+ keyVersion: key.keyVersion,
265
+ kind: 'text',
266
+ ciphertext,
267
+ });
268
+ }
269
+ }
270
+ exports.Bot = Bot;
271
+ function readBody(req) {
272
+ return new Promise((resolve, reject) => {
273
+ let data = '';
274
+ let size = 0;
275
+ req.on('data', (chunk) => {
276
+ size += chunk.length;
277
+ // A body far past the batch limit did not come from the graygate server.
278
+ if (size > 8 * 1024 * 1024) {
279
+ req.destroy();
280
+ reject(new Error('body too large'));
281
+ return;
282
+ }
283
+ data += chunk.toString('utf8');
284
+ });
285
+ req.on('end', () => resolve(data));
286
+ req.on('error', reject);
287
+ });
288
+ }