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,491 @@
1
+ import type {
2
+ CategoryChannel,
3
+ DiscordCard,
4
+ DiscordPoster,
5
+ ThreadCloser,
6
+ ThreadState,
7
+ } from "../discord/poster/poster.ts";
8
+ import {
9
+ checkChannelName,
10
+ checkSnowflake,
11
+ } from "../discord/poster/poster-checks.ts";
12
+ import { DiscordPosterError } from "../discord/poster/poster-errors.ts";
13
+
14
+ /** A message the fake holds: what Discord would show, and who it pinged. */
15
+ export interface FakePosterMessage {
16
+ id: string;
17
+ username: string;
18
+ content: string;
19
+ files: { name: string; bytes: number }[];
20
+ /** The bytes of the files, in the same order. */
21
+ blobs: { data: Uint8Array; contentType?: string }[];
22
+ /** The users the message was allowed to ping. */
23
+ mentions: string[];
24
+ /** The picture the post asked to show beside its name; absent when it asked for none. */
25
+ avatarUrl?: string;
26
+ /** The name of the webhook that posted it, and its id; absent for a message a person or the bot wrote. */
27
+ webhook?: string;
28
+ webhookId?: string;
29
+ /** Who wrote it, when a person or the bot did (`speak`, `say`); a webhook post is by its webhook. */
30
+ authorId?: string;
31
+ /** The files Discord lists on it, by name, when a test gives them addresses of their own (`speak`); else the fake's own. */
32
+ urls?: string[];
33
+ /** When it was posted, in the fake's own clock (one more for each message). */
34
+ at: number;
35
+ /** The card, for a message `sayCard` posted; `content` then holds its text. */
36
+ card?: DiscordCard;
37
+ /** Discord's message type; absent for an ordinary message, 6 for the notice a pin makes. */
38
+ type?: number;
39
+ /** For a pin notice: the message that was pinned. */
40
+ referencedMessageId?: string;
41
+ }
42
+
43
+ export interface FakePosterChannel {
44
+ /** The name and category of a channel the poster made; undefined for one that only received posts. */
45
+ name: string | undefined;
46
+ parentId: string | undefined;
47
+ /** The topic of a channel the poster made. */
48
+ topic: string;
49
+ messages: FakePosterMessage[];
50
+ pins: string[];
51
+ /** The last picture set on each of the channel's webhooks, by webhook name. */
52
+ avatars: Map<string, Uint8Array>;
53
+ /** For a thread: the message of the parent channel it was started on. */
54
+ startedOn?: string;
55
+ /** For a thread: the minutes without a message after which it archives itself. */
56
+ autoArchiveMinutes?: number;
57
+ /** For a thread: whether it is archived. A post by the bot wakes it, as on Discord. */
58
+ archived?: boolean;
59
+ /** Every change of the archive state, in order (`true` is archived). */
60
+ archiveLog?: boolean[];
61
+ /** For a thread: whether it is locked (only a person with Manage Threads opens it again). */
62
+ locked?: boolean;
63
+ /** For a thread: when it was archived, in the fake's clock. */
64
+ archivedAt?: number;
65
+ /** For a thread: who closed it: the bot's id, a person's id, or none (Discord's own auto-archive). */
66
+ closedBy?: string;
67
+ /** For a thread: when the audit log says it was closed, in the fake's clock; the archive time when absent. */
68
+ auditAt?: number;
69
+ }
70
+
71
+ /** How a channel may be renamed: twice in this many milliseconds. */
72
+ const RENAME_WINDOW_MS = 10 * 60_000;
73
+ const RENAMES_PER_WINDOW = 2;
74
+ /** How close an audit entry's time must be to the archive time to be that archive. */
75
+ const AUDIT_MATCH = 2 * 60_000;
76
+
77
+ /** A card as plain text, the way a test reads it. */
78
+ export function cardText(card: DiscordCard): string {
79
+ return [
80
+ `### ${card.title}`,
81
+ ...card.sections,
82
+ ...(card.footer ? [`-# ${card.footer}`] : []),
83
+ ].join("\n");
84
+ }
85
+
86
+ /** What `failNext` can fail: any call of the poster. */
87
+ export type PosterOperation = keyof DiscordPoster;
88
+
89
+ /** The fake's state, and the poster's channel, thread and member calls. */
90
+ export abstract class FakePosterGuild {
91
+ readonly channels = new Map<string, FakePosterChannel>();
92
+ readonly directs: { userId: string; content: string }[] = [];
93
+ /** People who take no direct message from the bot. */
94
+ readonly closedDirects = new Set<string>();
95
+ /** Channels that do not exist (any more). */
96
+ readonly goneChannels = new Set<string>();
97
+ /** The bot user's id: the author of what `say` posts. */
98
+ readonly botId: string = "990000000000000009";
99
+ /** The categories that exist; creating a channel under any other fails as `channel_gone`. */
100
+ readonly categories = new Set<string>();
101
+ /** How many channels a category holds (Discord: 50) and a guild holds (Discord: 500). */
102
+ categoryLimit = 50;
103
+ guildLimit = 500;
104
+ /** What the guild shows for a member, by user id; a user not listed has no name (`memberName` is undefined). */
105
+ readonly memberNames = new Map<string, string>();
106
+ /** Every `memberName` call, to see what was asked. */
107
+ readonly memberLookups: { guildId: string; userId: string }[] = [];
108
+ /** The guild every channel is in unless `channelGuilds` says another. */
109
+ guildId = "700000000000000001";
110
+ /** Channels (categories too) that are in another guild than `guildId`. */
111
+ readonly channelGuilds = new Map<string, string>();
112
+ /** `<channel id>:<user id>` pairs that cannot see the channel; everyone sees every channel otherwise. */
113
+ readonly hiddenFrom = new Set<string>();
114
+ /** People who are not members of the guild: they see no channel. */
115
+ readonly nonMembers = new Set<string>();
116
+ /** Whether a pin posts the system notice Discord posts (type 6); off by default so other tests keep their message counts. */
117
+ pinNotices = false;
118
+ /** Whether the bot may read the guild's audit log (View Audit Log); off makes `threadCloser` fail as `forbidden`. */
119
+ canReadAuditLog = true;
120
+ /** Every rename, in order, as `[channel id, new name]`. */
121
+ readonly renames: [string, string][] = [];
122
+ /** How many calls got past the argument checks, as the requests Discord would have seen. */
123
+ requests = 0;
124
+
125
+ protected next = 9_000_000_000_000_000_000n;
126
+ protected nextWebhook = 6_000_000_000_000_000_000n;
127
+ protected nextChannel = 8_000_000_000_000_000_000n;
128
+ protected clock = 0;
129
+ protected readonly now: () => number;
130
+ protected readonly failures = new Map<
131
+ PosterOperation,
132
+ DiscordPosterError[]
133
+ >();
134
+ readonly #renameTimes = new Map<string, number[]>();
135
+ readonly #guildsRead = new Set<string>();
136
+
137
+ protected constructor(idOffset: bigint, now: () => number) {
138
+ this.next += idOffset;
139
+ this.nextWebhook += idOffset;
140
+ this.nextChannel += idOffset;
141
+ this.now = now;
142
+ }
143
+
144
+ /** Makes the next call of `operation` throw `error`. */
145
+ failNext(operation: PosterOperation, error: DiscordPosterError): void {
146
+ this.failures.set(operation, [
147
+ ...(this.failures.get(operation) ?? []),
148
+ error,
149
+ ]);
150
+ }
151
+
152
+ /** Counts a call as a request, after its arguments passed; throws what `failNext` queued. */
153
+ protected sent(operation: PosterOperation): void {
154
+ this.requests += 1;
155
+ const error = this.failures.get(operation)?.shift();
156
+ if (error) throw error;
157
+ }
158
+
159
+ protected tick(): number {
160
+ this.clock += 1;
161
+ return this.clock;
162
+ }
163
+
164
+ protected channel(channelId: string): FakePosterChannel {
165
+ if (this.goneChannels.has(channelId))
166
+ throw new DiscordPosterError(
167
+ "channel_gone",
168
+ "the channel does not exist",
169
+ );
170
+ let channel = this.channels.get(channelId);
171
+ if (!channel) {
172
+ channel = {
173
+ name: undefined,
174
+ parentId: undefined,
175
+ topic: "",
176
+ messages: [],
177
+ pins: [],
178
+ avatars: new Map(),
179
+ };
180
+ this.channels.set(channelId, channel);
181
+ }
182
+ return channel;
183
+ }
184
+
185
+ protected found(channelId: string, messageId: string): FakePosterMessage {
186
+ const message = this.channel(channelId).messages.find(
187
+ (each) => each.id === messageId,
188
+ );
189
+ if (!message)
190
+ throw new DiscordPosterError(
191
+ "message_gone",
192
+ "the message does not exist",
193
+ );
194
+ return message;
195
+ }
196
+
197
+ /** A channel or thread that exists; unlike a post's target, one nothing was ever posted to is gone. */
198
+ protected existing(channelId: string): FakePosterChannel {
199
+ const channel = this.channels.get(channelId);
200
+ if (!channel || this.goneChannels.has(channelId))
201
+ throw new DiscordPosterError(
202
+ "channel_gone",
203
+ "the channel does not exist",
204
+ );
205
+ return channel;
206
+ }
207
+
208
+ /** A post by the bot into an archived thread unarchives it, as Discord does. */
209
+ protected wake(channel: FakePosterChannel): void {
210
+ // A locked thread stays closed: the bot cannot post into it without Manage Threads.
211
+ if (channel.archived && channel.locked)
212
+ throw new DiscordPosterError("forbidden", "the thread is locked");
213
+ if (channel.archived) {
214
+ channel.archived = false;
215
+ channel.archiveLog = [...(channel.archiveLog ?? []), false];
216
+ }
217
+ }
218
+
219
+ async botUserId(): Promise<string> {
220
+ return this.botId;
221
+ }
222
+
223
+ async channelGuild(channelId: string): Promise<string> {
224
+ checkSnowflake("a channel", channelId);
225
+ // The guild of a channel is kept after the first read, so the second asks Discord nothing.
226
+ if (!this.#guildsRead.has(channelId)) {
227
+ this.sent("channelGuild");
228
+ if (this.goneChannels.has(channelId))
229
+ throw new DiscordPosterError(
230
+ "channel_gone",
231
+ "the channel does not exist",
232
+ );
233
+ this.#guildsRead.add(channelId);
234
+ }
235
+ return this.channelGuilds.get(channelId) ?? this.guildId;
236
+ }
237
+
238
+ async canView(channelId: string, userId: string): Promise<boolean> {
239
+ checkSnowflake("a channel", channelId);
240
+ checkSnowflake("a user", userId);
241
+ this.sent("canView");
242
+ return (
243
+ !this.nonMembers.has(userId) &&
244
+ !this.hiddenFrom.has(`${channelId}:${userId}`)
245
+ );
246
+ }
247
+
248
+ async memberName(
249
+ guildId: string,
250
+ userId: string,
251
+ ): Promise<string | undefined> {
252
+ checkSnowflake("a guild", guildId);
253
+ checkSnowflake("a user", userId);
254
+ this.sent("memberName");
255
+ this.memberLookups.push({ guildId, userId });
256
+ return guildId === this.guildId ? this.memberNames.get(userId) : undefined;
257
+ }
258
+
259
+ async createTextChannel(
260
+ categoryId: string,
261
+ name: string,
262
+ topic?: string,
263
+ ): Promise<string> {
264
+ checkSnowflake("a category", categoryId);
265
+ checkChannelName("a channel name", name);
266
+ this.sent("createTextChannel");
267
+ if (!this.categories.has(categoryId)) {
268
+ if (this.channels.has(categoryId))
269
+ throw new DiscordPosterError(
270
+ "invalid",
271
+ "that channel is not a category",
272
+ {
273
+ reason: "not_category",
274
+ },
275
+ );
276
+ throw new DiscordPosterError(
277
+ "channel_gone",
278
+ "the category does not exist",
279
+ );
280
+ }
281
+ const made = [...this.channels.values()].filter(
282
+ (channel) => channel.name !== undefined,
283
+ );
284
+ if (made.length >= this.guildLimit)
285
+ throw new DiscordPosterError(
286
+ "rejected",
287
+ "the guild holds the most channels Discord allows",
288
+ { discordCode: 30013, status: 400 },
289
+ );
290
+ if (
291
+ made.filter((channel) => channel.parentId === categoryId).length >=
292
+ this.categoryLimit
293
+ )
294
+ throw new DiscordPosterError(
295
+ "rejected",
296
+ `a category holds at most ${this.categoryLimit} channels`,
297
+ { discordCode: 50035, status: 400, reason: "category_full" },
298
+ );
299
+ this.nextChannel += 1n;
300
+ const id = this.nextChannel.toString();
301
+ this.channels.set(id, {
302
+ name,
303
+ parentId: categoryId,
304
+ topic: topic ?? "",
305
+ messages: [],
306
+ pins: [],
307
+ avatars: new Map(),
308
+ });
309
+ return id;
310
+ }
311
+
312
+ async textChannels(categoryId: string): Promise<CategoryChannel[]> {
313
+ checkSnowflake("a category", categoryId);
314
+ this.sent("textChannels");
315
+ if (!this.categories.has(categoryId))
316
+ throw new DiscordPosterError(
317
+ "channel_gone",
318
+ "the category does not exist",
319
+ );
320
+ return [...this.channels.entries()]
321
+ .filter(
322
+ ([id, channel]) =>
323
+ channel.parentId === categoryId &&
324
+ channel.name !== undefined &&
325
+ channel.startedOn === undefined &&
326
+ !this.goneChannels.has(id),
327
+ )
328
+ .map(([id, channel]) => ({
329
+ id,
330
+ name: channel.name as string,
331
+ topic: channel.topic,
332
+ }));
333
+ }
334
+
335
+ async deleteChannel(channelId: string): Promise<void> {
336
+ checkSnowflake("a channel", channelId);
337
+ this.sent("deleteChannel");
338
+ if (this.goneChannels.has(channelId) || !this.channels.has(channelId))
339
+ throw new DiscordPosterError(
340
+ "channel_gone",
341
+ "the channel does not exist",
342
+ );
343
+ this.channels.delete(channelId);
344
+ this.goneChannels.add(channelId);
345
+ }
346
+
347
+ async renameChannel(channelId: string, name: string): Promise<void> {
348
+ checkSnowflake("a channel", channelId);
349
+ checkChannelName("a channel name", name);
350
+ this.sent("renameChannel");
351
+ const channel = this.channel(channelId);
352
+ // Discord lets a channel be renamed twice in ten minutes; the third is held back.
353
+ const at = this.now();
354
+ const recent = (this.#renameTimes.get(channelId) ?? []).filter(
355
+ (time) => at - time < RENAME_WINDOW_MS,
356
+ );
357
+ if (recent.length >= RENAMES_PER_WINDOW)
358
+ throw new DiscordPosterError(
359
+ "unavailable",
360
+ "renaming a channel failed (held back by Discord's rate limit)",
361
+ );
362
+ this.#renameTimes.set(channelId, [...recent, at]);
363
+ channel.name = name;
364
+ this.renames.push([channelId, name]);
365
+ }
366
+
367
+ async startThread(
368
+ channelId: string,
369
+ messageId: string,
370
+ name: string,
371
+ ): Promise<string> {
372
+ checkSnowflake("a channel", channelId);
373
+ checkSnowflake("a message", messageId);
374
+ checkChannelName("a thread name", name);
375
+ this.sent("startThread");
376
+ this.found(channelId, messageId);
377
+ this.nextChannel += 1n;
378
+ const id = this.nextChannel.toString();
379
+ this.channels.set(id, {
380
+ name,
381
+ parentId: channelId,
382
+ topic: "",
383
+ messages: [],
384
+ pins: [],
385
+ avatars: new Map(),
386
+ startedOn: messageId,
387
+ autoArchiveMinutes: 10_080,
388
+ });
389
+ return id;
390
+ }
391
+
392
+ async setThreadArchived(threadId: string, archived: boolean): Promise<void> {
393
+ checkSnowflake("a thread", threadId);
394
+ this.sent("setThreadArchived");
395
+ const channel = this.existing(threadId);
396
+ // A locked thread is opened again only with Manage Threads, which the bot does not need for its own.
397
+ if (!archived && channel.locked)
398
+ throw new DiscordPosterError("forbidden", "the thread is locked");
399
+ channel.archived = archived;
400
+ channel.archiveLog = [...(channel.archiveLog ?? []), archived];
401
+ if (archived) {
402
+ channel.closedBy = this.botId;
403
+ channel.archivedAt = this.tick();
404
+ delete channel.auditAt;
405
+ }
406
+ }
407
+
408
+ async threadState(threadId: string): Promise<ThreadState> {
409
+ checkSnowflake("a thread", threadId);
410
+ this.sent("threadState");
411
+ const channel = this.existing(threadId);
412
+ return {
413
+ archived: channel.archived === true,
414
+ locked: channel.locked === true,
415
+ archivedAt: channel.archivedAt,
416
+ };
417
+ }
418
+
419
+ async threadCloser(
420
+ threadId: string,
421
+ state: ThreadState,
422
+ ): Promise<ThreadCloser> {
423
+ checkSnowflake("a thread", threadId);
424
+ this.sent("threadCloser");
425
+ if (!this.canReadAuditLog)
426
+ throw new DiscordPosterError("forbidden", "missing View Audit Log");
427
+ const channel = this.existing(threadId);
428
+ if (!(state.archived || state.locked) || channel.closedBy === undefined)
429
+ return { kind: "unknown" };
430
+ // An archive's entry counts only when it is of the moment the thread was archived.
431
+ if (
432
+ !channel.locked &&
433
+ channel.auditAt !== undefined &&
434
+ channel.archivedAt !== undefined &&
435
+ Math.abs(channel.auditAt - channel.archivedAt) > AUDIT_MATCH
436
+ )
437
+ return { kind: "unknown" };
438
+ return channel.closedBy === this.botId
439
+ ? { kind: "bot" }
440
+ : { kind: "person", userId: channel.closedBy };
441
+ }
442
+
443
+ /** A person archives (or locks) the given thread; `auditSkew` moves the audit entry's time away from the archive's. */
444
+ personCloses(
445
+ threadId: string,
446
+ userId: string,
447
+ options: { lock?: boolean; auditSkew?: number } = {},
448
+ ): void {
449
+ const channel = this.channel(threadId);
450
+ channel.archived = true;
451
+ channel.archiveLog = [...(channel.archiveLog ?? []), true];
452
+ channel.closedBy = userId;
453
+ channel.archivedAt = this.tick();
454
+ if (options.auditSkew !== undefined)
455
+ channel.auditAt = channel.archivedAt + options.auditSkew;
456
+ else delete channel.auditAt;
457
+ if (options.lock) channel.locked = true;
458
+ }
459
+
460
+ /** Discord archives a quiet thread on its own: no one is logged. */
461
+ autoArchives(threadId: string): void {
462
+ const channel = this.channel(threadId);
463
+ channel.archived = true;
464
+ channel.archiveLog = [...(channel.archiveLog ?? []), true];
465
+ delete channel.closedBy;
466
+ channel.archivedAt = this.tick();
467
+ }
468
+
469
+ /** A person with Manage Threads opens a thread again. */
470
+ personOpens(threadId: string): void {
471
+ const channel = this.channel(threadId);
472
+ channel.archived = false;
473
+ channel.locked = false;
474
+ channel.archiveLog = [...(channel.archiveLog ?? []), false];
475
+ }
476
+
477
+ /** The threads started in a channel, oldest first: their ids and the message each was started on. */
478
+ threadsIn(
479
+ channelId: string,
480
+ ): { id: string; name: string; startedOn: string }[] {
481
+ return [...this.channels.entries()]
482
+ .filter(
483
+ ([, channel]) => channel.parentId === channelId && channel.startedOn,
484
+ )
485
+ .map(([id, channel]) => ({
486
+ id,
487
+ name: channel.name ?? "",
488
+ startedOn: channel.startedOn as string,
489
+ }));
490
+ }
491
+ }