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 CHANGED
@@ -5,6 +5,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.18.0] - 2026-10-17
9
+
10
+ ### Added
11
+
12
+ - `OpsAlerts`, `OpsAlertsOptions`, `OpsRaiseOptions` and `OpsAlertKind` (`pi-roundtable/kit`): short alerts for the people who run a host, for a plugin's own faults. `new OpsAlerts({ sink?, logger, kinds?, windowMs?, kindCap?, now? })` posts through `sink(text)`, a function that resolves `true` when it posted and `false` when it chose not to, so the kit names no discord.js type. `raise(key, text, { code?, info? })` posts at once, never queued, a key at most once per window (one hour) and a kind (the key's word before the first colon) at most `kindCap` times per window (8), whatever the keys; what is held back is only counted. A sink that throws or answers `false` gives the key and the slot back, so the next raise tries again; a throw is logged by the error's name and `raise` never throws. `info: true` is news, posted once per key, outside the cap and outside `faults()`. `faults()` lists the last fault of each kind, newest first, with `name` (from `kinds`), `code`, `raised` and `suppressed`, in memory. With no `sink` the alerts are recorded, the key stays held, and the log has one line per window (`an ops alert was raised and there is no sink to post it to`). No summary of the held-back alerts is posted, so there is no new text to translate.
13
+ - `DISCORD.poster` (`pi-roundtable/discord`): Discord's REST API for a plugin's own posts, as one new required member of `DiscordServices`, over the Discord plugin's own gateway client, so a host has one REST queue and one set of rate limits. It posts, edits and deletes through webhooks under names the plugin chooses, posts the bot's own messages and Components V2 cards, pins, sends direct messages, starts and archives threads, reads who closed one from the audit log, and makes, lists, renames and deletes text channels. A call never pings anyone it does not name, and a failure is a `DiscordPosterError` whose message holds no URL, webhook token or bot token. Making it sends nothing, so `Roundtable.check()` still never reaches Discord. The interface is `DiscordPoster`; its types are `WebhookPost`, `PosterFile`, `PostedMessage`, `DiscordMessage`, `RecentMessage`, `DiscordCard`, `CardButton`, `CardLink`, `CardSelect`, `ThreadState`, `ThreadCloser` and `CategoryChannel`; its failures are `DiscordPosterError` with `DiscordFailure` and `DiscordReason`; and `DISCORD_LIMITS` is the frozen table of the limits it checks. The guide has a new section, "Posting through `DISCORD.poster`".
14
+ - `fakeDiscordPoster`, `FakeDiscordPoster`, `FakePosterChannel` and `FakePosterMessage` (`pi-roundtable/testing`): `fakeDiscordPoster({ idOffset?, now? })` is a `DiscordPoster` in memory with its state open to the test. It and the real poster run the same 14 scenarios (the fake, and the real poster over a local emulation of Discord's HTTP API, which is not published), so they answer alike: webhook names, `allowed_mentions`, edits through the named webhooks, pins across pages, Components V2 cards, threads and their audit-log closers, channels and their limits, the rename limit, `canView` and `memberName`.
15
+ - `fakeDiscord({ poster })` and `testHost({ discord: { poster, channelInfo } })` (`pi-roundtable/testing`) take the poster and the channel lookup the test wants; by default `DISCORD.poster` is a new `fakeDiscordPoster()`, and `fakeDiscord()` exposes it as `poster`.
16
+ - Where `fakeDiscordPoster` differs from a bare in-memory list, as the real poster does: `failNext` fails the next call of an operation after its arguments pass the checks (a `channelGuild` read the fake has already kept does not consume it); `edit` succeeds only for a message that one of the listed webhooks posted; `setAvatar` makes the webhook (and announces it) when it does not exist; `textChannels` lists a category's text channels, not threads.
17
+
18
+ ### Changed
19
+
20
+ - `ErrorReporter` takes its once-an-hour and five-reports-an-hour limits from internal throttle code that `OpsAlerts` also uses, each with its own limits and state. What it reports, when, and the summary of the suppressed errors are unchanged.
21
+ - The `pi-web-access` peer dependency range is `>=0.35.0 <0.41.0` (was `>=0.35.0 <0.36.0`), the range the package is tested against: 0.35.0 and the 0.40.0 its checks now run on both keep `"pi": { "extensions": ["./dist"] }`, which is how the delegation worker loads it, and nothing in 0.36 to 0.40 changes that. The error a project without it gets now says `bun add pi-web-access@0.40.0` (was 0.35.0).
22
+ - **Breaking for hand-made stand-ins of `DISCORD`:** `DiscordServices` has a new required member, `poster`. A test double written as `as unknown as DiscordServices` compiles unchanged, and fails at run time with `poster` undefined only when the code under test posts through it. Give it `fakeDiscordPoster()`, or use `fakeDiscord()` or `testHost`, which do. The host's own Discord plugin and `testHost` provide it.
23
+ - The Discord plugin sets its bot token on its client's REST queue when it sets up, not at login, so the poster works before the gateway is up. Nothing else about the connection changes.
24
+ - The packages depend on `typebox` 1.3.37 (was 1.3.34), and a project made by `roundtable init` pins `typebox` 1.3.37 and `@types/bun` 1.4.3 (were 1.3.34 and 1.4.2), so a new project matches the package's own tools. The packages depend on `pi-mcp-adapter` 5.3.0 (was 5.2.0), and the `pi-web-access` used for the package's own checks is 0.40.0. The pi range the package accepts is unchanged; it is built and tested against pi 1.3.0.
25
+
8
26
  ## [0.17.3] - 2026-10-16
9
27
 
10
28
  ### Fixed
package/docs/plugins.md CHANGED
@@ -164,6 +164,12 @@ It then calls `DefineOverrides.errorSink(entry)` if you supply one; this functio
164
164
  A logger you supply reaches the ops agent and `errorSink` only if it forwards its error lines there.
165
165
  The report names the plugin next to `app` and `module`; the same error is reported at most once an hour, regardless of which plugin wrote it.
166
166
 
167
+ `OpsAlerts` in `pi-roundtable/kit` is for a plugin's own alerts, such as a failed push, that the people who run the host should read; it is not the error report above, and a host that wants both uses both.
168
+ `new OpsAlerts({ sink?, logger, kinds?, windowMs?, kindCap?, now? })` takes `sink(text) => Promise<boolean>`, which posts the text where those people read it: resolve `true` when it was posted, `false` when you chose not to (log why yourself, for example a channel outside the one guild alerts may go to), or throw. The kit names no discord.js type, so the sink is the only place a transport appears.
169
+ `raise(key, text, { code?, info? })` resolves whether the alert was posted. The kind is the key's word before the first colon (`push:group-7` is `push`).
170
+ A key is posted at most once per window (one hour by default) and a kind at most `kindCap` times per window (8 by default), whatever the keys, so a fault that touches everything cannot flood the sink. An alert is posted when it is raised, not queued, and what is held back is only counted: `faults()` lists the last fault of each kind, newest first, with its `name` (from `kinds`, else the kind itself), `code`, `raised` and `suppressed`, in memory only, for a status command. `info: true` marks news, not a fault: it posts at most once per key, does not count against the kind's cap and is not listed in `faults()`.
171
+ A sink that throws or answers `false` gives the key and the kind's slot back, so the next raise tries again, and a throw is logged by the error's name only. `raise` never throws. With no `sink`, alerts are only recorded, the key stays held and the log has one line per window (`an ops alert was raised and there is no sink to post it to`); `posts` tells which. `ErrorReporter` and `OpsAlerts` each keep their own limits, so alerts never count against the error reports' five an hour.
172
+
167
173
  ### Services: what plugins provide to each other
168
174
 
169
175
  A service is something one plugin builds and others read, such as the schedule store or the agent team.
@@ -206,7 +212,7 @@ The built-in plugins provide these, from the main entry:
206
212
 
207
213
  The data types the ports use (`Schedule`, `NewSchedule`, `Agent`, `AgentGroup`, `TeamStatus`, `Memory`, `SkillSet`, `DelegationJob`, and so on) are in the main entry too.
208
214
  Your plugins run after the built-ins, so they can read every key above.
209
- The Discord connection's key, `DISCORD`, is in the Discord entry, because its port names discord.js types: `DiscordServices` has `connection`, `commands`, `guard`, and `threads` (see [`commands.add`](#slash-commands-commandsadd)).
215
+ The Discord connection's key, `DISCORD`, is in the Discord entry, because its port names discord.js types: `DiscordServices` has `connection`, `commands`, `guard`, `threads`, `poster` (see [Posting through `DISCORD.poster`](#posting-through-discordposter)) and `channelContext`; commands are in [`commands.add`](#slash-commands-commandsadd). A plugin's own stand-in for `DISCORD` (such as one written with `as unknown as DiscordServices`) needs a `poster` too before code that posts runs under it; `fakeDiscord()` and `testHost` give one.
210
216
 
211
217
  To share your own service, export its key as a constant and its port as an interface.
212
218
  Plugins registered after yours can read it during setup.
@@ -2429,6 +2435,38 @@ export function tavern(channelIds: readonly string[]) {
2429
2435
  ```
2430
2436
  <!-- /example -->
2431
2437
 
2438
+ ### Posting through `DISCORD.poster`
2439
+
2440
+ `DISCORD.poster` (`DiscordPoster`, from `pi-roundtable/discord`) is Discord's REST API for a plugin's own posts, over the Discord plugin's own client.
2441
+ A host has one REST queue for the whole process, so the poster and the gateway client share Discord's rate-limit state, which Discord keeps per bot token.
2442
+ It is usable from a service's `start` on; do not call it from `setup`, which `Roundtable.check()` also runs. Making it sends nothing, so the check never reaches Discord.
2443
+ There is no factory: a host that has no Discord has no poster.
2444
+
2445
+ ```ts
2446
+ import { DISCORD } from "pi-roundtable/discord";
2447
+
2448
+ const { poster } = services.get(DISCORD);
2449
+ const posted = await poster.post(channelId, { webhook: "relay", username: "Ann", content: "Hello" });
2450
+ await poster.edit(channelId, posted.messageId, { content: "Hello again" }, ["relay"]);
2451
+ await poster.remove(channelId, posted.messageId, ["relay"]);
2452
+ ```
2453
+
2454
+ What it covers: webhook posts under names you choose (`post`, `edit`, `remove`, `webhookIds`, `setAvatar`, `onWebhookMade`), the bot's own messages and cards (`say`, `sayCard`, `editCard`, `deleteAsBot`), direct messages (`sendDirect`), pins, reading (`message`, `recentMessages`, `botUserId`, `channelGuild`, `canView`, `memberName`), text channels (`createTextChannel`, `textChannels`, `deleteChannel`, `renameChannel`) and threads (`startThread`, `setThreadArchived`, `threadState`, `threadCloser`).
2455
+ Use `DISCORD.connection.channelInfo(channelId)` to read what a channel is called now: it comes from the gateway's cache.
2456
+
2457
+ The rules the poster keeps, so a plugin does not rediscover them:
2458
+
2459
+ - **Webhooks are named by the plugin.** `post` finds the bot's webhook of that name in the channel, or makes it on first use; a same-named webhook someone else made is never used. A plugin that needs several voices keeps one name per voice, so the `webhookId` on a message tells them apart. A webhook name, and a post's `username`, are 1 to 80 characters without "discord" or "clyde" in any case (full-width letters too); a username is not "everyone" or "here" either. Anything else is refused as `invalid` before a request is sent. `edit` and `remove` take the names to ask, in order, since a webhook changes only its own messages; they make no webhook. `remove` falls back to deleting as the bot (Manage Messages) for a message an earlier webhook of the same name posted.
2460
+ - **Nothing pings by accident.** Every post, edit, card and direct message sends `allowed_mentions` with `parse: []` and, for a post, the `mentions` users you list (at most 20). `@everyone`, roles and users merely named in the text never ping.
2461
+ - **Cards are Components V2 messages.** A card carries no `content`. Three lessons: every `editCard` re-sends the Components V2 flag (without it Discord answers a 400); a message posted by `say` cannot be edited into a card (`rejected`: delete it and post a card, which changes the message id); and a pin makes Discord post a system notice (type 6) as the bot, which `recentMessages` lists, `deleteAsBot` deletes, and nothing can edit (`rejected`, Discord's 50021), so skip it when looking for your own message.
2462
+ - **A channel is renamed twice in ten minutes.** Discord answers a third with a wait of minutes. `renameChannel` does not queue it: after ten seconds it fails as `unavailable`, and the held request is aborted, never sent later.
2463
+ - **Threads.** `startThread` makes a public thread on a message that archives itself after a week and wakes on the next post. The bot needs no Manage Threads to archive or open a thread it started; a locked thread needs it to open again (`forbidden`). `threadCloser` reads the guild's audit log (View Audit Log, else `forbidden`): a person's id, the bot, or `unknown` for Discord's own auto-archive. An archive counts only if its entry is within two minutes of the thread's `archive_timestamp`. It reads the newest 50 `THREAD_UPDATE` entries, so on a busy guild an old close may have left the log and read as `unknown`.
2464
+ - **Errors.** Every failure is a `DiscordPosterError` with a `kind`: `invalid` (refused before sending), `channel_gone`, `message_gone`, `forbidden` (a missing permission), `rejected` (Discord refused; asking again fails again) and `unavailable` (Discord or the network failed, or a rate limit held the call; `retryable`). `reason` says more for a full category (`category_full`), a channel that is no category (`not_category`) and one in no guild (`wrong_guild`). A webhook deleted by hand is found or made again once. A person who takes no direct messages is the answer `"closed"`, and a user who is not a member is `false` from `canView` and `undefined` from `memberName`, not failures. A message never holds a URL, a webhook token or the bot token.
2465
+
2466
+ `DISCORD_LIMITS` (frozen) holds the limits the poster checks: 2,000 characters a post, 80 for a username, 10 files and 10 MiB a post, 1 MiB for an avatar, 20 mentions, 250 pins, 100 characters for a channel name, 3,800 characters of card text, 5 card buttons and 25 menu choices.
2467
+
2468
+ In a test, `fakeDiscordPoster()` is a `DiscordPoster` in memory and `fakeDiscord({ poster })` or `testHost({ discord: { poster } })` gives it to the plugin; see [Testing a plugin](#testing-a-plugin). The real poster and the fake run the same scenarios, the real one over a local emulation of Discord's HTTP API.
2469
+
2432
2470
  ### `backgroundTargets`: whose turn a schedule or delegated task is
2433
2471
 
2434
2472
  Schedules and delegated tasks return later as turns without a new message in the channel.
@@ -3272,8 +3310,9 @@ Use `partial<Port>({ ... })` to stand in for a port your code takes as an argume
3272
3310
  It provides the members you give it and throws an error naming any missing member you read, so the test needs no `as unknown as Port` cast.
3273
3311
  `fakePrecheck(name, answer, { description?, timeoutMs? })` is a precheck that answers `answer` (a `PrecheckResult`, an `Error` it throws, or a function of its context) and records each context in `calls`; `fakePrechecks(...prechecks)` is a real in-memory `PrecheckRegistry` with them registered, which a test gives a plugin as `servicePair(PRECHECKS, registry)` and reads back with `registry.get(name)`.
3274
3312
  `fakeScriptRunner(answer, { describe?, timeoutMs? })` is a `PrecheckScriptRunner` that runs nothing and answers `answer` (a result, an `Error` it throws, or a function of the script and its context), recording each call in `calls`; give it to `registry.useScriptRunner`.
3275
- `fakeDiscord({ ownerId?, rootCommand?, channelContext? })` is the `DISCORD` service for a plugin that adds slash commands or reads channel context: give it as `services: [discord.service]`, read what the plugin added with `discord.added()`, and compose the tree Discord would get with `discord.compose()`; `channelContext` answers `DISCORD.channelContext`, by default with no context.
3276
- Only `commands` and `guard` are given; a plugin that reads another member of `DISCORD` in a test gives its own with `servicePair(DISCORD, { ... })`.
3313
+ `fakeDiscord({ ownerId?, rootCommand?, channelContext?, poster? })` is the `DISCORD` service for a plugin that adds slash commands, reads channel context or posts through `DISCORD.poster`: give it as `services: [discord.service]`, read what the plugin added with `discord.added()`, and compose the tree Discord would get with `discord.compose()`; `channelContext` answers `DISCORD.channelContext`, by default with no context.
3314
+ Only `commands`, `guard`, `poster` and `channelContext` are given; `discord.poster` is the poster the test passed, else a new `fakeDiscordPoster()`. A plugin that reads another member of `DISCORD` in a test gives its own with `servicePair(DISCORD, { ... })`.
3315
+ `fakeDiscordPoster({ idOffset?, now? })` is a `DiscordPoster` in memory: `channels` with their messages, pins and webhook pictures, `directs`, and the knobs a test turns (`closedDirects`, `goneChannels`, `categories`, `memberNames`, `hiddenFrom`, `nonMembers`, `pinNotices`, `canReadAuditLog`, `failNext(operation, error)`, `speak`, `personCloses`, `autoArchives`, `personOpens`, `deleteWebhook`, `recreateWebhook`). `messagesIn(channelId)` lists what a channel holds. It keeps the real poster's argument checks and error kinds, a channel's two renames in ten minutes (by `now`) and the 250 pins. `idOffset` moves every id it makes, so test files that share a database never meet the same id.
3277
3316
 
3278
3317
  ### `testHost`: the built-in plugins and yours, over PostgreSQL
3279
3318
 
@@ -3287,7 +3326,7 @@ It boots `defineRoundtable` over `ROUNDTABLE_TEST_DATABASE_URL`, with stand-ins
3287
3326
  | `plugins` | Your plugins, placed after the built-in ones as `defineRoundtable` places them |
3288
3327
  | `runtime` | The runtime every turn runs on; by default one that answers `""` and builds no Pi session |
3289
3328
  | `apiKeys` | The credentials `context.apiKey(provider)` returns, by provider name; a provider not listed reads as having none, whatever login the machine holds |
3290
- | `discord` | What the stand-in Discord hands out: `agentChannels(guildId)` and `ownerChannel()`; `false` boots a [host without Discord](#a-host-without-discord), with no `discord`, `http`, or `agents` in the test configuration |
3329
+ | `discord` | What the stand-in Discord hands out: `agentChannels(guildId)`, `ownerChannel()`, `channelInfo(channelId)` and `poster` (what `DISCORD.poster` is; by default a `fakeDiscordPoster()`); `false` boots a [host without Discord](#a-host-without-discord), with no `discord`, `http`, or `agents` in the test configuration |
3291
3330
 
3292
3331
  It returns:
3293
3332
 
@@ -3871,6 +3910,10 @@ Import from the entries listed below; source area files are internal.
3871
3910
  | `SurfaceObservation` | `pi-roundtable/testing` | type |
3872
3911
  | `eagerText` | `pi-roundtable/testing` | value |
3873
3912
  | `fakeDiscord` | `pi-roundtable/testing` | value |
3913
+ | `fakeDiscordPoster` | `pi-roundtable/testing` | value |
3914
+ | `FakeDiscordPoster` | `pi-roundtable/testing` | type |
3915
+ | `FakePosterChannel` | `pi-roundtable/testing` | type |
3916
+ | `FakePosterMessage` | `pi-roundtable/testing` | type |
3874
3917
  | `fakePrecheck` | `pi-roundtable/testing` | value |
3875
3918
  | `fakePrechecks` | `pi-roundtable/testing` | value |
3876
3919
  | `FakePrecheck` | `pi-roundtable/testing` | type |
@@ -3989,6 +4032,10 @@ Import from the entries listed below; source area files are internal.
3989
4032
  | `isRuleLoad` | `pi-roundtable/kit` | value |
3990
4033
  | `InterimPoster` | `pi-roundtable/kit` | value |
3991
4034
  | `InterimPosterOptions` | `pi-roundtable/kit` | type |
4035
+ | `OpsAlerts` | `pi-roundtable/kit` | value |
4036
+ | `OpsAlertsOptions` | `pi-roundtable/kit` | type |
4037
+ | `OpsRaiseOptions` | `pi-roundtable/kit` | type |
4038
+ | `OpsAlertKind` | `pi-roundtable/kit` | type |
3992
4039
  | `JEV_COMPACTION_ENGINE` | `pi-roundtable/kit` | value |
3993
4040
  | `JEV_GOAL` | `pi-roundtable/kit` | value |
3994
4041
  | `JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS` | `pi-roundtable/kit` | value |
@@ -4060,6 +4107,23 @@ Import from the entries listed below; source area files are internal.
4060
4107
  | `DiscordAdapterConfig` | `pi-roundtable/discord` | type |
4061
4108
  | `DiscordConnection` | `pi-roundtable/discord` | type |
4062
4109
  | `DiscordServices` | `pi-roundtable/discord` | type |
4110
+ | `DISCORD_LIMITS` | `pi-roundtable/discord` | value |
4111
+ | `DiscordPosterError` | `pi-roundtable/discord` | value |
4112
+ | `CardButton` | `pi-roundtable/discord` | type |
4113
+ | `CardLink` | `pi-roundtable/discord` | type |
4114
+ | `CardSelect` | `pi-roundtable/discord` | type |
4115
+ | `CategoryChannel` | `pi-roundtable/discord` | type |
4116
+ | `DiscordCard` | `pi-roundtable/discord` | type |
4117
+ | `DiscordFailure` | `pi-roundtable/discord` | type |
4118
+ | `DiscordMessage` | `pi-roundtable/discord` | type |
4119
+ | `DiscordPoster` | `pi-roundtable/discord` | type |
4120
+ | `DiscordReason` | `pi-roundtable/discord` | type |
4121
+ | `PostedMessage` | `pi-roundtable/discord` | type |
4122
+ | `PosterFile` | `pi-roundtable/discord` | type |
4123
+ | `RecentMessage` | `pi-roundtable/discord` | type |
4124
+ | `ThreadCloser` | `pi-roundtable/discord` | type |
4125
+ | `ThreadState` | `pi-roundtable/discord` | type |
4126
+ | `WebhookPost` | `pi-roundtable/discord` | type |
4063
4127
  | `InteractionContribution` | `pi-roundtable/discord` | type |
4064
4128
  | `InteractionModule` | `pi-roundtable/discord` | type |
4065
4129
  | `ManagedChannel` | `pi-roundtable/discord` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.17.3",
3
+ "version": "0.18.0",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,6 +27,8 @@
27
27
  "!src/**/*.test.ts",
28
28
  "!src/core/testing/file-size.ts",
29
29
  "!src/core/testing/prompt-capture*.ts",
30
+ "!src/core/testing/discord-api-emulator*.ts",
31
+ "!src/core/discord/poster/poster-contract*.ts",
30
32
  "!src/core/testing/fixtures",
31
33
  "!src/prompt-text.snapshot.json",
32
34
  "!src/cli/testing",
@@ -65,19 +67,19 @@
65
67
  "canvas": "3.2.3",
66
68
  "discord.js": "14.27.0",
67
69
  "pi-jev-compaction": "1.0.0",
68
- "pi-mcp-adapter": "5.2.0",
70
+ "pi-mcp-adapter": "5.3.0",
69
71
  "pino": "10.4.0",
70
- "typebox": "1.3.34",
72
+ "typebox": "1.3.37",
71
73
  "unpdf": "1.8.1"
72
74
  },
73
75
  "peerDependencies": {
74
- "pi-web-access": ">=0.35.0 <0.36.0"
76
+ "pi-web-access": ">=0.35.0 <0.41.0"
75
77
  },
76
78
  "devDependencies": {
77
79
  "typescript": "7.0.2",
78
80
  "@biomejs/biome": "2.5.15",
79
- "@types/bun": "1.4.2",
81
+ "@types/bun": "1.4.3",
80
82
  "pi-self-compact": "0.2.0",
81
- "pi-web-access": "0.39.0"
83
+ "pi-web-access": "0.40.0"
82
84
  }
83
85
  }
@@ -18,6 +18,8 @@ import type {
18
18
  } from "../discord/interaction-module.ts";
19
19
  import { OwnerCards } from "../discord/owner-cards.ts";
20
20
  import { commandGuard, ownerRootCommand } from "../discord/owner-command.ts";
21
+ import type { DiscordPoster } from "../discord/poster/poster.ts";
22
+ import { restOf, restPoster } from "../discord/poster/rest-poster.ts";
21
23
  import { stopButtonModule } from "../discord/stop-button.ts";
22
24
  import type { InboundMessage } from "../domain/conversation.ts";
23
25
  import type { RoundtablePlugin } from "../plugin.ts";
@@ -32,6 +34,13 @@ export interface DiscordServices {
32
34
  guard: CommandGuard;
33
35
  /** The threads that carry background reports. */
34
36
  threads: DispatchThreads;
37
+ /**
38
+ * Discord's REST API for a plugin's own posts, over the Discord plugin's own client and its rate
39
+ * limits: webhook posts under names the plugin chooses, the bot's own messages and cards, pins,
40
+ * direct messages, threads and channels. Usable from a service's `start` on; a call never pings
41
+ * anyone it does not name. See `DiscordPoster`.
42
+ */
43
+ poster: DiscordPoster;
35
44
  /**
36
45
  * What was said in a server channel since the assistant last posted there, before `message`:
37
46
  * for a claim that answers it, to append to its turn's text with `withChannelContext`. Call it
@@ -189,6 +198,8 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
189
198
  commands: collection.registrar,
190
199
  guard,
191
200
  threads,
201
+ // Built over the client's REST queue and sends nothing until a plugin calls it.
202
+ poster: restPoster(restOf(connected.rest())),
192
203
  channelContext: (message, given) => context.of(message, given),
193
204
  });
194
205
  return {
@@ -9,6 +9,7 @@ import {
9
9
  type Message,
10
10
  MessageFlags,
11
11
  Partials,
12
+ type REST,
12
13
  } from "discord.js";
13
14
  import type { AgentChannels, DashboardBoard } from "../agents/agent-ports.ts";
14
15
  import type { ChatSurface } from "../contract/surface.ts";
@@ -94,6 +95,13 @@ export class DiscordSurface
94
95
  constructor(options: DiscordSurfaceOptions) {
95
96
  this.#options = options;
96
97
  this.formatting = pickFormatting(options.formatting, DISCORD_FORMATTING);
98
+ // REST works before the gateway logs in; `login` sets the same token again.
99
+ this.#client.rest.setToken(options.token);
100
+ }
101
+
102
+ /** The client's REST queue, whose rate limits every request of this process shares. */
103
+ rest(): REST {
104
+ return this.#client.rest;
97
105
  }
98
106
 
99
107
  /** Slash commands and the modules that answer them; the commands are registered globally at startup. */
@@ -0,0 +1,272 @@
1
+ import {
2
+ DISCORD_LIMITS,
3
+ type DiscordCard,
4
+ type WebhookPost,
5
+ } from "./poster.ts";
6
+ import { DiscordPosterError } from "./poster-errors.ts";
7
+
8
+ /** The flag that makes a message a Components V2 message. */
9
+ export const COMPONENTS_V2_FLAG = 1 << 15;
10
+
11
+ /** The longest picture address a post carries. */
12
+ const AVATAR_URL_MAX = 2_048;
13
+
14
+ /** The longest address Discord accepts on a link button. */
15
+ const LINK_BUTTON_URL_MAX = 512;
16
+
17
+ /** Discord's style number for a button that opens an address. */
18
+ const LINK_BUTTON_STYLE = 5;
19
+
20
+ const BUTTON_STYLES = {
21
+ primary: 1,
22
+ secondary: 2,
23
+ success: 3,
24
+ danger: 4,
25
+ } as const;
26
+
27
+ const SNOWFLAKE = /^[0-9]{17,20}$/;
28
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
29
+
30
+ export function invalid(message: string): DiscordPosterError {
31
+ return new DiscordPosterError("invalid", message);
32
+ }
33
+
34
+ /** Refuses an id that is not a Discord id before it is put in a route. */
35
+ export function checkSnowflake(label: string, value: string): string {
36
+ if (!SNOWFLAKE.test(value))
37
+ throw invalid(`${label} is not a Discord id (17 to 20 digits)`);
38
+ return value;
39
+ }
40
+
41
+ function checkMentions(mentions: readonly string[] | undefined): string[] {
42
+ const list = [...new Set(mentions ?? [])];
43
+ if (list.length > DISCORD_LIMITS.mentions)
44
+ throw invalid(`a post may ping at most ${DISCORD_LIMITS.mentions} people`);
45
+ return list.map((id) => checkSnowflake("a mention", id));
46
+ }
47
+
48
+ /** Whether Discord would refuse the name: "discord" or "clyde" in it (any case, full-width too). */
49
+ function forbiddenWord(name: string): boolean {
50
+ return /clyde|discord/i.test(name.normalize("NFKC"));
51
+ }
52
+
53
+ /** A webhook's name: 1 to 80 characters, without the words Discord refuses. */
54
+ export function checkWebhookName(name: string): string {
55
+ const { min, max } = DISCORD_LIMITS.usernameChars;
56
+ const chars = [...name].length;
57
+ if (chars < min || chars > max)
58
+ throw invalid(`a webhook name is ${min} to ${max} characters`);
59
+ if (forbiddenWord(name))
60
+ throw invalid('a webhook name has no "discord" or "clyde" in it');
61
+ return name;
62
+ }
63
+
64
+ /** The username a post shows: the same rule, and not "everyone" or "here". */
65
+ function checkUsername(raw: string): string {
66
+ const username = raw.trim();
67
+ const { min, max } = DISCORD_LIMITS.usernameChars;
68
+ if (username.length < min || username.length > max)
69
+ throw invalid(`a username is ${min} to ${max} characters`);
70
+ if (
71
+ forbiddenWord(username) ||
72
+ ["everyone", "here"].includes(username.normalize("NFKC").toLowerCase())
73
+ )
74
+ throw invalid(
75
+ 'a username is not "everyone" or "here" and has no "discord" or "clyde" in it',
76
+ );
77
+ return username;
78
+ }
79
+
80
+ /** The body fields every post and edit carries: the text, and the only people it may ping. */
81
+ export function checkedText(
82
+ content: string | undefined,
83
+ mentions: readonly string[] | undefined,
84
+ ): { content?: string; allowed_mentions: { parse: []; users: string[] } } {
85
+ if (content !== undefined && content.length > DISCORD_LIMITS.contentChars)
86
+ throw invalid(
87
+ `a message is at most ${DISCORD_LIMITS.contentChars} characters; split it first`,
88
+ );
89
+ return {
90
+ ...(content === undefined ? {} : { content }),
91
+ // parse is empty: no @everyone, role or user is pinged from the text; users names them.
92
+ allowed_mentions: { parse: [], users: checkMentions(mentions) },
93
+ };
94
+ }
95
+
96
+ /** The checked body of a webhook post, its webhook name, and the files to send with it. */
97
+ export function checkedPost(post: WebhookPost) {
98
+ const webhook = checkWebhookName(post.webhook);
99
+ const username = checkUsername(post.username);
100
+ const files = post.files ?? [];
101
+ if (files.length > DISCORD_LIMITS.filesPerPost)
102
+ throw invalid(
103
+ `a post carries at most ${DISCORD_LIMITS.filesPerPost} files`,
104
+ );
105
+ const bytes = files.reduce((sum, file) => sum + file.data.byteLength, 0);
106
+ if (bytes > DISCORD_LIMITS.postBytes)
107
+ throw invalid(
108
+ `the files of a post total at most ${DISCORD_LIMITS.postBytes} bytes`,
109
+ );
110
+ if (!post.content && files.length === 0)
111
+ throw invalid("a post needs text or a file");
112
+ return {
113
+ webhook,
114
+ body: {
115
+ username,
116
+ ...(isHttps(post.avatarUrl) ? { avatar_url: post.avatarUrl } : {}),
117
+ ...checkedText(post.content, post.mentions),
118
+ },
119
+ files,
120
+ };
121
+ }
122
+
123
+ function isHttps(url: string | undefined): url is string {
124
+ if (url === undefined || url.length > AVATAR_URL_MAX) return false;
125
+ try {
126
+ return new URL(url).protocol === "https:";
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ /** Whether the bytes are a PNG no larger than a webhook avatar may be. */
133
+ export function checkAvatar(png: Uint8Array): void {
134
+ if (png.byteLength > DISCORD_LIMITS.avatarBytes)
135
+ throw invalid(
136
+ `an avatar is at most ${DISCORD_LIMITS.avatarBytes} bytes, this one is ${png.byteLength}`,
137
+ );
138
+ if (!PNG_SIGNATURE.every((byte, index) => png[index] === byte))
139
+ throw invalid("an avatar is a PNG");
140
+ }
141
+
142
+ /** A channel or thread name: 1 to 100 characters. */
143
+ export function checkChannelName(label: string, name: string): void {
144
+ const chars = [...name].length;
145
+ if (chars < 1 || chars > DISCORD_LIMITS.channelNameChars)
146
+ throw invalid(
147
+ `${label} is 1 to ${DISCORD_LIMITS.channelNameChars} characters`,
148
+ );
149
+ }
150
+
151
+ /**
152
+ * The body of a card: a container (with a colour bar when `accent` is set) holding the heading, a
153
+ * divider, one text block per section and a quiet line at the end. It refuses a card whose text
154
+ * would not fit one message, so a caller shortens it first. No mention in it pings.
155
+ */
156
+ export function cardBody(card: DiscordCard) {
157
+ const heading = `### ${card.title}`;
158
+ const texts = [
159
+ heading,
160
+ ...card.sections.filter((section) => section !== ""),
161
+ ...(card.footer ? [`-# ${card.footer}`] : []),
162
+ ];
163
+ const size = texts.reduce((sum, text) => sum + [...text].length, 0);
164
+ if (size > DISCORD_LIMITS.cardTextChars)
165
+ throw invalid(
166
+ `a card holds at most ${DISCORD_LIMITS.cardTextChars} characters of text; shorten it first`,
167
+ );
168
+ if (texts.some((text) => text.trim() === ""))
169
+ throw invalid("a card has no empty text");
170
+ const [first, ...rest] = texts;
171
+ const buttons = card.buttons ?? [];
172
+ const links = card.links ?? [];
173
+ if (buttons.length + links.length > DISCORD_LIMITS.cardButtons)
174
+ throw invalid(`a card holds at most ${DISCORD_LIMITS.cardButtons} buttons`);
175
+ for (const link of links) {
176
+ if (link.label === "" || [...link.label].length > 80)
177
+ throw invalid("a link button has a label of 1 to 80 characters");
178
+ if (
179
+ !link.url.startsWith("https://") ||
180
+ [...link.url].length > LINK_BUTTON_URL_MAX
181
+ )
182
+ throw invalid(
183
+ `a link button has an https address of at most ${LINK_BUTTON_URL_MAX} characters`,
184
+ );
185
+ }
186
+ const selects = card.selects ?? [];
187
+ for (const select of selects) {
188
+ if (
189
+ select.customId === "" ||
190
+ [...select.customId].length > 100 ||
191
+ select.placeholder === "" ||
192
+ [...select.placeholder].length > 150 ||
193
+ select.options.length < 1 ||
194
+ select.options.length > DISCORD_LIMITS.selectOptions ||
195
+ select.options.some(
196
+ (option) =>
197
+ option.label === "" ||
198
+ [...option.label].length > 100 ||
199
+ option.value === "" ||
200
+ [...option.value].length > 100,
201
+ )
202
+ )
203
+ throw invalid(
204
+ "a menu has an id of 1 to 100 characters, a placeholder of 1 to 150 and 1 to 25 choices of 1 to 100 characters",
205
+ );
206
+ }
207
+ for (const button of buttons) {
208
+ if (
209
+ button.customId === "" ||
210
+ [...button.customId].length > 100 ||
211
+ button.label === "" ||
212
+ [...button.label].length > 80
213
+ )
214
+ throw invalid(
215
+ "a button has an id of 1 to 100 characters and a label of 1 to 80",
216
+ );
217
+ }
218
+ return {
219
+ flags: COMPONENTS_V2_FLAG,
220
+ components: [
221
+ {
222
+ type: 17,
223
+ ...(card.accent === undefined ? {} : { accent_color: card.accent }),
224
+ components: [
225
+ { type: 10, content: first },
226
+ ...(rest.length > 0 ? [{ type: 14, divider: true, spacing: 1 }] : []),
227
+ ...rest.map((content) => ({ type: 10, content })),
228
+ ...selects.map((select) => ({
229
+ type: 1,
230
+ components: [
231
+ {
232
+ type: 3,
233
+ custom_id: select.customId,
234
+ placeholder: select.placeholder,
235
+ min_values: 1,
236
+ max_values: 1,
237
+ options: select.options.map((option) => ({
238
+ label: option.label,
239
+ value: option.value,
240
+ ...(option.selected ? { default: true } : {}),
241
+ })),
242
+ },
243
+ ],
244
+ })),
245
+ ...(buttons.length + links.length > 0
246
+ ? [
247
+ {
248
+ type: 1,
249
+ components: [
250
+ ...buttons.map((button) => ({
251
+ type: 2,
252
+ style: BUTTON_STYLES[button.style],
253
+ label: button.label,
254
+ custom_id: button.customId,
255
+ ...(button.disabled ? { disabled: true } : {}),
256
+ })),
257
+ ...links.map((link) => ({
258
+ type: 2,
259
+ style: LINK_BUTTON_STYLE,
260
+ label: link.label,
261
+ url: link.url,
262
+ })),
263
+ ],
264
+ },
265
+ ]
266
+ : []),
267
+ ],
268
+ },
269
+ ],
270
+ allowed_mentions: { parse: [] as never[] },
271
+ };
272
+ }
@@ -0,0 +1,106 @@
1
+ import { DiscordAPIError, HTTPError, RESTJSONErrorCodes } from "discord.js";
2
+
3
+ /** Why a call to Discord failed. */
4
+ export type DiscordFailure =
5
+ | "invalid" // the call was refused before it was sent (a bad argument)
6
+ | "channel_gone" // the channel does not exist (any more)
7
+ | "message_gone" // the message does not exist (any more)
8
+ | "forbidden" // the bot lacks a permission
9
+ | "rejected" // Discord refused it for another reason; asking again fails again
10
+ | "unavailable"; // Discord or the network failed, or a rate limit held it back; asking again may work
11
+
12
+ /**
13
+ * More about why Discord refused, where its code alone does not say: a category that is full (a
14
+ * 400 with a form-body error) and a channel that is no category or in no right guild.
15
+ */
16
+ export type DiscordReason = "category_full" | "not_category" | "wrong_guild";
17
+
18
+ /**
19
+ * A failed Discord call. The message names the operation and Discord's own reason, never a URL,
20
+ * a webhook token or the bot token.
21
+ */
22
+ export class DiscordPosterError extends Error {
23
+ override name = "DiscordPosterError";
24
+ readonly kind: DiscordFailure;
25
+ /** Discord's JSON error code, when it sent one. */
26
+ readonly discordCode: number | undefined;
27
+ readonly status: number | undefined;
28
+ readonly reason: DiscordReason | undefined;
29
+ constructor(
30
+ kind: DiscordFailure,
31
+ message: string,
32
+ detail: {
33
+ discordCode?: number;
34
+ status?: number;
35
+ reason?: DiscordReason;
36
+ } = {},
37
+ ) {
38
+ super(message);
39
+ this.kind = kind;
40
+ this.discordCode = detail.discordCode;
41
+ this.status = detail.status;
42
+ this.reason = detail.reason;
43
+ }
44
+ /** Whether the same call may work later. */
45
+ get retryable(): boolean {
46
+ return this.kind === "unavailable";
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Turns whatever the REST client threw into a `DiscordPosterError`. Discord's error carries the
52
+ * request URL, which for a webhook holds its token, so only the operation and Discord's own words
53
+ * are kept.
54
+ */
55
+ export function failure(operation: string, error: unknown): DiscordPosterError {
56
+ if (error instanceof DiscordPosterError) return error;
57
+ if (error instanceof DiscordAPIError) {
58
+ const detail: {
59
+ discordCode: number;
60
+ status: number;
61
+ reason?: "category_full";
62
+ } = { discordCode: Number(error.code), status: error.status };
63
+ // A full category comes back as a form-body error naming the parent, not as a code of its own.
64
+ if (
65
+ Number(error.code) === RESTJSONErrorCodes.InvalidFormBodyOrContentType &&
66
+ JSON.stringify(error.rawError).includes("CHANNEL_PARENT_MAX_CHANNELS")
67
+ )
68
+ detail.reason = "category_full";
69
+ const said = `${operation} failed (${error.status}, code ${error.code}): ${error.message}`;
70
+ switch (error.code) {
71
+ case RESTJSONErrorCodes.UnknownChannel:
72
+ return new DiscordPosterError("channel_gone", said, detail);
73
+ case RESTJSONErrorCodes.UnknownMessage:
74
+ return new DiscordPosterError("message_gone", said, detail);
75
+ case RESTJSONErrorCodes.UnknownWebhook:
76
+ case RESTJSONErrorCodes.InvalidWebhookToken:
77
+ return new DiscordPosterError("rejected", said, detail);
78
+ case RESTJSONErrorCodes.MissingPermissions:
79
+ case RESTJSONErrorCodes.MissingAccess:
80
+ return new DiscordPosterError("forbidden", said, detail);
81
+ default:
82
+ return new DiscordPosterError(
83
+ error.status >= 500 ? "unavailable" : "rejected",
84
+ said,
85
+ detail,
86
+ );
87
+ }
88
+ }
89
+ if (error instanceof HTTPError)
90
+ return new DiscordPosterError(
91
+ "unavailable",
92
+ `${operation} failed (${error.status})`,
93
+ { status: error.status },
94
+ );
95
+ // A network failure or a timeout: the reason without the request.
96
+ const reason = error instanceof Error ? error.name : "unknown error";
97
+ return new DiscordPosterError(
98
+ "unavailable",
99
+ `${operation} failed (${reason})`,
100
+ );
101
+ }
102
+
103
+ /** Whether the REST client threw Discord's error with one of these JSON codes. */
104
+ export function isCode(error: unknown, ...codes: number[]): boolean {
105
+ return error instanceof DiscordAPIError && codes.includes(Number(error.code));
106
+ }