@cogitator-ai/channels 0.15.12 → 0.17.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.
Files changed (120) hide show
  1. package/README.md +129 -23
  2. package/dist/assistant-config.d.ts +78 -0
  3. package/dist/assistant-config.d.ts.map +1 -1
  4. package/dist/assistant-config.js +127 -0
  5. package/dist/assistant-config.js.map +1 -1
  6. package/dist/channels/bluesky/channel.d.ts.map +1 -1
  7. package/dist/channels/bluesky/channel.js +1 -0
  8. package/dist/channels/bluesky/channel.js.map +1 -1
  9. package/dist/channels/email/address.d.ts +44 -0
  10. package/dist/channels/email/address.d.ts.map +1 -0
  11. package/dist/channels/email/address.js +211 -0
  12. package/dist/channels/email/address.js.map +1 -0
  13. package/dist/channels/email/auth.d.ts +59 -0
  14. package/dist/channels/email/auth.d.ts.map +1 -0
  15. package/dist/channels/email/auth.js +212 -0
  16. package/dist/channels/email/auth.js.map +1 -0
  17. package/dist/channels/email/channel.d.ts +256 -0
  18. package/dist/channels/email/channel.d.ts.map +1 -0
  19. package/dist/channels/email/channel.js +701 -0
  20. package/dist/channels/email/channel.js.map +1 -0
  21. package/dist/channels/email/errors.d.ts +11 -0
  22. package/dist/channels/email/errors.d.ts.map +1 -0
  23. package/dist/channels/email/errors.js +13 -0
  24. package/dist/channels/email/errors.js.map +1 -0
  25. package/dist/channels/email/imap.d.ts +125 -0
  26. package/dist/channels/email/imap.d.ts.map +1 -0
  27. package/dist/channels/email/imap.js +309 -0
  28. package/dist/channels/email/imap.js.map +1 -0
  29. package/dist/channels/email/index.d.ts +7 -0
  30. package/dist/channels/email/index.d.ts.map +1 -0
  31. package/dist/channels/email/index.js +3 -0
  32. package/dist/channels/email/index.js.map +1 -0
  33. package/dist/channels/email/mime.d.ts +71 -0
  34. package/dist/channels/email/mime.d.ts.map +1 -0
  35. package/dist/channels/email/mime.js +83 -0
  36. package/dist/channels/email/mime.js.map +1 -0
  37. package/dist/channels/email/multipart.d.ts +28 -0
  38. package/dist/channels/email/multipart.d.ts.map +1 -0
  39. package/dist/channels/email/multipart.js +102 -0
  40. package/dist/channels/email/multipart.js.map +1 -0
  41. package/dist/channels/email/providers.d.ts +49 -0
  42. package/dist/channels/email/providers.d.ts.map +1 -0
  43. package/dist/channels/email/providers.js +292 -0
  44. package/dist/channels/email/providers.js.map +1 -0
  45. package/dist/channels/email/reply.d.ts +9 -0
  46. package/dist/channels/email/reply.d.ts.map +1 -0
  47. package/dist/channels/email/reply.js +122 -0
  48. package/dist/channels/email/reply.js.map +1 -0
  49. package/dist/channels/github/channel.d.ts +222 -0
  50. package/dist/channels/github/channel.d.ts.map +1 -0
  51. package/dist/channels/github/channel.js +736 -0
  52. package/dist/channels/github/channel.js.map +1 -0
  53. package/dist/channels/github/client.d.ts +45 -0
  54. package/dist/channels/github/client.d.ts.map +1 -0
  55. package/dist/channels/github/client.js +119 -0
  56. package/dist/channels/github/client.js.map +1 -0
  57. package/dist/channels/github/events.d.ts +49 -0
  58. package/dist/channels/github/events.d.ts.map +1 -0
  59. package/dist/channels/github/events.js +147 -0
  60. package/dist/channels/github/events.js.map +1 -0
  61. package/dist/channels/github/index.d.ts +4 -0
  62. package/dist/channels/github/index.d.ts.map +1 -0
  63. package/dist/channels/github/index.js +4 -0
  64. package/dist/channels/github/index.js.map +1 -0
  65. package/dist/channels/slack.d.ts.map +1 -1
  66. package/dist/channels/slack.js +2 -1
  67. package/dist/channels/slack.js.map +1 -1
  68. package/dist/channels/telegram.d.ts.map +1 -1
  69. package/dist/channels/telegram.js +14 -6
  70. package/dist/channels/telegram.js.map +1 -1
  71. package/dist/channels/threads/channel.d.ts.map +1 -1
  72. package/dist/channels/threads/channel.js +1 -0
  73. package/dist/channels/threads/channel.js.map +1 -1
  74. package/dist/channels/webchat.d.ts +8 -0
  75. package/dist/channels/webchat.d.ts.map +1 -1
  76. package/dist/channels/webchat.js +54 -15
  77. package/dist/channels/webchat.js.map +1 -1
  78. package/dist/channels/whatsapp.d.ts.map +1 -1
  79. package/dist/channels/whatsapp.js +13 -0
  80. package/dist/channels/whatsapp.js.map +1 -1
  81. package/dist/formatters/html.d.ts +16 -0
  82. package/dist/formatters/html.d.ts.map +1 -0
  83. package/dist/formatters/html.js +535 -0
  84. package/dist/formatters/html.js.map +1 -0
  85. package/dist/formatters/markdown.d.ts.map +1 -1
  86. package/dist/formatters/markdown.js +2 -0
  87. package/dist/formatters/markdown.js.map +1 -1
  88. package/dist/gateway.d.ts +91 -1
  89. package/dist/gateway.d.ts.map +1 -1
  90. package/dist/gateway.js +345 -46
  91. package/dist/gateway.js.map +1 -1
  92. package/dist/heartbeat.d.ts +5 -1
  93. package/dist/heartbeat.d.ts.map +1 -1
  94. package/dist/heartbeat.js +13 -3
  95. package/dist/heartbeat.js.map +1 -1
  96. package/dist/inbound-debounce.d.ts +4 -1
  97. package/dist/inbound-debounce.d.ts.map +1 -1
  98. package/dist/inbound-debounce.js +4 -2
  99. package/dist/inbound-debounce.js.map +1 -1
  100. package/dist/index.d.ts +8 -2
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +6 -2
  103. package/dist/index.js.map +1 -1
  104. package/dist/merge-messages.d.ts +3 -2
  105. package/dist/merge-messages.d.ts.map +1 -1
  106. package/dist/merge-messages.js +4 -2
  107. package/dist/merge-messages.js.map +1 -1
  108. package/dist/message-queue.d.ts +14 -4
  109. package/dist/message-queue.d.ts.map +1 -1
  110. package/dist/message-queue.js +36 -35
  111. package/dist/message-queue.js.map +1 -1
  112. package/dist/runtime-builder.d.ts +9 -0
  113. package/dist/runtime-builder.d.ts.map +1 -1
  114. package/dist/runtime-builder.js +149 -5
  115. package/dist/runtime-builder.js.map +1 -1
  116. package/dist/thread-key.d.ts +50 -0
  117. package/dist/thread-key.d.ts.map +1 -0
  118. package/dist/thread-key.js +82 -0
  119. package/dist/thread-key.js.map +1 -0
  120. package/package.json +26 -8
