@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 +21 -0
- package/README.md +131 -0
- package/dist/api.d.ts +31 -0
- package/dist/api.js +105 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.js +288 -0
- package/dist/signature.d.ts +18 -0
- package/dist/signature.js +47 -0
- package/dist/state.d.ts +21 -0
- package/dist/state.js +85 -0
- package/dist/wire/body.d.ts +124 -0
- package/dist/wire/body.js +134 -0
- package/dist/wire/constants.d.ts +28 -0
- package/dist/wire/constants.js +31 -0
- package/dist/wire/crypto.d.ts +40 -0
- package/dist/wire/crypto.js +125 -0
- package/dist/wire/events.d.ts +75 -0
- package/dist/wire/events.js +28 -0
- package/dist/wire/index.d.ts +9 -0
- package/dist/wire/index.js +25 -0
- package/dist/wire/webhook.d.ts +569 -0
- package/dist/wire/webhook.js +33 -0
- package/package.json +55 -0
- package/src/api.ts +111 -0
- package/src/index.ts +369 -0
- package/src/signature.test.ts +37 -0
- package/src/signature.ts +51 -0
- package/src/state.ts +104 -0
- package/src/wire/body.ts +140 -0
- package/src/wire/constants.ts +35 -0
- package/src/wire/crypto.ts +132 -0
- package/src/wire/events.ts +29 -0
- package/src/wire/index.ts +9 -0
- package/src/wire/webhook.ts +38 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Generated from protocol/src/rest.ts in the graygate monorepo — do not edit by hand.
|
|
4
|
+
* Only the declarations the bot SDK needs are here; the rest of that module stays private.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.botWebhookBody = exports.botWebhookItem = void 0;
|
|
8
|
+
const constants_1 = require("./constants");
|
|
9
|
+
const events_1 = require("./events");
|
|
10
|
+
const zod_1 = require("zod");
|
|
11
|
+
const uuid = zod_1.z.string().uuid();
|
|
12
|
+
/**
|
|
13
|
+
* The webhook body a bot receives. Each item has **the same shape** as the payload the WebSocket
|
|
14
|
+
* pushes to a phone.
|
|
15
|
+
*
|
|
16
|
+
* What leaves is ciphertext, so a webhook delivered to the wrong address leaks the metadata around
|
|
17
|
+
* a message — room id, sender id, timestamp — and not what was said.
|
|
18
|
+
*/
|
|
19
|
+
exports.botWebhookItem = zod_1.z.discriminatedUnion('type', [
|
|
20
|
+
zod_1.z.object({ type: zod_1.z.literal('msg.new'), payload: events_1.msgNewPayload }),
|
|
21
|
+
zod_1.z.object({ type: zod_1.z.literal('envelope.new'), payload: events_1.envelopeNewPayload }),
|
|
22
|
+
zod_1.z.object({ type: zod_1.z.literal('member.joined'), payload: zod_1.z.object({ roomId: uuid, userId: uuid }) }),
|
|
23
|
+
zod_1.z.object({ type: zod_1.z.literal('member.left'), payload: zod_1.z.object({ roomId: uuid, userId: uuid }) }),
|
|
24
|
+
zod_1.z.object({
|
|
25
|
+
type: zod_1.z.literal('member.key_changed'),
|
|
26
|
+
payload: zod_1.z.object({ roomId: uuid, userId: uuid, keyUpdatedAt: zod_1.z.string().datetime() }),
|
|
27
|
+
}),
|
|
28
|
+
zod_1.z.object({ type: zod_1.z.literal('room.deleted'), payload: zod_1.z.object({ roomId: uuid }) }),
|
|
29
|
+
]);
|
|
30
|
+
exports.botWebhookBody = zod_1.z.object({
|
|
31
|
+
botId: uuid,
|
|
32
|
+
items: zod_1.z.array(exports.botWebhookItem).max(constants_1.BOT_WEBHOOK_BATCH_MAX),
|
|
33
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@graygate/bot-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Runtime SDK for graygate bots — webhook receiver, signature verification, end-to-end encryption.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/graygate/bot-sdk.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://graygate.app/bots/",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/graygate/bot-sdk/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"graygate",
|
|
16
|
+
"bot",
|
|
17
|
+
"sdk",
|
|
18
|
+
"webhook",
|
|
19
|
+
"end-to-end-encryption",
|
|
20
|
+
"messaging"
|
|
21
|
+
],
|
|
22
|
+
"main": "dist/index.js",
|
|
23
|
+
"types": "dist/index.d.ts",
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.json",
|
|
38
|
+
"prepare": "tsc -p tsconfig.json",
|
|
39
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
40
|
+
"test": "vitest run"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"crypto-js": "^4.2.0",
|
|
44
|
+
"graygate-cypher": "^1.0.4",
|
|
45
|
+
"node-forge": "^1.3.1",
|
|
46
|
+
"zod": "^3.25.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/crypto-js": "^4.2.2",
|
|
50
|
+
"@types/node": "^22.0.0",
|
|
51
|
+
"@types/node-forge": "^1.3.11",
|
|
52
|
+
"typescript": "^5.8.0",
|
|
53
|
+
"vitest": "^3.2.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { BOT_CLIENT_TOKEN } from './wire';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The REST client between a bot runtime and the graygate server.
|
|
5
|
+
*
|
|
6
|
+
* Access tokens last fifteen minutes and are kept **in memory only**; when one is close to expiring
|
|
7
|
+
* it is traded for another using the bot token. There is no refresh token because the bot token is
|
|
8
|
+
* itself the long-lived credential. That token never leaves this object — not into a log, not into
|
|
9
|
+
* the state directory.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class BotApiError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
public readonly status: number,
|
|
15
|
+
public readonly code: string,
|
|
16
|
+
message?: string,
|
|
17
|
+
) {
|
|
18
|
+
super(message ?? code);
|
|
19
|
+
this.name = 'BotApiError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Renew this long before expiry (ms), so a token cannot die between the check and the request. */
|
|
24
|
+
const REFRESH_MARGIN_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
export class BotApi {
|
|
27
|
+
private accessToken: string | null = null;
|
|
28
|
+
private expiresAt = 0;
|
|
29
|
+
private botId: string | null = null;
|
|
30
|
+
private userId: string | null = null;
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
private readonly origin: string,
|
|
34
|
+
private readonly token: string,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
get ids(): { botId: string | null; userId: string | null } {
|
|
38
|
+
return { botId: this.botId, userId: this.userId };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private async ensureAuth(): Promise<string> {
|
|
42
|
+
if (this.accessToken && Date.now() < this.expiresAt - REFRESH_MARGIN_MS) return this.accessToken;
|
|
43
|
+
const res = await this.raw('POST', '/bot/auth', { token: this.token }, null);
|
|
44
|
+
this.accessToken = res.accessToken as string;
|
|
45
|
+
this.botId = res.botId as string;
|
|
46
|
+
this.userId = (res.userId as string | null) ?? 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
|
+
|
|
52
|
+
private async raw(
|
|
53
|
+
method: string,
|
|
54
|
+
path: string,
|
|
55
|
+
body: unknown,
|
|
56
|
+
access: string | null,
|
|
57
|
+
): Promise<Record<string, unknown>> {
|
|
58
|
+
const res = await fetch(`${this.origin}${path}`, {
|
|
59
|
+
method,
|
|
60
|
+
headers: {
|
|
61
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
62
|
+
// Client header for the edge in front of the server. Not a secret; see BOT_CLIENT_TOKEN.
|
|
63
|
+
'x-graygate-client': BOT_CLIENT_TOKEN,
|
|
64
|
+
...(access ? { authorization: `Bearer ${access}` } : {}),
|
|
65
|
+
},
|
|
66
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
67
|
+
});
|
|
68
|
+
const text = await res.text();
|
|
69
|
+
const json = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
const err = (json.error ?? {}) as { code?: string; message?: string };
|
|
72
|
+
throw new BotApiError(res.status, err.code ?? 'error', err.message);
|
|
73
|
+
}
|
|
74
|
+
return json;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async call(method: string, path: string, body?: unknown): Promise<Record<string, unknown>> {
|
|
78
|
+
const access = await this.ensureAuth();
|
|
79
|
+
try {
|
|
80
|
+
return await this.raw(method, path, body, access);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// The access token expired early — trade for another one and retry, once.
|
|
83
|
+
if (err instanceof BotApiError && err.status === 401) {
|
|
84
|
+
this.accessToken = null;
|
|
85
|
+
return this.raw(method, path, body, await this.ensureAuth());
|
|
86
|
+
}
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** For endpoints that answer with an array (`GET /invites`, member lists). */
|
|
92
|
+
async callList(method: string, path: string, body?: unknown): Promise<unknown[]> {
|
|
93
|
+
const access = await this.ensureAuth();
|
|
94
|
+
const res = await fetch(`${this.origin}${path}`, {
|
|
95
|
+
method,
|
|
96
|
+
headers: {
|
|
97
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
98
|
+
'x-graygate-client': BOT_CLIENT_TOKEN,
|
|
99
|
+
authorization: `Bearer ${access}`,
|
|
100
|
+
},
|
|
101
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
102
|
+
});
|
|
103
|
+
const text = await res.text();
|
|
104
|
+
const json = text ? JSON.parse(text) : [];
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
const err = (json.error ?? {}) as { code?: string; message?: string };
|
|
107
|
+
throw new BotApiError(res.status, err.code ?? 'error', err.message);
|
|
108
|
+
}
|
|
109
|
+
return Array.isArray(json) ? json : [];
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
COMMENT_TEXT_MAX,
|
|
5
|
+
botWebhookBody,
|
|
6
|
+
messageBodySchema,
|
|
7
|
+
type BotWebhookItem,
|
|
8
|
+
type MessageBody,
|
|
9
|
+
} from './wire';
|
|
10
|
+
import {
|
|
11
|
+
decryptMessage,
|
|
12
|
+
encryptMessage,
|
|
13
|
+
generateRsaKeyPair,
|
|
14
|
+
unwrapRoomKey,
|
|
15
|
+
type EncryptedMessage,
|
|
16
|
+
} from './wire';
|
|
17
|
+
import { BotApi, BotApiError } from './api';
|
|
18
|
+
import { BotState } from './state';
|
|
19
|
+
import { tokenSecret, verifyWebhookSignature } from './signature';
|
|
20
|
+
|
|
21
|
+
export { BotApiError } from './api';
|
|
22
|
+
export { verifyWebhookSignature, webhookSigningKey } from './signature';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The graygate bot runtime.
|
|
26
|
+
*
|
|
27
|
+
* Four things happen here: it authenticates with the bot token and, on a first run, generates a key
|
|
28
|
+
* pair and activates the account; it receives webhooks, **verifies the signature before anything
|
|
29
|
+
* else**, and consumes envelopes to learn room keys; it decrypts messages and hands them to your
|
|
30
|
+
* handlers; and it encrypts replies and posts them back.
|
|
31
|
+
*
|
|
32
|
+
* Every cryptographic operation goes through the wire module — no hand-rolled crypto here.
|
|
33
|
+
*
|
|
34
|
+
* **Media handed to a handler is unverified.** This runtime is not a renderer, and what you do with
|
|
35
|
+
* those bytes is yours to decide: do not pass them straight to an image decoder or a file path.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export interface BotOptions {
|
|
39
|
+
/** `gg1.<botId>.<secret>`. Read it from the environment; never write it to a file. */
|
|
40
|
+
token: string;
|
|
41
|
+
/** Server origin. Bots do not use the app's fallback-domain logic — you choose the address. */
|
|
42
|
+
origin?: string;
|
|
43
|
+
/** Where the private key, the room keys and the handled ids live. **Back this up.** */
|
|
44
|
+
stateDir: string;
|
|
45
|
+
webhook: {
|
|
46
|
+
port: number;
|
|
47
|
+
/** Defaults to `/hook`. */
|
|
48
|
+
path?: string;
|
|
49
|
+
/** The public https address to register. Private and loopback addresses are refused. */
|
|
50
|
+
publicUrl: string;
|
|
51
|
+
/** Bind to one interface only — `127.0.0.1` when a reverse proxy sits in front. */
|
|
52
|
+
host?: string;
|
|
53
|
+
};
|
|
54
|
+
/** Defaults to true; set false to decide inside `bot.on('invite')`. */
|
|
55
|
+
autoAcceptInvites?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MessageContext {
|
|
59
|
+
roomId: string;
|
|
60
|
+
messageId: string;
|
|
61
|
+
senderId: string;
|
|
62
|
+
body: MessageBody;
|
|
63
|
+
/** The text, when `body.t === 'text'`. */
|
|
64
|
+
text: string | null;
|
|
65
|
+
reply(text: string): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface JoinContext {
|
|
69
|
+
roomId: string;
|
|
70
|
+
userId: string;
|
|
71
|
+
reply(text: string): Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface InviteContext {
|
|
75
|
+
inviteId: string;
|
|
76
|
+
roomId: string;
|
|
77
|
+
accept(): Promise<void>;
|
|
78
|
+
decline(): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CommandContext extends MessageContext {
|
|
82
|
+
command: string;
|
|
83
|
+
args: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type MessageHandler = (ctx: MessageContext) => void | Promise<void>;
|
|
87
|
+
type JoinHandler = (ctx: JoinContext) => void | Promise<void>;
|
|
88
|
+
type InviteHandler = (ctx: InviteContext) => void | Promise<void>;
|
|
89
|
+
type CommandHandler = (ctx: CommandContext) => void | Promise<void>;
|
|
90
|
+
|
|
91
|
+
export class Bot {
|
|
92
|
+
private readonly api: BotApi;
|
|
93
|
+
private readonly state: BotState;
|
|
94
|
+
private readonly secret: string;
|
|
95
|
+
private readonly path: string;
|
|
96
|
+
private server: Server | null = null;
|
|
97
|
+
private username = '';
|
|
98
|
+
|
|
99
|
+
private messageHandlers: MessageHandler[] = [];
|
|
100
|
+
private joinHandlers: JoinHandler[] = [];
|
|
101
|
+
private inviteHandlers: InviteHandler[] = [];
|
|
102
|
+
private commandHandlers = new Map<string, CommandHandler>();
|
|
103
|
+
|
|
104
|
+
constructor(private readonly options: BotOptions) {
|
|
105
|
+
this.api = new BotApi(options.origin ?? 'https://server.graygate.app', options.token);
|
|
106
|
+
this.state = new BotState(options.stateDir);
|
|
107
|
+
this.secret = tokenSecret(options.token);
|
|
108
|
+
this.path = options.webhook.path ?? '/hook';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
on(event: 'message', handler: MessageHandler): void;
|
|
112
|
+
on(event: 'join', handler: JoinHandler): void;
|
|
113
|
+
on(event: 'invite', handler: InviteHandler): void;
|
|
114
|
+
on(event: 'message' | 'join' | 'invite', handler: MessageHandler | JoinHandler | InviteHandler): void {
|
|
115
|
+
if (event === 'message') this.messageHandlers.push(handler as MessageHandler);
|
|
116
|
+
else if (event === 'join') this.joinHandlers.push(handler as JoinHandler);
|
|
117
|
+
else this.inviteHandlers.push(handler as InviteHandler);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A command such as `/start`. Names are ASCII lowercase, digits and underscores. */
|
|
121
|
+
command(name: string, handler: CommandHandler): void {
|
|
122
|
+
this.commandHandlers.set(name, handler);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Starts the bot: activates it (generating a key pair on a first run), serves the webhook, and
|
|
127
|
+
* registers it. **Registering is also the request for a backfill** — there is no polling, so this
|
|
128
|
+
* is when everything that piled up while the bot was down comes in.
|
|
129
|
+
*/
|
|
130
|
+
async start(): Promise<void> {
|
|
131
|
+
await this.ensureActivated();
|
|
132
|
+
await this.listen();
|
|
133
|
+
await this.api.call('PUT', '/bot/webhook', { url: this.options.webhook.publicUrl });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async stop(): Promise<void> {
|
|
137
|
+
const server = this.server;
|
|
138
|
+
this.server = null;
|
|
139
|
+
if (!server) return;
|
|
140
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async ensureActivated(): Promise<void> {
|
|
144
|
+
const me = await this.api.call('POST', '/bot/auth', undefined).catch(() => null);
|
|
145
|
+
void me; // The client handles authentication; all this decides is whether we are active.
|
|
146
|
+
const profile = await this.api.call('GET', '/bot/me');
|
|
147
|
+
this.username = String(profile.username ?? '');
|
|
148
|
+
if (profile.active === true) {
|
|
149
|
+
if (!this.state.readPrivateKey()) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
'This bot is active but the state directory has no private key, so its old room keys are ' +
|
|
152
|
+
'gone. Replace the key with PUT /bot/public-key and ask each room admin to invite it again.',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const pair = await generateRsaKeyPair();
|
|
158
|
+
this.state.writePrivateKey(pair.privateKeyPem);
|
|
159
|
+
await this.api.call('POST', '/bot/activate', { rsaPublicKey: pair.publicKeyPem });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private listen(): Promise<void> {
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const server = createServer((req, res) => {
|
|
165
|
+
void this.handleRequest(req, res).catch(() => {
|
|
166
|
+
// A throwing handler answers non-2xx, and the server sends the batch again.
|
|
167
|
+
if (!res.headersSent) res.writeHead(500);
|
|
168
|
+
res.end();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
server.on('error', reject);
|
|
172
|
+
server.listen(this.options.webhook.port, this.options.webhook.host, () => {
|
|
173
|
+
this.server = server;
|
|
174
|
+
resolve();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
180
|
+
if (req.method !== 'POST' || (req.url ?? '').split('?')[0] !== this.path) {
|
|
181
|
+
res.writeHead(404);
|
|
182
|
+
res.end();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const raw = await readBody(req);
|
|
186
|
+
const signature = req.headers['x-graygate-signature'];
|
|
187
|
+
if (!verifyWebhookSignature(this.secret, Array.isArray(signature) ? signature[0] : signature, raw)) {
|
|
188
|
+
// A request with a bad signature is not even parsed.
|
|
189
|
+
res.writeHead(401);
|
|
190
|
+
res.end();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const parsed = botWebhookBody.safeParse(JSON.parse(raw));
|
|
195
|
+
if (!parsed.success) {
|
|
196
|
+
res.writeHead(400);
|
|
197
|
+
res.end();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 2xx goes back only once every handler has finished without throwing. A 2xx **is** the
|
|
203
|
+
* acknowledgement: answer it after a failure and the message is dropped from the server's queue
|
|
204
|
+
* for good. Anything else and the server retries after a backoff.
|
|
205
|
+
*/
|
|
206
|
+
for (const item of parsed.data.items) await this.handleItem(item);
|
|
207
|
+
res.writeHead(200);
|
|
208
|
+
res.end();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async handleItem(item: BotWebhookItem): Promise<void> {
|
|
212
|
+
switch (item.type) {
|
|
213
|
+
case 'envelope.new': {
|
|
214
|
+
const privateKey = this.state.readPrivateKey();
|
|
215
|
+
if (!privateKey) return;
|
|
216
|
+
const roomKey = await unwrapRoomKey(item.payload.ciphertext, privateKey);
|
|
217
|
+
this.state.putRoomKey(item.payload.roomId, item.payload.keyVersion, roomKey);
|
|
218
|
+
if (item.payload.purpose === 'invite') await this.onInvite(item.payload.roomId);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
case 'msg.new': {
|
|
222
|
+
// At-least-once delivery: the same item can arrive twice.
|
|
223
|
+
if (this.state.markSeen(item.payload.id)) return;
|
|
224
|
+
const roomKey = this.state.roomKey(item.payload.roomId, item.payload.keyVersion);
|
|
225
|
+
// No key, no way to open it. **Acknowledge anyway** — refusing would replay it for 14 days.
|
|
226
|
+
if (!roomKey) return;
|
|
227
|
+
let body: unknown;
|
|
228
|
+
try {
|
|
229
|
+
body = await decryptMessage(roomKey, item.payload.ciphertext as unknown as EncryptedMessage);
|
|
230
|
+
} catch {
|
|
231
|
+
return; // Integrity check failed — dropped quietly, exactly as a phone would.
|
|
232
|
+
}
|
|
233
|
+
const parsed = messageBodySchema.safeParse(body);
|
|
234
|
+
if (!parsed.success) return;
|
|
235
|
+
await this.dispatchMessage(item.payload.roomId, item.payload.id, item.payload.senderId, parsed.data);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
case 'member.joined': {
|
|
239
|
+
for (const handler of this.joinHandlers) {
|
|
240
|
+
await handler({
|
|
241
|
+
roomId: item.payload.roomId,
|
|
242
|
+
userId: item.payload.userId,
|
|
243
|
+
reply: (text) => this.send(item.payload.roomId, text),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
case 'room.deleted': {
|
|
249
|
+
this.state.forgetRoom(item.payload.roomId);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
case 'member.left':
|
|
253
|
+
case 'member.key_changed':
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async dispatchMessage(
|
|
259
|
+
roomId: string,
|
|
260
|
+
messageId: string,
|
|
261
|
+
senderId: string,
|
|
262
|
+
body: MessageBody,
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
const text = body.t === 'text' ? body.text : null;
|
|
265
|
+
const ctx: MessageContext = {
|
|
266
|
+
roomId,
|
|
267
|
+
messageId,
|
|
268
|
+
senderId,
|
|
269
|
+
body,
|
|
270
|
+
text,
|
|
271
|
+
reply: (value) => this.send(roomId, value),
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
if (text?.startsWith('/')) {
|
|
275
|
+
const [head, ...rest] = text.slice(1).split(/\s+/);
|
|
276
|
+
// With more than one bot in a room people write `/start@some_bot`; ignore other bots' commands.
|
|
277
|
+
const [name, target] = (head ?? '').split('@');
|
|
278
|
+
if (!target || target === this.username) {
|
|
279
|
+
const handler = this.commandHandlers.get(name ?? '');
|
|
280
|
+
if (handler) {
|
|
281
|
+
await handler({ ...ctx, command: name ?? '', args: rest.join(' ') });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const handler of this.messageHandlers) await handler(ctx);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async onInvite(roomId: string): Promise<void> {
|
|
291
|
+
const invites = (await this.api.callList('GET', '/invites')) as { id: string; roomId: string }[];
|
|
292
|
+
const invite = invites.find((i) => i.roomId === roomId);
|
|
293
|
+
if (!invite) return;
|
|
294
|
+
const ctx: InviteContext = {
|
|
295
|
+
inviteId: invite.id,
|
|
296
|
+
roomId,
|
|
297
|
+
accept: async () => {
|
|
298
|
+
await this.api.call('POST', `/invites/${invite.id}/accept`, {});
|
|
299
|
+
},
|
|
300
|
+
decline: async () => {
|
|
301
|
+
await this.api.call('POST', `/invites/${invite.id}/decline`, {});
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
if (this.inviteHandlers.length === 0) {
|
|
305
|
+
if (this.options.autoAcceptInvites !== false) await ctx.accept();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
for (const handler of this.inviteHandlers) await handler(ctx);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Sends text. Does nothing when there is no room key — not invited yet, or removed. */
|
|
312
|
+
async send(roomId: string, text: string): Promise<void> {
|
|
313
|
+
const key = this.state.latestRoomKey(roomId);
|
|
314
|
+
if (!key) return;
|
|
315
|
+
// The length limit is enforced here before sending, the same place a phone enforces it.
|
|
316
|
+
const trimmed = text.slice(0, 8000);
|
|
317
|
+
const ciphertext = await encryptMessage(key.roomKey, { t: 'text', text: trimmed } satisfies MessageBody);
|
|
318
|
+
try {
|
|
319
|
+
await this.api.call('POST', '/bot/messages', {
|
|
320
|
+
id: randomUUID(),
|
|
321
|
+
roomId,
|
|
322
|
+
keyVersion: key.keyVersion,
|
|
323
|
+
kind: 'text',
|
|
324
|
+
ciphertext,
|
|
325
|
+
});
|
|
326
|
+
} catch (err) {
|
|
327
|
+
// The room key moved on; the next envelope arrives by webhook and the next send works.
|
|
328
|
+
if (err instanceof BotApiError && (err.code === 'stale_key' || err.code === 'not_member')) return;
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Comments on a post — channels only; carries the parent post's id. */
|
|
334
|
+
async replyToPost(roomId: string, parentId: string, text: string): Promise<void> {
|
|
335
|
+
const key = this.state.latestRoomKey(roomId);
|
|
336
|
+
if (!key) return;
|
|
337
|
+
const ciphertext = await encryptMessage(key.roomKey, {
|
|
338
|
+
t: 'text',
|
|
339
|
+
text: text.slice(0, COMMENT_TEXT_MAX),
|
|
340
|
+
parentId,
|
|
341
|
+
} satisfies MessageBody);
|
|
342
|
+
await this.api.call('POST', '/bot/messages', {
|
|
343
|
+
id: randomUUID(),
|
|
344
|
+
roomId,
|
|
345
|
+
keyVersion: key.keyVersion,
|
|
346
|
+
kind: 'text',
|
|
347
|
+
ciphertext,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
353
|
+
return new Promise((resolve, reject) => {
|
|
354
|
+
let data = '';
|
|
355
|
+
let size = 0;
|
|
356
|
+
req.on('data', (chunk: Buffer) => {
|
|
357
|
+
size += chunk.length;
|
|
358
|
+
// A body far past the batch limit did not come from the graygate server.
|
|
359
|
+
if (size > 8 * 1024 * 1024) {
|
|
360
|
+
req.destroy();
|
|
361
|
+
reject(new Error('body too large'));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
data += chunk.toString('utf8');
|
|
365
|
+
});
|
|
366
|
+
req.on('end', () => resolve(data));
|
|
367
|
+
req.on('error', reject);
|
|
368
|
+
});
|
|
369
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signature only works if **the server and the bot runtime compute the same value.**
|
|
3
|
+
*
|
|
4
|
+
* The two implementations deliberately do not import each other: the server pulling in this package
|
|
5
|
+
* would blur the line that keeps it a relay and nothing more. Instead both sides pin **the same
|
|
6
|
+
* fixed vector**, so changing the algorithm breaks both tests at once.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { verifyWebhookSignature, webhookSigningKey } from './signature';
|
|
10
|
+
import { createHmac } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
const SECRET = 'test-secret';
|
|
13
|
+
const T = 1_700_000_000;
|
|
14
|
+
const BODY = '{"botId":"b","items":[]}';
|
|
15
|
+
/** The same value the server's own signature test pins. */
|
|
16
|
+
const VECTOR = '97e0c9ab16c9698d7d8b62875e857cefef01def27a5d95e38fdecda714318a52';
|
|
17
|
+
|
|
18
|
+
describe('webhook signature', () => {
|
|
19
|
+
it('matches the fixed vector, so both implementations agree', () => {
|
|
20
|
+
const mac = createHmac('sha256', webhookSigningKey(SECRET)).update(`${T}.${BODY}`).digest('hex');
|
|
21
|
+
expect(mac).toBe(VECTOR);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('accepts only a correct signature', () => {
|
|
25
|
+
expect(verifyWebhookSignature(SECRET, `t=${T},v1=${VECTOR}`, BODY, T)).toBe(true);
|
|
26
|
+
expect(verifyWebhookSignature(SECRET, `t=${T},v1=${VECTOR}`, `${BODY} `, T)).toBe(false);
|
|
27
|
+
expect(verifyWebhookSignature('other-secret', `t=${T},v1=${VECTOR}`, BODY, T)).toBe(false);
|
|
28
|
+
expect(verifyWebhookSignature(SECRET, undefined, BODY, T)).toBe(false);
|
|
29
|
+
expect(verifyWebhookSignature(SECRET, 'garbage', BODY, T)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('treats a timestamp outside the tolerance as a replay', () => {
|
|
33
|
+
expect(verifyWebhookSignature(SECRET, `t=${T},v1=${VECTOR}`, BODY, T + 299)).toBe(true);
|
|
34
|
+
expect(verifyWebhookSignature(SECRET, `t=${T},v1=${VECTOR}`, BODY, T + 301)).toBe(false);
|
|
35
|
+
expect(verifyWebhookSignature(SECRET, `t=${T},v1=${VECTOR}`, BODY, T - 301)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/signature.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash, createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { BOT_WEBHOOK_SIGNATURE_TOLERANCE_S } from './wire';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Webhook signature verification — **the same computation the server performs** when it signs.
|
|
6
|
+
*
|
|
7
|
+
* The key is derived with HKDF from the sha256 of the token's secret part. The server stores only
|
|
8
|
+
* that hash and never the secret itself, so the hash is the one value both sides can arrive at.
|
|
9
|
+
*
|
|
10
|
+
* A request that fails verification is **discarded without being read.** A webhook address is a
|
|
11
|
+
* public HTTPS endpoint that anyone can POST to; without the check, a stranger's "message" would
|
|
12
|
+
* walk straight into the bot's handlers.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function webhookSigningKey(tokenSecret: string): Buffer {
|
|
16
|
+
const tokenHash = createHash('sha256').update(tokenSecret).digest('hex');
|
|
17
|
+
return Buffer.from(hkdfSync('sha256', Buffer.from(tokenHash, 'hex'), Buffer.alloc(0), 'graygate:bot-webhook-v1', 32));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* True when the header reads `t=<unix seconds>,v1=<hex>` and the HMAC matches this body. A
|
|
22
|
+
* timestamp outside the tolerance is treated as a replay.
|
|
23
|
+
*/
|
|
24
|
+
export function verifyWebhookSignature(
|
|
25
|
+
tokenSecret: string,
|
|
26
|
+
header: string | undefined,
|
|
27
|
+
rawBody: string,
|
|
28
|
+
nowS = Math.floor(Date.now() / 1000),
|
|
29
|
+
): boolean {
|
|
30
|
+
if (!header) return false;
|
|
31
|
+
const parts = Object.fromEntries(
|
|
32
|
+
header.split(',').map((piece) => {
|
|
33
|
+
const [k, v] = piece.split('=');
|
|
34
|
+
return [k?.trim() ?? '', v?.trim() ?? ''];
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
const t = Number(parts.t);
|
|
38
|
+
const v1 = parts.v1;
|
|
39
|
+
if (!Number.isFinite(t) || !v1) return false;
|
|
40
|
+
if (Math.abs(nowS - t) > BOT_WEBHOOK_SIGNATURE_TOLERANCE_S) return false;
|
|
41
|
+
|
|
42
|
+
const expected = createHmac('sha256', webhookSigningKey(tokenSecret)).update(`${t}.${rawBody}`).digest();
|
|
43
|
+
const given = Buffer.from(v1, 'hex');
|
|
44
|
+
return given.length === expected.length && timingSafeEqual(given, expected);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The secret part of a `gg1.<botId>.<secret>` token — the material the signing key comes from. */
|
|
48
|
+
export function tokenSecret(token: string): string {
|
|
49
|
+
const parts = token.split('.');
|
|
50
|
+
return parts.length === 3 ? parts[2]! : '';
|
|
51
|
+
}
|