pi-roundtable 0.30.4 → 0.31.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,162 @@
1
+ import {
2
+ DISCORD_LIMITS,
3
+ type ForumPost,
4
+ type ForumTag,
5
+ type ForumTagInput,
6
+ type ThreadAutoArchive,
7
+ } from "./poster.ts";
8
+ import {
9
+ cardBody,
10
+ checkChannelName,
11
+ checkedText,
12
+ checkSnowflake,
13
+ } from "./poster-checks.ts";
14
+ import { DiscordPosterError, invalid } from "./poster-errors.ts";
15
+
16
+ /** Discord's channel type numbers the forum calls tell apart. */
17
+ export const CHANNEL_TYPE = Object.freeze({
18
+ text: 0,
19
+ category: 4,
20
+ announcementThread: 10,
21
+ publicThread: 11,
22
+ privateThread: 12,
23
+ forum: 15,
24
+ } as const);
25
+
26
+ /** The auto-archive durations Discord accepts, in minutes. */
27
+ const AUTO_ARCHIVE_MINUTES: readonly ThreadAutoArchive[] = [
28
+ 60, 1_440, 4_320, 10_080,
29
+ ];
30
+
31
+ /** After how many minutes without a message a post archives itself when its maker says nothing: a week, as a thread. */
32
+ export const POST_ARCHIVE_MINUTES: ThreadAutoArchive = 10_080;
33
+
34
+ /** Whether the channel type is a thread's. */
35
+ export function isThreadType(type: unknown): boolean {
36
+ return (
37
+ type === CHANNEL_TYPE.announcementThread ||
38
+ type === CHANNEL_TYPE.publicThread ||
39
+ type === CHANNEL_TYPE.privateThread
40
+ );
41
+ }
42
+
43
+ /** Discord's JSON error code for an edit of a thread that is archived ("Thread is archived"). */
44
+ export const THREAD_ARCHIVED = 50083;
45
+ /** Discord's JSON error code for an action a channel's type does not take. */
46
+ export const WRONG_CHANNEL_TYPE = 50024;
47
+
48
+ /** What Discord answers an edit of an archived thread (a message in it, its name or its tags) that does not reopen it. */
49
+ export function archivedThread(): DiscordPosterError {
50
+ return new DiscordPosterError("rejected", "the thread is archived", {
51
+ discordCode: THREAD_ARCHIVED,
52
+ status: 400,
53
+ });
54
+ }
55
+
56
+ /** What Discord answers a message posted straight into a forum: posts go through `createForumPost`. */
57
+ export function forumTakesPosts(): DiscordPosterError {
58
+ return new DiscordPosterError(
59
+ "rejected",
60
+ "a forum takes posts, not messages: use createForumPost",
61
+ { discordCode: WRONG_CHANNEL_TYPE, status: 400 },
62
+ );
63
+ }
64
+
65
+ /** The error for a channel that is no forum (or a thread that is no forum post). */
66
+ export function notForum(what: string): DiscordPosterError {
67
+ return new DiscordPosterError("invalid", `${what} is not a forum`, {
68
+ reason: "not_forum",
69
+ });
70
+ }
71
+
72
+ /** The tag ids of a post: at most five Discord ids, none twice. */
73
+ export function checkedTagIds(tagIds: readonly string[] | undefined): string[] {
74
+ const ids = [...new Set(tagIds ?? [])];
75
+ if (ids.length > DISCORD_LIMITS.postTags)
76
+ throw invalid(`a post carries at most ${DISCORD_LIMITS.postTags} tags`);
77
+ return ids.map((id) => checkSnowflake("a tag", id));
78
+ }
79
+
80
+ /** The checked tags of a forum, each as the wire form Discord's `available_tags` takes. */
81
+ export function checkedForumTags(tags: readonly ForumTagInput[]) {
82
+ if (tags.length > DISCORD_LIMITS.forumTags)
83
+ throw invalid(`a forum holds at most ${DISCORD_LIMITS.forumTags} tags`);
84
+ const seen = new Set<string>();
85
+ return tags.map((tag) => {
86
+ const chars = [...tag.name].length;
87
+ if (chars < 1 || chars > DISCORD_LIMITS.forumTagNameChars)
88
+ throw invalid(
89
+ `a tag name is 1 to ${DISCORD_LIMITS.forumTagNameChars} characters`,
90
+ );
91
+ if (tag.emoji !== undefined && tag.emojiId !== undefined)
92
+ throw invalid("a tag has a Unicode emoji or a custom emoji, not both");
93
+ if (tag.emoji === "") throw invalid("a tag's emoji is not empty");
94
+ if (tag.id !== undefined) {
95
+ checkSnowflake("a tag", tag.id);
96
+ if (seen.has(tag.id)) throw invalid("a tag id is given once");
97
+ seen.add(tag.id);
98
+ }
99
+ if (tag.emojiId !== undefined) checkSnowflake("a tag's emoji", tag.emojiId);
100
+ return {
101
+ ...(tag.id === undefined ? {} : { id: tag.id }),
102
+ name: tag.name,
103
+ moderated: tag.moderated === true,
104
+ emoji_id: tag.emojiId ?? null,
105
+ emoji_name: tag.emoji ?? null,
106
+ };
107
+ });
108
+ }
109
+
110
+ /** A tag as Discord holds it, as `ForumTag`; undefined for an entry with no id or name. */
111
+ export function readTag(raw: unknown): ForumTag | undefined {
112
+ if (raw === null || typeof raw !== "object") return undefined;
113
+ const tag = raw as Record<string, unknown>;
114
+ const id = typeof tag.id === "string" && tag.id !== "" ? tag.id : undefined;
115
+ const name =
116
+ typeof tag.name === "string" && tag.name !== "" ? tag.name : undefined;
117
+ if (!(id && name)) return undefined;
118
+ const emojiId =
119
+ typeof tag.emoji_id === "string" && tag.emoji_id !== ""
120
+ ? tag.emoji_id
121
+ : undefined;
122
+ const emoji =
123
+ typeof tag.emoji_name === "string" && tag.emoji_name !== ""
124
+ ? tag.emoji_name
125
+ : undefined;
126
+ return {
127
+ id,
128
+ name,
129
+ moderated: tag.moderated === true,
130
+ ...(emojiId ? { emojiId } : emoji ? { emoji } : {}),
131
+ };
132
+ }
133
+
134
+ /** The checked parts of a forum post: its name, tags, archive time and first message body. */
135
+ export function checkedForumPost(post: ForumPost) {
136
+ checkChannelName("a post name", post.name);
137
+ if ((post.card === undefined) === (post.content === undefined))
138
+ throw invalid("a post has a card or text as its first message, not both");
139
+ const autoArchive = post.autoArchive ?? POST_ARCHIVE_MINUTES;
140
+ if (!AUTO_ARCHIVE_MINUTES.includes(autoArchive))
141
+ throw invalid(
142
+ `a post archives after ${AUTO_ARCHIVE_MINUTES.join(", ")} minutes`,
143
+ );
144
+ let message: ReturnType<typeof cardBody> | ReturnType<typeof checkedText>;
145
+ let mentions: string[] = [];
146
+ if (post.card) message = cardBody(post.card);
147
+ else {
148
+ if (!post.content) throw invalid("a post needs text");
149
+ const text = checkedText(post.content, post.mentions);
150
+ message = text;
151
+ mentions = text.allowed_mentions.users;
152
+ }
153
+ return {
154
+ name: post.name,
155
+ autoArchive,
156
+ appliedTags: checkedTagIds(post.appliedTags),
157
+ /** The first message as Discord takes it. */
158
+ message,
159
+ /** The users the first message may ping. */
160
+ mentions,
161
+ };
162
+ }
@@ -0,0 +1,84 @@
1
+ import { DISCORD_LIMITS, type DiscordCard } from "./poster.ts";
2
+ import {
3
+ cardRows,
4
+ checkCardTextSize,
5
+ checkPicture,
6
+ } from "./poster-card-parts.ts";
7
+ import { invalid } from "./poster-errors.ts";
8
+
9
+ /** A `preview` flattened to one line: every run of whitespace, line breaks included, becomes one space. */
10
+ export function flatPreview(preview: string): string {
11
+ return preview.replace(/\s+/g, " ").trim();
12
+ }
13
+
14
+ /**
15
+ * The body of a card with a `preview`: a legacy message whose `content` is the preview line (what
16
+ * the push notification shows), with one embed for the card's text and picture and ordinary action
17
+ * rows for its buttons and menus. No Components V2 flag, so Discord takes `content` and `embeds`.
18
+ * `components` is always sent, empty when the card has none, so an edit that drops the buttons
19
+ * removes them. The text guard is the V2 card's, and no mention in it pings.
20
+ */
21
+ export function legacyCardBody(card: DiscordCard & { preview: string }) {
22
+ const content = flatPreview(card.preview);
23
+ if (content === "")
24
+ throw invalid("a card's preview has text in it (or leave it out)");
25
+ if (content.length > DISCORD_LIMITS.contentChars)
26
+ throw invalid(
27
+ `a card's preview is at most ${DISCORD_LIMITS.contentChars} characters`,
28
+ );
29
+ const sections = card.sections.filter((section) => section !== "");
30
+ checkCardTextSize([
31
+ card.title,
32
+ ...sections,
33
+ ...(card.footer ? [card.footer] : []),
34
+ ]);
35
+ if ([...card.title].length > DISCORD_LIMITS.embedTitleChars)
36
+ throw invalid(
37
+ `a card with a preview has a title of at most ${DISCORD_LIMITS.embedTitleChars} characters`,
38
+ );
39
+ // The description joins the sections with blank lines, which the text guard above did not count.
40
+ const description = sections.join("\n\n");
41
+ const chars = (value: string) => [...value].length;
42
+ if (chars(description) > DISCORD_LIMITS.embedDescriptionChars)
43
+ throw invalid(
44
+ `a card with a preview has text of at most ${DISCORD_LIMITS.embedDescriptionChars} characters once its sections are joined`,
45
+ );
46
+ const footer = card.footer ?? "";
47
+ if (chars(footer) > DISCORD_LIMITS.embedFooterChars)
48
+ throw invalid(
49
+ `a card with a preview has a footer of at most ${DISCORD_LIMITS.embedFooterChars} characters`,
50
+ );
51
+ if (
52
+ chars(card.title) + chars(description) + chars(footer) >
53
+ DISCORD_LIMITS.embedChars
54
+ )
55
+ throw invalid(
56
+ `a card with a preview holds at most ${DISCORD_LIMITS.embedChars} characters in its title, text and footer`,
57
+ );
58
+ const thumbnail = card.thumbnail
59
+ ? checkPicture("a card's thumbnail", card.thumbnail)
60
+ : undefined;
61
+ const image = card.image
62
+ ? checkPicture("a card's image", card.image)
63
+ : undefined;
64
+ const components = cardRows(card);
65
+ if (components.length > DISCORD_LIMITS.actionRows)
66
+ throw invalid(
67
+ `a card with a preview holds at most ${DISCORD_LIMITS.actionRows} rows: one for each menu and one for the buttons`,
68
+ );
69
+ return {
70
+ content,
71
+ embeds: [
72
+ {
73
+ title: card.title,
74
+ ...(sections.length > 0 ? { description } : {}),
75
+ ...(card.accent === undefined ? {} : { color: card.accent }),
76
+ ...(card.footer ? { footer: { text: card.footer } } : {}),
77
+ ...(thumbnail ? { thumbnail: { url: thumbnail.media.url } } : {}),
78
+ ...(image ? { image: { url: image.media.url } } : {}),
79
+ },
80
+ ],
81
+ components,
82
+ allowed_mentions: { parse: [] as never[] },
83
+ };
84
+ }
@@ -94,10 +94,25 @@ export interface CardPicture {
94
94
 
95
95
  /**
96
96
  * A card the bot posts: a Components V2 container with a heading, text sections, a quiet last line
97
- * and an optional colour bar. Plain data, so a fake can hold and compare it. It carries no
98
- * `content` and no embeds and never pings.
97
+ * and an optional colour bar. Plain data, so a fake can hold and compare it. It never pings. A card
98
+ * without `preview` carries no `content` and no embeds, so its push notification is blank; with
99
+ * `preview` it is a legacy message (see `preview`).
99
100
  */
100
101
  export interface DiscordCard {
102
+ /**
103
+ * One line of plain text for the message's `content`, which is what Discord builds a push or
104
+ * desktop notification from (a Components V2 message has none). Whitespace runs, line breaks
105
+ * included, are flattened to one space; at most 2,000 characters after that, and a short line
106
+ * (about 120 characters) reads best. When set, the card is sent as a legacy message instead:
107
+ * `content: preview`, one embed (the title, the sections joined by blank lines as its
108
+ * description, `accent` as its colour, `footer`, `thumbnail` and `image`) and ordinary action
109
+ * rows for `buttons`, `links` and `selects` (at most five rows: one per menu, one for the
110
+ * buttons). An embed has no alt text, so a picture's `description` is dropped. `editCard` and
111
+ * `editWebhookCard` re-send the card (and an edit sets `content` too), but the poster cannot
112
+ * change a message's kind: an edit that would turn a Components V2 message into a preview card,
113
+ * or the other way round, is `rejected`.
114
+ */
115
+ preview?: string;
101
116
  /** The colour of the bar along the side, as 0xRRGGBB. */
102
117
  accent?: number;
103
118
  title: string;
@@ -166,6 +181,81 @@ export interface CategoryChannel {
166
181
  topic: string;
167
182
  }
168
183
 
184
+ /** What a channel is, as `channelKind` tells it. */
185
+ export type ChannelKind =
186
+ | "text"
187
+ | "category"
188
+ /** A thread of a text channel (or of any parent that is not a forum). */
189
+ | "thread"
190
+ | "forum"
191
+ /** A post of a forum: a thread whose parent is a forum, so its first message has the thread's own id. */
192
+ | "forum_post"
193
+ /** Voice, stage, announcement, media and the rest. */
194
+ | "other";
195
+
196
+ /** A channel's guild, kind and parent. */
197
+ export interface ChannelKindInfo {
198
+ guildId: string;
199
+ kind: ChannelKind;
200
+ /** The category of a text channel or forum, the parent channel of a thread (the forum for a `forum_post`); absent for a channel under no category. */
201
+ parentId?: string;
202
+ }
203
+
204
+ /** A tag a forum offers its posts. */
205
+ export interface ForumTag {
206
+ id: string;
207
+ /** 1 to 20 characters. */
208
+ name: string;
209
+ /** Only members with Manage Threads can apply a moderated tag. */
210
+ moderated: boolean;
211
+ /** A Unicode emoji shown beside the name. */
212
+ emoji?: string;
213
+ /** The id of a custom emoji shown beside the name; never together with `emoji`. */
214
+ emojiId?: string;
215
+ }
216
+
217
+ /**
218
+ * A tag as `setForumTags` takes it: a `ForumTag` read earlier is one, and a tag with no `id` is made
219
+ * new. Discord replaces the forum's whole set, so a tag left out is deleted.
220
+ */
221
+ export interface ForumTagInput {
222
+ id?: string;
223
+ name: string;
224
+ moderated?: boolean;
225
+ emoji?: string;
226
+ emojiId?: string;
227
+ }
228
+
229
+ /** How many minutes without a message Discord waits before it archives a thread. */
230
+ export type ThreadAutoArchive = 60 | 1_440 | 4_320 | 10_080;
231
+
232
+ /** A post for `createForumPost`: a name, tags, and a first message that is a card or plain text. */
233
+ export type ForumPost = {
234
+ /** 1 to 100 characters. */
235
+ name: string;
236
+ /** Tag ids of the forum, at most five (`invalid`); an id the forum does not offer is `rejected`. */
237
+ appliedTags?: readonly string[];
238
+ /** Default a week, as `startThread`. */
239
+ autoArchive?: ThreadAutoArchive;
240
+ } & (
241
+ | { card: DiscordCard; content?: undefined; mentions?: undefined }
242
+ | {
243
+ card?: undefined;
244
+ /** 1 to 2,000 characters. */
245
+ content: string;
246
+ /** The only users the first message may ping (at most 20). */
247
+ mentions?: readonly string[];
248
+ }
249
+ );
250
+
251
+ /** The post `createForumPost` made. */
252
+ export interface ForumPostCreated {
253
+ /** The post's id: a thread is a channel. */
254
+ threadId: string;
255
+ /** The id of the first message, which is the thread's own id; `editCard` and `pin` take it with `threadId`. */
256
+ messageId: string;
257
+ }
258
+
169
259
  /** Discord's limits as the poster keeps them. */
170
260
  export const DISCORD_LIMITS = Object.freeze({
171
261
  contentChars: 2_000,
@@ -184,6 +274,18 @@ export const DISCORD_LIMITS = Object.freeze({
184
274
  /** The most buttons (links too) a card's row holds. */
185
275
  cardButtons: 5,
186
276
  selectOptions: 25,
277
+ /** The most characters an embed title holds (a `preview` card's title). */
278
+ embedTitleChars: 256,
279
+ /** The most characters an embed's description and footer text hold, and its title, description and footer together. */
280
+ embedDescriptionChars: 4_096,
281
+ embedFooterChars: 2_048,
282
+ embedChars: 6_000,
283
+ /** The most action rows a legacy message holds (a `preview` card: one per menu, one for the buttons). */
284
+ actionRows: 5,
285
+ /** The most tags a forum holds, characters in a tag's name, and tags applied to one post. */
286
+ forumTags: 20,
287
+ forumTagNameChars: 20,
288
+ postTags: 5,
187
289
  } as const);
188
290
 
189
291
  export interface DiscordPoster {
@@ -258,7 +360,10 @@ export interface DiscordPoster {
258
360
  sayCard(channelId: string, card: DiscordCard): Promise<PostedMessage>;
259
361
  /**
260
362
  * Replaces a card the bot posted, keeping it a Components V2 message. A message posted without
261
- * the flag cannot become a card: `rejected` (delete it and post a new card).
363
+ * the flag cannot become a card: `rejected` (delete it and post a new card). Discord refuses the
364
+ * edit of any message in an archived thread (`rejected`, code 50083): call `setThreadArchived(id,
365
+ * false)` first. The one exception is a forum post's first message (`messageId` equal to
366
+ * `channelId`), where the poster opens the archived post again itself.
262
367
  */
263
368
  editCard(
264
369
  channelId: string,
@@ -312,11 +417,20 @@ export interface DiscordPoster {
312
417
  /** Deletes a channel; one that is already gone is `channel_gone`. */
313
418
  deleteChannel(channelId: string): Promise<void>;
314
419
  /**
315
- * Renames a channel (Manage Channels). Discord lets a channel be renamed twice in ten minutes; a
420
+ * Renames a channel, or a thread or a forum post (a thread is a channel; the call is the same,
421
+ * and Manage Threads is the permission for another person's post; a rename opens an archived thread again,
422
+ * as Discord refuses the rename otherwise). Discord lets a channel be renamed twice in ten minutes; a
316
423
  * rename past that is not queued: it fails as `unavailable` after a short wait and is never sent
317
424
  * later.
318
425
  */
319
426
  renameChannel(channelId: string, name: string): Promise<void>;
427
+ /**
428
+ * What a channel is: a text channel, category, thread, forum, forum post or something else (and
429
+ * its guild and parent), so a plugin can tell a post of a forum from a thread of a text channel
430
+ * before it starts a thread. Kept after the first read. `channel_gone`; `invalid` with
431
+ * `wrong_guild` for a direct-message channel.
432
+ */
433
+ channelKind(channelId: string): Promise<ChannelKindInfo>;
320
434
 
321
435
  // Threads
322
436
 
@@ -339,4 +453,27 @@ export interface DiscordPoster {
339
453
  * the entry is not among the log's newest 50.
340
454
  */
341
455
  threadCloser(threadId: string, state: ThreadState): Promise<ThreadCloser>;
456
+
457
+ // Forums
458
+
459
+ /** The tags a forum offers, in Discord's order. `invalid` with `not_forum` for a channel that is no forum. */
460
+ forumTags(forumId: string): Promise<ForumTag[]>;
461
+ /**
462
+ * Replaces the forum's tags with these (Manage Channels): a tag with an `id` is kept and may be
463
+ * renamed, one without is made, and one left out is deleted, so read `forumTags` first to add one.
464
+ * At most 20 tags, no id twice. Returns the forum's tags afterwards, with the new ids.
465
+ */
466
+ setForumTags(
467
+ forumId: string,
468
+ tags: readonly ForumTagInput[],
469
+ ): Promise<ForumTag[]>;
470
+ /**
471
+ * A post in a forum with its first message in the same request (Discord does not allow an empty
472
+ * post): a card (Components V2, or a legacy message when it has a `preview`), or plain text. `editCard(threadId, messageId, card)` edits a card
473
+ * first message. `invalid` with `not_forum` for a channel that is no forum; `forbidden` without
474
+ * Create Posts; `rejected` for a tag the forum does not offer.
475
+ */
476
+ createForumPost(forumId: string, post: ForumPost): Promise<ForumPostCreated>;
477
+ /** Replaces the tags applied to a forum post (at most five; none clears them), opening the post again if it is archived. `invalid` with `not_forum` when the thread is no forum post. */
478
+ setPostTags(threadId: string, tagIds: readonly string[]): Promise<void>;
342
479
  }
@@ -1,5 +1,6 @@
1
1
  import { type REST, Routes } from "discord.js";
2
2
  import { DiscordPosterError, failure } from "./poster-errors.ts";
3
+ import { isThreadType } from "./poster-forum.ts";
3
4
 
4
5
  /** The options of discord.js' REST client that the poster uses. */
5
6
  export interface RestOptions {
@@ -48,13 +49,28 @@ export function text(value: unknown): string | undefined {
48
49
  return typeof value === "string" && value !== "" ? value : undefined;
49
50
  }
50
51
 
52
+ /** What the poster keeps of a channel it read. */
53
+ export interface ReadChannel {
54
+ guildId: string;
55
+ type: unknown;
56
+ /** The category of a channel, the parent channel of a thread; kept for a thread only, read afresh for any other channel. */
57
+ parentId?: string;
58
+ }
59
+
51
60
  /** What the poster's two halves share: the REST client, the bot's id and the channels already read. */
52
61
  export interface RestCore {
53
62
  rest: DiscordRest;
54
63
  /** The bot's own user id, read on first use. */
55
64
  bot(): Promise<string>;
56
- /** A channel's guild and type; neither changes, so they are kept. */
57
- readChannel(channelId: string): Promise<{ guildId: string; type: unknown }>;
65
+ /**
66
+ * A channel's guild, type and, for a thread, parent; none changes, so they are kept. A text
67
+ * channel or forum can be moved to another category, so its `parentId` is only there when the
68
+ * call asks `withParent`, and then Discord is asked again.
69
+ */
70
+ readChannel(
71
+ channelId: string,
72
+ options?: { withParent?: boolean },
73
+ ): Promise<ReadChannel>;
58
74
  /** The guild of a category; fails when the channel is no category. */
59
75
  guildOf(categoryId: string): Promise<string>;
60
76
  }
@@ -76,11 +92,15 @@ export function restCore(rest: DiscordRest): RestCore {
76
92
  });
77
93
  return botId;
78
94
  };
79
- // A channel's guild and type never change; neither is a secret.
80
- const known = new Map<string, { guildId: string; type: unknown }>();
81
- const readChannel = async (channelId: string) => {
95
+ // A channel's guild and type never change, nor does a thread's parent; none is a secret.
96
+ // The category of any other channel does change, so it is never kept.
97
+ const known = new Map<string, ReadChannel>();
98
+ const readChannel = async (
99
+ channelId: string,
100
+ options: { withParent?: boolean } = {},
101
+ ): Promise<ReadChannel> => {
82
102
  const held = known.get(channelId);
83
- if (held) return held;
103
+ if (held && (!options.withParent || isThreadType(held.type))) return held;
84
104
  const channel = object(
85
105
  await rest.get(Routes.channel(channelId)).catch((error: unknown) => {
86
106
  throw failure("reading a channel", error);
@@ -91,8 +111,16 @@ export function restCore(rest: DiscordRest): RestCore {
91
111
  throw new DiscordPosterError("invalid", "that channel is in no guild", {
92
112
  reason: "wrong_guild",
93
113
  });
94
- const read = { guildId, type: channel.type };
95
- known.set(channelId, read);
114
+ const parentId = text(channel.parent_id);
115
+ const read = {
116
+ guildId,
117
+ type: channel.type,
118
+ ...(parentId ? { parentId } : {}),
119
+ };
120
+ known.set(
121
+ channelId,
122
+ isThreadType(read.type) ? read : { guildId, type: read.type },
123
+ );
96
124
  return read;
97
125
  };
98
126
  const guildOf = async (categoryId: string): Promise<string> => {
@@ -0,0 +1,133 @@
1
+ import { Routes } from "discord.js";
2
+ import type { ChannelKind, DiscordPoster } from "./poster.ts";
3
+ import { checkSnowflake } from "./poster-checks.ts";
4
+ import { DiscordPosterError, failure } from "./poster-errors.ts";
5
+ import {
6
+ CHANNEL_TYPE,
7
+ checkedForumPost,
8
+ checkedForumTags,
9
+ checkedTagIds,
10
+ isThreadType,
11
+ notForum,
12
+ readTag,
13
+ } from "./poster-forum.ts";
14
+ import { object, type RestCore, text } from "./rest-poster-core.ts";
15
+
16
+ /** The poster's calls that tell what a channel is, and the forum calls. */
17
+ export type ForumOperations = Pick<
18
+ DiscordPoster,
19
+ | "channelKind"
20
+ | "forumTags"
21
+ | "setForumTags"
22
+ | "createForumPost"
23
+ | "setPostTags"
24
+ >;
25
+
26
+ export function forumOperations(core: RestCore): ForumOperations {
27
+ const { rest } = core;
28
+
29
+ /** What a channel is, from its type and, for a thread, its parent's. */
30
+ const kindOf = async (channelId: string) => {
31
+ // A thread's parent never changes; a channel's category does, so it is read afresh.
32
+ const read = await core.readChannel(channelId, { withParent: true });
33
+ let kind: ChannelKind;
34
+ if (read.type === CHANNEL_TYPE.text) kind = "text";
35
+ else if (read.type === CHANNEL_TYPE.category) kind = "category";
36
+ else if (read.type === CHANNEL_TYPE.forum) kind = "forum";
37
+ else if (isThreadType(read.type)) {
38
+ const parent = read.parentId
39
+ ? await core.readChannel(read.parentId)
40
+ : undefined;
41
+ kind = parent?.type === CHANNEL_TYPE.forum ? "forum_post" : "thread";
42
+ } else kind = "other";
43
+ return {
44
+ guildId: read.guildId,
45
+ kind,
46
+ ...(read.parentId ? { parentId: read.parentId } : {}),
47
+ };
48
+ };
49
+
50
+ const requireForum = async (forumId: string) => {
51
+ if ((await core.readChannel(forumId)).type !== CHANNEL_TYPE.forum)
52
+ throw notForum("that channel");
53
+ };
54
+
55
+ return {
56
+ async channelKind(channelId) {
57
+ checkSnowflake("a channel", channelId);
58
+ return kindOf(channelId);
59
+ },
60
+ async forumTags(forumId) {
61
+ checkSnowflake("a forum", forumId);
62
+ const forum = object(
63
+ await rest.get(Routes.channel(forumId)).catch((error: unknown) => {
64
+ throw failure("reading a forum", error);
65
+ }),
66
+ );
67
+ if (forum.type !== CHANNEL_TYPE.forum) throw notForum("that channel");
68
+ return (Array.isArray(forum.available_tags) ? forum.available_tags : [])
69
+ .map(readTag)
70
+ .filter((tag) => tag !== undefined);
71
+ },
72
+ async setForumTags(forumId, tags) {
73
+ checkSnowflake("a forum", forumId);
74
+ const wire = checkedForumTags(tags);
75
+ await requireForum(forumId);
76
+ const changed = object(
77
+ await rest
78
+ .patch(Routes.channel(forumId), { body: { available_tags: wire } })
79
+ .catch((error: unknown) => {
80
+ throw failure("setting a forum's tags", error);
81
+ }),
82
+ );
83
+ return (
84
+ Array.isArray(changed.available_tags) ? changed.available_tags : []
85
+ )
86
+ .map(readTag)
87
+ .filter((tag) => tag !== undefined);
88
+ },
89
+ async createForumPost(forumId, post) {
90
+ checkSnowflake("a forum", forumId);
91
+ const checked = checkedForumPost(post);
92
+ await requireForum(forumId);
93
+ const made = object(
94
+ await rest
95
+ .post(Routes.threads(forumId), {
96
+ body: {
97
+ name: checked.name,
98
+ auto_archive_duration: checked.autoArchive,
99
+ ...(checked.appliedTags.length > 0
100
+ ? { applied_tags: checked.appliedTags }
101
+ : {}),
102
+ message: checked.message,
103
+ },
104
+ })
105
+ .catch((error: unknown) => {
106
+ throw failure("making a forum post", error);
107
+ }),
108
+ );
109
+ const threadId = text(made.id);
110
+ if (!threadId)
111
+ throw new DiscordPosterError("rejected", "Discord made no post");
112
+ // The first message of a post has the post's own id.
113
+ return {
114
+ threadId,
115
+ messageId: text(object(made.message).id) ?? threadId,
116
+ };
117
+ },
118
+ async setPostTags(threadId, tagIds) {
119
+ checkSnowflake("a thread", threadId);
120
+ const applied = checkedTagIds(tagIds);
121
+ if ((await kindOf(threadId)).kind !== "forum_post")
122
+ throw notForum("that thread's parent");
123
+ // An archived post is refused (50083) unless the same PATCH opens it again.
124
+ await rest
125
+ .patch(Routes.channel(threadId), {
126
+ body: { applied_tags: applied, archived: false },
127
+ })
128
+ .catch((error: unknown) => {
129
+ throw failure("setting a post's tags", error);
130
+ });
131
+ },
132
+ };
133
+ }
@@ -2,6 +2,7 @@ import { AuditLogEvent, RESTJSONErrorCodes, Routes } from "discord.js";
2
2
  import type { DiscordPoster } from "./poster.ts";
3
3
  import { checkChannelName, checkSnowflake } from "./poster-checks.ts";
4
4
  import { DiscordPosterError, failure, isCode } from "./poster-errors.ts";
5
+ import { isThreadType } from "./poster-forum.ts";
5
6
  import { object, type RestCore, text } from "./rest-poster-core.ts";
6
7
 
7
8
  /** How long a rename waits: past Discord's limit on renames the request would be held for minutes. */
@@ -146,13 +147,15 @@ export function guildOperations(
146
147
  async renameChannel(channelId, name) {
147
148
  checkSnowflake("a channel", channelId);
148
149
  checkChannelName("a channel name", name);
150
+ // Discord refuses a rename of an archived thread (50083) unless the same PATCH opens it again.
151
+ const { type } = await core.readChannel(channelId);
149
152
  // discord.js sleeps out a rate limit's wait (a rename sublimit can be minutes) without
150
153
  // looking at the signal, so the call is also raced against a timer: the caller is freed
151
154
  // at once, and the signal keeps the held request from being sent later.
152
155
  let timer: ReturnType<typeof setTimeout> | undefined;
153
156
  const patch = rest
154
157
  .patch(Routes.channel(channelId), {
155
- body: { name },
158
+ body: isThreadType(type) ? { name, archived: false } : { name },
156
159
  signal: AbortSignal.timeout(renameWaitMs),
157
160
  })
158
161
  .catch((error: unknown) => {