pi-roundtable 0.17.1 → 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,329 @@
1
+ import { AuditLogEvent, RESTJSONErrorCodes, Routes } from "discord.js";
2
+ import type { DiscordPoster } from "./poster.ts";
3
+ import { checkChannelName, checkSnowflake } from "./poster-checks.ts";
4
+ import { DiscordPosterError, failure, isCode } from "./poster-errors.ts";
5
+ import { object, type RestCore, text } from "./rest-poster-core.ts";
6
+
7
+ /** How long a rename waits: past Discord's limit on renames the request would be held for minutes. */
8
+ export const RENAME_WAIT_MS = 10_000;
9
+
10
+ /** How close an audit entry's time must be to the thread's archive time for the entry to be that archive. */
11
+ const AUDIT_MATCH_MS = 2 * 60_000;
12
+
13
+ /** After how many minutes without a message Discord archives a thread: a week, the longest it offers. */
14
+ const THREAD_ARCHIVE_MINUTES = 10_080;
15
+
16
+ const ADMINISTRATOR = 1n << 3n;
17
+ const VIEW_CHANNEL = 1n << 10n;
18
+
19
+ /** Discord's rule for whether a member can see a channel: owner and administrators always, else the roles and the channel's overwrites. */
20
+ export function canViewChannel(input: {
21
+ guildId: string;
22
+ userId: string;
23
+ ownerId: string | undefined;
24
+ memberRoles: readonly string[];
25
+ roles: readonly Record<string, unknown>[];
26
+ overwrites: readonly Record<string, unknown>[];
27
+ }): boolean {
28
+ if (input.ownerId === input.userId) return true;
29
+ const permissions = (value: unknown): bigint => {
30
+ try {
31
+ return typeof value === "string" || typeof value === "number"
32
+ ? BigInt(value)
33
+ : 0n;
34
+ } catch {
35
+ return 0n;
36
+ }
37
+ };
38
+ // The @everyone role has the guild's id.
39
+ let base = 0n;
40
+ for (const role of input.roles) {
41
+ const id = text(role.id);
42
+ if (id === input.guildId || (id && input.memberRoles.includes(id)))
43
+ base |= permissions(role.permissions);
44
+ }
45
+ if ((base & ADMINISTRATOR) !== 0n) return true;
46
+ let allowed = base;
47
+ const overwrite = (type: number, id: string) =>
48
+ input.overwrites.find(
49
+ (entry) => entry.type === type && text(entry.id) === id,
50
+ );
51
+ const apply = (entry: Record<string, unknown> | undefined) => {
52
+ if (!entry) return;
53
+ allowed = (allowed & ~permissions(entry.deny)) | permissions(entry.allow);
54
+ };
55
+ apply(overwrite(0, input.guildId));
56
+ let deny = 0n;
57
+ let allow = 0n;
58
+ for (const roleId of input.memberRoles) {
59
+ const entry = overwrite(0, roleId);
60
+ if (!entry) continue;
61
+ deny |= permissions(entry.deny);
62
+ allow |= permissions(entry.allow);
63
+ }
64
+ allowed = (allowed & ~deny) | allow;
65
+ apply(overwrite(1, input.userId));
66
+ return (allowed & VIEW_CHANNEL) !== 0n;
67
+ }
68
+
69
+ /** The poster's channel, thread, member and permission calls. */
70
+ export type GuildOperations = Pick<
71
+ DiscordPoster,
72
+ | "createTextChannel"
73
+ | "textChannels"
74
+ | "deleteChannel"
75
+ | "renameChannel"
76
+ | "startThread"
77
+ | "setThreadArchived"
78
+ | "threadState"
79
+ | "threadCloser"
80
+ | "channelGuild"
81
+ | "canView"
82
+ | "memberName"
83
+ >;
84
+
85
+ export function guildOperations(
86
+ core: RestCore,
87
+ options: { renameWaitMs?: number } = {},
88
+ ): GuildOperations {
89
+ const { rest } = core;
90
+ const renameWaitMs = options.renameWaitMs ?? RENAME_WAIT_MS;
91
+ return {
92
+ async channelGuild(channelId) {
93
+ checkSnowflake("a channel", channelId);
94
+ return (await core.readChannel(channelId)).guildId;
95
+ },
96
+ async createTextChannel(categoryId, name, topic) {
97
+ checkSnowflake("a category", categoryId);
98
+ checkChannelName("a channel name", name);
99
+ const guildId = await core.guildOf(categoryId);
100
+ // No permission overwrites: the channel is made under the category and takes its permissions.
101
+ const made = object(
102
+ await rest
103
+ .post(Routes.guildChannels(guildId), {
104
+ body: {
105
+ name,
106
+ type: 0,
107
+ parent_id: categoryId,
108
+ ...(topic ? { topic } : {}),
109
+ },
110
+ })
111
+ .catch((error: unknown) => {
112
+ throw failure("making a channel", error);
113
+ }),
114
+ );
115
+ const id = text(made.id);
116
+ if (!id)
117
+ throw new DiscordPosterError("rejected", "Discord made no channel");
118
+ return id;
119
+ },
120
+ async textChannels(categoryId) {
121
+ checkSnowflake("a category", categoryId);
122
+ const guildId = await core.guildOf(categoryId);
123
+ const listed = await rest
124
+ .get(Routes.guildChannels(guildId))
125
+ .catch((error: unknown) => {
126
+ throw failure("listing the guild's channels", error);
127
+ });
128
+ return (Array.isArray(listed) ? listed : []).flatMap((entry) => {
129
+ const channel = object(entry);
130
+ const id = text(channel.id);
131
+ const name = text(channel.name);
132
+ return channel.parent_id === categoryId &&
133
+ channel.type === 0 &&
134
+ id &&
135
+ name
136
+ ? [{ id, name, topic: text(channel.topic) ?? "" }]
137
+ : [];
138
+ });
139
+ },
140
+ async deleteChannel(channelId) {
141
+ checkSnowflake("a channel", channelId);
142
+ await rest.delete(Routes.channel(channelId)).catch((error: unknown) => {
143
+ throw failure("deleting a channel", error);
144
+ });
145
+ },
146
+ async renameChannel(channelId, name) {
147
+ checkSnowflake("a channel", channelId);
148
+ checkChannelName("a channel name", name);
149
+ // discord.js sleeps out a rate limit's wait (a rename sublimit can be minutes) without
150
+ // looking at the signal, so the call is also raced against a timer: the caller is freed
151
+ // at once, and the signal keeps the held request from being sent later.
152
+ let timer: ReturnType<typeof setTimeout> | undefined;
153
+ const patch = rest
154
+ .patch(Routes.channel(channelId), {
155
+ body: { name },
156
+ signal: AbortSignal.timeout(renameWaitMs),
157
+ })
158
+ .catch((error: unknown) => {
159
+ throw failure("renaming a channel", error);
160
+ });
161
+ // A late failure of the abandoned request is not an unhandled rejection.
162
+ patch.catch(() => undefined);
163
+ try {
164
+ await Promise.race([
165
+ patch,
166
+ new Promise<never>((_, reject) => {
167
+ timer = setTimeout(
168
+ () =>
169
+ reject(
170
+ new DiscordPosterError(
171
+ "unavailable",
172
+ "renaming a channel failed (held back by Discord's rate limit)",
173
+ ),
174
+ ),
175
+ renameWaitMs,
176
+ );
177
+ }),
178
+ ]);
179
+ } finally {
180
+ clearTimeout(timer);
181
+ }
182
+ },
183
+ async startThread(channelId, messageId, name) {
184
+ checkSnowflake("a channel", channelId);
185
+ checkSnowflake("a message", messageId);
186
+ checkChannelName("a thread name", name);
187
+ const made = object(
188
+ await rest
189
+ .post(Routes.threads(channelId, messageId), {
190
+ body: { name, auto_archive_duration: THREAD_ARCHIVE_MINUTES },
191
+ })
192
+ .catch((error: unknown) => {
193
+ throw failure("starting a thread", error);
194
+ }),
195
+ );
196
+ const id = text(made.id);
197
+ if (!id)
198
+ throw new DiscordPosterError("rejected", "Discord made no thread");
199
+ return id;
200
+ },
201
+ async setThreadArchived(threadId, archived) {
202
+ checkSnowflake("a thread", threadId);
203
+ await rest
204
+ .patch(Routes.channel(threadId), { body: { archived } })
205
+ .catch((error: unknown) => {
206
+ throw failure("changing a thread's archive state", error);
207
+ });
208
+ },
209
+ async threadState(threadId) {
210
+ checkSnowflake("a thread", threadId);
211
+ const channel = object(
212
+ await rest.get(Routes.channel(threadId)).catch((error: unknown) => {
213
+ throw failure("reading a thread", error);
214
+ }),
215
+ );
216
+ const meta = object(channel.thread_metadata);
217
+ const archivedAt = Date.parse(text(meta.archive_timestamp) ?? "");
218
+ return {
219
+ archived: meta.archived === true,
220
+ locked: meta.locked === true,
221
+ archivedAt: Number.isNaN(archivedAt) ? undefined : archivedAt,
222
+ };
223
+ },
224
+ async threadCloser(threadId, state) {
225
+ checkSnowflake("a thread", threadId);
226
+ const { guildId } = await core.readChannel(threadId);
227
+ const log = object(
228
+ await rest
229
+ .get(Routes.guildAuditLog(guildId), {
230
+ query: new URLSearchParams({
231
+ action_type: String(AuditLogEvent.ThreadUpdate),
232
+ limit: "50",
233
+ }),
234
+ })
235
+ .catch((error: unknown) => {
236
+ throw failure("reading the guild's audit log", error);
237
+ }),
238
+ );
239
+ const entries = Array.isArray(log.audit_log_entries)
240
+ ? log.audit_log_entries
241
+ : [];
242
+ // Newest first. An entry counts when it is about this thread, turns on the archive or the
243
+ // lock, and (for an archive) is of the moment the thread was archived: an older entry of an
244
+ // archive that was since undone says nothing about this one.
245
+ for (const raw of entries) {
246
+ const entry = object(raw);
247
+ if (text(entry.target_id) !== threadId) continue;
248
+ const changes = Array.isArray(entry.changes) ? entry.changes : [];
249
+ const turnedOn = (key: string) =>
250
+ changes.some((change) => {
251
+ const found = object(change);
252
+ return found.key === key && found.new_value === true;
253
+ });
254
+ const archives = state.archived && turnedOn("archived");
255
+ const locks = state.locked && turnedOn("locked");
256
+ if (!archives && !locks) continue;
257
+ const id = text(entry.id);
258
+ if (archives && !locks) {
259
+ if (!id || state.archivedAt === undefined) continue;
260
+ const at = Number((BigInt(id) >> 22n) + 1_420_070_400_000n);
261
+ if (Math.abs(at - state.archivedAt) > AUDIT_MATCH_MS) continue;
262
+ }
263
+ const userId = text(entry.user_id);
264
+ if (!userId) continue;
265
+ return userId === (await core.bot())
266
+ ? { kind: "bot" }
267
+ : { kind: "person", userId };
268
+ }
269
+ return { kind: "unknown" };
270
+ },
271
+ async canView(channelId, userId) {
272
+ checkSnowflake("a channel", channelId);
273
+ checkSnowflake("a user", userId);
274
+ const channel = object(
275
+ await rest.get(Routes.channel(channelId)).catch((error: unknown) => {
276
+ throw failure("reading a channel", error);
277
+ }),
278
+ );
279
+ const guildId = text(channel.guild_id);
280
+ if (!guildId)
281
+ throw new DiscordPosterError("invalid", "that channel is in no guild", {
282
+ reason: "wrong_guild",
283
+ });
284
+ let member: Record<string, unknown>;
285
+ try {
286
+ member = object(await rest.get(Routes.guildMember(guildId, userId)));
287
+ } catch (error) {
288
+ if (isCode(error, RESTJSONErrorCodes.UnknownMember)) return false;
289
+ throw failure("reading a guild member", error);
290
+ }
291
+ const guild = object(
292
+ await rest.get(Routes.guild(guildId)).catch((error: unknown) => {
293
+ throw failure("reading the guild", error);
294
+ }),
295
+ );
296
+ const roles = await rest
297
+ .get(Routes.guildRoles(guildId))
298
+ .catch((error: unknown) => {
299
+ throw failure("listing the guild's roles", error);
300
+ });
301
+ return canViewChannel({
302
+ guildId,
303
+ userId,
304
+ ownerId: text(guild.owner_id),
305
+ memberRoles: Array.isArray(member.roles)
306
+ ? member.roles.filter((id): id is string => typeof id === "string")
307
+ : [],
308
+ roles: Array.isArray(roles) ? roles.map(object) : [],
309
+ overwrites: Array.isArray(channel.permission_overwrites)
310
+ ? channel.permission_overwrites.map(object)
311
+ : [],
312
+ });
313
+ },
314
+ async memberName(guildId, userId) {
315
+ checkSnowflake("a guild", guildId);
316
+ checkSnowflake("a user", userId);
317
+ let member: Record<string, unknown>;
318
+ try {
319
+ member = object(await rest.get(Routes.guildMember(guildId, userId)));
320
+ } catch (error) {
321
+ // Not in the guild: an answer, not a failure.
322
+ if (isCode(error, RESTJSONErrorCodes.UnknownMember)) return undefined;
323
+ throw failure("reading a guild member", error);
324
+ }
325
+ const user = object(member.user);
326
+ return text(member.nick) ?? text(user.global_name) ?? text(user.username);
327
+ },
328
+ };
329
+ }