@@ -0,0 +1,736 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { z } from 'zod';
4
+ import { FeedError } from '../../feeds/errors.js';
5
+ import { chunkMessage } from '../../formatters/markdown.js';
6
+ import { GitHubClient } from './client.js';
7
+ import { githubInboundOf, isGitHubEventName, } from './events.js';
8
+ /** The longest comment GitHub takes, in characters. */
9
+ export const GITHUB_COMMENT_LIMIT = 65_536;
10
+ const MAX_TRACKED = 1000;
11
+ const MAX_BODY = 1024 * 1024;
12
+ /** The shortest time between two edits of one comment, under GitHub's limit on content changes. */
13
+ export const GITHUB_MIN_EDIT_INTERVAL = 3000;
14
+ /** How long to wait before trying to load the bot's login again for a delivery, in ms. */
15
+ const IDENTIFY_RETRY_DELAYS = [2000, 10_000];
16
+ /** The authors the bot answers by default: the repository's owner, the organization's members and collaborators. */
17
+ export const GITHUB_TRUSTED_ASSOCIATIONS = [
18
+ 'OWNER',
19
+ 'MEMBER',
20
+ 'COLLABORATOR',
21
+ ];
22
+ /**
23
+ * The reactions GitHub has, by the emoji each shows, and the nearest one for
24
+ * the emojis of status reactions: thinking is eyes, a tool a rocket, an error
25
+ * confused.
26
+ */
27
+ const REACTIONS = {
28
+ '\u{1F44D}': '+1',
29
+ '\u{1F44E}': '-1',
30
+ '\u{1F604}': 'laugh',
31
+ '\u{1F606}': 'laugh',
32
+ '\u{1F615}': 'confused',
33
+ '❤️': 'heart',
34
+ '❤': 'heart',
35
+ '\u{1F389}': 'hooray',
36
+ '\u{1F680}': 'rocket',
37
+ '\u{1F440}': 'eyes',
38
+ '\u{1F914}': 'eyes',
39
+ '\u{1F525}': 'rocket',
40
+ '\u{1F631}': 'confused',
41
+ };
42
+ const REACTION_NAMES = new Set(Object.values(REACTIONS));
43
+ const CommentSchema = z.object({ id: z.number() });
44
+ const ReactionSchema = z.object({ id: z.number() });
45
+ const UserSchema = z.object({ login: z.string() });
46
+ const MESSAGE_KINDS = ['issue', 'comment', 'review_comment', 'review'];
47
+ /** A header by its name in any letter case, as HTTP header names are. */
48
+ function header(headers, name) {
49
+ const wanted = name.toLowerCase();
50
+ const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === wanted);
51
+ const value = key === undefined ? undefined : headers[key];
52
+ return Array.isArray(value) ? value[0] : value;
53
+ }
54
+ /** Whether `signature` (`sha256=<hex>`) is the HMAC SHA-256 of `body` with `secret`. */
55
+ export function verifyGitHubSignature(body, signature, secret) {
56
+ if (!signature?.startsWith('sha256='))
57
+ return false;
58
+ const expected = Buffer.from(createHmac('sha256', secret).update(body).digest('hex'));
59
+ const given = Buffer.from(signature.slice('sha256='.length).toLowerCase());
60
+ return given.length === expected.length && timingSafeEqual(given, expected);
61
+ }
62
+ /** The JSON of a delivery, sent as `application/json` or as the `payload` field of a form. */
63
+ function payloadOf(raw, contentType) {
64
+ const text = typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf-8');
65
+ if (contentType?.toLowerCase().includes('application/x-www-form-urlencoded')) {
66
+ const payload = new URLSearchParams(text).get('payload');
67
+ if (payload === null)
68
+ throw new Error('The form has no payload field');
69
+ return JSON.parse(payload);
70
+ }
71
+ return JSON.parse(text);
72
+ }
73
+ /** The repository and issue or pull request a `channelId` (`owner/repo#number`) names. */
74
+ function parseConversation(channelId) {
75
+ const match = /^([^/\s#]+)\/([^/\s#]+)#(\d+)$/.exec(channelId);
76
+ if (!match) {
77
+ throw new Error(`[github] Unknown channel id "${channelId}": use owner/repo#number`);
78
+ }
79
+ return { owner: match[1], repo: match[2], number: Number(match[3]) };
80
+ }
81
+ function parseMessageId(messageId) {
82
+ const match = /^([a-z_]+):(\d+)$/.exec(messageId);
83
+ const kind = MESSAGE_KINDS.find((candidate) => candidate === match?.[1]);
84
+ if (!match || !kind) {
85
+ throw new Error(`[github] Unknown message id "${messageId}": use issue:, comment:, review_comment: or review: and a number`);
86
+ }
87
+ return { kind, id: Number(match[2]) };
88
+ }
89
+ function repoPath({ owner, repo }) {
90
+ return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
91
+ }
92
+ /** The review thread a reply goes to: the `topicId`, or the review comment it replies to. */
93
+ function reviewThreadOf(options) {
94
+ if (options?.topicId !== undefined) {
95
+ if (!/^\d+$/.test(options.topicId)) {
96
+ throw new Error(`[github] Unknown topic "${options.topicId}": use the id of a review thread's first comment`);
97
+ }
98
+ return Number(options.topicId);
99
+ }
100
+ if (options?.replyTo?.startsWith('review_comment:'))
101
+ return parseMessageId(options.replyTo).id;
102
+ return undefined;
103
+ }
104
+ function escapeRegExp(text) {
105
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
106
+ }
107
+ /**
108
+ * The text a mention counts in: without code (fenced, indented or inline)
109
+ * and quoted lines, which only repeat a mention.
110
+ */
111
+ function mentionableText(text) {
112
+ const paragraphs = [[]];
113
+ const keep = (line) => paragraphs[paragraphs.length - 1].push(line);
114
+ const breakParagraph = () => {
115
+ if (paragraphs[paragraphs.length - 1].length > 0)
116
+ paragraphs.push([]);
117
+ };
118
+ let fence;
119
+ let blank = true;
120
+ let indentedCode = false;
121
+ let inList = false;
122
+ for (const line of text.split('\n')) {
123
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
124
+ if (fence) {
125
+ if (marker?.[0] === fence[0] && marker.length >= fence.length)
126
+ fence = undefined;
127
+ continue;
128
+ }
129
+ if (!line.trim()) {
130
+ blank = true;
131
+ breakParagraph();
132
+ continue;
133
+ }
134
+ if (/^( {4}|\t)/.test(line)) {
135
+ indentedCode = indentedCode || (blank && !inList);
136
+ blank = false;
137
+ if (indentedCode)
138
+ breakParagraph();
139
+ else
140
+ keep(line);
141
+ continue;
142
+ }
143
+ blank = false;
144
+ indentedCode = false;
145
+ inList = /^ {0,3}([-*+]|\d+[.)])\s/.test(line);
146
+ if (marker) {
147
+ fence = marker;
148
+ breakParagraph();
149
+ }
150
+ else if (/^ {0,3}>/.test(line))
151
+ breakParagraph();
152
+ else
153
+ keep(line);
154
+ }
155
+ return paragraphs.map((lines) => withoutCodeSpans(lines.join('\n'))).join('\n');
156
+ }
157
+ /**
158
+ * `text` without its Markdown code spans: a run of backticks opens one that the next run of the
159
+ * same length closes, and a run nothing closes is plain text. Linear in the length of `text`.
160
+ */
161
+ function withoutCodeSpans(text) {
162
+ const runs = [];
163
+ for (let i = 0; i < text.length;) {
164
+ if (text[i] !== '`') {
165
+ i++;
166
+ continue;
167
+ }
168
+ const start = i;
169
+ while (i < text.length && text[i] === '`')
170
+ i++;
171
+ runs.push({ start, length: i - start });
172
+ }
173
+ const nextOfLength = new Map();
174
+ runs.forEach((run, index) => {
175
+ const indexes = nextOfLength.get(run.length) ?? [];
176
+ indexes.push(index);
177
+ nextOfLength.set(run.length, indexes);
178
+ });
179
+ const cursor = new Map();
180
+ let out = '';
181
+ let from = 0;
182
+ for (let index = 0; index < runs.length; index++) {
183
+ const run = runs[index];
184
+ if (run.start < from)
185
+ continue;
186
+ const sameLength = nextOfLength.get(run.length) ?? [];
187
+ let position = cursor.get(run.length) ?? 0;
188
+ while (position < sameLength.length && sameLength[position] <= index)
189
+ position++;
190
+ cursor.set(run.length, position);
191
+ const closing = sameLength[position];
192
+ if (closing === undefined)
193
+ continue;
194
+ out += text.slice(from, run.start);
195
+ from = runs[closing].start + run.length;
196
+ index = closing;
197
+ }
198
+ return out + text.slice(from);
199
+ }
200
+ /** A login without the `[bot]` an app's login ends with, in lower case. */
201
+ function loginKey(login) {
202
+ return login.replace(/\[bot\]$/i, '').toLowerCase();
203
+ }
204
+ function sleep(ms) {
205
+ return new Promise((resolve) => setTimeout(resolve, ms));
206
+ }
207
+ /** Keeps a bounded memory: past `MAX_TRACKED` entries the oldest is forgotten. */
208
+ function forgetOldest(collection) {
209
+ if (collection.size <= MAX_TRACKED)
210
+ return;
211
+ const oldest = collection.keys().next().value;
212
+ if (oldest !== undefined)
213
+ collection.delete(oldest);
214
+ }
215
+ class BodyTooLarge extends Error {
216
+ }
217
+ function readBody(req) {
218
+ return new Promise((resolve, reject) => {
219
+ const chunks = [];
220
+ let size = 0;
221
+ req.on('data', (chunk) => {
222
+ size += chunk.length;
223
+ if (size > MAX_BODY) {
224
+ reject(new BodyTooLarge('Webhook body too large'));
225
+ req.pause();
226
+ return;
227
+ }
228
+ chunks.push(chunk);
229
+ });
230
+ req.on('end', () => resolve(Buffer.concat(chunks)));
231
+ req.on('error', reject);
232
+ });
233
+ }
234
+ /**
235
+ * A GitHub channel: an agent answers issues, pull requests, their comments
236
+ * and reviews, in the repositories a webhook sends events from. Each issue or
237
+ * pull request is one conversation, `channelId` `owner/repo#number`, and
238
+ * answers are comments on it. A comment on a line of a pull request is
239
+ * answered in its review thread, which is the message's `topicId` (the id of
240
+ * the thread's first comment), so every review thread is a conversation of
241
+ * its own. Deliveries are checked against the webhook secret, answered at
242
+ * once and handled after.
243
+ */
244
+ export class GitHubChannel {
245
+ type = 'github';
246
+ editable;
247
+ nativeMarkdown = true;
248
+ maxMessageChars = GITHUB_COMMENT_LIMIT;
249
+ config;
250
+ client;
251
+ handler = null;
252
+ deliveries = new Set();
253
+ handled = new Set();
254
+ reactions = new Map();
255
+ reacting = new Map();
256
+ edits = new Map();
257
+ /** People who wrote to the bot in a conversation and were not answered yet, by conversation. */
258
+ waiting = new Map();
259
+ /** The conversation and author of each message handed to the agent, by `channelId|messageId`. */
260
+ askers = new Map();
261
+ engaged = new Set();
262
+ server;
263
+ identity;
264
+ botLogin;
265
+ running = false;
266
+ stopped = false;
267
+ constructor(config) {
268
+ if (!config.webhook?.secret) {
269
+ throw new Error('[github] webhook.secret is required: GitHub signs deliveries with it');
270
+ }
271
+ this.config = config;
272
+ this.editable = config.stream === true;
273
+ this.client = new GitHubClient({
274
+ token: config.token,
275
+ ...(config.apiUrl && { apiUrl: config.apiUrl }),
276
+ ...(config.fetch && { fetch: config.fetch }),
277
+ });
278
+ if (config.login)
279
+ this.botLogin = config.login;
280
+ }
281
+ /** The bot's login, once known: as configured, or loaded by `start()` or the first delivery. */
282
+ get login() {
283
+ return this.botLogin;
284
+ }
285
+ onMessage(handler) {
286
+ this.handler = handler;
287
+ }
288
+ async start() {
289
+ if (this.running)
290
+ return;
291
+ await this.identify();
292
+ if (!this.config.repositories?.length) {
293
+ console.warn('[github] No repositories set: the bot answers in every repository that sends it events');
294
+ }
295
+ this.running = true;
296
+ this.stopped = false;
297
+ const { port, path, host } = this.config.webhook;
298
+ if (port !== undefined) {
299
+ try {
300
+ await this.listen(port, path ?? '/github/webhook', host);
301
+ }
302
+ catch (error) {
303
+ this.running = false;
304
+ throw error;
305
+ }
306
+ }
307
+ }
308
+ async stop() {
309
+ this.running = false;
310
+ this.stopped = true;
311
+ const server = this.server;
312
+ this.server = undefined;
313
+ if (server) {
314
+ server.closeAllConnections();
315
+ await new Promise((resolve) => server.close(() => resolve()));
316
+ }
317
+ }
318
+ /**
319
+ * Answers a webhook delivery: checks its signature, answers at once and
320
+ * hands the event to the agent after. Mount it in any HTTP server, with the
321
+ * raw body unparsed. It works before `start()` too, and answers `503` once
322
+ * the channel is stopped. A delivery GitHub sends again is handled once:
323
+ * by its `X-GitHub-Delivery` and by the comment, issue or review it
324
+ * carries, which the signature covers. Both are remembered in memory only,
325
+ * for the latest 1000, so a delivery sent again after a restart is
326
+ * handled again.
327
+ */
328
+ async handleWebhook(request) {
329
+ if (this.stopped)
330
+ return { status: 503, body: 'The channel is stopped' };
331
+ if (request.method !== 'POST')
332
+ return { status: 405, body: 'Method not allowed' };
333
+ const raw = request.rawBody ?? '';
334
+ const signature = header(request.headers, 'x-hub-signature-256');
335
+ if (!verifyGitHubSignature(raw, signature, this.config.webhook.secret)) {
336
+ return { status: 401, body: 'Invalid signature' };
337
+ }
338
+ const event = header(request.headers, 'x-github-event');
339
+ if (!event)
340
+ return { status: 400, body: 'Missing X-GitHub-Event' };
341
+ if (event === 'ping')
342
+ return { status: 200, body: 'pong' };
343
+ let payload;
344
+ try {
345
+ payload = payloadOf(raw, header(request.headers, 'content-type'));
346
+ }
347
+ catch {
348
+ return { status: 400, body: 'Invalid JSON' };
349
+ }
350
+ if (!isGitHubEventName(event))
351
+ return { status: 200, body: 'Ignored' };
352
+ const delivery = header(request.headers, 'x-github-delivery');
353
+ if (delivery !== undefined) {
354
+ if (this.deliveries.has(delivery))
355
+ return { status: 200, body: 'Already received' };
356
+ this.deliveries.add(delivery);
357
+ forgetOldest(this.deliveries);
358
+ }
359
+ void this.receive(event, payload)
360
+ .then((taken) => {
361
+ if (!taken && delivery !== undefined)
362
+ this.deliveries.delete(delivery);
363
+ })
364
+ .catch((error) => console.error('[github] Webhook delivery failed:', error));
365
+ return { status: 200, body: 'OK' };
366
+ }
367
+ /**
368
+ * A Node request listener that answers webhook deliveries on any path it
369
+ * is mounted at, such as a route of a server that serves other things too.
370
+ * Bodies over 1 MB are refused with `413`.
371
+ */
372
+ webhookListener() {
373
+ return (req, res) => {
374
+ readBody(req)
375
+ .then((rawBody) => this.handleWebhook({ method: req.method ?? 'GET', headers: req.headers, rawBody }))
376
+ .then((response) => res.writeHead(response.status, { 'content-type': 'text/plain' }).end(response.body))
377
+ .catch((error) => {
378
+ if (error instanceof BodyTooLarge) {
379
+ res.writeHead(413, { 'content-type': 'text/plain', connection: 'close' });
380
+ res.end('Payload too large');
381
+ return;
382
+ }
383
+ console.error('[github] Webhook request failed:', error);
384
+ if (!res.headersSent)
385
+ res.writeHead(500);
386
+ res.end();
387
+ });
388
+ };
389
+ }
390
+ /**
391
+ * Comments on the issue or pull request of `channelId`, as several
392
+ * comments when the text is longer than GitHub takes. With a `topicId`, or
393
+ * a `replyTo` of a review comment, the comments are replies in that review
394
+ * thread. Resolves with the first comment's id.
395
+ */
396
+ async sendText(channelId, text, options) {
397
+ const conversation = parseConversation(channelId);
398
+ const thread = reviewThreadOf(options);
399
+ const parts = chunkMessage(text, GITHUB_COMMENT_LIMIT).filter((part) => part.trim());
400
+ if (parts.length === 0)
401
+ throw new Error('[github] Nothing to send');
402
+ const ids = [];
403
+ for (const part of parts)
404
+ ids.push(await this.comment(conversation, part, thread));
405
+ this.answered(channelId, thread, options?.replyTo);
406
+ return ids[0] ?? '';
407
+ }
408
+ /**
409
+ * Replaces the text of a comment the bot made (`comment:<id>` or
410
+ * `review_comment:<id>`). Edits of one comment go out at least
411
+ * `GITHUB_MIN_EDIT_INTERVAL` apart: an edit made sooner waits, and edits
412
+ * made while it waits are folded into it, the latest text winning.
413
+ */
414
+ async editText(channelId, messageId, text) {
415
+ const conversation = parseConversation(channelId);
416
+ if (text.length > GITHUB_COMMENT_LIMIT) {
417
+ throw new Error(`[github] A comment holds at most ${GITHUB_COMMENT_LIMIT} characters`);
418
+ }
419
+ const path = this.commentPath(conversation, messageId, 'edited');
420
+ const state = this.edits.get(path) ?? { text, lastAt: 0 };
421
+ state.text = text;
422
+ if (!this.edits.has(path)) {
423
+ this.edits.set(path, state);
424
+ forgetOldest(this.edits);
425
+ }
426
+ state.flight ??= this.flushEdit(path, state);
427
+ return state.flight;
428
+ }
429
+ /** Sends the latest text of an edit once the comment's last edit is far enough back. */
430
+ async flushEdit(path, state) {
431
+ const wait = state.lastAt + GITHUB_MIN_EDIT_INTERVAL - Date.now();
432
+ await (wait > 0 ? sleep(wait) : Promise.resolve());
433
+ state.flight = undefined;
434
+ state.lastAt = Date.now();
435
+ await this.client.request('PATCH', path, { body: state.text });
436
+ }
437
+ /**
438
+ * GitHub has no API to upload files to comments, so a file with a public
439
+ * `url` is sent as a comment that links it (an image shows inline), and a
440
+ * file that only has a `buffer` is refused.
441
+ */
442
+ async sendFile(channelId, file, options) {
443
+ if (!file.url || !/^https?:\/\//i.test(file.url)) {
444
+ throw new Error('[github] GitHub has no API to upload files to comments: send a file with a public url');
445
+ }
446
+ const name = (file.filename ?? (file.type === 'image' ? 'image' : 'file')).replace(/[[\]]/g, '');
447
+ const link = `${file.type === 'image' ? '!' : ''}[${name}](<${file.url}>)`;
448
+ return this.sendText(channelId, file.caption ? `${file.caption}\n\n${link}` : link, options);
449
+ }
450
+ async sendTyping() { }
451
+ /** Deletes a comment the bot made. */
452
+ async deleteMessage(channelId, messageId) {
453
+ const conversation = parseConversation(channelId);
454
+ await this.client.request('DELETE', this.commentPath(conversation, messageId, 'deleted'));
455
+ }
456
+ /**
457
+ * Reacts to an issue, pull request or comment, replacing the bot's earlier
458
+ * reaction on it. GitHub has eight reactions (+1, -1, laugh, confused,
459
+ * heart, hooray, rocket, eyes), named or as their emoji, and the status
460
+ * emojis of thinking, a tool and an error become eyes, rocket and
461
+ * confused. Other emojis are skipped, and so are reviews, which take no
462
+ * reactions. Reactions to one message are set one after another, so the
463
+ * last one called is the one that stays.
464
+ */
465
+ setReaction(channelId, messageId, emoji) {
466
+ const content = REACTIONS[emoji] ?? (REACTION_NAMES.has(emoji) ? emoji : undefined);
467
+ if (!content)
468
+ return Promise.resolve();
469
+ const path = this.reactionsPath(parseConversation(channelId), parseMessageId(messageId));
470
+ if (!path)
471
+ return Promise.resolve();
472
+ const key = `${channelId}:${messageId}`;
473
+ const previous = this.reacting.get(key) ?? Promise.resolve();
474
+ const next = previous.catch(() => undefined).then(() => this.react(key, path, content));
475
+ this.reacting.set(key, next);
476
+ void next
477
+ .catch(() => undefined)
478
+ .finally(() => {
479
+ if (this.reacting.get(key) === next)
480
+ this.reacting.delete(key);
481
+ });
482
+ return next;
483
+ }
484
+ async react(key, path, content) {
485
+ const previous = this.reactions.get(key);
486
+ if (previous?.content === content)
487
+ return;
488
+ const created = ReactionSchema.parse(await this.client.request('POST', path, { content }));
489
+ this.reactions.delete(key);
490
+ this.reactions.set(key, { id: created.id, content });
491
+ forgetOldest(this.reactions);
492
+ if (previous && previous.id !== created.id) {
493
+ await this.client.request('DELETE', `${path}/${previous.id}`);
494
+ }
495
+ }
496
+ async comment(conversation, body, thread) {
497
+ const base = repoPath(conversation);
498
+ if (thread !== undefined) {
499
+ const created = CommentSchema.parse(await this.client.request('POST', `${base}/pulls/${conversation.number}/comments/${thread}/replies`, { body }));
500
+ return `review_comment:${created.id}`;
501
+ }
502
+ const created = CommentSchema.parse(await this.client.request('POST', `${base}/issues/${conversation.number}/comments`, { body }));
503
+ return `comment:${created.id}`;
504
+ }
505
+ commentPath(conversation, messageId, verb) {
506
+ const ref = parseMessageId(messageId);
507
+ const base = repoPath(conversation);
508
+ if (ref.kind === 'comment')
509
+ return `${base}/issues/comments/${ref.id}`;
510
+ if (ref.kind === 'review_comment')
511
+ return `${base}/pulls/comments/${ref.id}`;
512
+ throw new Error(`[github] Only comments can be ${verb}, not "${messageId}"`);
513
+ }
514
+ reactionsPath(conversation, ref) {
515
+ const base = repoPath(conversation);
516
+ switch (ref.kind) {
517
+ case 'issue':
518
+ return `${base}/issues/${ref.id}/reactions`;
519
+ case 'comment':
520
+ return `${base}/issues/comments/${ref.id}/reactions`;
521
+ case 'review_comment':
522
+ return `${base}/pulls/comments/${ref.id}/reactions`;
523
+ case 'review':
524
+ return undefined;
525
+ }
526
+ }
527
+ /**
528
+ * Notes that the bot answered in a conversation. The person the answer is
529
+ * for (its `replyTo`, or everyone waiting there when it names no message
530
+ * the bot was asked in) may write on without a mention, and in a review
531
+ * thread everyone may.
532
+ */
533
+ answered(channelId, thread, replyTo) {
534
+ const conversation = thread === undefined ? channelId : `${channelId}:${thread}`;
535
+ if (thread !== undefined)
536
+ this.remember(this.engaged, conversation);
537
+ const asker = replyTo === undefined ? undefined : this.askers.get(`${channelId}|${replyTo}`);
538
+ if (asker) {
539
+ this.remember(this.engaged, `${asker.conversation}|${asker.userId}`);
540
+ this.waiting.get(asker.conversation)?.delete(asker.userId);
541
+ return;
542
+ }
543
+ for (const userId of this.waiting.get(conversation) ?? []) {
544
+ this.remember(this.engaged, `${conversation}|${userId}`);
545
+ }
546
+ this.waiting.delete(conversation);
547
+ }
548
+ remember(set, key) {
549
+ set.delete(key);
550
+ set.add(key);
551
+ forgetOldest(set);
552
+ }
553
+ /** Whether a message reaches the bot without a mention: in a conversation it answered. */
554
+ takesWithoutMention(conversation, userId) {
555
+ if (this.engaged.has(conversation))
556
+ return true;
557
+ return this.engaged.delete(`${conversation}|${userId}`);
558
+ }
559
+ /**
560
+ * Hands a delivery's event to the agent when it is one to answer. Resolves
561
+ * false when the event was dropped because the bot's login could not be
562
+ * loaded or the channel stopped, so the delivery may be sent again.
563
+ */
564
+ async receive(event, payload) {
565
+ const inbound = githubInboundOf(event, payload);
566
+ if (!inbound || !this.handler)
567
+ return true;
568
+ if (!this.allowsRepository(inbound.repository) || !this.trusts(inbound))
569
+ return true;
570
+ if (!(await this.loginForDelivery()) || this.stopped)
571
+ return false;
572
+ if (this.isOwnOrBot(inbound.sender))
573
+ return true;
574
+ const text = this.stripMention(inbound.text);
575
+ if (!text)
576
+ return true;
577
+ const key = `${inbound.repository.toLowerCase()}:${inbound.id}`;
578
+ if (this.handled.has(key))
579
+ return true;
580
+ const channelId = `${inbound.repository}#${inbound.number}`;
581
+ const conversation = inbound.topicId ? `${channelId}:${inbound.topicId}` : channelId;
582
+ const userId = String(inbound.sender.id);
583
+ if (this.config.trigger !== 'all' &&
584
+ !this.mentionsBot(inbound.text) &&
585
+ !this.takesWithoutMention(conversation, userId)) {
586
+ return true;
587
+ }
588
+ this.remember(this.handled, key);
589
+ const waiting = this.waiting.get(conversation) ?? new Set();
590
+ waiting.add(userId);
591
+ this.waiting.delete(conversation);
592
+ this.waiting.set(conversation, waiting);
593
+ forgetOldest(this.waiting);
594
+ this.askers.set(`${channelId}|${inbound.id}`, { conversation, userId });
595
+ forgetOldest(this.askers);
596
+ const message = {
597
+ id: inbound.id,
598
+ channelType: this.type,
599
+ channelId,
600
+ userId,
601
+ userName: inbound.sender.login,
602
+ groupId: inbound.repository,
603
+ text,
604
+ ...(inbound.replyTo && { replyTo: inbound.replyTo }),
605
+ ...(inbound.topicId && { topicId: inbound.topicId }),
606
+ raw: payload,
607
+ };
608
+ try {
609
+ await this.handler(message);
610
+ }
611
+ catch (error) {
612
+ console.error('[github] Message handler error:', error);
613
+ }
614
+ return true;
615
+ }
616
+ /**
617
+ * The bot's login for a delivery: loaded when it is not known yet, and
618
+ * tried again a few times, since GitHub does not send a delivery again by
619
+ * itself. The delivery is dropped, with an error logged, when it cannot be
620
+ * loaded.
621
+ */
622
+ async loginForDelivery() {
623
+ let failure;
624
+ for (const delay of [0, ...IDENTIFY_RETRY_DELAYS]) {
625
+ if (delay > 0)
626
+ await sleep(delay);
627
+ if (this.stopped)
628
+ return undefined;
629
+ try {
630
+ return await this.identify();
631
+ }
632
+ catch (error) {
633
+ failure = error;
634
+ }
635
+ }
636
+ console.error('[github] Could not load the bot login, a delivery was dropped:', failure);
637
+ return undefined;
638
+ }
639
+ /** Loads the bot's login once, unless it is configured. */
640
+ identify() {
641
+ if (this.botLogin)
642
+ return Promise.resolve(this.botLogin);
643
+ this.identity ??= this.client
644
+ .request('GET', '/user')
645
+ .then((user) => {
646
+ const { login } = UserSchema.parse(user);
647
+ this.botLogin = login;
648
+ return login;
649
+ })
650
+ .catch((error) => {
651
+ this.identity = undefined;
652
+ if (error instanceof FeedError && error.code === 'auth') {
653
+ throw new FeedError('github', 'auth', 'GET /user failed: set `login` when the token is a GitHub App installation token', { ...(error.status !== undefined && { status: error.status }), cause: error });
654
+ }
655
+ throw error;
656
+ });
657
+ return this.identity;
658
+ }
659
+ allowsRepository(repository) {
660
+ const allowed = this.config.repositories;
661
+ if (!allowed || allowed.length === 0)
662
+ return true;
663
+ const name = repository.toLowerCase();
664
+ const owner = name.split('/')[0];
665
+ return allowed.some((entry) => {
666
+ const wanted = entry.toLowerCase();
667
+ return wanted === name || wanted === `${owner}/*`;
668
+ });
669
+ }
670
+ /** Whether the author may talk to the bot: by their association, or named in `authors`. */
671
+ trusts(inbound) {
672
+ const login = loginKey(inbound.sender.login);
673
+ if (this.config.authors?.some((author) => loginKey(author) === login))
674
+ return true;
675
+ const associations = this.config.associations ?? GITHUB_TRUSTED_ASSOCIATIONS;
676
+ return associations === 'any' || associations.some((value) => value === inbound.association);
677
+ }
678
+ isOwnOrBot(sender) {
679
+ if (this.botLogin && loginKey(sender.login) === loginKey(this.botLogin))
680
+ return true;
681
+ return sender.type === 'Bot' && this.config.allowBots !== true;
682
+ }
683
+ /** The name people mention the bot by: its login, without `[bot]` for an app. */
684
+ mentionName() {
685
+ return this.botLogin?.replace(/\[bot\]$/i, '');
686
+ }
687
+ mentionsBot(text) {
688
+ const name = this.mentionName();
689
+ if (!name)
690
+ return false;
691
+ return new RegExp(`(^|[^\\w/@.-])@${escapeRegExp(name)}(?![\\w-])`, 'i').test(mentionableText(text));
692
+ }
693
+ stripMention(text) {
694
+ const name = this.mentionName();
695
+ const trimmed = text.trim();
696
+ if (!name)
697
+ return trimmed;
698
+ return (trimmed.replace(new RegExp(`^@${escapeRegExp(name)}(?![\\w-])[\\s,:]*`, 'i'), '').trim() ||
699
+ trimmed);
700
+ }
701
+ listen(port, path, host) {
702
+ const listener = this.webhookListener();
703
+ const server = createServer((req, res) => {
704
+ let pathname;
705
+ try {
706
+ pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
707
+ }
708
+ catch {
709
+ res.writeHead(400, { 'content-type': 'text/plain' }).end('Bad request');
710
+ return;
711
+ }
712
+ if (pathname !== path) {
713
+ res.writeHead(404).end();
714
+ return;
715
+ }
716
+ listener(req, res);
717
+ });
718
+ this.server = server;
719
+ return new Promise((resolve, reject) => {
720
+ const failed = (error) => {
721
+ this.server = undefined;
722
+ reject(error);
723
+ };
724
+ server.once('error', failed);
725
+ server.listen(port, host, () => {
726
+ server.off('error', failed);
727
+ resolve();
728
+ });
729
+ });
730
+ }
731
+ }
732
+ /** A GitHub channel that answers issues, pull requests, comments and reviews. */
733
+ export function githubChannel(config) {
734
+ return new GitHubChannel(config);
735
+ }
736
+ //# sourceMappingURL=channel.js.map