pi-roundtable 0.17.3 → 0.19.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,35 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.19.0] - 2026-10-17
9
+
10
+ ### Added
11
+
12
+ - `drawCodexImage`, `createCodexImages`, `ImageNotGeneratedError`, `CODEX_IMAGE_MODEL`, `CODEX_IMAGES_PROVIDER`, `CodexImage`, `CodexImageRequest`, `CodexImagesOptions` and `CodexFetch` (`pi-roundtable/kit`): the Codex image client and the `codex-images` plugin built on it, so a project imports one implementation instead of copying one. `drawCodexImage({ token, prompt, references?, model?, signal?, fetch? })` draws one image through ChatGPT's Codex backend and returns `{ data, revisedPrompt? }`, `data` being PNG bytes and `revisedPrompt` the prompt Codex says it drew. It aborts with `signal`, both before the response arrives and while its stream is read, and rejects with the signal's own reason, so a turn's `signal?.throwIfAborted()` still sees its own abort; it stops after five minutes with a `TimeoutError`, refuses a stream over 32 MiB (`Codex image response too large`), and reads only the first image. A call that ran and returned no image, and a completed response with no image at all, both throw `ImageNotGeneratedError`: asking again with the same prompt fails again. `createCodexImages({ fetch?, model?, onMissingLogin? })` is the plugin `codex-images`: it fills `providers.images`, reads the `openai-codex` login through `context.apiKey` for each drawing, and passes the drawing's `options.signal` on, so the turn that asked for an image can abort it. `CODEX_IMAGES_PROVIDER` is `"openai-codex"` and `CODEX_IMAGE_MODEL` is `"gpt-6-sol"`, the model that routes the request. The backend is ChatGPT's own, signed in with the owner's ChatGPT subscription, and OpenAI does not document it for this use: it can stop working without notice, and OpenAI's terms for the subscription apply.
13
+ - `jevJudge` and `JevJudgeOptions` (`pi-roundtable/kit`): a `Judge` over Jev's hosted service, built on pi-jev-compaction's client (no new dependency), for the `judge` provider. `jevJudge({ apiKey, model?, baseUrl?, timeoutMs?, fetch? })` sends each question as one request: a yes-or-no question as Jev's own question type, a choice answer checked against the options listed, and a score read by criterion index, every probability a number from 0 to 1. Every failure throws a `JudgeError`. A question has its own limit of `timeoutMs` (default 20,000) that also covers reading the answer. An empty or blank `apiKey` throws a `JudgeError` when the judge is made, because the client would otherwise use the key in the environment.
14
+
15
+ ### Changed
16
+
17
+ - The `codex-images` template (`roundtable add plugin codex-images`) is now a short file that imports its client and plugin from `pi-roundtable/kit` (`createCodexImages`, built on `drawCodexImage`) and makes the `codexImages` plugin your config lists, under the same warning head and still exporting `createCodexImages`, `ImageNotGeneratedError`, `CodexFetch` and `CodexImagesOptions`. Its `images` provider now aborts with the turn that asked (it ignored the `signal` the core's `generate_image` tool hands to a drawer), and it refuses a response stream over 32 MiB; a PNG of about 12 MiB, which the completed event may repeat, can be refused. Its copied test gains one that aborts a drawing through `options.signal`. A plugin already copied is unchanged until it is copied again.
18
+
19
+ ## [0.18.0] - 2026-10-17
20
+
21
+ ### Added
22
+
23
+ - `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.
24
+ - `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`".
25
+ - `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`.
26
+ - `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`.
27
+ - 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.
28
+
29
+ ### Changed
30
+
31
+ - `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.
32
+ - 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).
33
+ - **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.
34
+ - 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.
35
+ - 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.
36
+
8
37
  ## [0.17.3] - 2026-10-16
9
38
 
10
39
  ### Fixed
