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.
- package/CHANGELOG.md +18 -0
- package/docs/plugins.md +68 -4
- package/package.json +8 -6
- package/src/core/builtin/discord.ts +11 -0
- package/src/core/discord/discord-surface.ts +8 -0
- package/src/core/discord/poster/poster-checks.ts +272 -0
- package/src/core/discord/poster/poster-errors.ts +106 -0
- package/src/core/discord/poster/poster.ts +301 -0
- package/src/core/discord/poster/rest-poster-core.ts +112 -0
- package/src/core/discord/poster/rest-poster-guild.ts +329 -0
- package/src/core/discord/poster/rest-poster.ts +488 -0
- package/src/core/modules/delegation/web-research-worker.ts +1 -1
- package/src/core/ops/alert-throttle.ts +81 -0
- package/src/core/ops/error-reporter.ts +20 -17
- package/src/core/ops/ops-alerts.ts +132 -0
- package/src/core/testing/fake-poster-guild.ts +491 -0
- package/src/core/testing/fake-poster.ts +475 -0
- package/src/core/testing/test-host.ts +16 -5
- package/src/discord/index.ts +21 -0
- package/src/kit/index.ts +6 -0
- package/src/kit/ops-alerts.ts +9 -0
- package/src/testing.ts +15 -1
- package/templates/adapters/web/package.json.tmpl +2 -2
- package/templates/package.json.tmpl +2 -2
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DISCORD_LIMITS,
|
|
3
|
+
type DiscordCard,
|
|
4
|
+
type DiscordMessage,
|
|
5
|
+
type DiscordPoster,
|
|
6
|
+
type PostedMessage,
|
|
7
|
+
type RecentMessage,
|
|
8
|
+
} from "../discord/poster/poster.ts";
|
|
9
|
+
import {
|
|
10
|
+
cardBody,
|
|
11
|
+
checkAvatar,
|
|
12
|
+
checkedPost,
|
|
13
|
+
checkedText,
|
|
14
|
+
checkSnowflake,
|
|
15
|
+
checkWebhookName,
|
|
16
|
+
invalid,
|
|
17
|
+
} from "../discord/poster/poster-checks.ts";
|
|
18
|
+
import { DiscordPosterError } from "../discord/poster/poster-errors.ts";
|
|
19
|
+
import {
|
|
20
|
+
cardText,
|
|
21
|
+
type FakePosterChannel,
|
|
22
|
+
FakePosterGuild,
|
|
23
|
+
type FakePosterMessage,
|
|
24
|
+
} from "./fake-poster-guild.ts";
|
|
25
|
+
|
|
26
|
+
export type {
|
|
27
|
+
FakePosterChannel,
|
|
28
|
+
FakePosterMessage,
|
|
29
|
+
} from "./fake-poster-guild.ts";
|
|
30
|
+
export { cardText } from "./fake-poster-guild.ts";
|
|
31
|
+
|
|
32
|
+
function blobOf(file: { data: Uint8Array; contentType?: string }) {
|
|
33
|
+
return {
|
|
34
|
+
data: file.data,
|
|
35
|
+
...(file.contentType ? { contentType: file.contentType } : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What a test reads and sets on the fake poster, besides the `DiscordPoster` calls. */
|
|
40
|
+
export interface FakeDiscordPoster extends DiscordPoster {
|
|
41
|
+
readonly channels: Map<string, FakePosterChannel>;
|
|
42
|
+
readonly directs: { userId: string; content: string }[];
|
|
43
|
+
/** People who take no direct message from the bot. */
|
|
44
|
+
readonly closedDirects: Set<string>;
|
|
45
|
+
/** Channels that do not exist (any more). */
|
|
46
|
+
readonly goneChannels: Set<string>;
|
|
47
|
+
/** The categories that exist; creating a channel under any other fails as `channel_gone`. */
|
|
48
|
+
readonly categories: Set<string>;
|
|
49
|
+
/** How many channels a category holds (Discord: 50) and a guild holds (Discord: 500). */
|
|
50
|
+
categoryLimit: number;
|
|
51
|
+
guildLimit: number;
|
|
52
|
+
/** What the guild shows for a member, by user id; a user not listed has no name. */
|
|
53
|
+
readonly memberNames: Map<string, string>;
|
|
54
|
+
readonly memberLookups: { guildId: string; userId: string }[];
|
|
55
|
+
/** The guild every channel is in unless `channelGuilds` says another. */
|
|
56
|
+
guildId: string;
|
|
57
|
+
readonly channelGuilds: Map<string, string>;
|
|
58
|
+
/** `<channel id>:<user id>` pairs that cannot see the channel. */
|
|
59
|
+
readonly hiddenFrom: Set<string>;
|
|
60
|
+
/** People who are not members of the guild: they see no channel. */
|
|
61
|
+
readonly nonMembers: Set<string>;
|
|
62
|
+
/** Whether a pin posts the system notice Discord posts (type 6); off by default. */
|
|
63
|
+
pinNotices: boolean;
|
|
64
|
+
/** Whether the bot may read the audit log; off makes `threadCloser` fail as `forbidden`. */
|
|
65
|
+
canReadAuditLog: boolean;
|
|
66
|
+
/** Every rename, in order, as `[channel id, new name]`. */
|
|
67
|
+
readonly renames: [string, string][];
|
|
68
|
+
/** The bot user's id: the author of what `say` posts. */
|
|
69
|
+
readonly botId: string;
|
|
70
|
+
/** How many calls got past the argument checks, as the requests Discord would have seen. */
|
|
71
|
+
readonly requests: number;
|
|
72
|
+
/** Makes the next call of `operation` throw `error`. */
|
|
73
|
+
failNext(operation: keyof DiscordPoster, error: DiscordPosterError): void;
|
|
74
|
+
/** The channel's messages that still exist, oldest first. */
|
|
75
|
+
messagesIn(channelId: string): FakePosterMessage[];
|
|
76
|
+
/** A person writes in the channel (not through a webhook); the message's id. */
|
|
77
|
+
speak(
|
|
78
|
+
channelId: string,
|
|
79
|
+
author: { id: string; name: string },
|
|
80
|
+
content: string,
|
|
81
|
+
files?: { name: string; data: Uint8Array; contentType?: string }[],
|
|
82
|
+
urls?: string[],
|
|
83
|
+
): string;
|
|
84
|
+
/** A person archives (or locks) the thread; `auditSkew` moves the audit entry's time away from the archive's. */
|
|
85
|
+
personCloses(
|
|
86
|
+
threadId: string,
|
|
87
|
+
userId: string,
|
|
88
|
+
options?: { lock?: boolean; auditSkew?: number },
|
|
89
|
+
): void;
|
|
90
|
+
/** Discord archives a quiet thread on its own: no one is logged. */
|
|
91
|
+
autoArchives(threadId: string): void;
|
|
92
|
+
/** A person with Manage Threads opens a thread again. */
|
|
93
|
+
personOpens(threadId: string): void;
|
|
94
|
+
/** The threads started in a channel, oldest first. */
|
|
95
|
+
threadsIn(
|
|
96
|
+
channelId: string,
|
|
97
|
+
): { id: string; name: string; startedOn: string }[];
|
|
98
|
+
/** The named webhook was deleted by hand; the next post makes it again, with the default picture. */
|
|
99
|
+
deleteWebhook(channelId: string, webhook: string): void;
|
|
100
|
+
/** The named webhook was deleted by hand and is made again at once; `onWebhookMade` hears it. */
|
|
101
|
+
recreateWebhook(channelId: string, webhook: string): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A `DiscordPoster` in memory for tests, made by `fakeDiscordPoster()`: channels with their messages, pins and webhook pictures,
|
|
106
|
+
* and the direct messages sent. It keeps the real poster's argument checks (lengths, names,
|
|
107
|
+
* mentions, avatar), so a consumer that breaks a limit fails here too, and it can be told to fail
|
|
108
|
+
* the next call or to refuse direct messages from a person. The same scenarios run against it and
|
|
109
|
+
* against the real poster over an emulated Discord, so the two answer alike.
|
|
110
|
+
*/
|
|
111
|
+
class FakePosterImpl extends FakePosterGuild implements FakeDiscordPoster {
|
|
112
|
+
readonly #webhooks = new Map<string, string>();
|
|
113
|
+
readonly #madeListeners = new Set<
|
|
114
|
+
(channelId: string, webhook: string) => Promise<void>
|
|
115
|
+
>();
|
|
116
|
+
|
|
117
|
+
constructor(options: { idOffset?: bigint; now?: () => number } = {}) {
|
|
118
|
+
super(options.idOffset ?? 0n, options.now ?? Date.now);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The channel's messages that still exist, oldest first. */
|
|
122
|
+
messagesIn(channelId: string): FakePosterMessage[] {
|
|
123
|
+
return [...(this.channels.get(channelId)?.messages ?? [])];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The id of the channel's webhook of that name, made on first use as Discord would (and announced). */
|
|
127
|
+
async #webhook(channelId: string, name: string): Promise<string> {
|
|
128
|
+
const slot = `${channelId}:${name}`;
|
|
129
|
+
let id = this.#webhooks.get(slot);
|
|
130
|
+
if (!id) {
|
|
131
|
+
this.nextWebhook += 1n;
|
|
132
|
+
id = this.nextWebhook.toString();
|
|
133
|
+
this.#webhooks.set(slot, id);
|
|
134
|
+
for (const listener of this.#madeListeners)
|
|
135
|
+
await listener(channelId, name).catch(() => undefined);
|
|
136
|
+
}
|
|
137
|
+
return id;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Tests: the named webhook was deleted by hand. The next post makes it again, with the default picture. */
|
|
141
|
+
deleteWebhook(channelId: string, webhook: string): void {
|
|
142
|
+
this.#webhooks.delete(`${channelId}:${webhook}`);
|
|
143
|
+
this.channels.get(channelId)?.avatars.delete(webhook);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Tests: the named webhook was deleted by hand and is made again at once; `onWebhookMade` hears it. */
|
|
147
|
+
async recreateWebhook(channelId: string, webhook: string): Promise<void> {
|
|
148
|
+
this.deleteWebhook(channelId, webhook);
|
|
149
|
+
await this.#webhook(channelId, webhook);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async post(
|
|
153
|
+
channelId: string,
|
|
154
|
+
post: Parameters<DiscordPoster["post"]>[1],
|
|
155
|
+
): Promise<PostedMessage> {
|
|
156
|
+
checkSnowflake("a channel", channelId);
|
|
157
|
+
const { webhook, body, files } = checkedPost(post);
|
|
158
|
+
this.sent("post");
|
|
159
|
+
const channel = this.channel(channelId);
|
|
160
|
+
const webhookId = await this.#webhook(channelId, webhook);
|
|
161
|
+
this.next += 1n;
|
|
162
|
+
const message: FakePosterMessage = {
|
|
163
|
+
id: this.next.toString(),
|
|
164
|
+
username: body.username,
|
|
165
|
+
content: body.content ?? "",
|
|
166
|
+
files: files.map((file) => ({
|
|
167
|
+
name: file.name,
|
|
168
|
+
bytes: file.data.byteLength,
|
|
169
|
+
})),
|
|
170
|
+
blobs: files.map(blobOf),
|
|
171
|
+
mentions: body.allowed_mentions.users,
|
|
172
|
+
...("avatar_url" in body ? { avatarUrl: body.avatar_url } : {}),
|
|
173
|
+
webhook,
|
|
174
|
+
webhookId,
|
|
175
|
+
at: this.tick(),
|
|
176
|
+
};
|
|
177
|
+
channel.messages.push(message);
|
|
178
|
+
return { channelId, messageId: message.id };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A person writes in the channel (not through a webhook); the message's id. */
|
|
182
|
+
speak(
|
|
183
|
+
channelId: string,
|
|
184
|
+
author: { id: string; name: string },
|
|
185
|
+
content: string,
|
|
186
|
+
files: { name: string; data: Uint8Array; contentType?: string }[] = [],
|
|
187
|
+
urls?: string[],
|
|
188
|
+
): string {
|
|
189
|
+
const channel = this.channel(channelId);
|
|
190
|
+
this.next += 1n;
|
|
191
|
+
channel.messages.push({
|
|
192
|
+
id: this.next.toString(),
|
|
193
|
+
username: author.name,
|
|
194
|
+
content,
|
|
195
|
+
files: files.map((file) => ({
|
|
196
|
+
name: file.name,
|
|
197
|
+
bytes: file.data.byteLength,
|
|
198
|
+
})),
|
|
199
|
+
blobs: files.map(blobOf),
|
|
200
|
+
mentions: [],
|
|
201
|
+
authorId: author.id,
|
|
202
|
+
...(urls ? { urls } : {}),
|
|
203
|
+
at: this.tick(),
|
|
204
|
+
});
|
|
205
|
+
return this.next.toString();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Whether the message was posted by this webhook as it exists now. */
|
|
209
|
+
#ownedBy(message: FakePosterMessage, channelId: string, name: string) {
|
|
210
|
+
return (
|
|
211
|
+
message.webhook === name &&
|
|
212
|
+
message.webhookId === this.#webhooks.get(`${channelId}:${name}`)
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async edit(
|
|
217
|
+
channelId: string,
|
|
218
|
+
messageId: string,
|
|
219
|
+
edit: { content: string; mentions?: readonly string[] },
|
|
220
|
+
webhooks: readonly string[],
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
checkSnowflake("a channel", channelId);
|
|
223
|
+
checkSnowflake("a message", messageId);
|
|
224
|
+
const body = checkedText(edit.content, edit.mentions);
|
|
225
|
+
for (const name of webhooks) checkWebhookName(name);
|
|
226
|
+
this.sent("edit");
|
|
227
|
+
const message = this.found(channelId, messageId);
|
|
228
|
+
if (!webhooks.some((name) => this.#ownedBy(message, channelId, name)))
|
|
229
|
+
throw new DiscordPosterError(
|
|
230
|
+
"message_gone",
|
|
231
|
+
"the message does not exist",
|
|
232
|
+
);
|
|
233
|
+
message.content = body.content ?? "";
|
|
234
|
+
message.mentions = body.allowed_mentions.users;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async remove(
|
|
238
|
+
channelId: string,
|
|
239
|
+
messageId: string,
|
|
240
|
+
webhooks: readonly string[],
|
|
241
|
+
): Promise<void> {
|
|
242
|
+
checkSnowflake("a channel", channelId);
|
|
243
|
+
checkSnowflake("a message", messageId);
|
|
244
|
+
for (const name of webhooks) checkWebhookName(name);
|
|
245
|
+
this.sent("remove");
|
|
246
|
+
this.#drop(channelId, messageId);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#drop(channelId: string, messageId: string): void {
|
|
250
|
+
const channel = this.channel(channelId);
|
|
251
|
+
this.found(channelId, messageId);
|
|
252
|
+
channel.messages = channel.messages.filter(
|
|
253
|
+
(message) => message.id !== messageId,
|
|
254
|
+
);
|
|
255
|
+
channel.pins = channel.pins.filter((id) => id !== messageId);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async webhookIds(
|
|
259
|
+
channelId: string,
|
|
260
|
+
names: readonly string[],
|
|
261
|
+
): Promise<Readonly<Record<string, readonly string[]>>> {
|
|
262
|
+
checkSnowflake("a channel", channelId);
|
|
263
|
+
for (const name of names) checkWebhookName(name);
|
|
264
|
+
this.sent("webhookIds");
|
|
265
|
+
const ids: Record<string, string[]> = Object.create(null);
|
|
266
|
+
for (const name of names) {
|
|
267
|
+
const id = this.#webhooks.get(`${channelId}:${name}`);
|
|
268
|
+
ids[name] = id ? [id] : [];
|
|
269
|
+
}
|
|
270
|
+
return ids;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async setAvatar(
|
|
274
|
+
channelId: string,
|
|
275
|
+
webhook: string,
|
|
276
|
+
png: Uint8Array,
|
|
277
|
+
): Promise<void> {
|
|
278
|
+
checkSnowflake("a channel", channelId);
|
|
279
|
+
checkWebhookName(webhook);
|
|
280
|
+
checkAvatar(png);
|
|
281
|
+
this.sent("setAvatar");
|
|
282
|
+
const channel = this.channel(channelId);
|
|
283
|
+
await this.#webhook(channelId, webhook);
|
|
284
|
+
channel.avatars.set(webhook, png);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
onWebhookMade(
|
|
288
|
+
listener: (channelId: string, webhook: string) => Promise<void>,
|
|
289
|
+
): () => void {
|
|
290
|
+
this.#madeListeners.add(listener);
|
|
291
|
+
return () => void this.#madeListeners.delete(listener);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async say(
|
|
295
|
+
channelId: string,
|
|
296
|
+
post: { content: string; mentions?: readonly string[] },
|
|
297
|
+
): Promise<PostedMessage> {
|
|
298
|
+
checkSnowflake("a channel", channelId);
|
|
299
|
+
if (!post.content) throw invalid("a message needs text");
|
|
300
|
+
const body = checkedText(post.content, post.mentions);
|
|
301
|
+
this.sent("say");
|
|
302
|
+
const channel = this.channel(channelId);
|
|
303
|
+
this.wake(channel);
|
|
304
|
+
this.next += 1n;
|
|
305
|
+
channel.messages.push({
|
|
306
|
+
id: this.next.toString(),
|
|
307
|
+
username: "bot",
|
|
308
|
+
content: body.content ?? "",
|
|
309
|
+
files: [],
|
|
310
|
+
blobs: [],
|
|
311
|
+
mentions: body.allowed_mentions.users,
|
|
312
|
+
authorId: this.botId,
|
|
313
|
+
at: this.tick(),
|
|
314
|
+
});
|
|
315
|
+
return { channelId, messageId: this.next.toString() };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async sayCard(channelId: string, card: DiscordCard): Promise<PostedMessage> {
|
|
319
|
+
checkSnowflake("a channel", channelId);
|
|
320
|
+
cardBody(card);
|
|
321
|
+
this.sent("sayCard");
|
|
322
|
+
const channel = this.channel(channelId);
|
|
323
|
+
this.wake(channel);
|
|
324
|
+
this.next += 1n;
|
|
325
|
+
channel.messages.push({
|
|
326
|
+
id: this.next.toString(),
|
|
327
|
+
username: "bot",
|
|
328
|
+
content: cardText(card),
|
|
329
|
+
files: [],
|
|
330
|
+
blobs: [],
|
|
331
|
+
mentions: [],
|
|
332
|
+
authorId: this.botId,
|
|
333
|
+
card,
|
|
334
|
+
at: this.tick(),
|
|
335
|
+
});
|
|
336
|
+
return { channelId, messageId: this.next.toString() };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async editCard(
|
|
340
|
+
channelId: string,
|
|
341
|
+
messageId: string,
|
|
342
|
+
card: DiscordCard,
|
|
343
|
+
): Promise<void> {
|
|
344
|
+
checkSnowflake("a channel", channelId);
|
|
345
|
+
checkSnowflake("a message", messageId);
|
|
346
|
+
cardBody(card);
|
|
347
|
+
this.sent("editCard");
|
|
348
|
+
const message = this.found(channelId, messageId);
|
|
349
|
+
// A message posted without the Components V2 flag cannot become a card.
|
|
350
|
+
if (!message.card)
|
|
351
|
+
throw new DiscordPosterError(
|
|
352
|
+
"rejected",
|
|
353
|
+
"editing a card failed: that message is not a card",
|
|
354
|
+
{ discordCode: 50035, status: 400 },
|
|
355
|
+
);
|
|
356
|
+
message.card = card;
|
|
357
|
+
message.content = cardText(card);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async deleteAsBot(channelId: string, messageId: string): Promise<void> {
|
|
361
|
+
checkSnowflake("a channel", channelId);
|
|
362
|
+
checkSnowflake("a message", messageId);
|
|
363
|
+
this.sent("deleteAsBot");
|
|
364
|
+
this.#drop(channelId, messageId);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async sendDirect(
|
|
368
|
+
userId: string,
|
|
369
|
+
content: string,
|
|
370
|
+
): Promise<"sent" | "closed"> {
|
|
371
|
+
checkSnowflake("a user", userId);
|
|
372
|
+
checkedText(content, []);
|
|
373
|
+
this.sent("sendDirect");
|
|
374
|
+
if (this.closedDirects.has(userId)) return "closed";
|
|
375
|
+
this.directs.push({ userId, content });
|
|
376
|
+
return "sent";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async pin(channelId: string, messageId: string): Promise<void> {
|
|
380
|
+
checkSnowflake("a channel", channelId);
|
|
381
|
+
checkSnowflake("a message", messageId);
|
|
382
|
+
this.sent("pin");
|
|
383
|
+
const channel = this.channel(channelId);
|
|
384
|
+
this.found(channelId, messageId);
|
|
385
|
+
if (channel.pins.includes(messageId)) return;
|
|
386
|
+
if (channel.pins.length >= DISCORD_LIMITS.pins)
|
|
387
|
+
throw new DiscordPosterError(
|
|
388
|
+
"rejected",
|
|
389
|
+
`a channel keeps at most ${DISCORD_LIMITS.pins} pins`,
|
|
390
|
+
{ discordCode: 30003, status: 400 },
|
|
391
|
+
);
|
|
392
|
+
channel.pins.push(messageId);
|
|
393
|
+
if (this.pinNotices) {
|
|
394
|
+
this.next += 1n;
|
|
395
|
+
channel.messages.push({
|
|
396
|
+
id: this.next.toString(),
|
|
397
|
+
username: "bot",
|
|
398
|
+
content: "",
|
|
399
|
+
files: [],
|
|
400
|
+
blobs: [],
|
|
401
|
+
mentions: [],
|
|
402
|
+
authorId: this.botId,
|
|
403
|
+
type: 6,
|
|
404
|
+
referencedMessageId: messageId,
|
|
405
|
+
at: this.tick(),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async unpin(channelId: string, messageId: string): Promise<void> {
|
|
411
|
+
checkSnowflake("a channel", channelId);
|
|
412
|
+
checkSnowflake("a message", messageId);
|
|
413
|
+
this.sent("unpin");
|
|
414
|
+
const channel = this.channel(channelId);
|
|
415
|
+
channel.pins = channel.pins.filter((id) => id !== messageId);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async pinnedIds(channelId: string): Promise<string[]> {
|
|
419
|
+
checkSnowflake("a channel", channelId);
|
|
420
|
+
this.sent("pinnedIds");
|
|
421
|
+
return [...this.channel(channelId).pins].reverse();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async message(channelId: string, messageId: string): Promise<DiscordMessage> {
|
|
425
|
+
checkSnowflake("a channel", channelId);
|
|
426
|
+
checkSnowflake("a message", messageId);
|
|
427
|
+
this.sent("message");
|
|
428
|
+
const found = this.found(channelId, messageId);
|
|
429
|
+
return {
|
|
430
|
+
id: found.id,
|
|
431
|
+
authorId: found.webhookId ?? found.authorId ?? "0",
|
|
432
|
+
...(found.webhookId ? { webhookId: found.webhookId } : {}),
|
|
433
|
+
content: found.content,
|
|
434
|
+
files: found.files.map((file, index) => ({
|
|
435
|
+
name: file.name,
|
|
436
|
+
contentType: found.blobs[index]?.contentType ?? "",
|
|
437
|
+
size: file.bytes,
|
|
438
|
+
url:
|
|
439
|
+
found.urls?.[index] ??
|
|
440
|
+
`https://cdn.test/${found.id}/${encodeURIComponent(file.name)}`,
|
|
441
|
+
})),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async recentMessages(
|
|
446
|
+
channelId: string,
|
|
447
|
+
limit: number,
|
|
448
|
+
): Promise<RecentMessage[]> {
|
|
449
|
+
checkSnowflake("a channel", channelId);
|
|
450
|
+
this.sent("recentMessages");
|
|
451
|
+
return [...this.channel(channelId).messages]
|
|
452
|
+
.reverse()
|
|
453
|
+
.slice(0, Math.min(50, Math.max(1, Math.trunc(limit))))
|
|
454
|
+
.map((message) => ({
|
|
455
|
+
id: message.id,
|
|
456
|
+
type: message.type ?? 0,
|
|
457
|
+
authorId: message.webhookId ?? message.authorId ?? "0",
|
|
458
|
+
...(message.referencedMessageId
|
|
459
|
+
? { referencedMessageId: message.referencedMessageId }
|
|
460
|
+
: {}),
|
|
461
|
+
}));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** A `DiscordPoster` in memory, to give a plugin under test as `DISCORD.poster`. */
|
|
466
|
+
export function fakeDiscordPoster(
|
|
467
|
+
options: {
|
|
468
|
+
/** Moves every id it makes, so test files that share a database never meet the same id. */
|
|
469
|
+
idOffset?: bigint;
|
|
470
|
+
/** The clock of the rename limit; default `Date.now`. */
|
|
471
|
+
now?: () => number;
|
|
472
|
+
} = {},
|
|
473
|
+
): FakeDiscordPoster {
|
|
474
|
+
return new FakePosterImpl(options);
|
|
475
|
+
}
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
InteractionContribution,
|
|
22
22
|
} from "../discord/interaction-module.ts";
|
|
23
23
|
import { commandGuard, ownerRootCommand } from "../discord/owner-command.ts";
|
|
24
|
+
import type { DiscordPoster } from "../discord/poster/poster.ts";
|
|
24
25
|
import type { HoldDescription } from "../holds.ts";
|
|
25
26
|
import { Roundtable } from "../host.ts";
|
|
26
27
|
import { silentLogger } from "../log.ts";
|
|
@@ -37,6 +38,7 @@ import type {
|
|
|
37
38
|
import { type Speaker, THE_SPEAKER } from "../speakers.ts";
|
|
38
39
|
import { toolTurn } from "../tool-turn.ts";
|
|
39
40
|
import { testDatabaseUrl } from "./database.ts";
|
|
41
|
+
import { fakeDiscordPoster } from "./fake-poster.ts";
|
|
40
42
|
import { callRegisteredTool, registeredTools, toolNamed } from "./run-tool.ts";
|
|
41
43
|
|
|
42
44
|
export interface TestHostOptions {
|
|
@@ -61,7 +63,15 @@ export interface TestHostOptions {
|
|
|
61
63
|
*/
|
|
62
64
|
discord?:
|
|
63
65
|
| false
|
|
64
|
-
| Partial<
|
|
66
|
+
| (Partial<
|
|
67
|
+
Pick<
|
|
68
|
+
DiscordConnection,
|
|
69
|
+
"agentChannels" | "ownerChannel" | "channelInfo"
|
|
70
|
+
>
|
|
71
|
+
> & {
|
|
72
|
+
/** What `DISCORD.poster` is; by default a `fakeDiscordPoster()`. */
|
|
73
|
+
poster?: DiscordPoster;
|
|
74
|
+
});
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
export interface TestHost {
|
|
@@ -168,7 +178,7 @@ function quietRuntime(): AgentRuntime {
|
|
|
168
178
|
export function standInDiscord(
|
|
169
179
|
commands: CommandCollection,
|
|
170
180
|
guard: CommandGuard,
|
|
171
|
-
given: TestHostOptions["discord"],
|
|
181
|
+
given: Exclude<TestHostOptions["discord"], false>,
|
|
172
182
|
/** The primary owner's Discord user id, whose direct messages are the owner channel. */
|
|
173
183
|
ownerId: string,
|
|
174
184
|
/** What `DISCORD.channelContext` answers; by default no context, as on a host with it off. */
|
|
@@ -185,9 +195,9 @@ export function standInDiscord(
|
|
|
185
195
|
replaces: [DISCORD],
|
|
186
196
|
setup: ({ services }) => {
|
|
187
197
|
const identity = services.find(IDENTITY);
|
|
198
|
+
const { poster, ...overrides } = given ?? {};
|
|
188
199
|
const ownerChannel =
|
|
189
|
-
(
|
|
190
|
-
(async () => "discord:owner-dm" as const);
|
|
200
|
+
overrides.ownerChannel ?? (async () => "discord:owner-dm" as const);
|
|
191
201
|
// Each person's direct messages, as the Discord plugin's provider reaches them; the owner's is the owner channel.
|
|
192
202
|
const direct = discordDirectChannel({
|
|
193
203
|
directChannel: async (userId) =>
|
|
@@ -204,11 +214,12 @@ export function standInDiscord(
|
|
|
204
214
|
onChannelDeleted: () => undefined,
|
|
205
215
|
ownerChannel: async () => "discord:owner-dm",
|
|
206
216
|
ownerOperations: () => ({}),
|
|
207
|
-
...
|
|
217
|
+
...overrides,
|
|
208
218
|
},
|
|
209
219
|
commands: commands.registrar,
|
|
210
220
|
guard,
|
|
211
221
|
threads: { open: async () => undefined, sweep: async () => undefined },
|
|
222
|
+
poster: poster ?? fakeDiscordPoster(),
|
|
212
223
|
channelContext,
|
|
213
224
|
} as unknown as DiscordServices);
|
|
214
225
|
return { surfaces: [surface], directChannels: [direct] };
|
package/src/discord/index.ts
CHANGED
|
@@ -78,5 +78,26 @@ export {
|
|
|
78
78
|
plain,
|
|
79
79
|
replyWithPanels,
|
|
80
80
|
} from "../core/discord/owner-panel.ts";
|
|
81
|
+
export type {
|
|
82
|
+
CardButton,
|
|
83
|
+
CardLink,
|
|
84
|
+
CardSelect,
|
|
85
|
+
CategoryChannel,
|
|
86
|
+
DiscordCard,
|
|
87
|
+
DiscordFailure,
|
|
88
|
+
DiscordMessage,
|
|
89
|
+
DiscordPoster,
|
|
90
|
+
DiscordReason,
|
|
91
|
+
PostedMessage,
|
|
92
|
+
PosterFile,
|
|
93
|
+
RecentMessage,
|
|
94
|
+
ThreadCloser,
|
|
95
|
+
ThreadState,
|
|
96
|
+
WebhookPost,
|
|
97
|
+
} from "../core/discord/poster/poster.ts";
|
|
98
|
+
export {
|
|
99
|
+
DISCORD_LIMITS,
|
|
100
|
+
DiscordPosterError,
|
|
101
|
+
} from "../core/discord/poster/poster.ts";
|
|
81
102
|
export type { OwnerOperations } from "../core/modules/discord-admin/discord-admin.ts";
|
|
82
103
|
export { DISCORD_ADMIN_TOOLS } from "../core/modules/discord-admin/discord-admin.ts";
|
package/src/kit/index.ts
CHANGED
|
@@ -100,6 +100,12 @@ export {
|
|
|
100
100
|
SCHEDULE_TOOLS,
|
|
101
101
|
scheduleToolSpecs,
|
|
102
102
|
} from "./mirror.ts";
|
|
103
|
+
export type {
|
|
104
|
+
OpsAlertKind,
|
|
105
|
+
OpsAlertsOptions,
|
|
106
|
+
OpsRaiseOptions,
|
|
107
|
+
} from "./ops-alerts.ts";
|
|
108
|
+
export { OpsAlerts } from "./ops-alerts.ts";
|
|
103
109
|
export {
|
|
104
110
|
headline,
|
|
105
111
|
quietLinks,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Plugin helpers, versioned like the main entry; see the plugin guide.
|
|
2
|
+
// Short alerts for the people who run a host, held to a window so a fault cannot flood them.
|
|
3
|
+
|
|
4
|
+
export type {
|
|
5
|
+
OpsAlertKind,
|
|
6
|
+
OpsAlertsOptions,
|
|
7
|
+
OpsRaiseOptions,
|
|
8
|
+
} from "../core/ops/ops-alerts.ts";
|
|
9
|
+
export { OpsAlerts } from "../core/ops/ops-alerts.ts";
|
package/src/testing.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
commandGuard,
|
|
30
30
|
ownerRootCommand,
|
|
31
31
|
} from "./core/discord/owner-command.ts";
|
|
32
|
+
import type { DiscordPoster } from "./core/discord/poster/poster.ts";
|
|
32
33
|
import type { ClientPart, ClientPort } from "./core/domain/client.ts";
|
|
33
34
|
import type {
|
|
34
35
|
PendingConfirmation,
|
|
@@ -76,6 +77,7 @@ import {
|
|
|
76
77
|
} from "./core/services.ts";
|
|
77
78
|
import type { ChannelKey, SessionContext } from "./core/sessions.ts";
|
|
78
79
|
import { type Speaker, THE_SPEAKER } from "./core/speakers.ts";
|
|
80
|
+
import { fakeDiscordPoster } from "./core/testing/fake-poster.ts";
|
|
79
81
|
import { mapIdentity } from "./core/testing/map-identity.ts";
|
|
80
82
|
import {
|
|
81
83
|
callRegisteredTool,
|
|
@@ -95,6 +97,12 @@ export {
|
|
|
95
97
|
testDatabaseUrl,
|
|
96
98
|
} from "./core/testing/database.ts";
|
|
97
99
|
export { eagerText, useEagerCatalog } from "./core/testing/eager-catalog.ts";
|
|
100
|
+
export type {
|
|
101
|
+
FakeDiscordPoster,
|
|
102
|
+
FakePosterChannel,
|
|
103
|
+
FakePosterMessage,
|
|
104
|
+
} from "./core/testing/fake-poster.ts";
|
|
105
|
+
export { fakeDiscordPoster } from "./core/testing/fake-poster.ts";
|
|
98
106
|
export type { TestLocale } from "./core/testing/locale.ts";
|
|
99
107
|
export { useTestLocale } from "./core/testing/locale.ts";
|
|
100
108
|
export { OWNER_SPEAKER } from "./core/testing/owner.ts";
|
|
@@ -156,6 +164,8 @@ export interface FakeDiscord {
|
|
|
156
164
|
readonly service: ServicePair;
|
|
157
165
|
/** What `DISCORD.commands` is to the plugin: the real registrar, which refuses what the Discord plugin refuses. */
|
|
158
166
|
readonly commands: CommandRegistrar;
|
|
167
|
+
/** What `DISCORD.poster` is: the one the test gave, else a `fakeDiscordPoster()`. */
|
|
168
|
+
readonly poster: DiscordPoster;
|
|
159
169
|
/** What `DISCORD.guard` is: the owner, `owner` unless the test gave another id, serving the root command. */
|
|
160
170
|
readonly guard: CommandGuard;
|
|
161
171
|
/** Every contribution the plugin added, in the order it added them. */
|
|
@@ -169,13 +179,14 @@ export interface FakeDiscord {
|
|
|
169
179
|
* plugin's `commands.add` calls are recorded, and `compose()` shows the tree Discord would get.
|
|
170
180
|
* `rootCommand` is the root command's name, `roundtable` by default. `channelContext` is what
|
|
171
181
|
* `DISCORD.channelContext` answers, by default no context, as on a host with it off. Only
|
|
172
|
-
* `commands`, `guard`, and `channelContext` are given; reading another member throws.
|
|
182
|
+
* `commands`, `guard`, `poster`, and `channelContext` are given; reading another member throws.
|
|
173
183
|
*/
|
|
174
184
|
export function fakeDiscord(
|
|
175
185
|
options: {
|
|
176
186
|
ownerId?: string;
|
|
177
187
|
rootCommand?: string;
|
|
178
188
|
channelContext?: DiscordServices["channelContext"];
|
|
189
|
+
poster?: DiscordPoster;
|
|
179
190
|
} = {},
|
|
180
191
|
): FakeDiscord {
|
|
181
192
|
const collection = new CommandCollection();
|
|
@@ -185,12 +196,15 @@ export function fakeDiscord(
|
|
|
185
196
|
root: rootCommand,
|
|
186
197
|
logger: silentLogger(),
|
|
187
198
|
});
|
|
199
|
+
const poster = options.poster ?? fakeDiscordPoster();
|
|
188
200
|
return {
|
|
189
201
|
service: servicePair(DISCORD, {
|
|
190
202
|
commands: collection.registrar,
|
|
191
203
|
guard,
|
|
204
|
+
poster,
|
|
192
205
|
channelContext: options.channelContext ?? (async () => undefined),
|
|
193
206
|
}),
|
|
207
|
+
poster,
|
|
194
208
|
commands: collection.registrar,
|
|
195
209
|
guard,
|
|
196
210
|
added: () => collection.added(),
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
"pi-roundtable": "__VERSION__",
|
|
17
17
|
"pi-roundtable-webchat": "__VERSION__",
|
|
18
18
|
"pi-self-compact": "0.2.0",
|
|
19
|
-
"typebox": "1.3.
|
|
19
|
+
"typebox": "1.3.37"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@biomejs/biome": "2.5.15",
|
|
23
|
-
"@types/bun": "1.4.
|
|
23
|
+
"@types/bun": "1.4.3",
|
|
24
24
|
"typescript": "7.0.2"
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"pi-roundtable": "__VERSION__",
|
|
17
17
|
"pi-self-compact": "0.2.0",
|
|
18
|
-
"typebox": "1.3.
|
|
18
|
+
"typebox": "1.3.37"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@biomejs/biome": "2.5.15",
|
|
22
|
-
"@types/bun": "1.4.
|
|
22
|
+
"@types/bun": "1.4.3",
|
|
23
23
|
"typescript": "7.0.2"
|
|
24
24
|
}
|
|
25
25
|
}
|