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.
- package/CHANGELOG.md +36 -0
- package/docs/plugins.md +75 -6
- package/package.json +8 -6
- package/src/core/builtin/discord.ts +11 -0
- package/src/core/contract/runtime.ts +1 -1
- 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/host.ts +3 -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/runtime/prompt-slot.ts +4 -12
- package/src/core/runtime/runtime-plugin.ts +0 -3
- package/src/core/runtime/turn-budget.ts +10 -1
- 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 +19 -4
- package/templates/adapters/web/package.json.tmpl +2 -2
- package/templates/package.json.tmpl +2 -2
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { RESTJSONErrorCodes, Routes } from "discord.js";
|
|
2
|
+
import type {
|
|
3
|
+
DiscordMessage,
|
|
4
|
+
DiscordPoster,
|
|
5
|
+
PosterFile,
|
|
6
|
+
RecentMessage,
|
|
7
|
+
} from "./poster.ts";
|
|
8
|
+
import {
|
|
9
|
+
cardBody,
|
|
10
|
+
checkAvatar,
|
|
11
|
+
checkedPost,
|
|
12
|
+
checkedText,
|
|
13
|
+
checkSnowflake,
|
|
14
|
+
checkWebhookName,
|
|
15
|
+
invalid,
|
|
16
|
+
} from "./poster-checks.ts";
|
|
17
|
+
import { DiscordPosterError, failure, isCode } from "./poster-errors.ts";
|
|
18
|
+
import {
|
|
19
|
+
type DiscordRest,
|
|
20
|
+
object,
|
|
21
|
+
restCore,
|
|
22
|
+
text,
|
|
23
|
+
} from "./rest-poster-core.ts";
|
|
24
|
+
import { guildOperations } from "./rest-poster-guild.ts";
|
|
25
|
+
|
|
26
|
+
export type { DiscordRest, RestOptions } from "./rest-poster-core.ts";
|
|
27
|
+
export { restOf } from "./rest-poster-core.ts";
|
|
28
|
+
|
|
29
|
+
/** How many pins Discord lists in a page, and how many pages the poster reads (250 pins, the most a channel keeps). */
|
|
30
|
+
const PIN_PAGE = 50;
|
|
31
|
+
const PIN_PAGES = 5;
|
|
32
|
+
|
|
33
|
+
interface Webhook {
|
|
34
|
+
id: string;
|
|
35
|
+
token: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The files as the REST client sends them. */
|
|
39
|
+
function rawFiles(files: readonly PosterFile[]) {
|
|
40
|
+
return files.map((file) => ({
|
|
41
|
+
name: file.name,
|
|
42
|
+
data: file.data,
|
|
43
|
+
...(file.contentType ? { contentType: file.contentType } : {}),
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function messageIdOf(sent: unknown): string {
|
|
48
|
+
const messageId = text(object(sent).id);
|
|
49
|
+
if (!messageId)
|
|
50
|
+
throw new DiscordPosterError("rejected", "Discord returned no message");
|
|
51
|
+
return messageId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The poster over Discord's REST API. A webhook is found by name among the ones the bot made in the
|
|
56
|
+
* channel, or made, the first time the channel is used under that name. Their ids and tokens are
|
|
57
|
+
* held in this process's memory only and are never logged, put in an error or kept on disk.
|
|
58
|
+
*/
|
|
59
|
+
export function restPoster(
|
|
60
|
+
rest: DiscordRest,
|
|
61
|
+
options: { renameWaitMs?: number } = {},
|
|
62
|
+
): DiscordPoster {
|
|
63
|
+
const core = restCore(rest);
|
|
64
|
+
// Held in the closure so that printing or serializing the poster shows no token.
|
|
65
|
+
const webhooks = new Map<string, Webhook>();
|
|
66
|
+
const finding = new Map<string, Promise<Webhook | undefined>>();
|
|
67
|
+
const madeListeners = new Set<
|
|
68
|
+
(channelId: string, webhook: string) => Promise<void>
|
|
69
|
+
>();
|
|
70
|
+
|
|
71
|
+
/** The channel's webhooks the bot made. */
|
|
72
|
+
const listMine = async (
|
|
73
|
+
channelId: string,
|
|
74
|
+
): Promise<Record<string, unknown>[]> => {
|
|
75
|
+
const listed = await rest
|
|
76
|
+
.get(Routes.channelWebhooks(channelId))
|
|
77
|
+
.catch((error: unknown) => {
|
|
78
|
+
throw failure("listing the channel's webhooks", error);
|
|
79
|
+
});
|
|
80
|
+
const mine = await core.bot();
|
|
81
|
+
return (Array.isArray(listed) ? listed : [])
|
|
82
|
+
.map(object)
|
|
83
|
+
.filter((hook) => object(hook.user).id === mine);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** The channel's webhook of that name; made when there is none, unless `make` is false (then undefined). */
|
|
87
|
+
const find = async (
|
|
88
|
+
channelId: string,
|
|
89
|
+
name: string,
|
|
90
|
+
make: boolean,
|
|
91
|
+
release: () => void,
|
|
92
|
+
): Promise<Webhook | undefined> => {
|
|
93
|
+
for (const hook of await listMine(channelId)) {
|
|
94
|
+
const id = text(hook.id);
|
|
95
|
+
const token = text(hook.token);
|
|
96
|
+
if (hook.name === name && id && token) return { id, token };
|
|
97
|
+
}
|
|
98
|
+
if (!make) return undefined;
|
|
99
|
+
const made = object(
|
|
100
|
+
await rest
|
|
101
|
+
.post(Routes.channelWebhooks(channelId), { body: { name } })
|
|
102
|
+
.catch((error: unknown) => {
|
|
103
|
+
throw failure("making the channel's webhook", error);
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
const id = text(made.id);
|
|
107
|
+
const token = text(made.token);
|
|
108
|
+
if (!id || !token)
|
|
109
|
+
throw new DiscordPosterError(
|
|
110
|
+
"rejected",
|
|
111
|
+
"Discord made no usable webhook",
|
|
112
|
+
);
|
|
113
|
+
// The new webhook has the default picture; whoever kept an avatar for the channel forgets it
|
|
114
|
+
// before the webhook is used, so a late record cannot outlive the picture it describes.
|
|
115
|
+
// The webhook is kept, and the look released, before the listeners run: one that posts through
|
|
116
|
+
// it (or sets its picture) finds it instead of waiting on this very look, and a listener that
|
|
117
|
+
// never returns holds only the call that made the webhook, not later looks for it. Concurrent
|
|
118
|
+
// calls may use the new webhook before the listeners finish.
|
|
119
|
+
const fresh = { id, token };
|
|
120
|
+
webhooks.set(`${channelId}:${name}`, fresh);
|
|
121
|
+
release();
|
|
122
|
+
for (const listener of madeListeners)
|
|
123
|
+
await listener(channelId, name).catch(() => undefined);
|
|
124
|
+
return fresh;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const webhook = (
|
|
128
|
+
channelId: string,
|
|
129
|
+
name: string,
|
|
130
|
+
make: boolean,
|
|
131
|
+
): Promise<Webhook | undefined> => {
|
|
132
|
+
const slot = `${channelId}:${name}`;
|
|
133
|
+
const known = webhooks.get(slot);
|
|
134
|
+
if (known) return Promise.resolve(known);
|
|
135
|
+
// A look that may not make the webhook answers `undefined` for a channel that has none, which
|
|
136
|
+
// a call that needs one must not share: the two are different questions.
|
|
137
|
+
const search = `${slot}:${make ? "make" : "find"}`;
|
|
138
|
+
let pending = finding.get(search);
|
|
139
|
+
if (!pending) {
|
|
140
|
+
const release = () => {
|
|
141
|
+
if (finding.get(search) === pending) finding.delete(search);
|
|
142
|
+
};
|
|
143
|
+
pending = find(channelId, name, make, release)
|
|
144
|
+
.then((found) => {
|
|
145
|
+
// A made webhook is already kept; one made again while its listeners ran must not be replaced
|
|
146
|
+
// by this older one.
|
|
147
|
+
if (found && !webhooks.has(slot)) webhooks.set(slot, found);
|
|
148
|
+
return found;
|
|
149
|
+
})
|
|
150
|
+
.finally(release);
|
|
151
|
+
finding.set(search, pending);
|
|
152
|
+
}
|
|
153
|
+
return pending;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Runs a call through the channel's webhook; a webhook deleted meanwhile is found or made again once. */
|
|
157
|
+
const through = async <T>(
|
|
158
|
+
operation: string,
|
|
159
|
+
channelId: string,
|
|
160
|
+
name: string,
|
|
161
|
+
call: (hook: Webhook) => Promise<T>,
|
|
162
|
+
make: boolean,
|
|
163
|
+
): Promise<T | undefined> => {
|
|
164
|
+
for (let attempt = 0; ; attempt++) {
|
|
165
|
+
const hook = await webhook(channelId, name, make);
|
|
166
|
+
// Only a call that may not make the webhook gets here without one.
|
|
167
|
+
if (!hook) return undefined;
|
|
168
|
+
try {
|
|
169
|
+
return await call(hook);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (
|
|
172
|
+
attempt === 0 &&
|
|
173
|
+
isCode(
|
|
174
|
+
error,
|
|
175
|
+
RESTJSONErrorCodes.UnknownWebhook,
|
|
176
|
+
RESTJSONErrorCodes.InvalidWebhookToken,
|
|
177
|
+
)
|
|
178
|
+
) {
|
|
179
|
+
webhooks.delete(`${channelId}:${name}`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
throw failure(operation, error);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** Asks each named webhook in turn to act on a message; the first that does it ends the search, and `message_gone` is what is left when none could. */
|
|
188
|
+
const eachWebhook = async (
|
|
189
|
+
operation: string,
|
|
190
|
+
channelId: string,
|
|
191
|
+
names: readonly string[],
|
|
192
|
+
call: (hook: Webhook) => Promise<unknown>,
|
|
193
|
+
): Promise<void> => {
|
|
194
|
+
let gone: DiscordPosterError | undefined;
|
|
195
|
+
for (const name of names) {
|
|
196
|
+
try {
|
|
197
|
+
const done = await through(
|
|
198
|
+
operation,
|
|
199
|
+
channelId,
|
|
200
|
+
name,
|
|
201
|
+
(hook) => call(hook).then(() => true),
|
|
202
|
+
false,
|
|
203
|
+
);
|
|
204
|
+
if (done) return;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (
|
|
207
|
+
!(error instanceof DiscordPosterError) ||
|
|
208
|
+
error.kind !== "message_gone"
|
|
209
|
+
)
|
|
210
|
+
throw error;
|
|
211
|
+
gone = error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
throw (
|
|
215
|
+
gone ??
|
|
216
|
+
new DiscordPosterError("message_gone", "the message does not exist")
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const guild = guildOperations(core, options);
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
...guild,
|
|
224
|
+
async post(channelId, post) {
|
|
225
|
+
checkSnowflake("a channel", channelId);
|
|
226
|
+
const { webhook: name, body, files } = checkedPost(post);
|
|
227
|
+
const sent = await through(
|
|
228
|
+
"posting a message",
|
|
229
|
+
channelId,
|
|
230
|
+
name,
|
|
231
|
+
(hook) =>
|
|
232
|
+
rest.post(Routes.webhook(hook.id, hook.token), {
|
|
233
|
+
auth: false,
|
|
234
|
+
body,
|
|
235
|
+
query: new URLSearchParams({ wait: "true" }),
|
|
236
|
+
...(files.length > 0 ? { files: rawFiles(files) } : {}),
|
|
237
|
+
}),
|
|
238
|
+
true,
|
|
239
|
+
);
|
|
240
|
+
return { channelId, messageId: messageIdOf(sent) };
|
|
241
|
+
},
|
|
242
|
+
async edit(channelId, messageId, edit, names) {
|
|
243
|
+
checkSnowflake("a channel", channelId);
|
|
244
|
+
checkSnowflake("a message", messageId);
|
|
245
|
+
const body = checkedText(edit.content, edit.mentions);
|
|
246
|
+
for (const name of names) checkWebhookName(name);
|
|
247
|
+
// A webhook edits only its own messages, and the caller may not know whose this is.
|
|
248
|
+
await eachWebhook("editing a message", channelId, names, (hook) =>
|
|
249
|
+
rest.patch(Routes.webhookMessage(hook.id, hook.token, messageId), {
|
|
250
|
+
auth: false,
|
|
251
|
+
body,
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
},
|
|
255
|
+
async remove(channelId, messageId, names) {
|
|
256
|
+
checkSnowflake("a channel", channelId);
|
|
257
|
+
checkSnowflake("a message", messageId);
|
|
258
|
+
for (const name of names) checkWebhookName(name);
|
|
259
|
+
try {
|
|
260
|
+
await eachWebhook("deleting a message", channelId, names, (hook) =>
|
|
261
|
+
rest.delete(Routes.webhookMessage(hook.id, hook.token, messageId), {
|
|
262
|
+
auth: false,
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (
|
|
267
|
+
!(error instanceof DiscordPosterError) ||
|
|
268
|
+
error.kind !== "message_gone"
|
|
269
|
+
)
|
|
270
|
+
throw error;
|
|
271
|
+
// A webhook deletes only its own messages. One an earlier webhook of the same name posted
|
|
272
|
+
// (the webhook was deleted and made again) is the bot's to delete, with Manage Messages;
|
|
273
|
+
// when it is gone for real, Discord says so here too.
|
|
274
|
+
await rest
|
|
275
|
+
.delete(Routes.channelMessage(channelId, messageId))
|
|
276
|
+
.catch((second: unknown) => {
|
|
277
|
+
const failed = failure("deleting a message as the bot", second);
|
|
278
|
+
throw failed.kind === "message_gone" ? error : failed;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
async webhookIds(channelId, names) {
|
|
283
|
+
checkSnowflake("a channel", channelId);
|
|
284
|
+
for (const name of names) checkWebhookName(name);
|
|
285
|
+
const ids: Record<string, string[]> = Object.create(null);
|
|
286
|
+
for (const name of names) ids[name] = [];
|
|
287
|
+
for (const hook of await listMine(channelId)) {
|
|
288
|
+
const id = text(hook.id);
|
|
289
|
+
const list = typeof hook.name === "string" ? ids[hook.name] : undefined;
|
|
290
|
+
if (id && list) list.push(id);
|
|
291
|
+
}
|
|
292
|
+
return ids;
|
|
293
|
+
},
|
|
294
|
+
async setAvatar(channelId, name, png) {
|
|
295
|
+
checkSnowflake("a channel", channelId);
|
|
296
|
+
checkWebhookName(name);
|
|
297
|
+
checkAvatar(png);
|
|
298
|
+
const avatar = `data:image/png;base64,${Buffer.from(png).toString("base64")}`;
|
|
299
|
+
await through(
|
|
300
|
+
"setting the webhook's avatar",
|
|
301
|
+
channelId,
|
|
302
|
+
name,
|
|
303
|
+
(hook) =>
|
|
304
|
+
rest.patch(Routes.webhook(hook.id, hook.token), {
|
|
305
|
+
auth: false,
|
|
306
|
+
body: { avatar },
|
|
307
|
+
}),
|
|
308
|
+
true,
|
|
309
|
+
);
|
|
310
|
+
},
|
|
311
|
+
onWebhookMade(listener) {
|
|
312
|
+
madeListeners.add(listener);
|
|
313
|
+
return () => void madeListeners.delete(listener);
|
|
314
|
+
},
|
|
315
|
+
async say(channelId, post) {
|
|
316
|
+
checkSnowflake("a channel", channelId);
|
|
317
|
+
if (!post.content) throw invalid("a message needs text");
|
|
318
|
+
const body = checkedText(post.content, post.mentions);
|
|
319
|
+
const sent = await rest
|
|
320
|
+
.post(Routes.channelMessages(channelId), { body })
|
|
321
|
+
.catch((error: unknown) => {
|
|
322
|
+
throw failure("posting as the bot", error);
|
|
323
|
+
});
|
|
324
|
+
return { channelId, messageId: messageIdOf(sent) };
|
|
325
|
+
},
|
|
326
|
+
async sayCard(channelId, card) {
|
|
327
|
+
checkSnowflake("a channel", channelId);
|
|
328
|
+
const sent = await rest
|
|
329
|
+
.post(Routes.channelMessages(channelId), { body: cardBody(card) })
|
|
330
|
+
.catch((error: unknown) => {
|
|
331
|
+
throw failure("posting a card as the bot", error);
|
|
332
|
+
});
|
|
333
|
+
return { channelId, messageId: messageIdOf(sent) };
|
|
334
|
+
},
|
|
335
|
+
async editCard(channelId, messageId, card) {
|
|
336
|
+
checkSnowflake("a channel", channelId);
|
|
337
|
+
checkSnowflake("a message", messageId);
|
|
338
|
+
await rest
|
|
339
|
+
.patch(Routes.channelMessage(channelId, messageId), {
|
|
340
|
+
body: cardBody(card),
|
|
341
|
+
})
|
|
342
|
+
.catch((error: unknown) => {
|
|
343
|
+
throw failure("editing a card", error);
|
|
344
|
+
});
|
|
345
|
+
},
|
|
346
|
+
async deleteAsBot(channelId, messageId) {
|
|
347
|
+
checkSnowflake("a channel", channelId);
|
|
348
|
+
checkSnowflake("a message", messageId);
|
|
349
|
+
await rest
|
|
350
|
+
.delete(Routes.channelMessage(channelId, messageId))
|
|
351
|
+
.catch((error: unknown) => {
|
|
352
|
+
throw failure("deleting a message as the bot", error);
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
async sendDirect(userId, content) {
|
|
356
|
+
checkSnowflake("a user", userId);
|
|
357
|
+
const body = checkedText(content, []);
|
|
358
|
+
try {
|
|
359
|
+
const channel = object(
|
|
360
|
+
await rest.post(Routes.userChannels(), {
|
|
361
|
+
body: { recipient_id: userId },
|
|
362
|
+
}),
|
|
363
|
+
);
|
|
364
|
+
const id = text(channel.id);
|
|
365
|
+
if (!id)
|
|
366
|
+
throw new DiscordPosterError("rejected", "Discord opened no channel");
|
|
367
|
+
await rest.post(Routes.channelMessages(id), { body });
|
|
368
|
+
return "sent";
|
|
369
|
+
} catch (error) {
|
|
370
|
+
// The person takes no direct messages from this bot: an answer, not a failure.
|
|
371
|
+
if (isCode(error, RESTJSONErrorCodes.CannotSendMessagesToThisUser))
|
|
372
|
+
return "closed";
|
|
373
|
+
throw failure("sending a direct message", error);
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
async pin(channelId, messageId) {
|
|
377
|
+
checkSnowflake("a channel", channelId);
|
|
378
|
+
checkSnowflake("a message", messageId);
|
|
379
|
+
await rest
|
|
380
|
+
.put(Routes.channelMessagesPin(channelId, messageId))
|
|
381
|
+
.catch((error: unknown) => {
|
|
382
|
+
throw failure("pinning a message", error);
|
|
383
|
+
});
|
|
384
|
+
},
|
|
385
|
+
async unpin(channelId, messageId) {
|
|
386
|
+
checkSnowflake("a channel", channelId);
|
|
387
|
+
checkSnowflake("a message", messageId);
|
|
388
|
+
await rest
|
|
389
|
+
.delete(Routes.channelMessagesPin(channelId, messageId))
|
|
390
|
+
.catch((error: unknown) => {
|
|
391
|
+
throw failure("unpinning a message", error);
|
|
392
|
+
});
|
|
393
|
+
},
|
|
394
|
+
async pinnedIds(channelId) {
|
|
395
|
+
checkSnowflake("a channel", channelId);
|
|
396
|
+
// Discord keeps at most 250 pins in a channel and lists 50 a page, newest first; `before`
|
|
397
|
+
// takes the `pinned_at` of the last one read. Five pages cover a channel.
|
|
398
|
+
const ids: string[] = [];
|
|
399
|
+
let before: string | undefined;
|
|
400
|
+
for (let page = 0; page < PIN_PAGES; page += 1) {
|
|
401
|
+
const query = new URLSearchParams({ limit: String(PIN_PAGE) });
|
|
402
|
+
if (before) query.set("before", before);
|
|
403
|
+
const listed = object(
|
|
404
|
+
await rest
|
|
405
|
+
.get(Routes.channelMessagesPins(channelId), { query })
|
|
406
|
+
.catch((error: unknown) => {
|
|
407
|
+
throw failure("listing a channel's pins", error);
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
const items = Array.isArray(listed.items) ? listed.items : [];
|
|
411
|
+
for (const entry of items) {
|
|
412
|
+
const id = text(object(object(entry).message).id);
|
|
413
|
+
if (id) ids.push(id);
|
|
414
|
+
}
|
|
415
|
+
const last = text(object(items.at(-1)).pinned_at);
|
|
416
|
+
if (listed.has_more !== true || !last) break;
|
|
417
|
+
before = last;
|
|
418
|
+
}
|
|
419
|
+
return ids;
|
|
420
|
+
},
|
|
421
|
+
async message(channelId, messageId): Promise<DiscordMessage> {
|
|
422
|
+
checkSnowflake("a channel", channelId);
|
|
423
|
+
checkSnowflake("a message", messageId);
|
|
424
|
+
const read = object(
|
|
425
|
+
await rest
|
|
426
|
+
.get(Routes.channelMessage(channelId, messageId))
|
|
427
|
+
.catch((error: unknown) => {
|
|
428
|
+
throw failure("reading a message", error);
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
const id = text(read.id);
|
|
432
|
+
if (!id)
|
|
433
|
+
throw new DiscordPosterError("rejected", "Discord sent no message");
|
|
434
|
+
const webhookId = text(read.webhook_id);
|
|
435
|
+
const files = (
|
|
436
|
+
Array.isArray(read.attachments) ? read.attachments : []
|
|
437
|
+
).flatMap((entry) => {
|
|
438
|
+
const file = object(entry);
|
|
439
|
+
const name = text(file.filename);
|
|
440
|
+
const url = text(file.url);
|
|
441
|
+
return name && url
|
|
442
|
+
? [
|
|
443
|
+
{
|
|
444
|
+
name,
|
|
445
|
+
url,
|
|
446
|
+
contentType: text(file.content_type) ?? "",
|
|
447
|
+
size: typeof file.size === "number" ? file.size : 0,
|
|
448
|
+
},
|
|
449
|
+
]
|
|
450
|
+
: [];
|
|
451
|
+
});
|
|
452
|
+
return {
|
|
453
|
+
id,
|
|
454
|
+
authorId: text(object(read.author).id) ?? "",
|
|
455
|
+
...(webhookId ? { webhookId } : {}),
|
|
456
|
+
content: typeof read.content === "string" ? read.content : "",
|
|
457
|
+
files,
|
|
458
|
+
};
|
|
459
|
+
},
|
|
460
|
+
async recentMessages(channelId, limit): Promise<RecentMessage[]> {
|
|
461
|
+
checkSnowflake("a channel", channelId);
|
|
462
|
+
const listed = await rest
|
|
463
|
+
.get(Routes.channelMessages(channelId), {
|
|
464
|
+
query: new URLSearchParams({
|
|
465
|
+
limit: String(Math.min(50, Math.max(1, Math.trunc(limit)))),
|
|
466
|
+
}),
|
|
467
|
+
})
|
|
468
|
+
.catch((error: unknown) => {
|
|
469
|
+
throw failure("listing a channel's messages", error);
|
|
470
|
+
});
|
|
471
|
+
return (Array.isArray(listed) ? listed : []).flatMap((entry) => {
|
|
472
|
+
const message = object(entry);
|
|
473
|
+
const id = text(message.id);
|
|
474
|
+
if (!id) return [];
|
|
475
|
+
const referenced = text(object(message.message_reference).message_id);
|
|
476
|
+
return [
|
|
477
|
+
{
|
|
478
|
+
id,
|
|
479
|
+
type: typeof message.type === "number" ? message.type : 0,
|
|
480
|
+
authorId: text(object(message.author).id) ?? "",
|
|
481
|
+
...(referenced ? { referencedMessageId: referenced } : {}),
|
|
482
|
+
},
|
|
483
|
+
];
|
|
484
|
+
});
|
|
485
|
+
},
|
|
486
|
+
botUserId: () => core.bot(),
|
|
487
|
+
};
|
|
488
|
+
}
|
package/src/core/host.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { Locale } from "./i18n/index.ts";
|
|
|
11
11
|
import type { JudgeModel } from "./judging/model-judge.ts";
|
|
12
12
|
import type { Logger } from "./log.ts";
|
|
13
13
|
import type { RoundtablePlugin, Service } from "./plugin.ts";
|
|
14
|
+
import { RUNTIME } from "./services.ts";
|
|
14
15
|
import type { ChannelKey } from "./sessions.ts";
|
|
15
16
|
import type { ToolTierTable } from "./tool-tiers.ts";
|
|
16
17
|
|
|
@@ -149,6 +150,8 @@ export class Roundtable {
|
|
|
149
150
|
return this.#pool;
|
|
150
151
|
});
|
|
151
152
|
for (const plugin of link.active) await plugin.preflight?.();
|
|
153
|
+
// Last, so the probe sees the tools other plugins fill in during their preflight.
|
|
154
|
+
await link.services?.find(RUNTIME)?.preflight?.();
|
|
152
155
|
for (const service of [...link.registry.services, ...retention]) {
|
|
153
156
|
await service.start?.();
|
|
154
157
|
this.#started.push(service);
|
|
@@ -27,7 +27,7 @@ function webAccessDir(): string {
|
|
|
27
27
|
return packageDir("pi-web-access");
|
|
28
28
|
} catch (error) {
|
|
29
29
|
throw new PluginError(
|
|
30
|
-
"the delegation worker loads pi-web-access, which this project does not have installed. It is a peer dependency of pi-roundtable: run `bun add pi-web-access@0.
|
|
30
|
+
"the delegation worker loads pi-web-access, which this project does not have installed. It is a peer dependency of pi-roundtable: run `bun add pi-web-access@0.40.0` (or your own build of it, which the worker then uses too).",
|
|
31
31
|
{ cause: error },
|
|
32
32
|
);
|
|
33
33
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** What `admit` finds: the key is held, its group is full, or the alert may go. */
|
|
2
|
+
export type Admission = "open" | "repeat" | "full";
|
|
3
|
+
|
|
4
|
+
export interface AlertThrottleOptions {
|
|
5
|
+
/** How long a key, and a slot of its group, is held. */
|
|
6
|
+
windowMs: number;
|
|
7
|
+
/** The most slots one group holds inside the window. */
|
|
8
|
+
groupCap: number;
|
|
9
|
+
now: () => number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The rolling window behind the error reports and the ops alerts: a key is held for a window, and
|
|
14
|
+
* a group of keys has a fixed number of slots inside it. Nothing here posts or schedules; the
|
|
15
|
+
* caller asks, then holds and takes a slot in the same synchronous step, so two alerts raised
|
|
16
|
+
* together for one key go out once.
|
|
17
|
+
*/
|
|
18
|
+
export class AlertThrottle {
|
|
19
|
+
readonly #windowMs: number;
|
|
20
|
+
readonly #groupCap: number;
|
|
21
|
+
readonly #now: () => number;
|
|
22
|
+
readonly #held = new Map<string, number>();
|
|
23
|
+
/** The times each group took a slot inside the window, oldest first. */
|
|
24
|
+
readonly #slots = new Map<string, number[]>();
|
|
25
|
+
|
|
26
|
+
constructor(options: AlertThrottleOptions) {
|
|
27
|
+
this.#windowMs = options.windowMs;
|
|
28
|
+
this.#groupCap = options.groupCap;
|
|
29
|
+
this.#now = options.now;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** "repeat" when the key is held, else "full" when the group is (unless `uncapped`), else "open". */
|
|
33
|
+
admit(key: string, group: string, uncapped = false): Admission {
|
|
34
|
+
const now = this.#now();
|
|
35
|
+
this.#pruneKeys(now);
|
|
36
|
+
if (this.#held.has(key)) return "repeat";
|
|
37
|
+
if (!uncapped && this.#live(group, now).length >= this.#groupCap)
|
|
38
|
+
return "full";
|
|
39
|
+
return "open";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Holds the key for a window. */
|
|
43
|
+
hold(key: string): void {
|
|
44
|
+
this.#held.set(key, this.#now());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Takes one slot of the group for a window. */
|
|
48
|
+
slot(group: string): void {
|
|
49
|
+
const now = this.#now();
|
|
50
|
+
this.#live(group, now).push(now);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Gives back the key and the group's newest slot (a post that failed). */
|
|
54
|
+
release(key: string, group?: string): void {
|
|
55
|
+
this.#held.delete(key);
|
|
56
|
+
if (group !== undefined) this.#slots.get(group)?.pop();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** When the group's oldest slot leaves the window; undefined while it has room. */
|
|
60
|
+
roomAt(group: string): number | undefined {
|
|
61
|
+
const slots = this.#live(group, this.#now());
|
|
62
|
+
const oldest = slots[0];
|
|
63
|
+
return slots.length >= this.#groupCap && oldest !== undefined
|
|
64
|
+
? oldest + this.#windowMs
|
|
65
|
+
: undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#pruneKeys(now: number): void {
|
|
69
|
+
for (const [key, at] of this.#held)
|
|
70
|
+
if (now - at >= this.#windowMs) this.#held.delete(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The group's slots still inside the window; the array is kept. */
|
|
74
|
+
#live(group: string, now: number): number[] {
|
|
75
|
+
const live = (this.#slots.get(group) ?? []).filter(
|
|
76
|
+
(at) => now - at < this.#windowMs,
|
|
77
|
+
);
|
|
78
|
+
this.#slots.set(group, live);
|
|
79
|
+
return live;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
2
2
|
import type { ScheduledOutcome } from "../contract/channels.ts";
|
|
3
3
|
import type { LogEntry, Logger } from "../log.ts";
|
|
4
4
|
import type { ChannelKey } from "../sessions.ts";
|
|
5
|
+
import { AlertThrottle } from "./alert-throttle.ts";
|
|
5
6
|
|
|
6
7
|
/** Errors that are expected and never reported, matched against the message and the error's. */
|
|
7
8
|
export const IGNORED_ERRORS: readonly RegExp[] = [];
|
|
@@ -9,6 +10,8 @@ export const IGNORED_ERRORS: readonly RegExp[] = [];
|
|
|
9
10
|
const HOUR_MS = 3_600_000;
|
|
10
11
|
/** Reports, summaries included, per rolling hour. */
|
|
11
12
|
const REPORTS_PER_HOUR = 5;
|
|
13
|
+
/** The throttle group of every report. */
|
|
14
|
+
const ALL = "*";
|
|
12
15
|
/** Errors kept from before the agent server is ready; older ones are dropped and counted. */
|
|
13
16
|
const EARLY_BUFFER = 20;
|
|
14
17
|
const REPORT_LIMIT = 1500;
|
|
@@ -70,8 +73,8 @@ export class ErrorReporter {
|
|
|
70
73
|
#earlyDropped = 0;
|
|
71
74
|
/** Report turns started and not yet finished. */
|
|
72
75
|
#turns = 0;
|
|
73
|
-
|
|
74
|
-
readonly #
|
|
76
|
+
/** One group for every report: the limit is on the process, not on a kind of error. */
|
|
77
|
+
readonly #throttle: AlertThrottle;
|
|
75
78
|
readonly #suppressed = new Map<string, number>();
|
|
76
79
|
#summaryDue = false;
|
|
77
80
|
|
|
@@ -80,6 +83,11 @@ export class ErrorReporter {
|
|
|
80
83
|
this.#app = options.app;
|
|
81
84
|
this.#ignored = options.ignored ?? IGNORED_ERRORS;
|
|
82
85
|
this.#now = options.now ?? Date.now;
|
|
86
|
+
this.#throttle = new AlertThrottle({
|
|
87
|
+
windowMs: HOUR_MS,
|
|
88
|
+
groupCap: REPORTS_PER_HOUR,
|
|
89
|
+
now: this.#now,
|
|
90
|
+
});
|
|
83
91
|
this.#setTimer =
|
|
84
92
|
options.setTimer ??
|
|
85
93
|
((run, ms) => {
|
|
@@ -134,43 +142,38 @@ export class ErrorReporter {
|
|
|
134
142
|
#consider(entry: LogEntry): void {
|
|
135
143
|
const now = this.#now();
|
|
136
144
|
const key = fingerprint(entry);
|
|
137
|
-
const
|
|
138
|
-
if (
|
|
139
|
-
if (
|
|
145
|
+
const admission = this.#throttle.admit(key, ALL);
|
|
146
|
+
if (admission === "repeat") return;
|
|
147
|
+
if (admission === "full") {
|
|
140
148
|
this.#suppressed.set(key, (this.#suppressed.get(key) ?? 0) + 1);
|
|
141
149
|
this.#scheduleSummary(now);
|
|
142
150
|
return;
|
|
143
151
|
}
|
|
144
|
-
this.#
|
|
145
|
-
this.#
|
|
152
|
+
this.#throttle.hold(key);
|
|
153
|
+
this.#throttle.slot(ALL);
|
|
146
154
|
void this.#deliver(reportText(entry, this.#app));
|
|
147
155
|
}
|
|
148
156
|
|
|
149
|
-
#hasRoom(now: number): boolean {
|
|
150
|
-
this.#sent = this.#sent.filter((at) => now - at < HOUR_MS);
|
|
151
|
-
return this.#sent.length < REPORTS_PER_HOUR;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
157
|
#scheduleSummary(now: number): void {
|
|
155
158
|
if (this.#summaryDue) return;
|
|
156
159
|
this.#summaryDue = true;
|
|
157
|
-
const
|
|
158
|
-
this.#setTimer(() => this.#summarize(),
|
|
160
|
+
const roomAt = this.#throttle.roomAt(ALL) ?? now;
|
|
161
|
+
this.#setTimer(() => this.#summarize(), roomAt - now);
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
#summarize(): void {
|
|
162
165
|
this.#summaryDue = false;
|
|
163
166
|
const now = this.#now();
|
|
164
167
|
if (this.#suppressed.size === 0) return;
|
|
165
|
-
if (
|
|
168
|
+
if (this.#throttle.roomAt(ALL) !== undefined) {
|
|
166
169
|
this.#scheduleSummary(now);
|
|
167
170
|
return;
|
|
168
171
|
}
|
|
169
172
|
const counts = [...this.#suppressed];
|
|
170
173
|
this.#suppressed.clear();
|
|
171
174
|
const total = counts.reduce((sum, [, n]) => sum + n, 0);
|
|
172
|
-
for (const [key] of counts) this.#
|
|
173
|
-
this.#
|
|
175
|
+
for (const [key] of counts) this.#throttle.hold(key);
|
|
176
|
+
this.#throttle.slot(ALL);
|
|
174
177
|
const list = counts
|
|
175
178
|
.map(([key, n]) => (n > 1 ? `${key} (×${n})` : key))
|
|
176
179
|
.join("; ");
|