@dsbot-channel/webhook 0.0.0-stage → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zerodegress
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.
@@ -0,0 +1,17 @@
1
+ ### webhook transport
2
+
3
+ | Field | Default | Meaning |
4
+ | --- | --- | --- |
5
+ | `listen.host` / `listen.port` / `listen.path` | `127.0.0.1` / `8463` / `/github/webhook` | where the calls arrive |
6
+ | `secret` | required | credential reference: the HMAC-SHA256 key of the raw body |
7
+ | `token` | required | credential reference used to post a comment |
8
+ | `dialect` | `auto` | `github`, `forgejo`, or `auto` to read it from the request headers; a request naming the other dialect gets 400 |
9
+ | `api` | `https://api.github.com` | base URL of the comment api |
10
+ | `login` | required | the bot's own login, used for `@` mentions |
11
+ | `bots` | `[]` | logins counted as bot speech; their events are answered without a mention check unless `policy.groupBots` says otherwise |
12
+ | `repos` | `[]` | optional `owner/repo` allow list |
13
+
14
+ An issue comment, a review comment, or a pull request review that carries a body runs a turn and the answer is posted as a comment on the issue. The turn text starts with `[github]` or `[forgejo]`, the repository and number, the author with `(person)` or `(bot)`, `event/action` and a link, then the comment itself; the text a policy matches on is still the comment. Events that are not comments, such as `issues`, `pull_request`, `push`, `release` and CI runs, are `action` events with a one line summary; they start no turn unless `events.turn` names the class (`action.push`, `action.ci`, …). Every `action` text starts with the dialect the request was signed with, `[github]` or `[forgejo]`, ahead of that summary. A `workflow_run` names the workflow, its state, the branch, the pull request and a link; a `check_run`, a `check_suite` and a commit `status` name their own name or context, state, commit or branch, and link. A failed signature is a 401 and never reaches the gateway, and a request that verifies but names no event header is a 400; a repeated delivery runs once.
15
+
16
+ `@dsbot-channel/webhook` supplies this transport: mount it after `@dsbot-channel/core`, then write these fields under `transport` in this plugin's own `config`, beside `channel` and `botName`; `kind` is not written. Each entry is one instance, so the same package mounts twice with two different `channel` values. Without `@dsbot-channel/core` the plugin stays pending on `dsbot-channel.registry`.
17
+
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsbot-channel-webhook
3
+ name: '@dsbot-channel/webhook'
@@ -0,0 +1,67 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import type { AdapterStatus, CapabilitySet } from '@dsbot-channel/core/adapter-status.js';
3
+ import type { AdapterHandle, AdapterPorts, ChannelAdapter, SendInput, SendResult } from '@dsbot-channel/core/contract.js';
4
+ import type { WebhookDialect } from './mapping.js';
5
+ export declare const DEFAULT_WEBHOOK_HOST = "127.0.0.1";
6
+ export declare const DEFAULT_WEBHOOK_PORT = 8463;
7
+ export declare const DEFAULT_WEBHOOK_PATH = "/github/webhook";
8
+ export declare const MAX_WEBHOOK_BODY_BYTES: number;
9
+ export declare const SEEN_TTL_MS: number;
10
+ export declare const SEND_QUEUE_MAX = 8;
11
+ export interface WebhookConfig {
12
+ channel: string;
13
+ botName: string;
14
+ status: boolean;
15
+ listen: {
16
+ host: string;
17
+ port: number;
18
+ path: string;
19
+ };
20
+ secret: string;
21
+ token: string;
22
+ dialect: WebhookDialect;
23
+ api: string;
24
+ login: string;
25
+ bots: readonly string[];
26
+ repos: readonly string[];
27
+ }
28
+ export interface WebhookOptions {
29
+ warn?: ((message: string) => void) | undefined;
30
+ now?: (() => number) | undefined;
31
+ }
32
+ type SignatureKind = 'github' | 'forgejo';
33
+ /** The signature the request carries, whichever dialect names it. */
34
+ export declare function signatureOf(request: IncomingMessage): {
35
+ kind: SignatureKind;
36
+ value: string;
37
+ } | undefined;
38
+ export declare function verifySignature(secret: string, raw: Buffer, signature: string): boolean;
39
+ export interface DialectPick {
40
+ dialect: 'github' | 'forgejo';
41
+ fallback: boolean;
42
+ }
43
+ /** Which dialect the request speaks, from its headers; only an ambiguous signature falls back to the payload. */
44
+ export declare function pickDialect(request: IncomingMessage, configured: WebhookDialect, payload: unknown): DialectPick | 'mismatch' | 'unknown';
45
+ /** The comment body the GitHub API expects, kept to what this adapter promises to write. */
46
+ export declare function commentRequest(config: WebhookConfig, input: SendInput): {
47
+ url: string;
48
+ body: string;
49
+ } | undefined;
50
+ export declare class WebhookAdapter implements ChannelAdapter {
51
+ #private;
52
+ readonly kind = "webhook";
53
+ readonly capabilities: CapabilitySet;
54
+ readonly guidance: {
55
+ peer: string;
56
+ group: string;
57
+ };
58
+ constructor(config: WebhookConfig, options?: WebhookOptions);
59
+ checkConfig(): void;
60
+ checkSessionKey(key: string): string | undefined;
61
+ checkTarget(target: string): string | undefined;
62
+ describeTargets(): string;
63
+ status(): AdapterStatus;
64
+ start(ports: AdapterPorts): Promise<AdapterHandle>;
65
+ send(input: SendInput): Promise<SendResult>;
66
+ }
67
+ export {};
@@ -0,0 +1,463 @@
1
+ import { createServer } from 'node:http';
2
+ import { ConfigError } from '@dsbot-channel/core/config.js';
3
+ import { createHmac, timingSafeEqual } from 'node:crypto';
4
+ import { NO_MEDIA, buildListenerHealth, buildStatus } from '@dsbot-channel/core/adapter-status.js';
5
+ import { ChannelStats, countersOf } from '@dsbot-channel/core/channel-stats.js';
6
+ import { ADAPTER_META_MAX_BYTES } from '@dsbot-channel/core/adapter-meta.js';
7
+ import { classOf, fitAdapterMeta, mapWebhook } from './mapping.js';
8
+ export const DEFAULT_WEBHOOK_HOST = '127.0.0.1';
9
+ export const DEFAULT_WEBHOOK_PORT = 8463;
10
+ export const DEFAULT_WEBHOOK_PATH = '/github/webhook';
11
+ export const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
12
+ export const SEEN_TTL_MS = 10 * 60_000;
13
+ export const SEND_QUEUE_MAX = 8;
14
+ const CAPABILITIES = {
15
+ text: true,
16
+ image: NO_MEDIA,
17
+ file: NO_MEDIA,
18
+ voice: NO_MEDIA,
19
+ recall: false,
20
+ history: { group: false, private: false },
21
+ mention: true,
22
+ reply: false,
23
+ stream: false,
24
+ outbound: true,
25
+ };
26
+ function headerOf(request, name) {
27
+ const value = request.headers[name];
28
+ return Array.isArray(value) ? value[0] : value;
29
+ }
30
+ function hexToBytes(hex) {
31
+ if (!/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0)
32
+ return undefined;
33
+ return Buffer.from(hex, 'hex');
34
+ }
35
+ /** The signature the request carries, whichever dialect names it. */
36
+ export function signatureOf(request) {
37
+ const github = headerOf(request, 'x-hub-signature-256') ?? headerOf(request, 'x-hub-signature');
38
+ if (github !== undefined) {
39
+ return { kind: 'github', value: github.startsWith('sha256=') ? github.slice('sha256='.length) : github };
40
+ }
41
+ for (const name of ['x-forgejo-signature', 'x-gitea-signature']) {
42
+ const found = headerOf(request, name);
43
+ if (found !== undefined) {
44
+ return { kind: 'forgejo', value: found.startsWith('sha256=') ? found.slice('sha256='.length) : found };
45
+ }
46
+ }
47
+ return undefined;
48
+ }
49
+ function retryAfterMs(response) {
50
+ const after = response.headers.get('retry-after');
51
+ if (after !== null) {
52
+ const seconds = Number(after);
53
+ if (Number.isFinite(seconds) && seconds >= 0)
54
+ return Math.min(seconds * 1000, 30_000);
55
+ }
56
+ const remaining = response.headers.get('x-ratelimit-remaining');
57
+ return remaining === '0' ? 1000 : undefined;
58
+ }
59
+ export function verifySignature(secret, raw, signature) {
60
+ const expected = hexToBytes(signature);
61
+ if (expected === undefined)
62
+ return false;
63
+ const computed = createHmac('sha256', secret).update(raw).digest();
64
+ if (expected.length !== computed.length)
65
+ return false;
66
+ return timingSafeEqual(expected, computed);
67
+ }
68
+ /** Which dialect the request speaks, from its headers; only an ambiguous signature falls back to the payload. */
69
+ export function pickDialect(request, configured, payload) {
70
+ const githubEvent = headerOf(request, 'x-github-event');
71
+ const forgejoEvent = headerOf(request, 'x-forgejo-event') ?? headerOf(request, 'x-gitea-event');
72
+ const seen = signatureOf(request)?.kind;
73
+ const byHeader = githubEvent !== undefined ? 'github' : forgejoEvent !== undefined ? 'forgejo' : undefined;
74
+ if (configured !== 'auto') {
75
+ if (byHeader !== undefined && byHeader !== configured)
76
+ return 'mismatch';
77
+ if (seen !== undefined && seen !== configured)
78
+ return 'mismatch';
79
+ return { dialect: configured, fallback: false };
80
+ }
81
+ if (byHeader !== undefined)
82
+ return { dialect: byHeader, fallback: false };
83
+ if (seen === undefined)
84
+ return 'unknown';
85
+ if (payload !== null && typeof payload === 'object' && 'repository' in payload) {
86
+ return { dialect: 'github', fallback: true };
87
+ }
88
+ return 'unknown';
89
+ }
90
+ /** The comment body the GitHub API expects, kept to what this adapter promises to write. */
91
+ export function commentRequest(config, input) {
92
+ const matched = /^gh:([^/]+)\/([^#]+)#(\d+)$/.exec(input.target);
93
+ if (matched === null)
94
+ return undefined;
95
+ const [, owner, repo, number] = matched;
96
+ const base = config.api.replace(/\/+$/, '');
97
+ return {
98
+ url: `${base}/repos/${owner}/${repo}/issues/${number}/comments`,
99
+ body: input.body,
100
+ };
101
+ }
102
+ export class WebhookAdapter {
103
+ kind = 'webhook';
104
+ capabilities = CAPABILITIES;
105
+ guidance = {
106
+ peer: 'An issue or a pull request is the room: answer there, in markdown, as a comment.',
107
+ group: 'An issue or a pull request is the room: answer there, in markdown, as a comment.',
108
+ };
109
+ #config;
110
+ #now;
111
+ #stats = new ChannelStats();
112
+ #seen = new Map();
113
+ #inFlightKeys = new Set();
114
+ #queue = [];
115
+ #server;
116
+ #inFlight = 0;
117
+ #stopped = false;
118
+ #dialectFallback = 0;
119
+ constructor(config, options = {}) {
120
+ this.#config = config;
121
+ this.#now = options.now ?? Date.now;
122
+ }
123
+ checkConfig() {
124
+ if (this.#config.secret.length === 0)
125
+ throw new ConfigError('transport.secret is required', ['transport', 'secret']);
126
+ if (this.#config.token.length === 0) {
127
+ throw new ConfigError('transport.token is required to answer with a comment', ['transport', 'token']);
128
+ }
129
+ if (this.#config.login.length === 0)
130
+ throw new ConfigError('transport.login names the bot and is required', ['transport', 'login']);
131
+ if (!/^https?:\/\//.test(this.#config.api)) {
132
+ throw new ConfigError(`transport.api must be an absolute http(s) url: ${this.#config.api}`, ['transport', 'api']);
133
+ }
134
+ if (!this.#config.listen.path.startsWith('/')) {
135
+ throw new ConfigError(`transport.listen.path must start with "/": ${this.#config.listen.path}`, ['transport', 'listen', 'path']);
136
+ }
137
+ }
138
+ checkSessionKey(key) {
139
+ if (!/^gh:[^/#]+\/[^#]+(#[0-9]+)?$/.test(key))
140
+ return 'a webhook session key reads "gh:<owner>/<repo>[#<number>]"';
141
+ return undefined;
142
+ }
143
+ checkTarget(target) {
144
+ const matched = /^gh:([^#]+)#[0-9]+$/.exec(target);
145
+ if (matched === null)
146
+ return 'a webhook reply target reads "gh:<owner>/<repo>#<number>"';
147
+ if (this.#config.repos.length > 0 && !this.#config.repos.includes(matched[1])) {
148
+ return `"${matched[1]}" is not in transport.repos`;
149
+ }
150
+ return undefined;
151
+ }
152
+ describeTargets() {
153
+ return 'gh:<owner>/<repo>#<number>';
154
+ }
155
+ status() {
156
+ const snapshot = this.#stats.snapshot();
157
+ const counters = countersOf(snapshot);
158
+ if (this.#dialectFallback > 0)
159
+ counters.dialectFallback = this.#dialectFallback;
160
+ return buildStatus({
161
+ channel: this.#config.channel,
162
+ account: this.#config.channel,
163
+ bot: this.#config.botName,
164
+ health: buildListenerHealth({
165
+ listenState: snapshot.state === 'listening' ? 'listening' : snapshot.state === 'failed' ? 'failed' : 'stopped',
166
+ boundPort: snapshot.boundPort,
167
+ lastAuthenticatedAt: snapshot.lastAuthenticatedAt,
168
+ queueDepth: this.#queue.length + this.#inFlight,
169
+ }),
170
+ counters,
171
+ capabilities: this.capabilities,
172
+ attributes: { stableMessageId: true },
173
+ quota: { softLimit: 0, hardLimit: 0 },
174
+ sampledAt: this.#now(),
175
+ queueDepth: this.#queue.length + this.#inFlight,
176
+ });
177
+ }
178
+ async start(ports) {
179
+ const secret = await ports.resolveSecret(this.#config.secret);
180
+ if (secret === undefined || secret.length === 0)
181
+ throw new Error(`webhook: credential "${this.#config.secret}" did not resolve`);
182
+ const token = await ports.resolveSecret(this.#config.token);
183
+ if (token === undefined || token.length === 0)
184
+ throw new Error(`webhook: credential "${this.#config.token}" did not resolve`);
185
+ this.#tokenValue = token;
186
+ const server = createServer((request, response) => {
187
+ void this.#handle(secret, ports, request, response);
188
+ });
189
+ this.#server = server;
190
+ await new Promise((resolve, reject) => {
191
+ server.once('error', reject);
192
+ server.listen(this.#config.listen.port, this.#config.listen.host, () => {
193
+ server.off('error', reject);
194
+ resolve();
195
+ });
196
+ });
197
+ const address = server.address();
198
+ this.#stats.listening(address.port);
199
+ return {
200
+ address: () => ({ port: address.port }),
201
+ stop: () => this.#stop(),
202
+ };
203
+ }
204
+ async send(input) {
205
+ if (this.#stopped)
206
+ return { ok: false, code: 'no_url', reason: 'the listener is closed', retryable: false };
207
+ const request = commentRequest(this.#config, input);
208
+ if (request === undefined) {
209
+ return { ok: false, code: 'no_url', reason: `"${input.target}" is not an issue address`, retryable: false };
210
+ }
211
+ if (this.#queue.length >= SEND_QUEUE_MAX) {
212
+ return { ok: false, code: 'busy', reason: 'too many comments are already waiting', retryable: true };
213
+ }
214
+ return new Promise((resolve) => {
215
+ this.#queue.push({ input, resolve });
216
+ void this.#drain();
217
+ });
218
+ }
219
+ async #drain() {
220
+ if (this.#inFlight > 0)
221
+ return;
222
+ const next = this.#queue.shift();
223
+ if (next === undefined)
224
+ return;
225
+ this.#inFlight = 1;
226
+ try {
227
+ next.resolve(await this.#postComment(next.input));
228
+ }
229
+ finally {
230
+ this.#inFlight = 0;
231
+ if (this.#queue.length > 0)
232
+ void this.#drain();
233
+ }
234
+ }
235
+ async #postComment(input) {
236
+ const request = commentRequest(this.#config, input);
237
+ if (request === undefined)
238
+ return { ok: false, code: 'no_url', reason: 'not an issue address', retryable: false };
239
+ const token = await this.#token();
240
+ if (token === undefined)
241
+ return { ok: false, code: 'unauthorized', reason: 'the token did not resolve', retryable: false };
242
+ const budget = input.timeoutMs ?? 10_000;
243
+ const deadline = Date.now() + budget;
244
+ for (let attempt = 0; attempt < 2; attempt += 1) {
245
+ const left = deadline - Date.now();
246
+ if (left <= 0)
247
+ break;
248
+ let response;
249
+ try {
250
+ response = await fetch(request.url, {
251
+ method: 'POST',
252
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}`, 'user-agent': 'dsbot-channel' },
253
+ body: JSON.stringify({ body: request.body }),
254
+ signal: AbortSignal.timeout(left),
255
+ });
256
+ }
257
+ catch (error) {
258
+ const name = error.name;
259
+ if (name === 'TimeoutError' || name === 'AbortError') {
260
+ return this.#fail('busy', true, 'the api did not answer inside the send timeout');
261
+ }
262
+ if (attempt === 0)
263
+ continue;
264
+ return this.#fail('unreachable', true, String(error));
265
+ }
266
+ if (response.ok) {
267
+ const body = (await response.json().catch(() => undefined));
268
+ const id = body !== undefined && (typeof body.id === 'number' || typeof body.id === 'string') ? String(body.id) : input.eventId;
269
+ return { ok: true, messageId: id, duplicate: false };
270
+ }
271
+ const status = response.status;
272
+ if (status === 401)
273
+ return this.#fail('unauthorized', false);
274
+ const waitMs = retryAfterMs(response);
275
+ if ((status === 429 || status === 403) && attempt === 0 && waitMs !== undefined && Date.now() + waitMs < deadline) {
276
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
277
+ continue;
278
+ }
279
+ if (status === 429)
280
+ return this.#fail('busy', true);
281
+ if (status === 403)
282
+ return this.#fail(waitMs === undefined ? 'forbidden' : 'busy', waitMs !== undefined);
283
+ if (status >= 500)
284
+ return this.#fail('unreachable', true);
285
+ return this.#fail('rejected', false);
286
+ }
287
+ return this.#fail('busy', true, 'the api did not answer inside the send timeout');
288
+ }
289
+ #fail(code, retryable, reason = 'the api refused the comment') {
290
+ this.#stats.sendFailed(code);
291
+ return { ok: false, code, reason, retryable };
292
+ }
293
+ #tokenValue;
294
+ async #token() {
295
+ return this.#tokenValue;
296
+ }
297
+ async #handle(secret, ports, request, response) {
298
+ const path = (request.url ?? '').split('?')[0] ?? '';
299
+ if (path !== this.#config.listen.path) {
300
+ this.#stats.unknownPath();
301
+ this.#answer(response, 404, 'not found');
302
+ return;
303
+ }
304
+ if (request.method !== 'POST') {
305
+ this.#answer(response, 405, 'method not allowed');
306
+ return;
307
+ }
308
+ const raw = await this.#readBody(request);
309
+ if (raw === undefined) {
310
+ this.#answer(response, 413, 'payload too large');
311
+ return;
312
+ }
313
+ const signature = signatureOf(request);
314
+ if (signature === undefined || !verifySignature(secret, raw, signature.value)) {
315
+ this.#stats.authFailure();
316
+ this.#answer(response, 401, 'bad signature');
317
+ return;
318
+ }
319
+ this.#stats.authenticated();
320
+ let payload;
321
+ try {
322
+ payload = JSON.parse(raw.toString('utf8'));
323
+ }
324
+ catch {
325
+ this.#answer(response, 400, 'bad json');
326
+ return;
327
+ }
328
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
329
+ this.#answer(response, 400, 'bad json');
330
+ return;
331
+ }
332
+ const record = payload;
333
+ const picked = pickDialect(request, this.#config.dialect, record);
334
+ if (picked === 'mismatch') {
335
+ this.#stats.protocolMismatch();
336
+ this.#answer(response, 400, 'the event headers name another dialect');
337
+ return;
338
+ }
339
+ if (picked === 'unknown') {
340
+ this.#answer(response, 400, 'cannot tell the dialect');
341
+ return;
342
+ }
343
+ if (picked.fallback)
344
+ this.#dialectFallback += 1;
345
+ const event = headerOf(request, 'x-github-event') ?? headerOf(request, 'x-forgejo-event') ?? headerOf(request, 'x-gitea-event') ?? '';
346
+ if (event === '') {
347
+ this.#answer(response, 400, 'cannot tell the event class');
348
+ return;
349
+ }
350
+ const delivery = headerOf(request, 'x-github-delivery') ?? headerOf(request, 'x-forgejo-delivery') ?? headerOf(request, 'x-gitea-delivery') ?? '';
351
+ if (this.#config.repos.length > 0) {
352
+ const repo = record.repository;
353
+ const full = repo === undefined ? '' : `${String(repo.owner?.login ?? '')}/${String(repo.name ?? '')}`;
354
+ if (!this.#config.repos.includes(full)) {
355
+ this.#answer(response, 200, 'ignored');
356
+ return;
357
+ }
358
+ }
359
+ const mapped = mapWebhook({
360
+ dialect: picked.dialect,
361
+ event,
362
+ delivery: delivery === '' ? `${event}:${this.#now()}` : delivery,
363
+ payload: record,
364
+ config: { channel: this.#config.channel, login: this.#config.login, bots: this.#config.bots },
365
+ });
366
+ if ('skip' in mapped) {
367
+ if (mapped.skip === 'ping')
368
+ this.#stats.event('webhook.ping');
369
+ this.#answer(response, 200, 'ok');
370
+ return;
371
+ }
372
+ const key = delivery === '' ? mapped.message.eventKey : delivery;
373
+ const keys = [key, mapped.message.eventKey];
374
+ if (keys.some((one) => this.#inFlightKeys.has(one))) {
375
+ this.#answer(response, 503, 'busy');
376
+ return;
377
+ }
378
+ if (keys.some((one) => this.#seen.has(one))) {
379
+ this.#answer(response, 200, 'duplicate');
380
+ return;
381
+ }
382
+ for (const one of keys)
383
+ this.#inFlightKeys.add(one);
384
+ const at = this.#now();
385
+ const message = {
386
+ ...mapped.message,
387
+ transport: { ...mapped.message.transport, timestamp: mapped.message.transport.timestamp === 0 ? at : mapped.message.transport.timestamp },
388
+ receivedAt: at,
389
+ adapter: fitAdapterMeta(mapped.message.adapter, ADAPTER_META_MAX_BYTES),
390
+ };
391
+ this.#stats.event(classOf(message));
392
+ let verdict;
393
+ try {
394
+ verdict = await ports.onMessage(message).catch((error) => ({ ok: false, code: 'rejected', reason: String(error) }));
395
+ }
396
+ finally {
397
+ for (const one of keys)
398
+ this.#inFlightKeys.delete(one);
399
+ }
400
+ if (verdict.ok) {
401
+ for (const one of keys)
402
+ this.#remember(one);
403
+ this.#answer(response, 200, 'ok');
404
+ return;
405
+ }
406
+ if (verdict.code === 'busy') {
407
+ response.writeHead(503, { 'retry-after': '1', 'content-type': 'text/plain' });
408
+ response.end('busy');
409
+ return;
410
+ }
411
+ for (const one of keys)
412
+ this.#remember(one);
413
+ this.#answer(response, 202, verdict.reason);
414
+ }
415
+ #remember(key) {
416
+ const at = this.#now();
417
+ for (const [seen, when] of this.#seen) {
418
+ if (at - when.at > SEEN_TTL_MS)
419
+ this.#seen.delete(seen);
420
+ }
421
+ this.#seen.set(key, { at });
422
+ }
423
+ #readBody(request) {
424
+ return new Promise((resolve) => {
425
+ const chunks = [];
426
+ let size = 0;
427
+ let settled = false;
428
+ const settle = (value) => {
429
+ if (settled)
430
+ return;
431
+ settled = true;
432
+ resolve(value);
433
+ };
434
+ request.on('data', (chunk) => {
435
+ size += chunk.length;
436
+ if (size > MAX_WEBHOOK_BODY_BYTES) {
437
+ settle(undefined);
438
+ request.resume();
439
+ return;
440
+ }
441
+ chunks.push(chunk);
442
+ });
443
+ request.on('end', () => settle(Buffer.concat(chunks)));
444
+ request.on('error', () => settle(undefined));
445
+ });
446
+ }
447
+ #answer(response, status, body) {
448
+ response.writeHead(status, { 'content-type': 'text/plain', 'content-length': Buffer.byteLength(body) });
449
+ response.end(body);
450
+ }
451
+ async #stop() {
452
+ this.#stopped = true;
453
+ for (const waiting of this.#queue.splice(0)) {
454
+ waiting.resolve({ ok: false, code: 'no_url', reason: 'the listener is closed', retryable: false });
455
+ }
456
+ const server = this.#server;
457
+ this.#server = undefined;
458
+ if (server === undefined)
459
+ return;
460
+ await new Promise((resolve) => server.close(() => resolve()));
461
+ this.#stats.closed();
462
+ }
463
+ }
@@ -0,0 +1,108 @@
1
+ import type { AdapterFactory, AdapterMount } from '@dsbot-channel/core/adapter-registry.js';
2
+ import { WebhookAdapter } from './adapter.js';
3
+ import type { WebhookConfig } from './adapter.js';
4
+ /** Reads the webhook block; every field is named, so a typo fails at mount. */
5
+ export declare function parseWebhookConfig(mount: AdapterMount): WebhookConfig;
6
+ export declare const createWebhookAdapter: AdapterFactory;
7
+ export { WebhookAdapter };
8
+ export { mapWebhook, classOf, mentionsOf, semanticClass } from './mapping.js';
9
+ export type { WebhookConfig };
10
+ export declare const Config: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
11
+ channel: import("@deepseek-ai/schemastery").default<string, string, "plain">;
12
+ botName: import("@deepseek-ai/schemastery").default<string, string, "plain">;
13
+ anchor: import("@deepseek-ai/schemastery").default<string, string, "plain">;
14
+ session: import("@deepseek-ai/schemastery").default<string, string, "plain">;
15
+ model: import("@deepseek-ai/schemastery").default<string, string, "plain">;
16
+ reply: import("@deepseek-ai/schemastery").default<any, any, "plain">;
17
+ turn: import("@deepseek-ai/schemastery").default<any, any, "plain">;
18
+ delivery: import("@deepseek-ai/schemastery").default<any, any, "plain">;
19
+ policy: import("@deepseek-ai/schemastery").default<any, any, "plain">;
20
+ pipeline: import("@deepseek-ai/schemastery").default<any, any, "plain">;
21
+ events: import("@deepseek-ai/schemastery").default<any, any, "plain">;
22
+ status: import("@deepseek-ai/schemastery").default<boolean, boolean, "plain">;
23
+ transport: import("@deepseek-ai/schemastery").default<any, any, "plain">;
24
+ }>>, Schemastery.ObjectT<NoInfer<{
25
+ channel: import("@deepseek-ai/schemastery").default<string, string, "plain">;
26
+ botName: import("@deepseek-ai/schemastery").default<string, string, "plain">;
27
+ anchor: import("@deepseek-ai/schemastery").default<string, string, "plain">;
28
+ session: import("@deepseek-ai/schemastery").default<string, string, "plain">;
29
+ model: import("@deepseek-ai/schemastery").default<string, string, "plain">;
30
+ reply: import("@deepseek-ai/schemastery").default<any, any, "plain">;
31
+ turn: import("@deepseek-ai/schemastery").default<any, any, "plain">;
32
+ delivery: import("@deepseek-ai/schemastery").default<any, any, "plain">;
33
+ policy: import("@deepseek-ai/schemastery").default<any, any, "plain">;
34
+ pipeline: import("@deepseek-ai/schemastery").default<any, any, "plain">;
35
+ events: import("@deepseek-ai/schemastery").default<any, any, "plain">;
36
+ status: import("@deepseek-ai/schemastery").default<boolean, boolean, "plain">;
37
+ transport: import("@deepseek-ai/schemastery").default<any, any, "plain">;
38
+ }>>, "plain">;
39
+ export declare const apply: (ctx: import("@deepseek-ai/cordis").Context, config?: Record<string, unknown>) => void;
40
+ export declare const plugin: {
41
+ name: string;
42
+ inject: string[];
43
+ Config: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
44
+ channel: import("@deepseek-ai/schemastery").default<string, string, "plain">;
45
+ botName: import("@deepseek-ai/schemastery").default<string, string, "plain">;
46
+ anchor: import("@deepseek-ai/schemastery").default<string, string, "plain">;
47
+ session: import("@deepseek-ai/schemastery").default<string, string, "plain">;
48
+ model: import("@deepseek-ai/schemastery").default<string, string, "plain">;
49
+ reply: import("@deepseek-ai/schemastery").default<any, any, "plain">;
50
+ turn: import("@deepseek-ai/schemastery").default<any, any, "plain">;
51
+ delivery: import("@deepseek-ai/schemastery").default<any, any, "plain">;
52
+ policy: import("@deepseek-ai/schemastery").default<any, any, "plain">;
53
+ pipeline: import("@deepseek-ai/schemastery").default<any, any, "plain">;
54
+ events: import("@deepseek-ai/schemastery").default<any, any, "plain">;
55
+ status: import("@deepseek-ai/schemastery").default<boolean, boolean, "plain">;
56
+ transport: import("@deepseek-ai/schemastery").default<any, any, "plain">;
57
+ }>>, Schemastery.ObjectT<NoInfer<{
58
+ channel: import("@deepseek-ai/schemastery").default<string, string, "plain">;
59
+ botName: import("@deepseek-ai/schemastery").default<string, string, "plain">;
60
+ anchor: import("@deepseek-ai/schemastery").default<string, string, "plain">;
61
+ session: import("@deepseek-ai/schemastery").default<string, string, "plain">;
62
+ model: import("@deepseek-ai/schemastery").default<string, string, "plain">;
63
+ reply: import("@deepseek-ai/schemastery").default<any, any, "plain">;
64
+ turn: import("@deepseek-ai/schemastery").default<any, any, "plain">;
65
+ delivery: import("@deepseek-ai/schemastery").default<any, any, "plain">;
66
+ policy: import("@deepseek-ai/schemastery").default<any, any, "plain">;
67
+ pipeline: import("@deepseek-ai/schemastery").default<any, any, "plain">;
68
+ events: import("@deepseek-ai/schemastery").default<any, any, "plain">;
69
+ status: import("@deepseek-ai/schemastery").default<boolean, boolean, "plain">;
70
+ transport: import("@deepseek-ai/schemastery").default<any, any, "plain">;
71
+ }>>, "plain">;
72
+ apply: (ctx: import("@deepseek-ai/cordis").Context, config?: Record<string, unknown>) => void;
73
+ };
74
+ declare const _default: {
75
+ name: string;
76
+ inject: string[];
77
+ Config: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
78
+ channel: import("@deepseek-ai/schemastery").default<string, string, "plain">;
79
+ botName: import("@deepseek-ai/schemastery").default<string, string, "plain">;
80
+ anchor: import("@deepseek-ai/schemastery").default<string, string, "plain">;
81
+ session: import("@deepseek-ai/schemastery").default<string, string, "plain">;
82
+ model: import("@deepseek-ai/schemastery").default<string, string, "plain">;
83
+ reply: import("@deepseek-ai/schemastery").default<any, any, "plain">;
84
+ turn: import("@deepseek-ai/schemastery").default<any, any, "plain">;
85
+ delivery: import("@deepseek-ai/schemastery").default<any, any, "plain">;
86
+ policy: import("@deepseek-ai/schemastery").default<any, any, "plain">;
87
+ pipeline: import("@deepseek-ai/schemastery").default<any, any, "plain">;
88
+ events: import("@deepseek-ai/schemastery").default<any, any, "plain">;
89
+ status: import("@deepseek-ai/schemastery").default<boolean, boolean, "plain">;
90
+ transport: import("@deepseek-ai/schemastery").default<any, any, "plain">;
91
+ }>>, Schemastery.ObjectT<NoInfer<{
92
+ channel: import("@deepseek-ai/schemastery").default<string, string, "plain">;
93
+ botName: import("@deepseek-ai/schemastery").default<string, string, "plain">;
94
+ anchor: import("@deepseek-ai/schemastery").default<string, string, "plain">;
95
+ session: import("@deepseek-ai/schemastery").default<string, string, "plain">;
96
+ model: import("@deepseek-ai/schemastery").default<string, string, "plain">;
97
+ reply: import("@deepseek-ai/schemastery").default<any, any, "plain">;
98
+ turn: import("@deepseek-ai/schemastery").default<any, any, "plain">;
99
+ delivery: import("@deepseek-ai/schemastery").default<any, any, "plain">;
100
+ policy: import("@deepseek-ai/schemastery").default<any, any, "plain">;
101
+ pipeline: import("@deepseek-ai/schemastery").default<any, any, "plain">;
102
+ events: import("@deepseek-ai/schemastery").default<any, any, "plain">;
103
+ status: import("@deepseek-ai/schemastery").default<boolean, boolean, "plain">;
104
+ transport: import("@deepseek-ai/schemastery").default<any, any, "plain">;
105
+ }>>, "plain">;
106
+ apply: (ctx: import("@deepseek-ai/cordis").Context, config?: Record<string, unknown>) => void;
107
+ };
108
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,78 @@
1
+ import { makeAdapterPlugin } from '@dsbot-channel/core/adapter-plugin.js';
2
+ import { ConfigError, assertBindHost } from '@dsbot-channel/core/config.js';
3
+ import { DEFAULT_WEBHOOK_HOST, DEFAULT_WEBHOOK_PATH, DEFAULT_WEBHOOK_PORT, WebhookAdapter } from './adapter.js';
4
+ import { WEBHOOK_DIALECTS } from './mapping.js';
5
+ const FIELDS = new Set(['listen', 'secret', 'token', 'dialect', 'api', 'login', 'bots', 'repos']);
6
+ function refuse(message, path = ['transport']) {
7
+ throw new ConfigError(message, path);
8
+ }
9
+ function strings(value, field) {
10
+ if (value === undefined)
11
+ return [];
12
+ if (!Array.isArray(value))
13
+ refuse(`transport.${field} must be a list of names`, ['transport', field]);
14
+ return value.map((one, index) => {
15
+ if (typeof one !== 'string' || one.length === 0)
16
+ refuse(`transport.${field}[${index}] must be a non-empty string`, ['transport', field, index]);
17
+ return one;
18
+ });
19
+ }
20
+ /** Reads the webhook block; every field is named, so a typo fails at mount. */
21
+ export function parseWebhookConfig(mount) {
22
+ const raw = mount.transport;
23
+ const unknown = Object.keys(raw).filter((key) => !FIELDS.has(key));
24
+ if (unknown.length > 0)
25
+ refuse(`transport names a field this adapter does not read: ${unknown.join(', ')}`);
26
+ const listen = (raw.listen ?? {});
27
+ const host = typeof listen.host === 'string' && listen.host.length > 0 ? listen.host : DEFAULT_WEBHOOK_HOST;
28
+ assertBindHost(host);
29
+ const port = typeof listen.port === 'number' && Number.isInteger(listen.port) ? listen.port : DEFAULT_WEBHOOK_PORT;
30
+ if (port < 0 || port > 65535)
31
+ refuse(`transport.listen.port must be between 0 and 65535: ${port}`, ['transport', 'listen', 'port']);
32
+ const path = typeof listen.path === 'string' && listen.path.length > 0 ? listen.path : DEFAULT_WEBHOOK_PATH;
33
+ if (!path.startsWith('/'))
34
+ refuse(`transport.listen.path must start with "/": ${path}`, ['transport', 'listen', 'path']);
35
+ const secret = raw.secret;
36
+ if (typeof secret !== 'string' || secret.length === 0)
37
+ refuse('transport.secret must be a credential reference name', ['transport', 'secret']);
38
+ const token = raw.token;
39
+ if (typeof token !== 'string' || token.length === 0)
40
+ refuse('transport.token must be a credential reference name', ['transport', 'token']);
41
+ const dialect = (raw.dialect ?? 'auto');
42
+ if (!WEBHOOK_DIALECTS.includes(dialect)) {
43
+ refuse(`transport.dialect must be one of ${WEBHOOK_DIALECTS.join(', ')}: ${String(dialect)}`, ['transport', 'dialect']);
44
+ }
45
+ const api = raw.api === undefined ? 'https://api.github.com' : raw.api;
46
+ if (typeof api !== 'string' || !/^https?:\/\//.test(api)) {
47
+ refuse(`transport.api must be an absolute http(s) url: ${String(api)}`, ['transport', 'api']);
48
+ }
49
+ const login = raw.login;
50
+ if (typeof login !== 'string' || login.length === 0)
51
+ refuse('transport.login names the bot and is required', ['transport', 'login']);
52
+ return {
53
+ channel: mount.channel,
54
+ botName: mount.botName,
55
+ status: mount.status !== false,
56
+ listen: { host, port, path },
57
+ secret,
58
+ token,
59
+ dialect,
60
+ api: api.replace(/\/+$/, ''),
61
+ login,
62
+ bots: strings(raw.bots, 'bots'),
63
+ repos: strings(raw.repos, 'repos'),
64
+ };
65
+ }
66
+ export const createWebhookAdapter = (mount, options) => new WebhookAdapter(parseWebhookConfig(mount), options);
67
+ export { WebhookAdapter };
68
+ export { mapWebhook, classOf, mentionsOf, semanticClass } from './mapping.js';
69
+ const adapter = makeAdapterPlugin({
70
+ name: 'dsbot-channel/webhook',
71
+ kind: 'webhook',
72
+ factory: createWebhookAdapter,
73
+ check: parseWebhookConfig,
74
+ });
75
+ export const Config = adapter.Config;
76
+ export const apply = adapter.apply;
77
+ export const plugin = adapter.plugin;
78
+ export default adapter.plugin;
@@ -0,0 +1,58 @@
1
+ import type { InboundMessage } from '@dsbot-channel/core/contract.js';
2
+ export declare const WEBHOOK_DIALECTS: readonly ["github", "forgejo", "auto"];
3
+ export type WebhookDialect = (typeof WEBHOOK_DIALECTS)[number];
4
+ export interface MappingConfig {
5
+ channel: string;
6
+ /** The bot's own login, used for `mentioned.self`. */
7
+ login: string;
8
+ /** Logins whose comments count as bot speech. */
9
+ bots: readonly string[];
10
+ }
11
+ export interface MappingInput {
12
+ dialect: 'github' | 'forgejo';
13
+ event: string;
14
+ delivery: string;
15
+ payload: Record<string, unknown>;
16
+ config: MappingConfig;
17
+ }
18
+ export type MappingResult = {
19
+ message: InboundMessage;
20
+ } | {
21
+ skip: 'ping' | 'malformed';
22
+ };
23
+ type Payload = Record<string, unknown>;
24
+ /** The class an adapter may list in `events.turn`; always two segments. */
25
+ export declare function semanticClass(event: string): string;
26
+ interface Entity {
27
+ owner: string;
28
+ repo: string;
29
+ full: string;
30
+ number?: number;
31
+ }
32
+ /** A workflow run names the fields a failure has to show, so a reader need not fetch the run. */
33
+ export declare function workflowRunSummary(actor: string, entity: Entity, payload: Payload): string;
34
+ /** A check run names the check, its state, the commit it ran on and a link. */
35
+ export declare function checkRunSummary(actor: string, entity: Entity, payload: Payload): string;
36
+ /** A check suite names its state, the branch it ran on and a link. */
37
+ export declare function checkSuiteSummary(actor: string, entity: Entity, payload: Payload): string;
38
+ /** A commit status names the commit, the context that reported it, the state and a link. */
39
+ export declare function commitStatusSummary(actor: string, entity: Entity, payload: Payload): string;
40
+ /** One line, no clock, so a redelivery of the same event reads the same. */
41
+ export declare function summaryOf(input: {
42
+ actor: string;
43
+ event: string;
44
+ action: string;
45
+ entity: Entity;
46
+ title: string;
47
+ }): string;
48
+ export declare function mentionsOf(body: string, login: string): {
49
+ self: boolean;
50
+ others: boolean;
51
+ };
52
+ /** Turns a webhook call into the one inbound event core sees, or says it carries none. */
53
+ export declare function mapWebhook(input: MappingInput): MappingResult;
54
+ /** The class name an adapter may list in `events.turn`, e.g. `action.push`. */
55
+ export declare function classOf(message: InboundMessage): string;
56
+ /** Keeps `meta.adapter` under the core limit by dropping the biggest fields first. */
57
+ export declare function fitAdapterMeta(meta: Record<string, unknown>, limit: number): Record<string, unknown>;
58
+ export {};
@@ -0,0 +1,422 @@
1
+ import { safeUrl } from '@dsbot-channel/core/safe-url.js';
2
+ export const WEBHOOK_DIALECTS = ['github', 'forgejo', 'auto'];
3
+ const CI_EVENTS = new Set(['workflow_run', 'check_run', 'check_suite', 'status']);
4
+ const VERBS = {
5
+ opened: 'opened',
6
+ closed: 'closed',
7
+ reopened: 'reopened',
8
+ edited: 'edited',
9
+ deleted: 'deleted',
10
+ created: 'added',
11
+ submitted: 'submitted',
12
+ synchronize: 'updated',
13
+ completed: 'completed',
14
+ labeled: 'labeled',
15
+ unlabeled: 'unlabeled',
16
+ };
17
+ function record(value) {
18
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
19
+ }
20
+ function text(value) {
21
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
22
+ }
23
+ function whole(value) {
24
+ return typeof value === 'number' && Number.isInteger(value) ? value : undefined;
25
+ }
26
+ function normalizedName(event) {
27
+ return event.replace(/[^a-z0-9_]/gi, '_').toLowerCase();
28
+ }
29
+ /** The class an adapter may list in `events.turn`; always two segments. */
30
+ export function semanticClass(event) {
31
+ if (event === 'push')
32
+ return 'push';
33
+ if (CI_EVENTS.has(event))
34
+ return 'ci';
35
+ if (event === 'issues')
36
+ return 'issues';
37
+ if (event === 'pull_request')
38
+ return 'pull_request';
39
+ if (event === 'issue_comment' || event === 'pull_request_review_comment')
40
+ return 'comment';
41
+ if (event === 'pull_request_review')
42
+ return 'review';
43
+ if (event === 'release')
44
+ return 'release';
45
+ return normalizedName(event);
46
+ }
47
+ function entityOf(payload) {
48
+ const repo = record(payload.repository);
49
+ const owner = text(record(repo?.owner)?.login) ?? text(record(repo?.owner)?.username);
50
+ const name = text(repo?.name);
51
+ if (owner === undefined || name === undefined)
52
+ return undefined;
53
+ const number = whole(record(payload.issue)?.number) ??
54
+ whole(record(payload.pull_request)?.number) ??
55
+ whole(record(payload.discussion)?.number);
56
+ return { owner, repo: name, full: `${owner}/${name}`, ...(number === undefined ? {} : { number }) };
57
+ }
58
+ function scopeOf(entity) {
59
+ return entity.number === undefined ? `gh:${entity.full}` : `gh:${entity.full}#${entity.number}`;
60
+ }
61
+ function titleOf(payload) {
62
+ return (text(record(payload.issue)?.title) ??
63
+ text(record(payload.pull_request)?.title) ??
64
+ text(record(payload.release)?.name) ??
65
+ text(record(payload.release)?.tag_name) ??
66
+ text(record(payload.head_commit)?.message)?.split('\n')[0] ??
67
+ '');
68
+ }
69
+ const OBJECT = {
70
+ issues: 'issue',
71
+ pull_request: 'pull request',
72
+ pull_request_review: 'review',
73
+ release: 'release',
74
+ };
75
+ /** Longest field a CI line keeps; a summary states the facts, not the whole payload. */
76
+ const CI_FIELD_MAX = 200;
77
+ function clipped(value) {
78
+ if (value === undefined)
79
+ return undefined;
80
+ const flat = value.replace(/\s+/g, ' ').trim();
81
+ if (flat === '')
82
+ return undefined;
83
+ return flat.length <= CI_FIELD_MAX ? flat : flat.slice(0, CI_FIELD_MAX);
84
+ }
85
+ /** A workflow run names the fields a failure has to show, so a reader need not fetch the run. */
86
+ export function workflowRunSummary(actor, entity, payload) {
87
+ const run = record(payload.workflow_run) ?? {};
88
+ const name = clipped(text(run.name) ?? text(run.display_title));
89
+ const status = text(run.status) ?? 'unknown';
90
+ const conclusion = text(run.conclusion);
91
+ const state = conclusion === undefined ? status : `${status}/${conclusion}`;
92
+ const branch = clipped(text(run.head_branch));
93
+ const number = Array.isArray(run.pull_requests)
94
+ ? run.pull_requests.map((one) => whole(record(one)?.number)).find((one) => one !== undefined)
95
+ : undefined;
96
+ const url = safeUrl(text(run.html_url), CI_FIELD_MAX);
97
+ const parts = [`${actor} workflow_run ${entity.full}`];
98
+ if (name !== undefined)
99
+ parts.push(`"${name}"`);
100
+ parts.push(state);
101
+ if (branch !== undefined)
102
+ parts.push(`on ${branch}`);
103
+ if (number !== undefined)
104
+ parts.push(`pr #${number}`);
105
+ if (url !== undefined)
106
+ parts.push(url);
107
+ return parts.join(' ');
108
+ }
109
+ /** A check run names the check, its state, the commit it ran on and a link. */
110
+ export function checkRunSummary(actor, entity, payload) {
111
+ const run = record(payload.check_run) ?? {};
112
+ const name = clipped(text(run.name));
113
+ const status = text(run.status) ?? 'unknown';
114
+ const conclusion = text(run.conclusion);
115
+ const state = conclusion === undefined ? status : `${status}/${conclusion}`;
116
+ const sha = clipped(text(run.head_sha));
117
+ const url = safeUrl(text(run.html_url), CI_FIELD_MAX);
118
+ const parts = [`${actor} check_run ${entity.full}`];
119
+ if (name !== undefined)
120
+ parts.push(`"${name}"`);
121
+ parts.push(state);
122
+ if (sha !== undefined)
123
+ parts.push(sha);
124
+ if (url !== undefined)
125
+ parts.push(url);
126
+ return parts.join(' ');
127
+ }
128
+ /** A check suite names its state, the branch it ran on and a link. */
129
+ export function checkSuiteSummary(actor, entity, payload) {
130
+ const suite = record(payload.check_suite) ?? {};
131
+ const status = text(suite.status) ?? 'unknown';
132
+ const conclusion = text(suite.conclusion);
133
+ const state = conclusion === undefined ? status : `${status}/${conclusion}`;
134
+ const branch = clipped(text(suite.head_branch));
135
+ const url = safeUrl(text(suite.html_url), CI_FIELD_MAX);
136
+ const parts = [`${actor} check_suite ${entity.full}`, state];
137
+ if (branch !== undefined)
138
+ parts.push(`on ${branch}`);
139
+ if (url !== undefined)
140
+ parts.push(url);
141
+ return parts.join(' ');
142
+ }
143
+ /** A commit status names the commit, the context that reported it, the state and a link. */
144
+ export function commitStatusSummary(actor, entity, payload) {
145
+ const sha = clipped(text(payload.sha));
146
+ const context = clipped(text(payload.context));
147
+ const state = text(payload.state) ?? 'unknown';
148
+ const url = safeUrl(text(payload.target_url), CI_FIELD_MAX);
149
+ const parts = [`${actor} status ${entity.full}`];
150
+ if (sha !== undefined)
151
+ parts.push(sha);
152
+ parts.push(context === undefined ? state : `${context}: ${state}`);
153
+ if (url !== undefined)
154
+ parts.push(url);
155
+ return parts.join(' ');
156
+ }
157
+ /** One line, no clock, so a redelivery of the same event reads the same. */
158
+ export function summaryOf(input) {
159
+ const verb = VERBS[input.action] ?? input.action;
160
+ const object = OBJECT[input.event] ?? input.event;
161
+ const subject = input.entity.number === undefined ? input.entity.full : `${input.entity.full}#${input.entity.number}`;
162
+ const head = `${input.actor} ${verb} ${object} ${subject}`.replace(/\s+/g, ' ').trim();
163
+ return input.title === '' ? head : `${head}: ${input.title}`;
164
+ }
165
+ function withoutCode(body) {
166
+ return body.replace(/```[\s\S]*?```/g, ' ').replace(/```[\s\S]*$/g, ' ').replace(/`[^`\n]*`/g, ' ');
167
+ }
168
+ const MENTION = /@([A-Za-z0-9][A-Za-z0-9-]*(?:\/[A-Za-z0-9][A-Za-z0-9-]*)?)/g;
169
+ export function mentionsOf(body, login) {
170
+ const seen = new Set();
171
+ for (const match of withoutCode(body).matchAll(MENTION)) {
172
+ if (match[1] !== undefined)
173
+ seen.add(match[1].toLowerCase());
174
+ }
175
+ const mine = login.toLowerCase();
176
+ return {
177
+ self: mine.length > 0 && seen.has(mine),
178
+ others: [...seen].some((one) => one !== mine),
179
+ };
180
+ }
181
+ function sender(payload, action) {
182
+ const actor = record(payload.sender);
183
+ const comment = record(payload.comment);
184
+ const review = record(payload.review);
185
+ return (text(record(comment?.user)?.login) ??
186
+ text(record(comment?.user)?.username) ??
187
+ text(record(review?.user)?.login) ??
188
+ text(record(review?.user)?.username) ??
189
+ text(actor?.login) ??
190
+ text(actor?.username) ??
191
+ action);
192
+ }
193
+ function eventKeyOf(input) {
194
+ const comment = whole(record(input.payload.comment)?.id);
195
+ if (comment !== undefined) {
196
+ const base = input.event === 'pull_request_review_comment' ? `review_comment:${comment}` : `comment:${comment}`;
197
+ return input.action === 'created' ? base : `${base}:${input.action}`;
198
+ }
199
+ const review = whole(record(input.payload.review)?.id);
200
+ if (review !== undefined) {
201
+ return input.action === 'submitted' || input.action === 'created' ? `review:${review}` : `review:${review}:${input.action}`;
202
+ }
203
+ if (input.entity.number !== undefined) {
204
+ const stamp = text(record(input.payload.issue)?.updated_at) ??
205
+ text(record(input.payload.pull_request)?.updated_at) ??
206
+ text(record(input.payload.discussion)?.updated_at) ??
207
+ '';
208
+ const prefix = input.event === 'pull_request' ? 'pr' : 'issue';
209
+ return stamp === ''
210
+ ? `${prefix}:${input.entity.full}#${input.entity.number}:${input.action}:${input.delivery}`
211
+ : `${prefix}:${input.entity.full}#${input.entity.number}:${input.action}:${stamp}`;
212
+ }
213
+ if (input.event === 'push') {
214
+ const after = text(input.payload.after) ?? text(record(input.payload.head_commit)?.id) ?? '';
215
+ return `push:${input.entity.full}:${after}`;
216
+ }
217
+ if (input.event === 'workflow_run') {
218
+ const run = record(input.payload.workflow_run) ?? {};
219
+ return `run:${input.entity.full}:${whole(run.id) ?? ''}:${whole(run.run_attempt) ?? 1}:${text(run.conclusion) ?? ''}`;
220
+ }
221
+ if (input.event === 'check_run') {
222
+ const run = record(input.payload.check_run) ?? {};
223
+ return `check:${input.entity.full}:${whole(run.id) ?? ''}:${text(run.status) ?? ''}:${text(run.conclusion) ?? ''}`;
224
+ }
225
+ if (input.event === 'check_suite') {
226
+ const run = record(input.payload.check_suite) ?? {};
227
+ return `suite:${input.entity.full}:${whole(run.id) ?? ''}:${text(run.status) ?? ''}:${text(run.conclusion) ?? ''}`;
228
+ }
229
+ if (input.event === 'status') {
230
+ const sha = text(input.payload.sha) ?? '';
231
+ const context = text(input.payload.context) ?? '';
232
+ const state = text(input.payload.state) ?? '';
233
+ return `status:${input.entity.full}:${sha}:${context}:${state}`;
234
+ }
235
+ return `delivery:${input.delivery}`;
236
+ }
237
+ function botOrPerson(bots, login, type) {
238
+ return type === 'Bot' || bots.some((one) => one.toLowerCase() === login.toLowerCase()) ? 'bot' : 'person';
239
+ }
240
+ function bodyOf(payload) {
241
+ const comment = record(payload.comment);
242
+ const review = record(payload.review);
243
+ if (comment !== undefined) {
244
+ const parent = whole(comment.in_reply_to_id);
245
+ return {
246
+ body: text(comment.body) ?? '',
247
+ id: whole(comment.id) ?? 0,
248
+ ...(parent === undefined ? {} : { parent }),
249
+ user: comment.user,
250
+ };
251
+ }
252
+ const comments = Array.isArray(review?.comments) ? review?.comments : undefined;
253
+ const first = comments === undefined ? undefined : record(comments[0]);
254
+ return {
255
+ body: text(review?.body) ?? text(first?.body) ?? '',
256
+ id: whole(review?.id) ?? whole(first?.id) ?? 0,
257
+ ...(whole(first?.in_reply_to_id) === undefined ? {} : { parent: whole(first?.in_reply_to_id) }),
258
+ user: review?.user ?? first?.user,
259
+ };
260
+ }
261
+ /** The facts a comment names, on one line ahead of the body it carries. */
262
+ function commentHeader(input, entity, login, authorKind, action, link) {
263
+ const subject = entity.number === undefined ? entity.full : `${entity.full}#${entity.number}`;
264
+ const parts = [`[${input.dialect}] ${subject}`, `${login} (${authorKind})`, `${input.event}/${action}`];
265
+ if (link !== undefined)
266
+ parts.push(link);
267
+ return parts.join(' \u00b7 ');
268
+ }
269
+ function messageOf(input, entity, action) {
270
+ const payload = input.payload;
271
+ const found = bodyOf(payload);
272
+ const comment = record(payload.comment);
273
+ const body = found.body;
274
+ const login = sender(payload, action);
275
+ const id = found.id;
276
+ const parent = found.parent;
277
+ const mentioned = mentionsOf(body, input.config.login);
278
+ const scoped = scopeOf(entity);
279
+ const authorKind = botOrPerson(input.config.bots, login, record(found.user)?.type);
280
+ const link = safeUrl(text(comment?.html_url) ?? text(record(payload.review)?.html_url), CI_FIELD_MAX);
281
+ const header = commentHeader(input, entity, login, authorKind, action, link);
282
+ return {
283
+ eventKey: eventKeyOf({ event: input.event, payload, delivery: input.delivery, entity, action: 'created' }),
284
+ kind: 'message',
285
+ event: { postType: input.event, type: normalizedName(input.event) },
286
+ sessionKey: scoped,
287
+ replyTarget: scoped,
288
+ requestId: String(id),
289
+ chainId: scoped,
290
+ source: {
291
+ kind: 'webhook',
292
+ instance: input.config.channel,
293
+ peer: entity.number === undefined ? entity.full : `${entity.full}#${entity.number}`,
294
+ scope: 'group',
295
+ },
296
+ author: { senderId: login, kind: authorKind, personId: null },
297
+ rawText: body,
298
+ body: { text: `${header}\n${body}`, media: [], replyTo: parent === undefined ? null : String(parent) },
299
+ transport: {
300
+ messageId: String(id),
301
+ timestamp: 0,
302
+ mentioned: { self: mentioned.self, others: mentioned.others, all: false },
303
+ isReply: parent !== undefined,
304
+ signature: null,
305
+ },
306
+ declared: { nickname: login, avatar: '' },
307
+ adapter: {
308
+ event: input.event,
309
+ action,
310
+ delivery: input.delivery,
311
+ dialect: input.dialect,
312
+ ...(entity.number === undefined ? {} : { number: entity.number }),
313
+ ...(whole(comment?.id) === undefined ? {} : { commentId: whole(comment?.id) }),
314
+ },
315
+ receivedAt: 0,
316
+ };
317
+ }
318
+ function ciSummaryOf(input, entity, login) {
319
+ if (input.event === 'workflow_run')
320
+ return workflowRunSummary(login, entity, input.payload);
321
+ if (input.event === 'check_run')
322
+ return checkRunSummary(login, entity, input.payload);
323
+ if (input.event === 'check_suite')
324
+ return checkSuiteSummary(login, entity, input.payload);
325
+ if (input.event === 'status')
326
+ return commitStatusSummary(login, entity, input.payload);
327
+ return undefined;
328
+ }
329
+ function actionOf(input, entity, action) {
330
+ const payload = input.payload;
331
+ const login = sender(payload, action);
332
+ const summary = ciSummaryOf(input, entity, login) ??
333
+ summaryOf({ actor: login, event: input.event, action, entity, title: titleOf(payload) });
334
+ const line = `[${input.dialect}] ${summary}`;
335
+ const scoped = scopeOf(entity);
336
+ return {
337
+ eventKey: eventKeyOf({ event: input.event, payload, delivery: input.delivery, entity, action }),
338
+ kind: 'action',
339
+ event: {
340
+ postType: input.event,
341
+ type: record(payload.comment) !== undefined
342
+ ? 'comment'
343
+ : record(payload.review) !== undefined
344
+ ? 'review'
345
+ : semanticClass(input.event),
346
+ ...(action === '' ? {} : { subType: action }),
347
+ },
348
+ sessionKey: scoped,
349
+ replyTarget: scoped,
350
+ requestId: input.delivery,
351
+ chainId: scoped,
352
+ source: {
353
+ kind: 'webhook',
354
+ instance: input.config.channel,
355
+ peer: entity.number === undefined ? entity.full : `${entity.full}#${entity.number}`,
356
+ scope: 'group',
357
+ },
358
+ author: {
359
+ senderId: login,
360
+ kind: botOrPerson(input.config.bots, login, record(payload.sender)?.type),
361
+ personId: null,
362
+ },
363
+ rawText: summary,
364
+ body: { text: line, media: [], replyTo: null },
365
+ transport: {
366
+ messageId: input.delivery,
367
+ timestamp: 0,
368
+ mentioned: { self: false, others: false, all: false },
369
+ isReply: false,
370
+ signature: null,
371
+ },
372
+ declared: { nickname: login, avatar: '' },
373
+ adapter: {
374
+ event: input.event,
375
+ action,
376
+ delivery: input.delivery,
377
+ dialect: input.dialect,
378
+ ...(entity.number === undefined ? {} : { number: entity.number }),
379
+ },
380
+ receivedAt: 0,
381
+ };
382
+ }
383
+ /** Turns a webhook call into the one inbound event core sees, or says it carries none. */
384
+ export function mapWebhook(input) {
385
+ const { event, payload } = input;
386
+ if (event === 'ping' || event.startsWith('installation'))
387
+ return { skip: 'ping' };
388
+ const entity = entityOf(payload);
389
+ if (entity === undefined)
390
+ return { skip: 'malformed' };
391
+ const action = text(payload.action) ?? '';
392
+ const found = bodyOf(payload);
393
+ const written = found.body.length > 0;
394
+ const comment = record(payload.comment);
395
+ const review = record(payload.review);
396
+ const message = comment !== undefined ? action === 'created' : review !== undefined && written;
397
+ if (message)
398
+ return { message: messageOf(input, entity, action) };
399
+ return { message: actionOf(input, entity, action) };
400
+ }
401
+ /** The class name an adapter may list in `events.turn`, e.g. `action.push`. */
402
+ export function classOf(message) {
403
+ const kind = message.kind ?? 'message';
404
+ const type = message.event?.type ?? 'unknown';
405
+ return `${kind}.${type}`;
406
+ }
407
+ /** Keeps `meta.adapter` under the core limit by dropping the biggest fields first. */
408
+ export function fitAdapterMeta(meta, limit) {
409
+ const kept = { ...meta };
410
+ const size = (value) => JSON.stringify(value)?.length ?? 0;
411
+ for (const key of ['payload', 'title', 'body']) {
412
+ if (size(kept) <= limit)
413
+ return kept;
414
+ delete kept[key];
415
+ }
416
+ for (const key of Object.keys(kept).sort((left, right) => size({ [right]: kept[right] }) - size({ [left]: kept[left] }))) {
417
+ if (size(kept) <= limit)
418
+ return kept;
419
+ delete kept[key];
420
+ }
421
+ return kept;
422
+ }
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@dsbot-channel/webhook",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.7.1",
4
+ "description": "Webhook transport for dsbot-channel.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./*.js": {
15
+ "types": "./dist/*.d.ts",
16
+ "default": "./dist/*.js"
17
+ },
18
+ "./package.json": "./package.json",
19
+ "./cordis.patch.yml": "./cordis.patch.yml"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "assets",
24
+ "cordis.patch.yml",
25
+ "LICENSE"
26
+ ],
27
+ "license": "MIT",
28
+ "publishConfig": {
29
+ "registry": "https://registry.npmjs.org/",
30
+ "access": "public"
31
+ },
32
+ "engines": {
33
+ "node": "^20.19.0 || >=22.12.0"
34
+ },
35
+ "dependencies": {
36
+ "@dsbot-channel/core": "^0.7.1"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "~22.20.5",
40
+ "typescript": "~5.9.3"
41
+ },
42
+ "dsh": {
43
+ "bundle": {
44
+ "patch": "./cordis.patch.yml"
45
+ }
46
+ },
47
+ "scripts": {
48
+ "build": "pnpm clean && tsc -p tsconfig.build.json",
49
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
50
+ "typecheck": "tsc -p tsconfig.build.json --noEmit",
51
+ "verify:dist": "node ../../scripts/verify-dist.mjs packages/webhook"
52
+ }
6
53
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.