pi-roundtable 0.30.3 → 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.
- package/CHANGELOG.md +15 -0
- package/docs/plugins.md +18 -6
- package/package.json +1 -1
- package/src/core/contract/channels.ts +8 -0
- package/src/core/discord/inbound-message.ts +23 -1
- package/src/core/discord/poster/poster-card-parts.ts +160 -0
- package/src/core/discord/poster/poster-checks.ts +18 -150
- package/src/core/discord/poster/poster-errors.ts +11 -2
- package/src/core/discord/poster/poster-forum.ts +162 -0
- package/src/core/discord/poster/poster-legacy-card.ts +84 -0
- package/src/core/discord/poster/poster.ts +141 -4
- package/src/core/discord/poster/rest-poster-core.ts +36 -8
- package/src/core/discord/poster/rest-poster-forum.ts +133 -0
- package/src/core/discord/poster/rest-poster-guild.ts +4 -1
- package/src/core/discord/poster/rest-poster.ts +19 -6
- package/src/core/testing/fake-poster-cards.ts +27 -0
- package/src/core/testing/fake-poster-forum.ts +184 -0
- package/src/core/testing/fake-poster-guild.ts +4 -0
- package/src/core/testing/fake-poster-reads.ts +2 -2
- package/src/core/testing/fake-poster.ts +28 -8
- package/src/discord/index.ts +7 -0
- package/src/testing.ts +1 -0
|
@@ -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
|
|
98
|
-
* `content` and no embeds
|
|
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
|
|
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
|
-
/**
|
|
57
|
-
|
|
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;
|
|
80
|
-
|
|
81
|
-
const
|
|
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
|
|
95
|
-
|
|
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) => {
|