pi-roundtable 0.30.3 → 0.31.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,21 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.31.0] - 2026-11-01
9
+
10
+ ### Added
11
+
12
+ - `DiscordCard.preview`: one line of plain text that makes a card a legacy message instead of a Components V2 one, so a card's push and desktop notification is no longer blank. The preview (whitespace flattened, at most 2,000 characters) is the message's `content`, the card is one embed (title, sections as the description, `accent` as the colour, `footer`, `thumbnail`, `image`) and `buttons`, `links` and `selects` are ordinary action rows. It works through `sayCard`, `postCard`, `editCard`, `editWebhookCard` and a forum post's first message; an edit sets `content` too. A card without `preview` is unchanged. The poster cannot change a message's kind: an edit that would turn a Components V2 message into a preview card, or the other way round, is `rejected`. A preview card's embed text is at most 4,096 characters once the sections are joined, its footer 2,048 and the embed 6,000 in all, else `invalid`.
13
+ - `DiscordPoster.createForumPost(forumId, { name, card | content, appliedTags?, autoArchive?, mentions? })` returns `{ threadId, messageId }` (`ForumPost`, `ForumPostCreated`, `ThreadAutoArchive`); the first message of a post has the thread's own id, so `editCard(threadId, messageId, card)` edits a card first message. `DiscordPoster.forumTags(forumId)` and `setForumTags(forumId, tags)` read and replace a forum's tags (`ForumTag`, `ForumTagInput`; a tag sent with its id is kept), and `setPostTags(threadId, tagIds)` replaces a post's tags. `renameChannel` is documented to rename a thread or a forum post too.
14
+ - `DiscordPoster.channelKind(channelId)` returns `{ guildId, kind, parentId? }` (`ChannelKindInfo`) with `kind` one of `text`, `category`, `thread`, `forum`, `forum_post` and `other` (`ChannelKind`). `DiscordReason` has a new `not_forum`, for a forum call on a channel that is no forum and `setPostTags` on a thread that is no forum post.
15
+ - Archived forum posts: Discord refuses a rename, a retag and any message edit in an archived thread (400, code 50083), so `setPostTags` and `renameChannel` of a thread send `archived: false` in the same request, and `editCard` on a post's first message (`messageId` equal to `threadId`) opens an archived post and sends the edit again. `editCard` on another message of an archived thread stays `rejected`; call `setThreadArchived(threadId, false)` first. A locked post is `forbidden` without Manage Threads. `channelKind` no longer keeps the category of a text channel or forum (a thread's parent it still keeps), and `say`, `sayCard` and a webhook `post` into a forum's own id are `rejected` (use `createForumPost`). The fake and the emulator behave the same way.
16
+ - `InboundMessage.thread?: { parentId, parentKind }`, set by the Discord surface for a message written in a thread: `parentKind` is `text`, `forum` or `other`, so a claim answers a mention in a forum post in place instead of starting a thread.
17
+ - `fakeDiscordPoster` keeps the same calls: `forums` (a forum's tags, `FakeForum`), forum posts as channels with `appliedTags`, `channelKind`, and preview cards held with the preview as the message content. The poster contract scenarios cover all of it for the fake and the real poster over the emulated Discord, which now also emulates forums, forum posts, tags and legacy messages with embeds.
18
+
19
+ ## [0.30.4] - 2026-11-01
20
+
21
+ - Release for the web package's changes (see its changelog); no core behavior changes.
22
+
8
23
  ## [0.30.3] - 2026-11-01
9
24
 
10
25
  - 0.30.2 was tagged but never published: its release run stopped on lint (a hook's dependency suppression the change had dropped) before any package was published. 0.30.3 carries 0.30.2's changes. No core behavior changes.
package/docs/plugins.md CHANGED
@@ -2905,15 +2905,16 @@ await poster.edit(channelId, posted.messageId, { content: "Hello again" }, ["rel
2905
2905
  await poster.remove(channelId, posted.messageId, ["relay"]);
2906
2906
  ```
2907
2907
 
2908
- What it covers: webhook posts under names you choose (`post`, `edit`, `remove`, `webhookIds`, `setAvatar`, `onWebhookMade`), cards posted through such a webhook (`postCard`, `editWebhookCard`), 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`).
2908
+ What it covers: webhook posts under names you choose (`post`, `edit`, `remove`, `webhookIds`, `setAvatar`, `onWebhookMade`), cards posted through such a webhook (`postCard`, `editWebhookCard`), 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`, `channelKind`), threads (`startThread`, `setThreadArchived`, `threadState`, `threadCloser`) and forums (`forumTags`, `setForumTags`, `createForumPost`, `setPostTags`).
2909
2909
  Use `DISCORD.connection.channelInfo(channelId)` to read what a channel is called now: it comes from the gateway's cache.
2910
2910
 
2911
2911
  The rules the poster keeps, so a plugin does not rediscover them:
2912
2912
 
2913
2913
  - **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.
2914
2914
  - **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.
2915
- - **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.
2915
+ - **Cards are Components V2 messages.** A card carries no `content`, so a card alone makes a blank push or desktop notification; give it a `preview` (next bullet) when that matters. 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.
2916
2916
  - **A card can carry a picture.** `thumbnail: { url, description? }` makes the heading a Section with a small picture beside it; `image: { url, description? }` puts a Media Gallery of one picture under the text sections and above the quiet last line. Both take an `https` address of at most 2,048 characters and an alt text of 1 to 1,024 characters, else `invalid`; `editCard` and `editWebhookCard` keep them when you send them again, and a card without them is sent exactly as before. Discord fetches the address itself, so it must be reachable from the internet.
2917
+ - **A card can carry a preview line for the notification.** `DiscordCard.preview` is one line of plain text (whitespace runs, line breaks included, become one space; at most 2,000 characters after that, and about 120 reads best). Discord builds a push or desktop notification from a message's `content`, which a Components V2 message cannot have, so a card with a `preview` is sent as a legacy message instead: `content` is the preview, one embed holds the card (the title; the sections joined by blank lines as the description; `accent` as the colour; `footer`; `thumbnail` and `image` as the embed's, which have no alt text, so a `description` is dropped) and `buttons`, `links` and `selects` are ordinary action rows, no Components V2 flag. It works for `sayCard`, `postCard`, `editCard`, `editWebhookCard` and a forum post's first message alike, and an edit sets `content` too. The same text guard holds (3,800 characters in all), a title is at most 256 characters, the embed's text (the sections joined by blank lines) at most 4,096, its footer at most 2,048 and the title, text and footer together at most 6,000, and a card has at most five rows (one for each menu and one for the buttons), else `invalid`. A card without `preview` is sent exactly as before. The poster cannot change a message's kind: an edit that would turn a Components V2 message into a preview card, or a preview card into Components V2, is `rejected` (delete it and post a new one, which changes the message id), so decide once per message. An edit that leaves out the buttons removes them.
2917
2918
  - **A webhook can post a card.** `postCard` takes the same `webhook`, `username` and `avatarUrl` as `post` plus a `card`, and sends it with `with_components=true`, which Discord allows for the bot's own (application-owned) webhooks; a button or menu on it reaches your interaction handler like one on a bot card, and the colour bar stays. `editWebhookCard(channelId, messageId, card, webhooks)` asks each named webhook in order, as `edit` does: `message_gone` when none posted it, `rejected` for a message that is not a card. The webhook's token is never logged or put in an error.
2918
2919
 
2919
2920
  ```ts
@@ -2932,11 +2933,14 @@ await poster.postCard(channelId, {
2932
2933
  });
2933
2934
  ```
2934
2935
 
2935
- - **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.
2936
+ - **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. A thread, a forum post included, is a channel, so `renameChannel(threadId, name)` renames it the same way (Manage Threads for a post someone else made), under the same limit per thread; it opens an archived thread again, as Discord would refuse the rename otherwise.
2937
+ - **`channelKind` says what a channel is.** `channelKind(channelId)` returns `{ guildId, kind, parentId? }` with `kind` one of `text`, `category`, `thread`, `forum`, `forum_post` or `other` (voice, stage, announcement, media). A thread whose parent is a forum is a `forum_post`, a thread of any other channel a `thread`; `parentId` is the thread's parent (the forum for a post) or a channel's category. It keeps a channel's guild and type and a thread's parent after the first read; the category of any other channel can change when someone moves it, so `channelKind` reads it afresh each time. A direct-message channel is `invalid` with `wrong_guild`. A plugin that answers a mention by starting a thread asks it first, or reads `InboundMessage.thread`: a mention inside a thread or a forum post is answered in place, and `startThread` fails there.
2938
+ - **Forums.** `forumTags(forumId)` lists a forum's tags (`{ id, name, moderated, emoji? | emojiId? }`). `setForumTags(forumId, tags)` replaces the whole set (Manage Channels), because Discord only takes it whole: a tag sent with its `id` is kept and may be renamed, one without is made, one left out is deleted, so add a tag with `setForumTags(id, [...(await forumTags(id)), { name: "new" }])`. A forum holds at most 20 tags, a name 1 to 20 characters, a tag has a Unicode `emoji` or a custom `emojiId`, not both. A forum call on a channel that is no forum is `invalid` with `not_forum`. Making a forum channel is not in the poster.
2939
+ - **Forum posts.** `createForumPost(forumId, { name, card | content, appliedTags?, autoArchive?, mentions? })` returns `{ threadId, messageId }`. Discord takes a post and its first message in one request, so the first message is part of the call: a card (Components V2, or a preview card) or plain text (`mentions` names the users it may ping, as `say`'s). A post's first message has the thread's own id, so `messageId` equals `threadId`; `editCard(threadId, messageId, card)` edits a card first message, `pin`, `say` and `sayCard` take the thread id as a channel's. `appliedTags` is at most five ids the forum offers (a tag it does not offer is `rejected`); `autoArchive` is 60, 1,440, 4,320 or 10,080 minutes, a week by default. `setPostTags(threadId, tagIds)` replaces a post's tags (none clears them); a thread that is not a forum post is `invalid` with `not_forum`. `forbidden` without Create Posts. A forum takes posts, not messages: `say`, `sayCard` and a webhook `post` into the forum's own id are `rejected`. **An archived post.** A post nobody writes in archives itself (a week by default), and Discord refuses every edit of an archived thread (400, code 50083). `setPostTags` and `renameChannel` therefore send `archived: false` in the same request, which opens the post again; `editCard(threadId, threadId, card)` on a post's first message opens an archived post and sends the edit again. `editCard` on any other message of an archived thread is `rejected`: call `setThreadArchived(threadId, false)` first (`say` and `sayCard` already wake an archived thread). A locked post stays `forbidden` without Manage Threads. Scheduled events and moving channels between categories are not in the poster.
2936
2940
  - **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`.
2937
- - **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.
2941
+ - **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`), a channel that is no forum or a thread that is no forum post (`not_forum`) 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.
2938
2942
 
2939
- `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.
2943
+ `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, 256 for the title of a preview card, 5 card buttons and 25 menu choices, 20 forum tags, 20 characters for a tag name and 5 tags on a post.
2940
2944
 
2941
2945
  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.
2942
2946
 
@@ -4062,7 +4066,7 @@ It provides the members you give it and throws an error naming any missing membe
4062
4066
  `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`.
4063
4067
  `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.
4064
4068
  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, { ... })`.
4065
- `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.
4069
+ `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`, `forums` (a forum is an entry `{ tags }` here plus a `channels` entry; a forum post is a channel whose `parentId` is the forum, with `appliedTags`), `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.
4066
4070
 
4067
4071
  ### `testHost`: the built-in plugins and yours, over PostgreSQL
4068
4072
 
@@ -4767,6 +4771,7 @@ Import from the entries listed below; source area files are internal.
4767
4771
  | `fakeDiscordPoster` | `pi-roundtable/testing` | value |
4768
4772
  | `FakeDiscordPoster` | `pi-roundtable/testing` | type |
4769
4773
  | `FakePosterChannel` | `pi-roundtable/testing` | type |
4774
+ | `FakeForum` | `pi-roundtable/testing` | type |
4770
4775
  | `FakePosterMessage` | `pi-roundtable/testing` | type |
4771
4776
  | `fakePrecheck` | `pi-roundtable/testing` | value |
4772
4777
  | `fakePrechecks` | `pi-roundtable/testing` | value |
@@ -4988,14 +4993,21 @@ Import from the entries listed below; source area files are internal.
4988
4993
  | `CardPicture` | `pi-roundtable/discord` | type |
4989
4994
  | `CardSelect` | `pi-roundtable/discord` | type |
4990
4995
  | `CategoryChannel` | `pi-roundtable/discord` | type |
4996
+ | `ChannelKind` | `pi-roundtable/discord` | type |
4997
+ | `ChannelKindInfo` | `pi-roundtable/discord` | type |
4991
4998
  | `DiscordCard` | `pi-roundtable/discord` | type |
4992
4999
  | `DiscordFailure` | `pi-roundtable/discord` | type |
4993
5000
  | `DiscordMessage` | `pi-roundtable/discord` | type |
4994
5001
  | `DiscordPoster` | `pi-roundtable/discord` | type |
4995
5002
  | `DiscordReason` | `pi-roundtable/discord` | type |
5003
+ | `ForumPost` | `pi-roundtable/discord` | type |
5004
+ | `ForumPostCreated` | `pi-roundtable/discord` | type |
5005
+ | `ForumTag` | `pi-roundtable/discord` | type |
5006
+ | `ForumTagInput` | `pi-roundtable/discord` | type |
4996
5007
  | `PostedMessage` | `pi-roundtable/discord` | type |
4997
5008
  | `PosterFile` | `pi-roundtable/discord` | type |
4998
5009
  | `RecentMessage` | `pi-roundtable/discord` | type |
5010
+ | `ThreadAutoArchive` | `pi-roundtable/discord` | type |
4999
5011
  | `ThreadCloser` | `pi-roundtable/discord` | type |
5000
5012
  | `ThreadState` | `pi-roundtable/discord` | type |
5001
5013
  | `WebhookCardPost` | `pi-roundtable/discord` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.30.3",
3
+ "version": "0.31.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",
@@ -76,6 +76,14 @@ export interface InboundMessage {
76
76
  */
77
77
  space?: string;
78
78
  isDirect: boolean;
79
+ /**
80
+ * Set when the message was written in a thread (on Discord a thread of a text channel or a post
81
+ * of a forum); absent in a channel and in a direct conversation. `parentId` is the thread's
82
+ * parent channel and `parentKind` what it is: a post of a forum has `"forum"`, so a claim that
83
+ * answers in a thread by starting one does not start another inside it. `"other"` is any parent
84
+ * that is neither a text channel nor a forum, and also a parent the surface could not read.
85
+ */
86
+ thread?: { parentId: string; parentKind: "text" | "forum" | "other" };
79
87
  /**
80
88
  * Set when an integration posted the message, such as a Discord webhook; its name is
81
89
  * `authorName`. `own` says the integration is one the assistant posts through itself, such as an
@@ -1,4 +1,4 @@
1
- import { type Message, MessageReferenceType } from "discord.js";
1
+ import { ChannelType, type Message, MessageReferenceType } from "discord.js";
2
2
  import type { InboundMessage } from "../contract/channels.ts";
3
3
  import { channelKey } from "../contract/surface.ts";
4
4
  import type { AttachmentRef } from "../domain/attachment.ts";
@@ -36,6 +36,26 @@ function referenceOf(referenced: Message, mention: RegExp) {
36
36
  };
37
37
  }
38
38
 
39
+ /** What a thread's parent is: a text channel, a forum, or something else (also a parent that cannot be read). */
40
+ async function threadOf(
41
+ message: Message,
42
+ ): Promise<InboundMessage["thread"] | undefined> {
43
+ const { channel } = message;
44
+ if (!channel.isThread() || !channel.parentId) return undefined;
45
+ const parent =
46
+ channel.parent ??
47
+ (await channel.guild.channels.fetch(channel.parentId).catch(() => null));
48
+ return {
49
+ parentId: channel.parentId,
50
+ parentKind:
51
+ parent?.type === ChannelType.GuildText
52
+ ? "text"
53
+ : parent?.type === ChannelType.GuildForum
54
+ ? "forum"
55
+ : "other",
56
+ };
57
+ }
58
+
39
59
  /**
40
60
  * The facts of one Discord message, as the neutral `InboundMessage`; undefined for the bot's own
41
61
  * messages and before the client is ready. A forward reports the channel it came from as a
@@ -66,6 +86,7 @@ export async function toInbound(
66
86
  const roleIds = message.member
67
87
  ? [...message.member.roles.cache.keys()]
68
88
  : undefined;
89
+ const thread = await threadOf(message);
69
90
  return {
70
91
  channel: channelKey("discord", message.channelId),
71
92
  messageId: message.id,
@@ -97,6 +118,7 @@ export async function toInbound(
97
118
  }
98
119
  : {}),
99
120
  isDirect: message.channel.isDMBased(),
121
+ ...(thread ? { thread } : {}),
100
122
  ...(message.guildId ? { space: message.guildId } : {}),
101
123
  mentionsBot: message.mentions.users.has(botId),
102
124
  repliesToBot: referenced?.author.id === botId,
@@ -0,0 +1,160 @@
1
+ import {
2
+ type CardPicture,
3
+ DISCORD_LIMITS,
4
+ type DiscordCard,
5
+ } from "./poster.ts";
6
+ import { invalid } from "./poster-errors.ts";
7
+
8
+ /** The longest picture address a post carries. */
9
+ const AVATAR_URL_MAX = 2_048;
10
+
11
+ /** The longest address Discord accepts on a link button. */
12
+ const LINK_BUTTON_URL_MAX = 512;
13
+
14
+ /** The longest alt text Discord takes for a thumbnail or a gallery item. */
15
+ const PICTURE_DESCRIPTION_MAX = 1_024;
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
+ export function isHttps(url: string | undefined): url is string {
28
+ if (url === undefined || url.length > AVATAR_URL_MAX) return false;
29
+ try {
30
+ return new URL(url).protocol === "https:";
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /** The checked wire form of a card's picture: an `https` address, and an alt text of 1 to 1,024 characters when given. */
37
+ export function checkPicture(label: string, picture: CardPicture) {
38
+ if (!(isHttps(picture.url) && picture.url === picture.url.trim()))
39
+ throw invalid(
40
+ `${label} has an https address of at most ${AVATAR_URL_MAX} characters`,
41
+ );
42
+ const { description } = picture;
43
+ if (
44
+ description !== undefined &&
45
+ (description === "" || [...description].length > PICTURE_DESCRIPTION_MAX)
46
+ )
47
+ throw invalid(
48
+ `${label} has a description of 1 to ${PICTURE_DESCRIPTION_MAX} characters`,
49
+ );
50
+ return {
51
+ media: { url: picture.url },
52
+ ...(description === undefined ? {} : { description }),
53
+ };
54
+ }
55
+
56
+ /** Refuses a card whose text would not fit one message, so a caller shortens it first. */
57
+ export function checkCardTextSize(texts: readonly string[]): void {
58
+ const size = texts.reduce((sum, text) => sum + [...text].length, 0);
59
+ if (size > DISCORD_LIMITS.cardTextChars)
60
+ throw invalid(
61
+ `a card holds at most ${DISCORD_LIMITS.cardTextChars} characters of text; shorten it first`,
62
+ );
63
+ if (texts.some((text) => text.trim() === ""))
64
+ throw invalid("a card has no empty text");
65
+ }
66
+
67
+ /**
68
+ * The checked action rows of a card, in Discord's wire form: a row for each menu, then one row of
69
+ * the buttons and link buttons. A Components V2 container and a legacy message hold the same rows.
70
+ */
71
+ export function cardRows(card: DiscordCard) {
72
+ const buttons = card.buttons ?? [];
73
+ const links = card.links ?? [];
74
+ if (buttons.length + links.length > DISCORD_LIMITS.cardButtons)
75
+ throw invalid(`a card holds at most ${DISCORD_LIMITS.cardButtons} buttons`);
76
+ for (const link of links) {
77
+ if (link.label === "" || [...link.label].length > 80)
78
+ throw invalid("a link button has a label of 1 to 80 characters");
79
+ if (
80
+ !link.url.startsWith("https://") ||
81
+ [...link.url].length > LINK_BUTTON_URL_MAX
82
+ )
83
+ throw invalid(
84
+ `a link button has an https address of at most ${LINK_BUTTON_URL_MAX} characters`,
85
+ );
86
+ }
87
+ const selects = card.selects ?? [];
88
+ for (const select of selects) {
89
+ if (
90
+ select.customId === "" ||
91
+ [...select.customId].length > 100 ||
92
+ select.placeholder === "" ||
93
+ [...select.placeholder].length > 150 ||
94
+ select.options.length < 1 ||
95
+ select.options.length > DISCORD_LIMITS.selectOptions ||
96
+ select.options.some(
97
+ (option) =>
98
+ option.label === "" ||
99
+ [...option.label].length > 100 ||
100
+ option.value === "" ||
101
+ [...option.value].length > 100,
102
+ )
103
+ )
104
+ throw invalid(
105
+ "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",
106
+ );
107
+ }
108
+ for (const button of buttons) {
109
+ if (
110
+ button.customId === "" ||
111
+ [...button.customId].length > 100 ||
112
+ button.label === "" ||
113
+ [...button.label].length > 80
114
+ )
115
+ throw invalid(
116
+ "a button has an id of 1 to 100 characters and a label of 1 to 80",
117
+ );
118
+ }
119
+ return [
120
+ ...selects.map((select) => ({
121
+ type: 1,
122
+ components: [
123
+ {
124
+ type: 3,
125
+ custom_id: select.customId,
126
+ placeholder: select.placeholder,
127
+ min_values: 1,
128
+ max_values: 1,
129
+ options: select.options.map((option) => ({
130
+ label: option.label,
131
+ value: option.value,
132
+ ...(option.selected ? { default: true } : {}),
133
+ })),
134
+ },
135
+ ],
136
+ })),
137
+ ...(buttons.length + links.length > 0
138
+ ? [
139
+ {
140
+ type: 1,
141
+ components: [
142
+ ...buttons.map((button) => ({
143
+ type: 2,
144
+ style: BUTTON_STYLES[button.style],
145
+ label: button.label,
146
+ custom_id: button.customId,
147
+ ...(button.disabled ? { disabled: true } : {}),
148
+ })),
149
+ ...links.map((link) => ({
150
+ type: 2,
151
+ style: LINK_BUTTON_STYLE,
152
+ label: link.label,
153
+ url: link.url,
154
+ })),
155
+ ],
156
+ },
157
+ ]
158
+ : []),
159
+ ];
160
+ }
@@ -1,41 +1,26 @@
1
1
  import {
2
- type CardPicture,
3
2
  DISCORD_LIMITS,
4
3
  type DiscordCard,
5
4
  type WebhookCardPost,
6
5
  type WebhookPost,
7
6
  } from "./poster.ts";
8
- import { DiscordPosterError } from "./poster-errors.ts";
7
+ import {
8
+ cardRows,
9
+ checkCardTextSize,
10
+ checkPicture,
11
+ isHttps,
12
+ } from "./poster-card-parts.ts";
13
+ import { invalid } from "./poster-errors.ts";
14
+ import { legacyCardBody } from "./poster-legacy-card.ts";
15
+
16
+ export { invalid };
9
17
 
10
18
  /** The flag that makes a message a Components V2 message. */
11
19
  export const COMPONENTS_V2_FLAG = 1 << 15;
12
20
 
13
- /** The longest picture address a post carries. */
14
- const AVATAR_URL_MAX = 2_048;
15
-
16
- /** The longest address Discord accepts on a link button. */
17
- const LINK_BUTTON_URL_MAX = 512;
18
-
19
- /** The longest alt text Discord takes for a thumbnail or a gallery item. */
20
- const PICTURE_DESCRIPTION_MAX = 1_024;
21
-
22
- /** Discord's style number for a button that opens an address. */
23
- const LINK_BUTTON_STYLE = 5;
24
-
25
- const BUTTON_STYLES = {
26
- primary: 1,
27
- secondary: 2,
28
- success: 3,
29
- danger: 4,
30
- } as const;
31
-
32
21
  const SNOWFLAKE = /^[0-9]{17,20}$/;
33
22
  const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
34
23
 
35
- export function invalid(message: string): DiscordPosterError {
36
- return new DiscordPosterError("invalid", message);
37
- }
38
-
39
24
  /** Refuses an id that is not a Discord id before it is put in a route. */
40
25
  export function checkSnowflake(label: string, value: string): string {
41
26
  if (!SNOWFLAKE.test(value))
@@ -140,35 +125,6 @@ export function checkedPost(post: WebhookPost) {
140
125
  };
141
126
  }
142
127
 
143
- /** The checked wire form of a card's picture: an `https` address, and an alt text of 1 to 1,024 characters when given. */
144
- function checkPicture(label: string, picture: CardPicture) {
145
- if (!(isHttps(picture.url) && picture.url === picture.url.trim()))
146
- throw invalid(
147
- `${label} has an https address of at most ${AVATAR_URL_MAX} characters`,
148
- );
149
- const { description } = picture;
150
- if (
151
- description !== undefined &&
152
- (description === "" || [...description].length > PICTURE_DESCRIPTION_MAX)
153
- )
154
- throw invalid(
155
- `${label} has a description of 1 to ${PICTURE_DESCRIPTION_MAX} characters`,
156
- );
157
- return {
158
- media: { url: picture.url },
159
- ...(description === undefined ? {} : { description }),
160
- };
161
- }
162
-
163
- function isHttps(url: string | undefined): url is string {
164
- if (url === undefined || url.length > AVATAR_URL_MAX) return false;
165
- try {
166
- return new URL(url).protocol === "https:";
167
- } catch {
168
- return false;
169
- }
170
- }
171
-
172
128
  /** Whether the bytes are a PNG no larger than a webhook avatar may be. */
173
129
  export function checkAvatar(png: Uint8Array): void {
174
130
  if (png.byteLength > DISCORD_LIMITS.avatarBytes)
@@ -189,22 +145,19 @@ export function checkChannelName(label: string, name: string): void {
189
145
  }
190
146
 
191
147
  /**
192
- * The body of a card: a container (with a colour bar when `accent` is set) holding the heading, a
193
- * divider, one text block per section and a quiet line at the end. It refuses a card whose text
194
- * would not fit one message, so a caller shortens it first. No mention in it pings.
148
+ * The body of a card. A card with a `preview` is a legacy message (see `legacyCardBody`); any
149
+ * other is a Components V2 message: a container (with a colour bar when `accent` is set) holding
150
+ * the heading, a divider, one text block per section and a quiet line at the end. It refuses a
151
+ * card whose text would not fit one message, so a caller shortens it first. No mention in it pings.
195
152
  */
196
153
  export function cardBody(card: DiscordCard) {
154
+ if (card.preview !== undefined)
155
+ return legacyCardBody({ ...card, preview: card.preview });
197
156
  const heading = `### ${card.title}`;
198
157
  const main = [heading, ...card.sections.filter((section) => section !== "")];
199
158
  const footer = card.footer ? [`-# ${card.footer}`] : [];
200
159
  const texts = [...main, ...footer];
201
- const size = texts.reduce((sum, text) => sum + [...text].length, 0);
202
- if (size > DISCORD_LIMITS.cardTextChars)
203
- throw invalid(
204
- `a card holds at most ${DISCORD_LIMITS.cardTextChars} characters of text; shorten it first`,
205
- );
206
- if (texts.some((text) => text.trim() === ""))
207
- throw invalid("a card has no empty text");
160
+ checkCardTextSize(texts);
208
161
  const [first, ...rest] = main;
209
162
  const thumbnail = card.thumbnail
210
163
  ? checkPicture("a card's thumbnail", card.thumbnail)
@@ -212,53 +165,6 @@ export function cardBody(card: DiscordCard) {
212
165
  const image = card.image
213
166
  ? checkPicture("a card's image", card.image)
214
167
  : undefined;
215
- const buttons = card.buttons ?? [];
216
- const links = card.links ?? [];
217
- if (buttons.length + links.length > DISCORD_LIMITS.cardButtons)
218
- throw invalid(`a card holds at most ${DISCORD_LIMITS.cardButtons} buttons`);
219
- for (const link of links) {
220
- if (link.label === "" || [...link.label].length > 80)
221
- throw invalid("a link button has a label of 1 to 80 characters");
222
- if (
223
- !link.url.startsWith("https://") ||
224
- [...link.url].length > LINK_BUTTON_URL_MAX
225
- )
226
- throw invalid(
227
- `a link button has an https address of at most ${LINK_BUTTON_URL_MAX} characters`,
228
- );
229
- }
230
- const selects = card.selects ?? [];
231
- for (const select of selects) {
232
- if (
233
- select.customId === "" ||
234
- [...select.customId].length > 100 ||
235
- select.placeholder === "" ||
236
- [...select.placeholder].length > 150 ||
237
- select.options.length < 1 ||
238
- select.options.length > DISCORD_LIMITS.selectOptions ||
239
- select.options.some(
240
- (option) =>
241
- option.label === "" ||
242
- [...option.label].length > 100 ||
243
- option.value === "" ||
244
- [...option.value].length > 100,
245
- )
246
- )
247
- throw invalid(
248
- "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",
249
- );
250
- }
251
- for (const button of buttons) {
252
- if (
253
- button.customId === "" ||
254
- [...button.customId].length > 100 ||
255
- button.label === "" ||
256
- [...button.label].length > 80
257
- )
258
- throw invalid(
259
- "a button has an id of 1 to 100 characters and a label of 1 to 80",
260
- );
261
- }
262
168
  return {
263
169
  flags: COMPONENTS_V2_FLAG,
264
170
  components: [
@@ -279,45 +185,7 @@ export function cardBody(card: DiscordCard) {
279
185
  ...rest.map((content) => ({ type: 10, content })),
280
186
  ...(image ? [{ type: 12, items: [image] }] : []),
281
187
  ...footer.map((content) => ({ type: 10, content })),
282
- ...selects.map((select) => ({
283
- type: 1,
284
- components: [
285
- {
286
- type: 3,
287
- custom_id: select.customId,
288
- placeholder: select.placeholder,
289
- min_values: 1,
290
- max_values: 1,
291
- options: select.options.map((option) => ({
292
- label: option.label,
293
- value: option.value,
294
- ...(option.selected ? { default: true } : {}),
295
- })),
296
- },
297
- ],
298
- })),
299
- ...(buttons.length + links.length > 0
300
- ? [
301
- {
302
- type: 1,
303
- components: [
304
- ...buttons.map((button) => ({
305
- type: 2,
306
- style: BUTTON_STYLES[button.style],
307
- label: button.label,
308
- custom_id: button.customId,
309
- ...(button.disabled ? { disabled: true } : {}),
310
- })),
311
- ...links.map((link) => ({
312
- type: 2,
313
- style: LINK_BUTTON_STYLE,
314
- label: link.label,
315
- url: link.url,
316
- })),
317
- ],
318
- },
319
- ]
320
- : []),
188
+ ...cardRows(card),
321
189
  ],
322
190
  },
323
191
  ],
@@ -11,9 +11,13 @@ export type DiscordFailure =
11
11
 
12
12
  /**
13
13
  * More about why Discord refused, where its code alone does not say: a category that is full (a
14
- * 400 with a form-body error) and a channel that is no category or in no right guild.
14
+ * 400 with a form-body error), a channel that is no category or forum, and one in no right guild.
15
15
  */
16
- export type DiscordReason = "category_full" | "not_category" | "wrong_guild";
16
+ export type DiscordReason =
17
+ | "category_full"
18
+ | "not_category"
19
+ | "not_forum" // a forum call on a channel that is no forum, or a post call on a thread that is no forum post
20
+ | "wrong_guild";
17
21
 
18
22
  /**
19
23
  * A failed Discord call. The message names the operation and Discord's own reason, never a URL,
@@ -104,3 +108,8 @@ export function failure(operation: string, error: unknown): DiscordPosterError {
104
108
  export function isCode(error: unknown, ...codes: number[]): boolean {
105
109
  return error instanceof DiscordAPIError && codes.includes(Number(error.code));
106
110
  }
111
+
112
+ /** A call refused before it was sent, for an argument Discord would refuse. */
113
+ export function invalid(message: string): DiscordPosterError {
114
+ return new DiscordPosterError("invalid", message);
115
+ }