package/docs/plugins.md CHANGED
@@ -63,6 +63,12 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
63
63
  - Square pictures: `squarePng(bytes, { size?, maxBytes?, maxSide? })` crops a PNG, JPEG or GIF to its centered square and scales it down to at most `size` pixels per side (default 512; a smaller picture is not enlarged), as PNG: the helper the agent avatars are made with, for a plugin that needs a profile picture or an icon.
64
64
  The header is read first, and nothing is decoded for a picture that declares a width or height over `SQUARE_PNG_MAX_SIDE` (4096), which rejects with `ImagePreparationError` (`pixel-limit`, the message naming the side limit; `maxSide` sets another limit, and the 64 MP decoded-pixel budget always applies), or for bytes of no such type, a WebP (the canvas cannot decode it) or a picture that fails to decode (`invalid-image`).
65
65
  With `maxBytes` the side steps down by a quarter at a time until the PNG is no larger; a picture that does not fit even at 16 pixels throws a `RangeError`, as does a `size` or `maxBytes` that is not a whole number in range. `SquarePngOptions` types the options.
66
+ - Codex images: `drawCodexImage({ token, prompt, references?, model?, signal?, fetch? })` draws one image through ChatGPT's Codex backend with the owner's `openai-codex` login (`CODEX_IMAGES_PROVIDER`) and returns `{ data, revisedPrompt? }`: `data` is PNG bytes, and `revisedPrompt` is the prompt Codex says it drew, when it says. `model` defaults to `CODEX_IMAGE_MODEL` (`gpt-6-sol`), which only routes the request; the backend picks the image model. `fetch` is a `CodexFetch`, which `typeof fetch` satisfies, for tests.
67
+ The call aborts with `signal`, before the response arrives and while its stream is read, and rejects with the signal's own reason, so a turn's `signal?.throwIfAborted()` still sees its own abort. It stops after five minutes with a `TimeoutError`, refuses a stream over 32 MiB (`Codex image response too large`; the completed event may repeat the image, so a PNG of about 12 MiB can be refused), and reads only the first image. An HTTP error carries its status and the first 300 characters of the body, never the token.
68
+ A call that ran and returned no image, and a response that completed without one, both throw `ImageNotGeneratedError`, usually because Codex refused the prompt; asking again with the same prompt fails again.
69
+ `createCodexImages({ fetch?, model?, onMissingLogin? })` is the `codex-images` plugin built on it. It fills the `images` provider, reads the login through `context.apiKey` for each drawing (the login refreshes), and passes the drawing's `options.signal` on, so the turn that asked for an image can abort it. Without a login, setup throws a `PluginError`; `onMissingLogin: "warn"` logs once and lets the host start, and a drawing then fails with the same `PluginError`. The kit exports no ready-made plugin object, because a plugin object shared between `check()` and `run()` should come from your own module; the `codex-images` template makes one.
70
+ The types are `CodexFetch`, `CodexImage`, `CodexImageRequest` and `CodexImagesOptions`.
71
+ This uses ChatGPT's Codex backend, signed in with the owner's ChatGPT subscription. OpenAI does not document it for this use, so it can stop working without notice, and OpenAI's terms for the subscription apply.
66
72
  - Work: `promptSlot` (how a run asks the owner while it works), `workTimeout` (a time limit that does not count the time spent waiting on the owner), `runWorkerTask`, `archiveSessions`, and `approvalCard`, `approvalDetails` and `canonicalJson` for the cards of held actions.
67
73
  `InterimPoster(posts, { logger, channel, primaryChars?, editMs? })` posts a run's [interim text](#interim-text-what-a-turn-writes-before-its-final-answer) to an `InterimPosts`, such as a dispatch thread's `interim`: feed it `messageEnd(message)` and `toolStart(name)` from the session's events, bind `() => poster.flush()` as the slot's `beforeCard`, and `await poster.flush()` before the final report.
68
74
  - Diagnostics: `scrubDiagnostic(text, max = 600)` masks credentials (URL userinfo, token shapes, secret-named assignments and JSON fields, `Authorization`/`Cookie`/`x-api-key` headers, JWTs, PEM blocks), turns control characters other than tab and newline into spaces, and cuts the result at `max` characters.
