pi-roundtable 0.17.3 → 0.18.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,301 @@
1
+ /**
2
+ * Discord's REST API for a plugin's own posts: webhook posts under names the plugin chooses, the
3
+ * bot's own messages and cards, pins, direct messages, threads and channels. A host's Discord
4
+ * plugin provides one as `DISCORD.poster`, over the gateway client's own REST queue; `fakeDiscordPoster`
5
+ * (from `pi-roundtable/testing`) stands in for it in tests, and no test reaches Discord.
6
+ */
7
+
8
+ export type {
9
+ DiscordFailure,
10
+ DiscordReason,
11
+ } from "./poster-errors.ts";
12
+ export { DiscordPosterError } from "./poster-errors.ts";
13
+
14
+ /** A file sent with a post. */
15
+ export interface PosterFile {
16
+ name: string;
17
+ data: Uint8Array;
18
+ contentType?: string;
19
+ }
20
+
21
+ /** One message a channel's webhook posts. */
22
+ export interface WebhookPost {
23
+ /**
24
+ * The name of the bot's webhook that posts it, found among the webhooks this bot made in the
25
+ * channel or made on first use: 1 to 80 characters, without "discord" or "clyde" in any case
26
+ * (Discord refuses those; refused here as `invalid` before anything is sent). A plugin keeps one
27
+ * name per voice it needs, so a message's `webhookId` tells its voices apart.
28
+ */
29
+ webhook: string;
30
+ /** The name shown for this message: 1 to 80 characters, not "everyone" or "here", without "discord" or "clyde" (`invalid`). */
31
+ username: string;
32
+ /** An `https` address of at most 2,048 characters for the picture beside the name; anything else is left out. */
33
+ avatarUrl?: string;
34
+ /** Up to 2,000 characters; a post needs this or a file. */
35
+ content?: string;
36
+ /** Up to 10 files, 10 MiB in all. */
37
+ files?: readonly PosterFile[];
38
+ /** The only users this message may ping (at most 20); `@everyone`, roles and users merely named never are. */
39
+ mentions?: readonly string[];
40
+ }
41
+
42
+ /** A message the poster posted, to edit, delete or pin later. */
43
+ export interface PostedMessage {
44
+ channelId: string;
45
+ messageId: string;
46
+ }
47
+
48
+ /** One message as Discord holds it: enough to tell who posted it and to read its text and files. */
49
+ export interface DiscordMessage {
50
+ id: string;
51
+ /** The user who wrote it; for a webhook's post, the webhook's own pseudo-user. */
52
+ authorId: string;
53
+ /** The webhook that posted it; absent for a message a person or the bot wrote. */
54
+ webhookId?: string;
55
+ content: string;
56
+ files: readonly {
57
+ name: string;
58
+ /** Empty when Discord does not say. */
59
+ contentType: string;
60
+ /** Bytes, as Discord reports. */
61
+ size: number;
62
+ url: string;
63
+ }[];
64
+ }
65
+
66
+ /** A message of a channel as the recent listing shows it. */
67
+ export interface RecentMessage {
68
+ id: string;
69
+ /** Discord's message type: 0 is an ordinary message, 6 the notice a pin makes (by the bot when the bot pinned). */
70
+ type: number;
71
+ authorId: string;
72
+ /** For a pin notice: the message that was pinned. */
73
+ referencedMessageId?: string;
74
+ }
75
+
76
+ /**
77
+ * A card the bot posts: a Components V2 container with a heading, text sections, a quiet last line
78
+ * and an optional colour bar. Plain data, so a fake can hold and compare it. It carries no
79
+ * `content` and no embeds and never pings.
80
+ */
81
+ export interface DiscordCard {
82
+ /** The colour of the bar along the side, as 0xRRGGBB. */
83
+ accent?: number;
84
+ title: string;
85
+ sections: readonly string[];
86
+ footer?: string;
87
+ /** Up to five buttons in one row under the text; a press comes to the Discord plugin's interactions by `customId`. */
88
+ buttons?: readonly CardButton[];
89
+ /** Buttons that open an address, in the same row after `buttons` (five in all); a press reaches no interaction. */
90
+ links?: readonly CardLink[];
91
+ /** Drop-down menus, each in a row of its own above the buttons; a choice comes to the Discord plugin's interactions by `customId`. */
92
+ selects?: readonly CardSelect[];
93
+ }
94
+
95
+ /** A button on a card. */
96
+ export interface CardButton {
97
+ /** At most 100 characters; the interaction handler recognises its own by the prefix. */
98
+ customId: string;
99
+ /** At most 80 characters. */
100
+ label: string;
101
+ style: "primary" | "secondary" | "success" | "danger";
102
+ disabled?: boolean;
103
+ }
104
+
105
+ /** A button that opens an address and sends nothing back to the bot. */
106
+ export interface CardLink {
107
+ /** At most 80 characters. */
108
+ label: string;
109
+ /** An https address of at most 512 characters. */
110
+ url: string;
111
+ }
112
+
113
+ /** A drop-down menu on a card. */
114
+ export interface CardSelect {
115
+ /** At most 100 characters. */
116
+ customId: string;
117
+ /** At most 150 characters. */
118
+ placeholder: string;
119
+ /** One to 25 choices (labels and values of at most 100 characters); at most one marked `selected`. */
120
+ options: readonly { label: string; value: string; selected?: boolean }[];
121
+ }
122
+
123
+ /** Whether a thread is closed, as Discord's thread metadata says. */
124
+ export interface ThreadState {
125
+ archived: boolean;
126
+ /** Locked: only people with Manage Threads can open it again; auto-archiving never locks. */
127
+ locked: boolean;
128
+ /** The thread's `archive_timestamp`, in milliseconds; undefined when Discord does not say. */
129
+ archivedAt: number | undefined;
130
+ }
131
+
132
+ /** Who closed a thread. */
133
+ export type ThreadCloser =
134
+ | { kind: "person"; userId: string }
135
+ | { kind: "bot" }
136
+ | { kind: "unknown" };
137
+
138
+ /** A text channel under a category. */
139
+ export interface CategoryChannel {
140
+ id: string;
141
+ name: string;
142
+ /** The channel's topic; empty when it has none. */
143
+ topic: string;
144
+ }
145
+
146
+ /** Discord's limits as the poster keeps them. */
147
+ export const DISCORD_LIMITS = Object.freeze({
148
+ contentChars: 2_000,
149
+ usernameChars: Object.freeze({ min: 1, max: 80 }),
150
+ filesPerPost: 10,
151
+ /** The most bytes the poster sends in one post, files together. */
152
+ postBytes: 10 * 1024 * 1024,
153
+ avatarBytes: 1024 * 1024,
154
+ mentions: 20,
155
+ /** Discord keeps at most this many pins in a channel; the poster leaves it to Discord, the fake enforces it. */
156
+ pins: 250,
157
+ /** The longest name Discord gives a channel (a thread too). */
158
+ channelNameChars: 100,
159
+ /** Discord counts at most 4,000 characters of text across a Components V2 message; a card keeps a margin. */
160
+ cardTextChars: 3_800,
161
+ /** The most buttons (links too) a card's row holds. */
162
+ cardButtons: 5,
163
+ selectOptions: 25,
164
+ } as const);
165
+
166
+ export interface DiscordPoster {
167
+ // Webhook posts
168
+
169
+ /** Posts through the channel's webhook of that name, found or made on first use. */
170
+ post(channelId: string, post: WebhookPost): Promise<PostedMessage>;
171
+ /**
172
+ * Replaces a webhook post's text, asking each named webhook in order (a webhook edits only its
173
+ * own messages); makes none. `message_gone` when none of them posted it.
174
+ */
175
+ edit(
176
+ channelId: string,
177
+ messageId: string,
178
+ edit: { content: string; mentions?: readonly string[] },
179
+ webhooks: readonly string[],
180
+ ): Promise<void>;
181
+ /**
182
+ * Deletes a message one of the named webhooks posted, else as the bot (Manage Messages, for one
183
+ * an earlier webhook of the same name posted); makes none. `message_gone` when it does not exist.
184
+ */
185
+ remove(
186
+ channelId: string,
187
+ messageId: string,
188
+ webhooks: readonly string[],
189
+ ): Promise<void>;
190
+ /**
191
+ * The ids of the bot's webhooks in the channel under each name, found without making any; more
192
+ * than one of a name only when an earlier one was left behind.
193
+ */
194
+ webhookIds(
195
+ channelId: string,
196
+ names: readonly string[],
197
+ ): Promise<Readonly<Record<string, readonly string[]>>>;
198
+ /** Sets the named webhook's own picture from PNG bytes (at most 1 MiB; `invalid` otherwise). */
199
+ setAvatar(channelId: string, webhook: string, png: Uint8Array): Promise<void>;
200
+ /**
201
+ * Told, and awaited, when the poster makes a webhook (the first, or one made again after it was
202
+ * deleted: it carries the default picture). Only the call that made the webhook waits for the listeners; concurrent calls may use the new webhook before they finish. A listener that fails never fails the call. Returns
203
+ * the function that detaches it.
204
+ */
205
+ onWebhookMade(
206
+ listener: (channelId: string, webhook: string) => Promise<void>,
207
+ ): () => void;
208
+
209
+ // The bot's own messages
210
+
211
+ /** Posts as the bot itself, into a text channel or a thread, pinging only the users named. */
212
+ say(
213
+ channelId: string,
214
+ post: { content: string; mentions?: readonly string[] },
215
+ ): Promise<PostedMessage>;
216
+ /** Posts a card as the bot, into a text channel or a thread; it pings nobody. */
217
+ sayCard(channelId: string, card: DiscordCard): Promise<PostedMessage>;
218
+ /**
219
+ * Replaces a card the bot posted, keeping it a Components V2 message. A message posted without
220
+ * the flag cannot become a card: `rejected` (delete it and post a new card).
221
+ */
222
+ editCard(
223
+ channelId: string,
224
+ messageId: string,
225
+ card: DiscordCard,
226
+ ): Promise<void>;
227
+ /** Deletes a message the bot posted, or the notice its own pin made. `message_gone` when it does not exist. */
228
+ deleteAsBot(channelId: string, messageId: string): Promise<void>;
229
+ /** A direct message with no pings, at most 2,000 characters; `closed` when the person takes none from the bot. */
230
+ sendDirect(userId: string, content: string): Promise<"sent" | "closed">;
231
+
232
+ // Pins
233
+
234
+ pin(channelId: string, messageId: string): Promise<void>;
235
+ unpin(channelId: string, messageId: string): Promise<void>;
236
+ /** Newest pin first, all of a channel's (up to 250, read in pages of 50). */
237
+ pinnedIds(channelId: string): Promise<string[]>;
238
+
239
+ // Reading
240
+
241
+ /** Reads one message; `message_gone` when it does not exist. */
242
+ message(channelId: string, messageId: string): Promise<DiscordMessage>;
243
+ /** The newest messages of a channel or thread, newest first (at most `limit`, 1 to 50). */
244
+ recentMessages(channelId: string, limit: number): Promise<RecentMessage[]>;
245
+ /** The bot's own user id: the author of what it posts into a channel. */
246
+ botUserId(): Promise<string>;
247
+ /** The guild a channel or category is in; kept, since it never changes. */
248
+ channelGuild(channelId: string): Promise<string>;
249
+ /**
250
+ * Whether a member sees the channel (View Channel, from their roles, the channel's overwrites
251
+ * and the guild owner and administrator rules); false for someone who is not a member.
252
+ */
253
+ canView(channelId: string, userId: string): Promise<boolean>;
254
+ /** The server nickname, else the global display name, else the username; `undefined` for a non-member. */
255
+ memberName(guildId: string, userId: string): Promise<string | undefined>;
256
+
257
+ // Channels
258
+
259
+ /**
260
+ * A text channel under a category, with a topic when one is given, and no overwrites of its own,
261
+ * so it takes the category's. `channel_gone`, `forbidden` (Manage Channels), `rejected` with
262
+ * `reason: "category_full"`, `invalid` with `not_category` or `wrong_guild`.
263
+ */
264
+ createTextChannel(
265
+ categoryId: string,
266
+ name: string,
267
+ topic?: string,
268
+ ): Promise<string>;
269
+ /** The text channels under a category, to pick a name that is not taken and to find one made earlier. */
270
+ textChannels(categoryId: string): Promise<CategoryChannel[]>;
271
+ /** Deletes a channel; one that is already gone is `channel_gone`. */
272
+ deleteChannel(channelId: string): Promise<void>;
273
+ /**
274
+ * Renames a channel (Manage Channels). Discord lets a channel be renamed twice in ten minutes; a
275
+ * rename past that is not queued: it fails as `unavailable` after a short wait and is never sent
276
+ * later.
277
+ */
278
+ renameChannel(channelId: string, name: string): Promise<void>;
279
+
280
+ // Threads
281
+
282
+ /**
283
+ * A public thread on a message of a text channel, archiving itself after a week without a message
284
+ * and waking on the next (Create Public Threads). Returns the thread's id (a thread is a channel).
285
+ */
286
+ startThread(
287
+ channelId: string,
288
+ messageId: string,
289
+ name: string,
290
+ ): Promise<string>;
291
+ /** A thread the bot started needs no Manage Threads either way; a locked thread needs it to open again. `channel_gone` when it does not exist. */
292
+ setThreadArchived(threadId: string, archived: boolean): Promise<void>;
293
+ /** Whether a thread is archived or locked now, and when it was archived. `channel_gone` when it does not exist. */
294
+ threadState(threadId: string): Promise<ThreadState>;
295
+ /**
296
+ * Who archived or locked a thread, from the guild's audit log (View Audit Log, else `forbidden`),
297
+ * for the archive or lock `state` describes; `unknown` for Discord's own auto-archive, and when
298
+ * the entry is not among the log's newest 50.
299
+ */
300
+ threadCloser(threadId: string, state: ThreadState): Promise<ThreadCloser>;
301
+ }
@@ -0,0 +1,112 @@
1
+ import { type REST, Routes } from "discord.js";
2
+ import { DiscordPosterError, failure } from "./poster-errors.ts";
3
+
4
+ /** The options of discord.js' REST client that the poster uses. */
5
+ export interface RestOptions {
6
+ body?: unknown;
7
+ files?: readonly { name: string; data: Uint8Array; contentType?: string }[];
8
+ query?: URLSearchParams;
9
+ /** `false` sends no bot token: a webhook is addressed by its own token. */
10
+ auth?: boolean;
11
+ /** Gives up on the request when it fires (a call that discord.js would otherwise hold for a rate limit). */
12
+ signal?: AbortSignal;
13
+ }
14
+
15
+ /** The part of discord.js' `REST` the poster calls, so a test can answer without a network. */
16
+ export interface DiscordRest {
17
+ get(route: `/${string}`, options?: RestOptions): Promise<unknown>;
18
+ post(route: `/${string}`, options?: RestOptions): Promise<unknown>;
19
+ patch(route: `/${string}`, options?: RestOptions): Promise<unknown>;
20
+ put(route: `/${string}`, options?: RestOptions): Promise<unknown>;
21
+ delete(route: `/${string}`, options?: RestOptions): Promise<unknown>;
22
+ }
23
+
24
+ function mutable(options: RestOptions | undefined) {
25
+ if (!options) return undefined;
26
+ const { files, ...rest } = options;
27
+ return { ...rest, ...(files ? { files: [...files] } : {}) };
28
+ }
29
+
30
+ /** The poster's view of a discord.js REST client. The token stays inside the client. */
31
+ export function restOf(rest: REST): DiscordRest {
32
+ return {
33
+ get: (route, options) => rest.get(route, mutable(options)),
34
+ post: (route, options) => rest.post(route, mutable(options)),
35
+ patch: (route, options) => rest.patch(route, mutable(options)),
36
+ put: (route, options) => rest.put(route, mutable(options)),
37
+ delete: (route, options) => rest.delete(route, mutable(options)),
38
+ };
39
+ }
40
+
41
+ export function object(value: unknown): Record<string, unknown> {
42
+ return value !== null && typeof value === "object" && !Array.isArray(value)
43
+ ? (value as Record<string, unknown>)
44
+ : {};
45
+ }
46
+
47
+ export function text(value: unknown): string | undefined {
48
+ return typeof value === "string" && value !== "" ? value : undefined;
49
+ }
50
+
51
+ /** What the poster's two halves share: the REST client, the bot's id and the channels already read. */
52
+ export interface RestCore {
53
+ rest: DiscordRest;
54
+ /** The bot's own user id, read on first use. */
55
+ 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 }>;
58
+ /** The guild of a category; fails when the channel is no category. */
59
+ guildOf(categoryId: string): Promise<string>;
60
+ }
61
+
62
+ export function restCore(rest: DiscordRest): RestCore {
63
+ let botId: Promise<string> | undefined;
64
+ const bot = (): Promise<string> => {
65
+ botId ??= rest
66
+ .get(Routes.user())
67
+ .then((me) => {
68
+ const id = text(object(me).id);
69
+ if (!id) throw new DiscordPosterError("rejected", "the bot has no id");
70
+ return id;
71
+ })
72
+ .catch((error: unknown) => {
73
+ // A failed read is asked again by the next call.
74
+ botId = undefined;
75
+ throw failure("reading the bot", error);
76
+ });
77
+ return botId;
78
+ };
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) => {
82
+ const held = known.get(channelId);
83
+ if (held) return held;
84
+ const channel = object(
85
+ await rest.get(Routes.channel(channelId)).catch((error: unknown) => {
86
+ throw failure("reading a channel", error);
87
+ }),
88
+ );
89
+ const guildId = text(channel.guild_id);
90
+ if (!guildId)
91
+ throw new DiscordPosterError("invalid", "that channel is in no guild", {
92
+ reason: "wrong_guild",
93
+ });
94
+ const read = { guildId, type: channel.type };
95
+ known.set(channelId, read);
96
+ return read;
97
+ };
98
+ const guildOf = async (categoryId: string): Promise<string> => {
99
+ const category = await readChannel(categoryId);
100
+ // 4 is a category channel; anything else is not a place to put channels.
101
+ if (category.type !== 4)
102
+ throw new DiscordPosterError(
103
+ "invalid",
104
+ "that channel is not a category",
105
+ {
106
+ reason: "not_category",
107
+ },
108
+ );
109
+ return category.guildId;
110
+ };
111
+ return { rest, bot, readChannel, guildOf };
112
+ }