pi-roundtable 0.30.4 → 0.31.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.
@@ -11,12 +11,14 @@ import {
11
11
  invalid,
12
12
  } from "./poster-checks.ts";
13
13
  import { DiscordPosterError, failure, isCode } from "./poster-errors.ts";
14
+ import { THREAD_ARCHIVED } from "./poster-forum.ts";
14
15
  import {
15
16
  type DiscordRest,
16
17
  object,
17
18
  restCore,
18
19
  text,
19
20
  } from "./rest-poster-core.ts";
21
+ import { forumOperations } from "./rest-poster-forum.ts";
20
22
  import { guildOperations } from "./rest-poster-guild.ts";
21
23
  import { readOperations } from "./rest-poster-read.ts";
22
24
 
@@ -214,6 +216,7 @@ export function restPoster(
214
216
 
215
217
  return {
216
218
  ...guild,
219
+ ...forumOperations(core),
217
220
  async post(channelId, post) {
218
221
  checkSnowflake("a channel", channelId);
219
222
  const { webhook: name, body, files } = checkedPost(post);
@@ -362,13 +365,23 @@ export function restPoster(
362
365
  async editCard(channelId, messageId, card) {
363
366
  checkSnowflake("a channel", channelId);
364
367
  checkSnowflake("a message", messageId);
365
- await rest
366
- .patch(Routes.channelMessage(channelId, messageId), {
367
- body: cardBody(card),
368
- })
369
- .catch((error: unknown) => {
368
+ const body = cardBody(card);
369
+ const edit = () =>
370
+ rest.patch(Routes.channelMessage(channelId, messageId), { body });
371
+ try {
372
+ await edit();
373
+ } catch (error) {
374
+ // A forum post's first message has the post's own id. Discord refuses its edit while the
375
+ // post is archived (50083), so the post is opened again and the edit sent once more.
376
+ if (channelId !== messageId || !isCode(error, THREAD_ARCHIVED))
370
377
  throw failure("editing a card", error);
371
- });
378
+ await rest
379
+ .patch(Routes.channel(channelId), { body: { archived: false } })
380
+ .then(edit)
381
+ .catch((again: unknown) => {
382
+ throw failure("editing a card", again);
383
+ });
384
+ }
372
385
  },
373
386
  async deleteAsBot(channelId, messageId) {
374
387
  checkSnowflake("a channel", channelId);
@@ -0,0 +1,27 @@
1
+ import type { DiscordCard } from "../discord/poster/poster.ts";
2
+ import { DiscordPosterError } from "../discord/poster/poster-errors.ts";
3
+ import { flatPreview } from "../discord/poster/poster-legacy-card.ts";
4
+ import { cardText } from "./fake-poster-guild.ts";
5
+
6
+ /**
7
+ * The `content` the fake keeps for a card message: the preview line of a card that has one (as
8
+ * Discord holds it), else the card as plain text for a test to read.
9
+ */
10
+ export function cardContent(card: DiscordCard): string {
11
+ return card.preview === undefined
12
+ ? cardText(card)
13
+ : flatPreview(card.preview);
14
+ }
15
+
16
+ /**
17
+ * Refuses an edit that changes a message's kind: a Components V2 message cannot be edited into a
18
+ * `preview` (legacy) card, and a legacy message cannot become a Components V2 card.
19
+ */
20
+ export function assertSameCardKind(held: DiscordCard, edit: DiscordCard): void {
21
+ if ((held.preview !== undefined) !== (edit.preview !== undefined))
22
+ throw new DiscordPosterError(
23
+ "rejected",
24
+ "editing a card failed: the poster cannot change a message's kind, Components V2 or a preview card",
25
+ { discordCode: 50035, status: 400 },
26
+ );
27
+ }
@@ -0,0 +1,184 @@
1
+ import type {
2
+ ChannelKindInfo,
3
+ ForumPost,
4
+ ForumPostCreated,
5
+ ForumTag,
6
+ ForumTagInput,
7
+ } from "../discord/poster/poster.ts";
8
+ import { checkSnowflake } from "../discord/poster/poster-checks.ts";
9
+ import { DiscordPosterError } from "../discord/poster/poster-errors.ts";
10
+ import {
11
+ checkedForumPost,
12
+ checkedForumTags,
13
+ checkedTagIds,
14
+ forumTakesPosts,
15
+ notForum,
16
+ readTag,
17
+ } from "../discord/poster/poster-forum.ts";
18
+ import { cardContent } from "./fake-poster-cards.ts";
19
+ import {
20
+ type FakePosterChannel,
21
+ FakePosterGuild,
22
+ } from "./fake-poster-guild.ts";
23
+
24
+ /** A forum the fake holds: the tags it offers. */
25
+ export interface FakeForum {
26
+ tags: ForumTag[];
27
+ }
28
+
29
+ /** The fake's forum calls and `channelKind`, on top of its channel, thread and member calls. */
30
+ export abstract class FakePosterForum extends FakePosterGuild {
31
+ /**
32
+ * The forums that exist, with their tags. A test makes one by setting an entry (and registering
33
+ * the channel in `channels`); a post made in a channel that is none fails as `not_forum`.
34
+ */
35
+ readonly forums = new Map<string, FakeForum>();
36
+
37
+ #forum(forumId: string): FakeForum {
38
+ if (this.goneChannels.has(forumId))
39
+ throw new DiscordPosterError(
40
+ "channel_gone",
41
+ "the channel does not exist",
42
+ );
43
+ const forum = this.forums.get(forumId);
44
+ if (forum) return forum;
45
+ if (this.channels.has(forumId) || this.categories.has(forumId))
46
+ throw notForum("that channel");
47
+ throw new DiscordPosterError("channel_gone", "the channel does not exist");
48
+ }
49
+
50
+ /** Discord takes no message straight into a forum: a post is made with `createForumPost`. */
51
+ protected refuseForum(channelId: string): void {
52
+ if (this.forums.has(channelId)) throw forumTakesPosts();
53
+ }
54
+
55
+ /** A tag the forum does not offer is Discord's 400. */
56
+ #offered(forum: FakeForum, tagIds: readonly string[]): void {
57
+ if (tagIds.some((id) => !forum.tags.some((tag) => tag.id === id)))
58
+ throw new DiscordPosterError(
59
+ "rejected",
60
+ "a tag is not one the forum offers",
61
+ { discordCode: 50035, status: 400 },
62
+ );
63
+ }
64
+
65
+ async channelKind(channelId: string): Promise<ChannelKindInfo> {
66
+ checkSnowflake("a channel", channelId);
67
+ this.sent("channelKind");
68
+ if (this.goneChannels.has(channelId))
69
+ throw new DiscordPosterError(
70
+ "channel_gone",
71
+ "the channel does not exist",
72
+ );
73
+ const guildId = this.channelGuilds.get(channelId) ?? this.guildId;
74
+ if (this.categories.has(channelId)) return { guildId, kind: "category" };
75
+ const channel = this.channels.get(channelId);
76
+ if (!channel)
77
+ throw new DiscordPosterError(
78
+ "channel_gone",
79
+ "the channel does not exist",
80
+ );
81
+ const parent = channel.parentId ? { parentId: channel.parentId } : {};
82
+ if (this.forums.has(channelId))
83
+ return { guildId, kind: "forum", ...parent };
84
+ if (channel.startedOn !== undefined)
85
+ return {
86
+ guildId,
87
+ kind:
88
+ channel.parentId && this.forums.has(channel.parentId)
89
+ ? "forum_post"
90
+ : "thread",
91
+ ...parent,
92
+ };
93
+ return { guildId, kind: "text", ...parent };
94
+ }
95
+
96
+ async forumTags(forumId: string): Promise<ForumTag[]> {
97
+ checkSnowflake("a forum", forumId);
98
+ this.sent("forumTags");
99
+ return this.#forum(forumId).tags.map((tag) => ({ ...tag }));
100
+ }
101
+
102
+ async setForumTags(
103
+ forumId: string,
104
+ tags: readonly ForumTagInput[],
105
+ ): Promise<ForumTag[]> {
106
+ checkSnowflake("a forum", forumId);
107
+ const wire = checkedForumTags(tags);
108
+ this.sent("setForumTags");
109
+ const forum = this.#forum(forumId);
110
+ const next = wire.map((tag) => {
111
+ if ("id" in tag && !forum.tags.some((held) => held.id === tag.id))
112
+ throw new DiscordPosterError("rejected", "a tag id is unknown", {
113
+ discordCode: 50035,
114
+ status: 400,
115
+ });
116
+ let id = "id" in tag ? tag.id : undefined;
117
+ if (id === undefined) {
118
+ this.nextChannel += 1n;
119
+ id = this.nextChannel.toString();
120
+ }
121
+ return readTag({ ...tag, id }) as ForumTag;
122
+ });
123
+ forum.tags = next;
124
+ return next.map((tag) => ({ ...tag }));
125
+ }
126
+
127
+ async createForumPost(
128
+ forumId: string,
129
+ post: ForumPost,
130
+ ): Promise<ForumPostCreated> {
131
+ checkSnowflake("a forum", forumId);
132
+ const checked = checkedForumPost(post);
133
+ this.sent("createForumPost");
134
+ const forum = this.#forum(forumId);
135
+ this.#offered(forum, checked.appliedTags);
136
+ this.nextChannel += 1n;
137
+ const id = this.nextChannel.toString();
138
+ const channel: FakePosterChannel = {
139
+ name: post.name,
140
+ parentId: forumId,
141
+ topic: "",
142
+ messages: [
143
+ {
144
+ id,
145
+ username: "bot",
146
+ content: post.card ? cardContent(post.card) : (post.content ?? ""),
147
+ files: [],
148
+ blobs: [],
149
+ mentions: checked.mentions,
150
+ authorId: this.botId,
151
+ ...(post.card ? { card: post.card } : {}),
152
+ at: this.tick(),
153
+ },
154
+ ],
155
+ pins: [],
156
+ avatars: new Map(),
157
+ // The first message of a post has the post's own id.
158
+ startedOn: id,
159
+ autoArchiveMinutes: checked.autoArchive,
160
+ appliedTags: checked.appliedTags,
161
+ };
162
+ this.channels.set(id, channel);
163
+ return { threadId: id, messageId: id };
164
+ }
165
+
166
+ async setPostTags(
167
+ threadId: string,
168
+ tagIds: readonly string[],
169
+ ): Promise<void> {
170
+ checkSnowflake("a thread", threadId);
171
+ const ids = checkedTagIds(tagIds);
172
+ this.sent("setPostTags");
173
+ const channel = this.existing(threadId);
174
+ const forum =
175
+ channel.startedOn !== undefined && channel.parentId
176
+ ? this.forums.get(channel.parentId)
177
+ : undefined;
178
+ if (!forum) throw notForum("that thread's parent");
179
+ this.#offered(forum, ids);
180
+ // The poster sends `archived: false` with the tags, so an archived post is opened again.
181
+ this.wake(channel);
182
+ channel.appliedTags = ids;
183
+ }
184
+ }
@@ -52,6 +52,8 @@ export interface FakePosterChannel {
52
52
  avatars: Map<string, Uint8Array>;
53
53
  /** For a thread: the message of the parent channel it was started on. */
54
54
  startedOn?: string;
55
+ /** For a forum post: the ids of the tags applied to it. */
56
+ appliedTags?: string[];
55
57
  /** For a thread: the minutes without a message after which it archives itself. */
56
58
  autoArchiveMinutes?: number;
57
59
  /** For a thread: whether it is archived. A post by the bot wakes it, as on Discord. */
@@ -359,6 +361,8 @@ export abstract class FakePosterGuild {
359
361
  "unavailable",
360
362
  "renaming a channel failed (held back by Discord's rate limit)",
361
363
  );
364
+ // The poster sends `archived: false` with a thread's name, so an archived thread is opened again.
365
+ this.wake(channel);
362
366
  this.#renameTimes.set(channelId, [...recent, at]);
363
367
  channel.name = name;
364
368
  this.renames.push([channelId, name]);
@@ -5,10 +5,10 @@ import {
5
5
  } from "../discord/poster/poster.ts";
6
6
  import { checkSnowflake } from "../discord/poster/poster-checks.ts";
7
7
  import { DiscordPosterError } from "../discord/poster/poster-errors.ts";
8
- import { FakePosterGuild } from "./fake-poster-guild.ts";
8
+ import { FakePosterForum } from "./fake-poster-forum.ts";
9
9
 
10
10
  /** The fake's pin calls and message reads, on top of its channel, thread and member calls. */
11
- export abstract class FakePosterReads extends FakePosterGuild {
11
+ export abstract class FakePosterReads extends FakePosterForum {
12
12
  async pin(channelId: string, messageId: string): Promise<void> {
13
13
  checkSnowflake("a channel", channelId);
14
14
  checkSnowflake("a message", messageId);
@@ -14,13 +14,16 @@ import {
14
14
  invalid,
15
15
  } from "../discord/poster/poster-checks.ts";
16
16
  import { DiscordPosterError } from "../discord/poster/poster-errors.ts";
17
- import {
18
- cardText,
19
- type FakePosterChannel,
20
- type FakePosterMessage,
17
+ import { archivedThread } from "../discord/poster/poster-forum.ts";
18
+ import { assertSameCardKind, cardContent } from "./fake-poster-cards.ts";
19
+ import type { FakeForum } from "./fake-poster-forum.ts";
20
+ import type {
21
+ FakePosterChannel,
22
+ FakePosterMessage,
21
23
  } from "./fake-poster-guild.ts";
22
24
  import { FakePosterReads } from "./fake-poster-reads.ts";
23
25
 
26
+ export type { FakeForum } from "./fake-poster-forum.ts";
24
27
  export type {
25
28
  FakePosterChannel,
26
29
  FakePosterMessage,
@@ -44,6 +47,11 @@ export interface FakeDiscordPoster extends DiscordPoster {
44
47
  readonly goneChannels: Set<string>;
45
48
  /** The categories that exist; creating a channel under any other fails as `channel_gone`. */
46
49
  readonly categories: Set<string>;
50
+ /**
51
+ * The forums that exist, with the tags each offers. Make one by setting an entry here and
52
+ * registering the channel in `channels`; a forum call on any other channel fails as `not_forum`.
53
+ */
54
+ readonly forums: Map<string, FakeForum>;
47
55
  /** How many channels a category holds (Discord: 50) and a guild holds (Discord: 500). */
48
56
  categoryLimit: number;
49
57
  guildLimit: number;
@@ -155,6 +163,7 @@ class FakePosterImpl extends FakePosterReads implements FakeDiscordPoster {
155
163
  const { webhook, body, files } = checkedPost(post);
156
164
  this.sent("post");
157
165
  const channel = this.channel(channelId);
166
+ this.refuseForum(channelId);
158
167
  const webhookId = await this.#webhook(channelId, webhook);
159
168
  this.next += 1n;
160
169
  const message: FakePosterMessage = {
@@ -245,7 +254,7 @@ class FakePosterImpl extends FakePosterReads implements FakeDiscordPoster {
245
254
  const message: FakePosterMessage = {
246
255
  id: this.next.toString(),
247
256
  username: body.username,
248
- content: cardText(post.card),
257
+ content: cardContent(post.card),
249
258
  files: [],
250
259
  blobs: [],
251
260
  mentions: [],
@@ -283,8 +292,9 @@ class FakePosterImpl extends FakePosterReads implements FakeDiscordPoster {
283
292
  "editing a card failed: that message is not a card",
284
293
  { discordCode: 50035, status: 400 },
285
294
  );
295
+ assertSameCardKind(message.card, card);
286
296
  message.card = card;
287
- message.content = cardText(card);
297
+ message.content = cardContent(card);
288
298
  }
289
299
 
290
300
  async remove(
@@ -353,6 +363,7 @@ class FakePosterImpl extends FakePosterReads implements FakeDiscordPoster {
353
363
  const body = checkedText(post.content, post.mentions);
354
364
  this.sent("say");
355
365
  const channel = this.channel(channelId);
366
+ this.refuseForum(channelId);
356
367
  this.wake(channel);
357
368
  this.next += 1n;
358
369
  channel.messages.push({
@@ -373,12 +384,13 @@ class FakePosterImpl extends FakePosterReads implements FakeDiscordPoster {
373
384
  cardBody(card);
374
385
  this.sent("sayCard");
375
386
  const channel = this.channel(channelId);
387
+ this.refuseForum(channelId);
376
388
  this.wake(channel);
377
389
  this.next += 1n;
378
390
  channel.messages.push({
379
391
  id: this.next.toString(),
380
392
  username: "bot",
381
- content: cardText(card),
393
+ content: cardContent(card),
382
394
  files: [],
383
395
  blobs: [],
384
396
  mentions: [],
@@ -399,6 +411,13 @@ class FakePosterImpl extends FakePosterReads implements FakeDiscordPoster {
399
411
  cardBody(card);
400
412
  this.sent("editCard");
401
413
  const message = this.found(channelId, messageId);
414
+ const thread = this.channels.get(channelId);
415
+ // Discord refuses the edit of a message in an archived thread; the poster opens a post again
416
+ // for its first message (which has the post's id) and sends the edit once more.
417
+ if (thread?.archived) {
418
+ if (channelId !== messageId) throw archivedThread();
419
+ this.wake(thread);
420
+ }
402
421
  // A message posted without the Components V2 flag cannot become a card.
403
422
  if (!message.card)
404
423
  throw new DiscordPosterError(
@@ -406,8 +425,9 @@ class FakePosterImpl extends FakePosterReads implements FakeDiscordPoster {
406
425
  "editing a card failed: that message is not a card",
407
426
  { discordCode: 50035, status: 400 },
408
427
  );
428
+ assertSameCardKind(message.card, card);
409
429
  message.card = card;
410
- message.content = cardText(card);
430
+ message.content = cardContent(card);
411
431
  }
412
432
 
413
433
  async deleteAsBot(channelId: string, messageId: string): Promise<void> {
@@ -84,14 +84,21 @@ export type {
84
84
  CardPicture,
85
85
  CardSelect,
86
86
  CategoryChannel,
87
+ ChannelKind,
88
+ ChannelKindInfo,
87
89
  DiscordCard,
88
90
  DiscordFailure,
89
91
  DiscordMessage,
90
92
  DiscordPoster,
91
93
  DiscordReason,
94
+ ForumPost,
95
+ ForumPostCreated,
96
+ ForumTag,
97
+ ForumTagInput,
92
98
  PostedMessage,
93
99
  PosterFile,
94
100
  RecentMessage,
101
+ ThreadAutoArchive,
95
102
  ThreadCloser,
96
103
  ThreadState,
97
104
  WebhookCardPost,
package/src/testing.ts CHANGED
@@ -103,6 +103,7 @@ export {
103
103
  export { eagerText, useEagerCatalog } from "./core/testing/eager-catalog.ts";
104
104
  export type {
105
105
  FakeDiscordPoster,
106
+ FakeForum,
106
107
  FakePosterChannel,
107
108
  FakePosterMessage,
108
109
  } from "./core/testing/fake-poster.ts";