@@ -80,6 +86,9 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
80
86
  Two adapters log each skip as `Jev leaves the compaction to Pi's summary` with `reason`, `detail` and `tokensBefore` through the `logger` they take: `jevCompactionExtension({ logger, ...options })`, the `compaction` session tool's extension, placed as `session.compaction.wrap(jevCompactionExtension({ logger }))` under the engine `JEV_COMPACTION_ENGINE` (`pi-jev-compaction`), and `jevCompactor({ logger, ...options })`, a `JevCompactor` for pi-roundtable-sandbox's `compaction` option that returns the compaction or `undefined` and logs with the `channel`.
81
87
  A `no_key` skip is logged once per adapter, as `Jev is not configured, so compaction uses Pi's summary`, and then not again; every other skip is logged each time.
82
88
  The types are `JevCompactInput`, `JevCompactOptions`, `JevCompactOutcome`, `JevCompactor`, `JevCompactRequest`, `JevExtensionOptions` and `JevSkipReason`.
89
+ - Jev judge: `jevJudge({ apiKey, model?, baseUrl?, timeoutMs?, fetch? })` is a `Judge` (the `judge` provider) that asks Jev, built on pi-jev-compaction's client. Each question is one request: a yes-or-no question goes out as Jev's own question type and its probability is read from Jev's answer, a choice question's answer must be one of the options you listed, and a score question's probabilities are read by criterion index, so a missing index is an error. Probabilities must be numbers from 0 to 1.
90
+ Every failure throws a `JudgeError`: a failed request, an answer that is missing or malformed, a choice that is not an option, and a question that outlasts `timeoutMs` (default 20,000, a limit of its own for each question that also covers reading the answer), as `Jev did not answer within <n> ms`.
91
+ The `apiKey` is required, and an empty or blank one throws a `JudgeError` when `jevJudge` is called, because pi-jev-compaction's client would otherwise fall back to the key in the environment. `baseUrl` and `model` default to pi-jev-compaction's; `fetch` replaces the request function, for tests. The option type is `JevJudgeOptions`.
83
92
  - Effort: `effortJudge` picks a turn's thinking level from a message with your own brief (`EffortBrief`, `JUDGE_WORK`). A host that wants other turns decided differently sets `effortPolicy` (an `EffortPolicy`) in its configuration: it gets the turn's `channel`, `kind`, `agent` and `origin` and answers `{ fixed: level }` (no judge call), `{ requiredMass: [0, medium, high, xhigh] }` (the highest level whose share of the judge's answer at or above it meets its entry; the previous level does not carry over and a failed judge gives low), or `undefined` for the default. The `kind` of an agent-server agent's turn is `"agent"`; a policy that throws, or a `requiredMass` that is not four finite numbers in [0, 1], fails the turn.
84
93
  - Presentation and small helpers: `thinkingLine`, `zonedStamp(date, timeZone)`, `channelQueue()` (a queue of your own, so work does not wait behind a running turn), `checkRepoName` and `SKILL_LIST_TOOL` with `skillListExtension` for repositories and skills, and `searchTerms` for memory search.
85
94
 
@@ -164,6 +173,12 @@ It then calls `DefineOverrides.errorSink(entry)` if you supply one; this functio
164
173
  A logger you supply reaches the ops agent and `errorSink` only if it forwards its error lines there.
165
174
  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
175
 
176
+ `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.
177
+ `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.
178
+ `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`).
179
+ 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()`.
180
+ 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.
181
+
167
182
  ### Services: what plugins provide to each other
168
183
 
169
184
  A service is something one plugin builds and others read, such as the schedule store or the agent team.
@@ -206,7 +221,7 @@ The built-in plugins provide these, from the main entry:
206
221
 
207
222
  The data types the ports use (`Schedule`, `NewSchedule`, `Agent`, `AgentGroup`, `TeamStatus`, `Memory`, `SkillSet`, `DelegationJob`, and so on) are in the main entry too.
208
223
  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)).
224
+ 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
225
 
211
226
  To share your own service, export its key as a constant and its port as an interface.
212
227
  Plugins registered after yours can read it during setup.
@@ -1391,7 +1406,7 @@ When no `images` provider is configured, `agent_create` has no `avatar_prompt` p
1391
1406
  The owner's profile panel reports the missing provider and offers no redraw option.
1392
1407
  Each agent gets a picture generated from its display name and the assistant's icon, so you can tell agents apart.
1393
1408
  `bunx roundtable doctor` reports whether the slot is filled.
1394
- The copy-in [`codex-images` provider template][codex-images-template] is a complete implementation of the `images` slot.
1409
+ The copy-in [`codex-images` provider template][codex-images-template] fills the `images` slot with `createCodexImages` from `pi-roundtable/kit`, which also exports the client it is built on, `drawCodexImage`.
1395
1410
 
1396
1411
  <!-- example: examples/providers.ts -->
1397
1412
  ```ts
