pi-roundtable 0.17.3 → 0.19.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.
@@ -0,0 +1,488 @@
1
+ import { RESTJSONErrorCodes, Routes } from "discord.js";
2
+ import type {
3
+ DiscordMessage,
4
+ DiscordPoster,
5
+ PosterFile,
6
+ RecentMessage,
7
+ } from "./poster.ts";
8
+ import {
9
+ cardBody,
10
+ checkAvatar,
11
+ checkedPost,
12
+ checkedText,
13
+ checkSnowflake,
14
+ checkWebhookName,
15
+ invalid,
16
+ } from "./poster-checks.ts";
17
+ import { DiscordPosterError, failure, isCode } from "./poster-errors.ts";
18
+ import {
19
+ type DiscordRest,
20
+ object,
21
+ restCore,
22
+ text,
23
+ } from "./rest-poster-core.ts";
24
+ import { guildOperations } from "./rest-poster-guild.ts";
25
+
26
+ export type { DiscordRest, RestOptions } from "./rest-poster-core.ts";
27
+ export { restOf } from "./rest-poster-core.ts";
28
+
29
+ /** How many pins Discord lists in a page, and how many pages the poster reads (250 pins, the most a channel keeps). */
30
+ const PIN_PAGE = 50;
31
+ const PIN_PAGES = 5;
32
+
33
+ interface Webhook {
34
+ id: string;
35
+ token: string;
36
+ }
37
+
38
+ /** The files as the REST client sends them. */
39
+ function rawFiles(files: readonly PosterFile[]) {
40
+ return files.map((file) => ({
41
+ name: file.name,
42
+ data: file.data,
43
+ ...(file.contentType ? { contentType: file.contentType } : {}),
44
+ }));
45
+ }
46
+
47
+ function messageIdOf(sent: unknown): string {
48
+ const messageId = text(object(sent).id);
49
+ if (!messageId)
50
+ throw new DiscordPosterError("rejected", "Discord returned no message");
51
+ return messageId;
52
+ }
53
+
54
+ /**
55
+ * The poster over Discord's REST API. A webhook is found by name among the ones the bot made in the
56
+ * channel, or made, the first time the channel is used under that name. Their ids and tokens are
57
+ * held in this process's memory only and are never logged, put in an error or kept on disk.
58
+ */
59
+ export function restPoster(
60
+ rest: DiscordRest,
61
+ options: { renameWaitMs?: number } = {},
62
+ ): DiscordPoster {
63
+ const core = restCore(rest);
64
+ // Held in the closure so that printing or serializing the poster shows no token.
65
+ const webhooks = new Map<string, Webhook>();
66
+ const finding = new Map<string, Promise<Webhook | undefined>>();
67
+ const madeListeners = new Set<
68
+ (channelId: string, webhook: string) => Promise<void>
69
+ >();
70
+
71
+ /** The channel's webhooks the bot made. */
72
+ const listMine = async (
73
+ channelId: string,
74
+ ): Promise<Record<string, unknown>[]> => {
75
+ const listed = await rest
76
+ .get(Routes.channelWebhooks(channelId))
77
+ .catch((error: unknown) => {
78
+ throw failure("listing the channel's webhooks", error);
79
+ });
80
+ const mine = await core.bot();
81
+ return (Array.isArray(listed) ? listed : [])
82
+ .map(object)
83
+ .filter((hook) => object(hook.user).id === mine);
84
+ };
85
+
86
+ /** The channel's webhook of that name; made when there is none, unless `make` is false (then undefined). */
87
+ const find = async (
88
+ channelId: string,
89
+ name: string,
90
+ make: boolean,
91
+ release: () => void,
92
+ ): Promise<Webhook | undefined> => {
93
+ for (const hook of await listMine(channelId)) {
94
+ const id = text(hook.id);
95
+ const token = text(hook.token);
96
+ if (hook.name === name && id && token) return { id, token };
97
+ }
98
+ if (!make) return undefined;
99
+ const made = object(
100
+ await rest
101
+ .post(Routes.channelWebhooks(channelId), { body: { name } })
102
+ .catch((error: unknown) => {
103
+ throw failure("making the channel's webhook", error);
104
+ }),
105
+ );
106
+ const id = text(made.id);
107
+ const token = text(made.token);
108
+ if (!id || !token)
109
+ throw new DiscordPosterError(
110
+ "rejected",
111
+ "Discord made no usable webhook",
112
+ );
113
+ // The new webhook has the default picture; whoever kept an avatar for the channel forgets it
114
+ // before the webhook is used, so a late record cannot outlive the picture it describes.
115
+ // The webhook is kept, and the look released, before the listeners run: one that posts through
116
+ // it (or sets its picture) finds it instead of waiting on this very look, and a listener that
117
+ // never returns holds only the call that made the webhook, not later looks for it. Concurrent
118
+ // calls may use the new webhook before the listeners finish.
119
+ const fresh = { id, token };
120
+ webhooks.set(`${channelId}:${name}`, fresh);
121
+ release();
122
+ for (const listener of madeListeners)
123
+ await listener(channelId, name).catch(() => undefined);
124
+ return fresh;
125
+ };
126
+
127
+ const webhook = (
128
+ channelId: string,
129
+ name: string,
130
+ make: boolean,
131
+ ): Promise<Webhook | undefined> => {
132
+ const slot = `${channelId}:${name}`;
133
+ const known = webhooks.get(slot);
134
+ if (known) return Promise.resolve(known);
135
+ // A look that may not make the webhook answers `undefined` for a channel that has none, which
136
+ // a call that needs one must not share: the two are different questions.
137
+ const search = `${slot}:${make ? "make" : "find"}`;
138
+ let pending = finding.get(search);
139
+ if (!pending) {
140
+ const release = () => {
141
+ if (finding.get(search) === pending) finding.delete(search);
142
+ };
143
+ pending = find(channelId, name, make, release)
144
+ .then((found) => {
145
+ // A made webhook is already kept; one made again while its listeners ran must not be replaced
146
+ // by this older one.
147
+ if (found && !webhooks.has(slot)) webhooks.set(slot, found);
148
+ return found;
149
+ })
150
+ .finally(release);
151
+ finding.set(search, pending);
152
+ }
153
+ return pending;
154
+ };
155
+
156
+ /** Runs a call through the channel's webhook; a webhook deleted meanwhile is found or made again once. */
157
+ const through = async <T>(
158
+ operation: string,
159
+ channelId: string,
160
+ name: string,
161
+ call: (hook: Webhook) => Promise<T>,
162
+ make: boolean,
163
+ ): Promise<T | undefined> => {
164
+ for (let attempt = 0; ; attempt++) {
165
+ const hook = await webhook(channelId, name, make);
166
+ // Only a call that may not make the webhook gets here without one.
167
+ if (!hook) return undefined;
168
+ try {
169
+ return await call(hook);
170
+ } catch (error) {
171
+ if (
172
+ attempt === 0 &&
173
+ isCode(
174
+ error,
175
+ RESTJSONErrorCodes.UnknownWebhook,
176
+ RESTJSONErrorCodes.InvalidWebhookToken,
177
+ )
178
+ ) {
179
+ webhooks.delete(`${channelId}:${name}`);
180
+ continue;
181
+ }
182
+ throw failure(operation, error);
183
+ }
184
+ }
185
+ };
186
+
187
+ /** Asks each named webhook in turn to act on a message; the first that does it ends the search, and `message_gone` is what is left when none could. */
188
+ const eachWebhook = async (
189
+ operation: string,
190
+ channelId: string,
191
+ names: readonly string[],
192
+ call: (hook: Webhook) => Promise<unknown>,
193
+ ): Promise<void> => {
194
+ let gone: DiscordPosterError | undefined;
195
+ for (const name of names) {
196
+ try {
197
+ const done = await through(
198
+ operation,
199
+ channelId,
200
+ name,
201
+ (hook) => call(hook).then(() => true),
202
+ false,
203
+ );
204
+ if (done) return;
205
+ } catch (error) {
206
+ if (
207
+ !(error instanceof DiscordPosterError) ||
208
+ error.kind !== "message_gone"
209
+ )
210
+ throw error;
211
+ gone = error;
212
+ }
213
+ }
214
+ throw (
215
+ gone ??
216
+ new DiscordPosterError("message_gone", "the message does not exist")
217
+ );
218
+ };
219
+
220
+ const guild = guildOperations(core, options);
221
+
222
+ return {
223
+ ...guild,
224
+ async post(channelId, post) {
225
+ checkSnowflake("a channel", channelId);
226
+ const { webhook: name, body, files } = checkedPost(post);
227
+ const sent = await through(
228
+ "posting a message",
229
+ channelId,
230
+ name,
231
+ (hook) =>
232
+ rest.post(Routes.webhook(hook.id, hook.token), {
233
+ auth: false,
234
+ body,
235
+ query: new URLSearchParams({ wait: "true" }),
236
+ ...(files.length > 0 ? { files: rawFiles(files) } : {}),
237
+ }),
238
+ true,
239
+ );
240
+ return { channelId, messageId: messageIdOf(sent) };
241
+ },
242
+ async edit(channelId, messageId, edit, names) {
243
+ checkSnowflake("a channel", channelId);
244
+ checkSnowflake("a message", messageId);
245
+ const body = checkedText(edit.content, edit.mentions);
246
+ for (const name of names) checkWebhookName(name);
247
+ // A webhook edits only its own messages, and the caller may not know whose this is.
248
+ await eachWebhook("editing a message", channelId, names, (hook) =>
249
+ rest.patch(Routes.webhookMessage(hook.id, hook.token, messageId), {
250
+ auth: false,
251
+ body,
252
+ }),
253
+ );
254
+ },
255
+ async remove(channelId, messageId, names) {
256
+ checkSnowflake("a channel", channelId);
257
+ checkSnowflake("a message", messageId);
258
+ for (const name of names) checkWebhookName(name);
259
+ try {
260
+ await eachWebhook("deleting a message", channelId, names, (hook) =>
261
+ rest.delete(Routes.webhookMessage(hook.id, hook.token, messageId), {
262
+ auth: false,
263
+ }),
264
+ );
265
+ } catch (error) {
266
+ if (
267
+ !(error instanceof DiscordPosterError) ||
268
+ error.kind !== "message_gone"
269
+ )
270
+ throw error;
271
+ // A webhook deletes only its own messages. One an earlier webhook of the same name posted
272
+ // (the webhook was deleted and made again) is the bot's to delete, with Manage Messages;
273
+ // when it is gone for real, Discord says so here too.
274
+ await rest
275
+ .delete(Routes.channelMessage(channelId, messageId))
276
+ .catch((second: unknown) => {
277
+ const failed = failure("deleting a message as the bot", second);
278
+ throw failed.kind === "message_gone" ? error : failed;
279
+ });
280
+ }
281
+ },
282
+ async webhookIds(channelId, names) {
283
+ checkSnowflake("a channel", channelId);
284
+ for (const name of names) checkWebhookName(name);
285
+ const ids: Record<string, string[]> = Object.create(null);
286
+ for (const name of names) ids[name] = [];
287
+ for (const hook of await listMine(channelId)) {
288
+ const id = text(hook.id);
289
+ const list = typeof hook.name === "string" ? ids[hook.name] : undefined;
290
+ if (id && list) list.push(id);
291
+ }
292
+ return ids;
293
+ },
294
+ async setAvatar(channelId, name, png) {
295
+ checkSnowflake("a channel", channelId);
296
+ checkWebhookName(name);
297
+ checkAvatar(png);
298
+ const avatar = `data:image/png;base64,${Buffer.from(png).toString("base64")}`;
299
+ await through(
300
+ "setting the webhook's avatar",
301
+ channelId,
302
+ name,
303
+ (hook) =>
304
+ rest.patch(Routes.webhook(hook.id, hook.token), {
305
+ auth: false,
306
+ body: { avatar },
307
+ }),
308
+ true,
309
+ );
310
+ },
311
+ onWebhookMade(listener) {
312
+ madeListeners.add(listener);
313
+ return () => void madeListeners.delete(listener);
314
+ },
315
+ async say(channelId, post) {
316
+ checkSnowflake("a channel", channelId);
317
+ if (!post.content) throw invalid("a message needs text");
318
+ const body = checkedText(post.content, post.mentions);
319
+ const sent = await rest
320
+ .post(Routes.channelMessages(channelId), { body })
321
+ .catch((error: unknown) => {
322
+ throw failure("posting as the bot", error);
323
+ });
324
+ return { channelId, messageId: messageIdOf(sent) };
325
+ },
326
+ async sayCard(channelId, card) {
327
+ checkSnowflake("a channel", channelId);
328
+ const sent = await rest
329
+ .post(Routes.channelMessages(channelId), { body: cardBody(card) })
330
+ .catch((error: unknown) => {
331
+ throw failure("posting a card as the bot", error);
332
+ });
333
+ return { channelId, messageId: messageIdOf(sent) };
334
+ },
335
+ async editCard(channelId, messageId, card) {
336
+ checkSnowflake("a channel", channelId);
337
+ checkSnowflake("a message", messageId);
338
+ await rest
339
+ .patch(Routes.channelMessage(channelId, messageId), {
340
+ body: cardBody(card),
341
+ })
342
+ .catch((error: unknown) => {
343
+ throw failure("editing a card", error);
344
+ });
345
+ },
346
+ async deleteAsBot(channelId, messageId) {
347
+ checkSnowflake("a channel", channelId);
348
+ checkSnowflake("a message", messageId);
349
+ await rest
350
+ .delete(Routes.channelMessage(channelId, messageId))
351
+ .catch((error: unknown) => {
352
+ throw failure("deleting a message as the bot", error);
353
+ });
354
+ },
355
+ async sendDirect(userId, content) {
356
+ checkSnowflake("a user", userId);
357
+ const body = checkedText(content, []);
358
+ try {
359
+ const channel = object(
360
+ await rest.post(Routes.userChannels(), {
361
+ body: { recipient_id: userId },
362
+ }),
363
+ );
364
+ const id = text(channel.id);
365
+ if (!id)
366
+ throw new DiscordPosterError("rejected", "Discord opened no channel");
367
+ await rest.post(Routes.channelMessages(id), { body });
368
+ return "sent";
369
+ } catch (error) {
370
+ // The person takes no direct messages from this bot: an answer, not a failure.
371
+ if (isCode(error, RESTJSONErrorCodes.CannotSendMessagesToThisUser))
372
+ return "closed";
373
+ throw failure("sending a direct message", error);
374
+ }
375
+ },
376
+ async pin(channelId, messageId) {
377
+ checkSnowflake("a channel", channelId);
378
+ checkSnowflake("a message", messageId);
379
+ await rest
380
+ .put(Routes.channelMessagesPin(channelId, messageId))
381
+ .catch((error: unknown) => {
382
+ throw failure("pinning a message", error);
383
+ });
384
+ },
385
+ async unpin(channelId, messageId) {
386
+ checkSnowflake("a channel", channelId);
387
+ checkSnowflake("a message", messageId);
388
+ await rest
389
+ .delete(Routes.channelMessagesPin(channelId, messageId))
390
+ .catch((error: unknown) => {
391
+ throw failure("unpinning a message", error);
392
+ });
393
+ },
394
+ async pinnedIds(channelId) {
395
+ checkSnowflake("a channel", channelId);
396
+ // Discord keeps at most 250 pins in a channel and lists 50 a page, newest first; `before`
397
+ // takes the `pinned_at` of the last one read. Five pages cover a channel.
398
+ const ids: string[] = [];
399
+ let before: string | undefined;
400
+ for (let page = 0; page < PIN_PAGES; page += 1) {
401
+ const query = new URLSearchParams({ limit: String(PIN_PAGE) });
402
+ if (before) query.set("before", before);
403
+ const listed = object(
404
+ await rest
405
+ .get(Routes.channelMessagesPins(channelId), { query })
406
+ .catch((error: unknown) => {
407
+ throw failure("listing a channel's pins", error);
408
+ }),
409
+ );
410
+ const items = Array.isArray(listed.items) ? listed.items : [];
411
+ for (const entry of items) {
412
+ const id = text(object(object(entry).message).id);
413
+ if (id) ids.push(id);
414
+ }
415
+ const last = text(object(items.at(-1)).pinned_at);
416
+ if (listed.has_more !== true || !last) break;
417
+ before = last;
418
+ }
419
+ return ids;
420
+ },
421
+ async message(channelId, messageId): Promise<DiscordMessage> {
422
+ checkSnowflake("a channel", channelId);
423
+ checkSnowflake("a message", messageId);
424
+ const read = object(
425
+ await rest
426
+ .get(Routes.channelMessage(channelId, messageId))
427
+ .catch((error: unknown) => {
428
+ throw failure("reading a message", error);
429
+ }),
430
+ );
431
+ const id = text(read.id);
432
+ if (!id)
433
+ throw new DiscordPosterError("rejected", "Discord sent no message");
434
+ const webhookId = text(read.webhook_id);
435
+ const files = (
436
+ Array.isArray(read.attachments) ? read.attachments : []
437
+ ).flatMap((entry) => {
438
+ const file = object(entry);
439
+ const name = text(file.filename);
440
+ const url = text(file.url);
441
+ return name && url
442
+ ? [
443
+ {
444
+ name,
445
+ url,
446
+ contentType: text(file.content_type) ?? "",
447
+ size: typeof file.size === "number" ? file.size : 0,
448
+ },
449
+ ]
450
+ : [];
451
+ });
452
+ return {
453
+ id,
454
+ authorId: text(object(read.author).id) ?? "",
455
+ ...(webhookId ? { webhookId } : {}),
456
+ content: typeof read.content === "string" ? read.content : "",
457
+ files,
458
+ };
459
+ },
460
+ async recentMessages(channelId, limit): Promise<RecentMessage[]> {
461
+ checkSnowflake("a channel", channelId);
462
+ const listed = await rest
463
+ .get(Routes.channelMessages(channelId), {
464
+ query: new URLSearchParams({
465
+ limit: String(Math.min(50, Math.max(1, Math.trunc(limit)))),
466
+ }),
467
+ })
468
+ .catch((error: unknown) => {
469
+ throw failure("listing a channel's messages", error);
470
+ });
471
+ return (Array.isArray(listed) ? listed : []).flatMap((entry) => {
472
+ const message = object(entry);
473
+ const id = text(message.id);
474
+ if (!id) return [];
475
+ const referenced = text(object(message.message_reference).message_id);
476
+ return [
477
+ {
478
+ id,
479
+ type: typeof message.type === "number" ? message.type : 0,
480
+ authorId: text(object(message.author).id) ?? "",
481
+ ...(referenced ? { referencedMessageId: referenced } : {}),
482
+ },
483
+ ];
484
+ });
485
+ },
486
+ botUserId: () => core.bot(),
487
+ };
488
+ }