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
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,42 @@ 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
|
+
|
|
26
|
+
## [0.17.3] - 2026-10-16
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- The 0.17.1 entry and the plugin guide called `turnTimeoutMs` and `turnMaxMs` options of the built-in runtime that a host can set. Nothing public sets them: the runtime plugin passes neither, and the runtime class is not exported. Every host runs a turn with 10 minutes of work, renewed by each steer up to 30 minutes in all. The guide now says the two limits are fixed, and its startup order lists the runtime's own `preflight`, which 0.17.2 moved after every plugin's.
|
|
31
|
+
- `testPlugin` runs the runtime's `preflight` where the host does, right after the plugin's own and before the plugin's services and surfaces start, not after they have. A runtime whose `preflight` throws fails `testPlugin` with that error, with nothing started and the runtime disposed.
|
|
32
|
+
- A time limit on a turn, or on a coding worker (`workTimeout`), that comes due while an owner's card is open looks at the turn again no oftener than once a second, not once a millisecond for as long as the card waits. The limit still fires once; a limit that waiting on a card pushed back can fire up to a second late.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- 0.17.2 was tagged but never published: its release run stopped on a LINE plugin test that raced its own frozen clock. 0.17.3 carries 0.17.2's fix, the runtime's startup probe after every plugin's `preflight`; the LINE tests that raced now step their clock, and no shipped LINE code changed.
|
|
37
|
+
|
|
38
|
+
## [0.17.2] - 2026-10-16
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- The runtime's startup probe of the model and the required tools runs after every plugin's `preflight`, not in the runtime plugin's own preflight, which ran first. Since 0.17.0 a plugin that resolves its MCP servers does so in its preflight, so the probe no longer saw the tools they yield, and a required tool that was missing failed only the first session instead of the start. The host now runs the `RUNTIME` service's `preflight` once every plugin's preflight has finished, still before any service starts or any listener opens; a failure stops the start, and the same host may try again. A custom runtime provider's `preflight` moves to the same place. The runtime plugin's claude-bridge memory warning stays in its own preflight. `Roundtable.check()` still runs no preflight.
|
|
43
|
+
|
|
8
44
|
## [0.17.1] - 2026-10-16
|
|
9
45
|
|
|
10
46
|
### Changed
|
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`,
|
|
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.
|
|
@@ -1443,7 +1449,7 @@ An `AgentRuntime` has these methods:
|
|
|
1443
1449
|
| `pendingConfirmation(conversation)`, `heldActions(conversation)` | The held actions known in memory, and those restored from the store after a restart: the agent server reads `heldActions` to show an approval card again after a restart. After a restart `pendingConfirmation` knows a conversation's held actions only once `heldActions` or `recentTranscript` has restored them, so a host that decides an approval asks one of them first. A `PendingConfirmation` carries the `selectionId` of the `TurnSelection` whose turn held the calls, an opaque string that the caller resolves again when the owner confirms; the core stores it and never reads it |
|
|
1444
1450
|
| `recentTranscript(conversation, limit)` | The latest messages, for the owner's and the dashboard's views of a conversation; the built-in runtime gives none of a history recorded, or a session opened, for someone else than the conversation serves now |
|
|
1445
1451
|
| `contextUsage?(conversation)` | How full the conversation's context is, `{ tokens, contextWindow }`; leave it out and the team status shows no context bar |
|
|
1446
|
-
| `preflight?()` | Runs
|
|
1452
|
+
| `preflight?()` | Runs after every plugin's preflight, before anything starts; a throw stops the boot |
|
|
1447
1453
|
| `dispose?()` | Runs when the host stops the runtime plugin's `runtime` service |
|
|
1448
1454
|
|
|
1449
1455
|
A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker` (required: the person the turn is for, at whose tier it runs; the built-in runtime refuses a request without one with an `AgentRunError` before asking the model, and a plugin that starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`), `conversation` (`TurnConversation`: `private` to a `principalId`, or `shared`, as `context.turns` records it; shared when absent), flags (`steerable`, `interactive`, `confirmed`: the speaker's message approved the conversation's held calls, which the runtime releases for the speaker, except those that [name approvers](#a-hold-that-names-its-approvers) the speaker is neither among nor an owner of, which stay held), and `interim`, where the turn may post the text it writes before its final answer ([interim text](#interim-text-what-a-turn-writes-before-its-final-answer)).
|
|
@@ -1698,6 +1704,10 @@ test("only one plugin may fill the runtime slot", async () => {
|
|
|
1698
1704
|
`preflight` also sits on the plugin.
|
|
1699
1705
|
It runs once every plugin is set up and linked, before any command is registered or any service starts.
|
|
1700
1706
|
A throw stops the boot, so a missing setting is caught before Discord connects.
|
|
1707
|
+
Plugins' preflights run in plugin order.
|
|
1708
|
+
Once every one has finished, the host runs the runtime's own `preflight` (the `RUNTIME` service's), which for Pi's runtime builds throwaway sessions and checks the model and the required tools.
|
|
1709
|
+
So tools a plugin fills in during its preflight, such as MCP servers it resolves there, are in those sessions, and a required tool still missing stops the start before any service starts or any listener opens.
|
|
1710
|
+
`Roundtable.check()` runs no preflight.
|
|
1701
1711
|
|
|
1702
1712
|
<!-- example: examples/preflight.ts -->
|
|
1703
1713
|
```ts
|
|
@@ -2425,6 +2435,38 @@ export function tavern(channelIds: readonly string[]) {
|
|
|
2425
2435
|
```
|
|
2426
2436
|
<!-- /example -->
|
|
2427
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
|
+
|
|
2428
2470
|
### `backgroundTargets`: whose turn a schedule or delegated task is
|
|
2429
2471
|
|
|
2430
2472
|
Schedules and delegated tasks return later as turns without a new message in the channel.
|
|
@@ -3268,8 +3310,9 @@ Use `partial<Port>({ ... })` to stand in for a port your code takes as an argume
|
|
|
3268
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.
|
|
3269
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)`.
|
|
3270
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`.
|
|
3271
|
-
`fakeDiscord({ ownerId?, rootCommand?, channelContext? })` is the `DISCORD` service for a plugin that adds slash commands
|
|
3272
|
-
Only `commands` and `
|
|
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.
|
|
3273
3316
|
|
|
3274
3317
|
### `testHost`: the built-in plugins and yours, over PostgreSQL
|
|
3275
3318
|
|
|
@@ -3283,7 +3326,7 @@ It boots `defineRoundtable` over `ROUNDTABLE_TEST_DATABASE_URL`, with stand-ins
|
|
|
3283
3326
|
| `plugins` | Your plugins, placed after the built-in ones as `defineRoundtable` places them |
|
|
3284
3327
|
| `runtime` | The runtime every turn runs on; by default one that answers `""` and builds no Pi session |
|
|
3285
3328
|
| `apiKeys` | The credentials `context.apiKey(provider)` returns, by provider name; a provider not listed reads as having none, whatever login the machine holds |
|
|
3286
|
-
| `discord` | What the stand-in Discord hands out: `agentChannels(guildId)` and `
|
|
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 |
|
|
3287
3330
|
|
|
3288
3331
|
It returns:
|
|
3289
3332
|
|
|
@@ -3367,6 +3410,7 @@ The host's `run()` handles startup:
|
|
|
3367
3410
|
From here `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` work.
|
|
3368
3411
|
5. Every plugin's `preflight` runs, in plugin order.
|
|
3369
3412
|
The Discord plugin's preflight is where it composes the slash commands every plugin added in `setup`; a clash stops the start here.
|
|
3413
|
+
Once every one has finished, the runtime's own `preflight` (the `RUNTIME` service's) runs, so its check of the model and the required tools sees the tools that other plugins filled in during theirs.
|
|
3370
3414
|
6. Every service starts: the plugins' in plugin order, and each plugin's own in the order it listed them, after the plugin's chat surfaces (the `surface:<prefix>` services).
|
|
3371
3415
|
7. The HTTP listeners open.
|
|
3372
3416
|
8. Every service's `startInBackground` runs, at once and without holding up the boot, and every plugin hears `serviceStarted` as each ends; the agent server's `team` service starts the agents' channels and the dashboard there.
|
|
@@ -3395,7 +3439,7 @@ On `SIGTERM` or `SIGINT` the bot takes no new work at once, finishes what runs,
|
|
|
3395
3439
|
`shutdown()` returns exit code `0`, or `1` if a listener, service, or pool failed to stop.
|
|
3396
3440
|
Every call shares the same shutdown.
|
|
3397
3441
|
Give the service manager a stop timeout longer than the drain limit plus about a minute for the steps after it, such as systemd's `TimeoutStopSec=5min` with the default three-minute drain; a shorter one has the manager kill the process in the middle of the drain.
|
|
3398
|
-
The built-in runtime aborts a turn that works 10 minutes
|
|
3442
|
+
The built-in runtime aborts a turn that works 10 minutes, not counting time spent waiting on the owner's cards. Each message steered into the running turn gives it the full 10 minutes again from that moment, up to a cap of 30 minutes of work for the whole turn; once the cap is reached the turn takes no more steering, so a message waits for the next turn, and the turn is aborted as on its timeout. Both limits are fixed: the runtime plugin passes no option for them, so a host cannot change them, and a limit that waiting on an owner's card pushed back can fire up to a second late. The log says `steer renewed the turn's time budget` with `deadlineMs`, the work time left, and the timeout warning names the limit that fired (`limit: "turn"` or `"turnMax"`). Task workers (`task.timeoutMs`), coding workers (`workTimeout`) and sandbox guests (their own `turnTimeoutMs`) keep their own limits, which steering does not renew.
|
|
3399
3443
|
A turn whose model ignores its abort (a subprocess that does not exit) is given up on too: a turn that outlives its timeout, or is stopped, gets `turnAbortGraceMs` (15 seconds by default) to end, and then its session is disposed of and the turn is reported failed, so it cannot hold the queue.
|
|
3400
3444
|
`listen()`, called by the command line and a host's own entry point, exits the process with that code.
|
|
3401
3445
|
|
|
@@ -3866,6 +3910,10 @@ Import from the entries listed below; source area files are internal.
|
|
|
3866
3910
|
| `SurfaceObservation` | `pi-roundtable/testing` | type |
|
|
3867
3911
|
| `eagerText` | `pi-roundtable/testing` | value |
|
|
3868
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 |
|
|
3869
3917
|
| `fakePrecheck` | `pi-roundtable/testing` | value |
|
|
3870
3918
|
| `fakePrechecks` | `pi-roundtable/testing` | value |
|
|
3871
3919
|
| `FakePrecheck` | `pi-roundtable/testing` | type |
|
|
@@ -3984,6 +4032,10 @@ Import from the entries listed below; source area files are internal.
|
|
|
3984
4032
|
| `isRuleLoad` | `pi-roundtable/kit` | value |
|
|
3985
4033
|
| `InterimPoster` | `pi-roundtable/kit` | value |
|
|
3986
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 |
|
|
3987
4039
|
| `JEV_COMPACTION_ENGINE` | `pi-roundtable/kit` | value |
|
|
3988
4040
|
| `JEV_GOAL` | `pi-roundtable/kit` | value |
|
|
3989
4041
|
| `JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS` | `pi-roundtable/kit` | value |
|
|
@@ -4055,6 +4107,23 @@ Import from the entries listed below; source area files are internal.
|
|
|
4055
4107
|
| `DiscordAdapterConfig` | `pi-roundtable/discord` | type |
|
|
4056
4108
|
| `DiscordConnection` | `pi-roundtable/discord` | type |
|
|
4057
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 |
|
|
4058
4127
|
| `InteractionContribution` | `pi-roundtable/discord` | type |
|
|
4059
4128
|
| `InteractionModule` | `pi-roundtable/discord` | type |
|
|
4060
4129
|
| `ManagedChannel` | `pi-roundtable/discord` | type |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
70
|
+
"pi-mcp-adapter": "5.3.0",
|
|
69
71
|
"pino": "10.4.0",
|
|
70
|
-
"typebox": "1.3.
|
|
72
|
+
"typebox": "1.3.37",
|
|
71
73
|
"unpdf": "1.8.1"
|
|
72
74
|
},
|
|
73
75
|
"peerDependencies": {
|
|
74
|
-
"pi-web-access": ">=0.35.0 <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.
|
|
81
|
+
"@types/bun": "1.4.3",
|
|
80
82
|
"pi-self-compact": "0.2.0",
|
|
81
|
-
"pi-web-access": "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 {
|
|
@@ -72,7 +72,7 @@ export interface AgentRuntime {
|
|
|
72
72
|
): Promise<TranscriptEntry[]>;
|
|
73
73
|
/** Undefined when unknown; the team status shows no context bar for the conversation then. */
|
|
74
74
|
contextUsage?(conversation: ChannelKey): ContextUse | undefined;
|
|
75
|
-
/** Runs
|
|
75
|
+
/** Runs after every plugin's preflight, before anything starts; a throw stops the boot. */
|
|
76
76
|
preflight?(): Promise<void>;
|
|
77
77
|
/** Runs when the host stops the agent server's runtime service. */
|
|
78
78
|
dispose?(): Promise<void> | void;
|
|
@@ -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
|
+
}
|