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,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<Pick<DiscordConnection, "agentChannels" | "ownerChannel">>;
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
- (given ? given.ownerChannel : undefined) ??
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
- ...given,
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] };
@@ -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.34"
19
+ "typebox": "1.3.37"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@biomejs/biome": "2.5.15",
23
- "@types/bun": "1.4.2",
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.34"
18
+ "typebox": "1.3.37"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@biomejs/biome": "2.5.15",
22
- "@types/bun": "1.4.2",
22
+ "@types/bun": "1.4.3",
23
23
  "typescript": "7.0.2"
24
24
  }
25
25
  }