@@ -2429,6 +2444,38 @@ export function tavern(channelIds: readonly string[]) {
2429
2444
  ```
2430
2445
  <!-- /example -->
2431
2446
 
2447
+ ### Posting through `DISCORD.poster`
2448
+
2449
+ `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.
2450
+ 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.
2451
+ 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.
2452
+ There is no factory: a host that has no Discord has no poster.
2453
+
2454
+ ```ts
2455
+ import { DISCORD } from "pi-roundtable/discord";
2456
+
2457
+ const { poster } = services.get(DISCORD);
2458
+ const posted = await poster.post(channelId, { webhook: "relay", username: "Ann", content: "Hello" });
2459
+ await poster.edit(channelId, posted.messageId, { content: "Hello again" }, ["relay"]);
2460
+ await poster.remove(channelId, posted.messageId, ["relay"]);
2461
+ ```
2462
+
2463
+ 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`).
2464
+ Use `DISCORD.connection.channelInfo(channelId)` to read what a channel is called now: it comes from the gateway's cache.
2465
+
2466
+ The rules the poster keeps, so a plugin does not rediscover them:
2467
+
2468
+ - **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.
2469
+ - **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.
2470
+ - **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.
2471
+ - **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.
2472
+ - **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`.
2473
+ - **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.
2474
+
2475
+ `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.
2476
+
2477
+ 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.
2478
+
2432
2479
  ### `backgroundTargets`: whose turn a schedule or delegated task is
2433
2480
 
2434
2481
  Schedules and delegated tasks return later as turns without a new message in the channel.
@@ -3122,6 +3169,10 @@ Install only the packages your host uses; the core does not depend on these work
3122
3169
  It sends requests to ChatGPT's Codex backend using the owner's ChatGPT subscription login.
3123
3170
  OpenAI doesn't document the backend for this use, so it can stop working without notice; the subscription's terms apply.
3124
3171
  The copied file begins with this warning.
3172
+ Its client and plugin come from `pi-roundtable/kit` (`createCodexImages`, built on `drawCodexImage`): the copied `plugins/codex-images.ts` imports them and makes the plugin you list, so a fix to the protocol arrives with an upgrade of the package, not with a new copy.
3173
+ A drawing aborts with the turn that asked for it, a request stops after five minutes, and a response stream over 32 MiB is refused.
3174
+ A copy made before 0.19.0 holds the whole client and keeps working as it was, without the abort and the stream limit, until you copy the template again.
3175
+ To change the model, pass `createCodexImages({ model })`; the default is `CODEX_IMAGE_MODEL`.
3125
3176
 
3126
3177
  `release-notice` listens to two [events](#events-hear-what-the-core-does).
3127
3178
  When the agent server's team service reports `ready`, it reads the release file and posts if the release's `sha` differs from the one it last announced (`announced-release` in the data directory), or if the previous shutdown cut work short.
@@ -3272,8 +3323,9 @@ Use `partial<Port>({ ... })` to stand in for a port your code takes as an argume
3272
3323
  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
3324
  `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
3325
  `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, { ... })`.
3326
+ `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.
3327
+ 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, { ... })`.
3328
+ `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
3329
 
3278
3330
  ### `testHost`: the built-in plugins and yours, over PostgreSQL
3279
3331
 
@@ -3287,7 +3339,7 @@ It boots `defineRoundtable` over `ROUNDTABLE_TEST_DATABASE_URL`, with stand-ins
3287
3339
  | `plugins` | Your plugins, placed after the built-in ones as `defineRoundtable` places them |
3288
3340
  | `runtime` | The runtime every turn runs on; by default one that answers `""` and builds no Pi session |
3289
3341
  | `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 |
3342
+ | `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
3343
 
3292
3344
  It returns:
3293
3345
 
@@ -3871,6 +3923,10 @@ Import from the entries listed below; source area files are internal.
3871
3923
  | `SurfaceObservation` | `pi-roundtable/testing` | type |
3872
3924
  | `eagerText` | `pi-roundtable/testing` | value |
3873
3925
  | `fakeDiscord` | `pi-roundtable/testing` | value |
3926
+ | `fakeDiscordPoster` | `pi-roundtable/testing` | value |
3927
+ | `FakeDiscordPoster` | `pi-roundtable/testing` | type |
3928
+ | `FakePosterChannel` | `pi-roundtable/testing` | type |
3929
+ | `FakePosterMessage` | `pi-roundtable/testing` | type |
3874
3930
  | `fakePrecheck` | `pi-roundtable/testing` | value |
3875
3931
  | `fakePrechecks` | `pi-roundtable/testing` | value |
3876
3932
  | `FakePrecheck` | `pi-roundtable/testing` | type |
@@ -3904,12 +3960,18 @@ Import from the entries listed below; source area files are internal.
3904
3960
  | `AgentTurnRunner` | `pi-roundtable/kit` | type |
3905
3961
  | `AssistantLike` | `pi-roundtable/kit` | type |
3906
3962
  | `Backlog` | `pi-roundtable/kit` | type |
3963
+ | `CODEX_IMAGES_PROVIDER` | `pi-roundtable/kit` | value |
3964
+ | `CODEX_IMAGE_MODEL` | `pi-roundtable/kit` | value |
3907
3965
  | `CardLimits` | `pi-roundtable/kit` | type |
3908
3966
  | `CategoryLayout` | `pi-roundtable/kit` | type |
3909
3967
  | `ChannelMessage` | `pi-roundtable/kit` | type |
3910
3968
  | `ChannelQueue` | `pi-roundtable/kit` | type |
3911
3969
  | `ChoiceAnswer` | `pi-roundtable/kit` | type |
3912
3970
  | `ChoiceQuestion` | `pi-roundtable/kit` | type |
3971
+ | `CodexFetch` | `pi-roundtable/kit` | type |
3972
+ | `CodexImage` | `pi-roundtable/kit` | type |
3973
+ | `CodexImageRequest` | `pi-roundtable/kit` | type |
3974
+ | `CodexImagesOptions` | `pi-roundtable/kit` | type |
3913
3975
  | `DELEGATE_TOOL` | `pi-roundtable/kit` | value |
3914
3976
  | `DELEGATE_TOOL_SPEC` | `pi-roundtable/kit` | value |
3915
3977
  | `DashboardBoard` | `pi-roundtable/kit` | type |
@@ -3925,7 +3987,9 @@ Import from the entries listed below; source area files are internal.
3925
3987
  | `EffortRequest` | `pi-roundtable/kit` | type |
3926
3988
  | `EffortRule` | `pi-roundtable/kit` | type |
3927
3989
  | `GroupMessage` | `pi-roundtable/kit` | type |
3990
+ | `ImageNotGeneratedError` | `pi-roundtable/kit` | value |
3928
3991
  | `JUDGE_WORK` | `pi-roundtable/kit` | value |
3992
+ | `JevJudgeOptions` | `pi-roundtable/kit` | type |
3929
3993
  | `McpEndpoint` | `pi-roundtable/kit` | type |
3930
3994
  | `ModelRef` | `pi-roundtable/kit` | type |
3931
3995
  | `OwnerIdentity` | `pi-roundtable/kit` | type |
@@ -3937,6 +4001,9 @@ Import from the entries listed below; source area files are internal.
3937
4001
  | `SCHEDULE_TOOLS` | `pi-roundtable/kit` | value |
3938
4002
  | `SHELL_TOOLS` | `pi-roundtable/kit` | value |
3939
4003
  | `SKILL_LIST_TOOL` | `pi-roundtable/kit` | value |
4004
+ | `createCodexImages` | `pi-roundtable/kit` | value |
4005
+ | `drawCodexImage` | `pi-roundtable/kit` | value |
4006
+ | `jevJudge` | `pi-roundtable/kit` | value |
3940
4007
  | `packageDir` | `pi-roundtable/kit` | value |
3941
4008
  | `serveUnix` | `pi-roundtable/kit` | value |
3942
4009
  | `ScheduleToolContext` | `pi-roundtable/kit` | type |
@@ -3989,6 +4056,10 @@ Import from the entries listed below; source area files are internal.
3989
4056
  | `isRuleLoad` | `pi-roundtable/kit` | value |
3990
4057
  | `InterimPoster` | `pi-roundtable/kit` | value |
3991
4058
  | `InterimPosterOptions` | `pi-roundtable/kit` | type |
4059
+ | `OpsAlerts` | `pi-roundtable/kit` | value |
4060
+ | `OpsAlertsOptions` | `pi-roundtable/kit` | type |
4061
+ | `OpsRaiseOptions` | `pi-roundtable/kit` | type |
4062
+ | `OpsAlertKind` | `pi-roundtable/kit` | type |
3992
4063
  | `JEV_COMPACTION_ENGINE` | `pi-roundtable/kit` | value |
3993
4064
  | `JEV_GOAL` | `pi-roundtable/kit` | value |
3994
4065
  | `JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS` | `pi-roundtable/kit` | value |
@@ -4060,6 +4131,23 @@ Import from the entries listed below; source area files are internal.
4060
4131
  | `DiscordAdapterConfig` | `pi-roundtable/discord` | type |
4061
4132
  | `DiscordConnection` | `pi-roundtable/discord` | type |
4062
4133
  | `DiscordServices` | `pi-roundtable/discord` | type |
4134
+ | `DISCORD_LIMITS` | `pi-roundtable/discord` | value |
4135
+ | `DiscordPosterError` | `pi-roundtable/discord` | value |
4136
+ | `CardButton` | `pi-roundtable/discord` | type |
4137
+ | `CardLink` | `pi-roundtable/discord` | type |
4138
+ | `CardSelect` | `pi-roundtable/discord` | type |
4139
+ | `CategoryChannel` | `pi-roundtable/discord` | type |
4140
+ | `DiscordCard` | `pi-roundtable/discord` | type |
4141
+ | `DiscordFailure` | `pi-roundtable/discord` | type |
4142
+ | `DiscordMessage` | `pi-roundtable/discord` | type |
4143
+ | `DiscordPoster` | `pi-roundtable/discord` | type |
4144
+ | `DiscordReason` | `pi-roundtable/discord` | type |
4145
+ | `PostedMessage` | `pi-roundtable/discord` | type |
4146
+ | `PosterFile` | `pi-roundtable/discord` | type |
4147
+ | `RecentMessage` | `pi-roundtable/discord` | type |
4148
+ | `ThreadCloser` | `pi-roundtable/discord` | type |
4149
+ | `ThreadState` | `pi-roundtable/discord` | type |
4150
+ | `WebhookPost` | `pi-roundtable/discord` | type |
4063
4151
  | `InteractionContribution` | `pi-roundtable/discord` | type |
4064
4152
  | `InteractionModule` | `pi-roundtable/discord` | type |
4065
4153
  | `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.19.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
+ }