pi-roundtable 0.31.1 → 0.31.3
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 +17 -0
- package/docs/plugins.md +8 -1
- package/package.json +1 -1
- package/src/core/contract/channels.ts +7 -0
- package/src/core/discord/inbound-message.ts +2 -0
- package/src/core/discord/poster/poster-card-parts.ts +32 -0
- package/src/core/discord/poster/poster-checks.ts +6 -1
- package/src/core/discord/poster/poster-legacy-card.ts +4 -0
- package/src/core/discord/poster/poster.ts +11 -2
- package/src/core/discord/poster/rest-poster.ts +60 -25
- package/src/core/routing/channel-router.ts +8 -0
- package/src/core/routing/peer-bots.ts +34 -2
- package/src/core/testing/fake-poster-cards.ts +10 -4
- package/src/core/testing/fake-poster.ts +5 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.31.3] - 2026-11-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `DiscordCard.titleUrl`: an absolute `http` or `https` address (at most 2,048 characters, else `invalid`) the card's title links to, so a renderer passes the title as plain text in both kinds. A card with a `preview` sends it as the embed's `url` (Discord does not render Markdown in an embed title); a Components V2 card writes its heading as a Markdown link whose text is the title, with `\`, `[` and `]` in the title escaped. Non-ASCII characters are percent-encoded and so are `(` and `)`. Absent, a card is sent exactly as before.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Editing a Components V2 card with a `preview` set no longer fails: `editCard` and `editWebhookCard` send the legacy body and, when Discord refuses it as an invalid form (`rejected`, code 50035), send the card once more as Components V2 without the preview, so the message keeps its kind (a second request; if that is `rejected` too the first error is thrown, and any other failure of the retry, such as `unavailable` or `message_gone`, is thrown as it is). A preview card edited without `preview` is still `rejected`. `fakeDiscordPoster` behaves the same way. This lets a renderer set `preview` on every card edit, including for cards posted before it did.
|
|
17
|
+
|
|
18
|
+
## [0.31.2] - 2026-11-02
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `PeerBotEntry.yields`: a host leaves to a listed peer bot a person's message that addresses both. When a person mentions or replies to the host and in the same message mentions that bot or replies to its message, where the entry lists the bot and not in a direct conversation, the host starts no turn and logs `message left to a peer bot`; observers still hear the message. Only the peer answers, and it hands over by mentioning the host, whose answer to a listed peer is unchanged. Set it on one side only: two hosts that yield to each other both stay silent.
|
|
23
|
+
- `InboundMessage.mentionedUserIds?` (every user the message mentions, the bot among them) and `InboundMessage.reference.authorId?` (who wrote the replied-to message), both set by the Discord surface.
|
|
24
|
+
|
|
8
25
|
## [0.31.1] - 2026-11-01
|
|
9
26
|
|
|
10
27
|
- Release in lockstep with pi-roundtable-web 0.31.1 (the animated scene's progress strip names the member at work and folds to one line); no core behavior changes.
|
package/docs/plugins.md
CHANGED
|
@@ -2868,6 +2868,12 @@ The router, not each claim, applies the list: for a message from a listed bot it
|
|
|
2868
2868
|
A channel answers at most `maxRounds` of the listed bots' messages in a row, counted when a claim takes the message; a person's message in the channel starts the count over, and the messages after the cap are ignored (logged at info).
|
|
2869
2869
|
Channel context is unchanged: its window still starts after the assistant's last post and other bots' messages are still cut at `botMessageChars`.
|
|
2870
2870
|
|
|
2871
|
+
When a person addresses two bots in one message, both would answer. An entry with `yields: true` makes the host leave such a message to that peer: a person's message (not in a direct conversation) that mentions or replies to the host and also mentions the peer or replies to its message, in a channel the entry covers, starts no turn here (logged at info as `message left to a peer bot`; observers still hear it). The peer answers alone and hands over by mentioning the host, which answers a listed peer as usual. Set it on one host only, and scope the entry with `guilds` or `channels` to where the peer actually answers; two hosts that yield to each other both stay silent. The router reads `InboundMessage.mentionedUserIds` (every user the message mentions) and `InboundMessage.reference.authorId` (who wrote the replied-to message), which the Discord surface sets.
|
|
2872
|
+
|
|
2873
|
+
```ts
|
|
2874
|
+
peerBots: { allow: [{ id: "800000000000000002", channels: ["700000000000000001"], yields: true }] },
|
|
2875
|
+
```
|
|
2876
|
+
|
|
2871
2877
|
The agent server answers a peer that mentions the agent or replies to it, like a guest. A listed peer's `speaker` is resolved like a person's, so a claim that checks only `speaker` answers it too. A claim of your own opts in where it once checked `!message.authorIsBot`, with `answersAuthor(message)` from `pi-roundtable/kit`, which is true for a person and for a flagged peer that mentions the bot or replies to it:
|
|
2872
2878
|
|
|
2873
2879
|
```ts
|
|
@@ -2914,7 +2920,8 @@ The rules the poster keeps, so a plugin does not rediscover them:
|
|
|
2914
2920
|
- **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
2921
|
- **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
2922
|
- **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
|
|
2923
|
+
- **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, and it does not read the message before an edit. An edit of a Components V2 card with a `preview` therefore stays a Components V2 card and the preview is left out (a Components V2 message has no `content`): the poster sends the legacy body, and when Discord refuses it as an invalid form (`rejected`, code 50035) it sends the card once more as Components V2 without `preview`; that makes a second request. When the retry is `rejected` too the first error is thrown; any other failure of the retry (`unavailable`, `forbidden`, `message_gone`) is thrown as it is. This lets one renderer always set `preview` on an edit, including for cards posted before it did. The other way is not repaired: a preview card edited without `preview` is `rejected` (delete it and post a new one, which changes the message id). An edit that leaves out the buttons removes them.
|
|
2924
|
+
- **A card's title can link.** `DiscordCard.titleUrl` is an absolute `http` or `https` address (at most 2,048 characters; anything else is `invalid`, before any request) that `title` links to, so `title` stays plain text in both kinds. A preview card sends it as the embed's `url`, because Discord does not render Markdown in an embed title. A Components V2 card writes its heading as a Markdown link whose text is the title (after `### `), with `\`, `[` and `]` in the title escaped (other Markdown in the title works as without `titleUrl`). The address is normalised: non-ASCII characters are percent-encoded (the host as punycode), and `(` and `)` become `%28` and `%29` so they cannot end the link. Without `titleUrl` a card is sent exactly as before; an edit without it removes the link.
|
|
2918
2925
|
- **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.
|
|
2919
2926
|
|
|
2920
2927
|
```ts
|
package/package.json
CHANGED
|
@@ -91,6 +91,11 @@ export interface InboundMessage {
|
|
|
91
91
|
*/
|
|
92
92
|
integration?: { id: string; own: boolean };
|
|
93
93
|
mentionsBot: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Every user the message mentions, the bot among them, in the surface's ids; absent when the
|
|
96
|
+
* surface does not report them. The router reads it for `peerBots` entries that yield.
|
|
97
|
+
*/
|
|
98
|
+
mentionedUserIds?: readonly string[];
|
|
94
99
|
/** The message replies to one of the bot's messages. */
|
|
95
100
|
repliesToBot: boolean;
|
|
96
101
|
/** Message text with the bot mention removed. */
|
|
@@ -100,6 +105,8 @@ export interface InboundMessage {
|
|
|
100
105
|
reference?: {
|
|
101
106
|
text: string;
|
|
102
107
|
attachments: AttachmentRef[];
|
|
108
|
+
/** The surface's id of the referenced message's author, when the surface reports it. */
|
|
109
|
+
authorId?: string;
|
|
103
110
|
/** The name a webhook posted the referenced message under, such as an agent's. */
|
|
104
111
|
webhookName?: string;
|
|
105
112
|
};
|
|
@@ -30,6 +30,7 @@ function referenceOf(referenced: Message, mention: RegExp) {
|
|
|
30
30
|
.replace(mention, "")
|
|
31
31
|
.trim(),
|
|
32
32
|
attachments: attachmentRefs(referenced),
|
|
33
|
+
authorId: referenced.author.id,
|
|
33
34
|
...(referenced.webhookId
|
|
34
35
|
? { webhookName: referenced.author.username }
|
|
35
36
|
: {}),
|
|
@@ -121,6 +122,7 @@ export async function toInbound(
|
|
|
121
122
|
...(thread ? { thread } : {}),
|
|
122
123
|
...(message.guildId ? { space: message.guildId } : {}),
|
|
123
124
|
mentionsBot: message.mentions.users.has(botId),
|
|
125
|
+
mentionedUserIds: [...message.mentions.users.keys()],
|
|
124
126
|
repliesToBot: referenced?.author.id === botId,
|
|
125
127
|
text: withEmbeds(message.content, message.embeds)
|
|
126
128
|
.replace(mention, "")
|
|
@@ -33,6 +33,38 @@ export function isHttps(url: string | undefined): url is string {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The checked, normalised address of a card's `titleUrl`: absolute `http` or `https`, at most 2,048
|
|
38
|
+
* characters once normalised. Non-ASCII characters are percent-encoded (the host as punycode), and
|
|
39
|
+
* so are `(` and `)`, which would end a Markdown link early.
|
|
40
|
+
*/
|
|
41
|
+
export function checkTitleUrl(url: string): string {
|
|
42
|
+
let href: string;
|
|
43
|
+
try {
|
|
44
|
+
const parsed = new URL(url);
|
|
45
|
+
if (
|
|
46
|
+
url !== url.trim() ||
|
|
47
|
+
!(parsed.protocol === "https:" || parsed.protocol === "http:")
|
|
48
|
+
)
|
|
49
|
+
throw new Error("not http(s)");
|
|
50
|
+
href = parsed.href.replace(/\(/g, "%28").replace(/\)/g, "%29");
|
|
51
|
+
} catch {
|
|
52
|
+
throw invalid(
|
|
53
|
+
`a card's titleUrl is an absolute http or https address of at most ${AVATAR_URL_MAX} characters`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (href.length > AVATAR_URL_MAX)
|
|
57
|
+
throw invalid(
|
|
58
|
+
`a card's titleUrl is an absolute http or https address of at most ${AVATAR_URL_MAX} characters`,
|
|
59
|
+
);
|
|
60
|
+
return href;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A card title as the label of a Markdown link: `\`, `[` and `]` escaped, so the label cannot end the link early. */
|
|
64
|
+
export function linkLabel(title: string): string {
|
|
65
|
+
return title.replace(/[\\[\]]/g, "\\$&");
|
|
66
|
+
}
|
|
67
|
+
|
|
36
68
|
/** 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
69
|
export function checkPicture(label: string, picture: CardPicture) {
|
|
38
70
|
if (!(isHttps(picture.url) && picture.url === picture.url.trim()))
|
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
cardRows,
|
|
9
9
|
checkCardTextSize,
|
|
10
10
|
checkPicture,
|
|
11
|
+
checkTitleUrl,
|
|
11
12
|
isHttps,
|
|
13
|
+
linkLabel,
|
|
12
14
|
} from "./poster-card-parts.ts";
|
|
13
15
|
import { invalid } from "./poster-errors.ts";
|
|
14
16
|
import { legacyCardBody } from "./poster-legacy-card.ts";
|
|
@@ -153,7 +155,10 @@ export function checkChannelName(label: string, name: string): void {
|
|
|
153
155
|
export function cardBody(card: DiscordCard) {
|
|
154
156
|
if (card.preview !== undefined)
|
|
155
157
|
return legacyCardBody({ ...card, preview: card.preview });
|
|
156
|
-
const heading =
|
|
158
|
+
const heading =
|
|
159
|
+
card.titleUrl === undefined
|
|
160
|
+
? `### ${card.title}`
|
|
161
|
+
: `### [${linkLabel(card.title)}](${checkTitleUrl(card.titleUrl)})`;
|
|
157
162
|
const main = [heading, ...card.sections.filter((section) => section !== "")];
|
|
158
163
|
const footer = card.footer ? [`-# ${card.footer}`] : [];
|
|
159
164
|
const texts = [...main, ...footer];
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
cardRows,
|
|
4
4
|
checkCardTextSize,
|
|
5
5
|
checkPicture,
|
|
6
|
+
checkTitleUrl,
|
|
6
7
|
} from "./poster-card-parts.ts";
|
|
7
8
|
import { invalid } from "./poster-errors.ts";
|
|
8
9
|
|
|
@@ -61,6 +62,8 @@ export function legacyCardBody(card: DiscordCard & { preview: string }) {
|
|
|
61
62
|
const image = card.image
|
|
62
63
|
? checkPicture("a card's image", card.image)
|
|
63
64
|
: undefined;
|
|
65
|
+
const url =
|
|
66
|
+
card.titleUrl === undefined ? undefined : checkTitleUrl(card.titleUrl);
|
|
64
67
|
const components = cardRows(card);
|
|
65
68
|
if (components.length > DISCORD_LIMITS.actionRows)
|
|
66
69
|
throw invalid(
|
|
@@ -71,6 +74,7 @@ export function legacyCardBody(card: DiscordCard & { preview: string }) {
|
|
|
71
74
|
embeds: [
|
|
72
75
|
{
|
|
73
76
|
title: card.title,
|
|
77
|
+
...(url ? { url } : {}),
|
|
74
78
|
...(sections.length > 0 ? { description } : {}),
|
|
75
79
|
...(card.accent === undefined ? {} : { color: card.accent }),
|
|
76
80
|
...(card.footer ? { footer: { text: card.footer } } : {}),
|
|
@@ -109,13 +109,22 @@ export interface DiscordCard {
|
|
|
109
109
|
* rows for `buttons`, `links` and `selects` (at most five rows: one per menu, one for the
|
|
110
110
|
* buttons). An embed has no alt text, so a picture's `description` is dropped. `editCard` and
|
|
111
111
|
* `editWebhookCard` re-send the card (and an edit sets `content` too), but the poster cannot
|
|
112
|
-
* change a message's kind
|
|
113
|
-
*
|
|
112
|
+
* change a message's kind. An edit with a `preview` of a message that is a Components V2 card
|
|
113
|
+
* keeps it a Components V2 card and leaves the `preview` out (the message has no `content`
|
|
114
|
+
* to put it in); an edit without `preview` of a preview card is `rejected`.
|
|
114
115
|
*/
|
|
115
116
|
preview?: string;
|
|
116
117
|
/** The colour of the bar along the side, as 0xRRGGBB. */
|
|
117
118
|
accent?: number;
|
|
118
119
|
title: string;
|
|
120
|
+
/**
|
|
121
|
+
* An absolute `http` or `https` address (at most 2,048 characters, else `invalid`) the title links
|
|
122
|
+
* to, so `title` stays plain text in both kinds: the embed's `url` for a card with a `preview`
|
|
123
|
+
* (Discord does not render Markdown in an embed title), and a masked Markdown link in
|
|
124
|
+
* a Components V2 card's heading, with `\`, `[` and `]` in the title escaped. Non-ASCII
|
|
125
|
+
* characters are percent-encoded and so are `(` and `)`. Absent, the title is plain text as before.
|
|
126
|
+
*/
|
|
127
|
+
titleUrl?: string;
|
|
119
128
|
sections: readonly string[];
|
|
120
129
|
/** A small picture beside the heading (a Section with a Thumbnail); the heading is the section's text. */
|
|
121
130
|
thumbnail?: CardPicture;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RESTJSONErrorCodes, Routes } from "discord.js";
|
|
2
|
-
import type { DiscordPoster, PosterFile } from "./poster.ts";
|
|
2
|
+
import type { DiscordCard, DiscordPoster, PosterFile } from "./poster.ts";
|
|
3
3
|
import {
|
|
4
4
|
cardBody,
|
|
5
5
|
checkAvatar,
|
|
@@ -179,6 +179,36 @@ export function restPoster(
|
|
|
179
179
|
}
|
|
180
180
|
};
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Sends a card edit. A message cannot change its kind, and the poster does not read the message
|
|
184
|
+
* first: when an edit of a card with a `preview` is `rejected` as an invalid form (Discord's 50035,
|
|
185
|
+
* what a change of kind answers), it is sent once more as a Components V2 card without the preview
|
|
186
|
+
* (the message was one). When that is `rejected` too the first error is thrown; any other failure
|
|
187
|
+
* of the retry (`unavailable`, `forbidden`, `message_gone`) is thrown as it is.
|
|
188
|
+
*/
|
|
189
|
+
const keepingKind = async (
|
|
190
|
+
card: DiscordCard,
|
|
191
|
+
send: (card: DiscordCard) => Promise<void>,
|
|
192
|
+
): Promise<void> => {
|
|
193
|
+
try {
|
|
194
|
+
await send(card);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (
|
|
197
|
+
card.preview === undefined ||
|
|
198
|
+
!(error instanceof DiscordPosterError) ||
|
|
199
|
+
error.kind !== "rejected" ||
|
|
200
|
+
error.discordCode !== 50035
|
|
201
|
+
)
|
|
202
|
+
throw error;
|
|
203
|
+
const { preview: _dropped, ...v2 } = card;
|
|
204
|
+
await send(v2).catch((retry: unknown) => {
|
|
205
|
+
throw retry instanceof DiscordPosterError && retry.kind === "rejected"
|
|
206
|
+
? error
|
|
207
|
+
: retry;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
182
212
|
/** Asks each named webhook in turn to act on a message; the first that does it ends the search, and `message_gone` is what is left when none could. */
|
|
183
213
|
const eachWebhook = async (
|
|
184
214
|
operation: string,
|
|
@@ -272,14 +302,16 @@ export function restPoster(
|
|
|
272
302
|
async editWebhookCard(channelId, messageId, card, names) {
|
|
273
303
|
checkSnowflake("a channel", channelId);
|
|
274
304
|
checkSnowflake("a message", messageId);
|
|
275
|
-
|
|
305
|
+
cardBody(card);
|
|
276
306
|
for (const name of names) checkWebhookName(name);
|
|
277
|
-
await
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
307
|
+
await keepingKind(card, (sent) =>
|
|
308
|
+
eachWebhook("editing a card", channelId, names, (hook) =>
|
|
309
|
+
rest.patch(Routes.webhookMessage(hook.id, hook.token, messageId), {
|
|
310
|
+
auth: false,
|
|
311
|
+
body: cardBody(sent),
|
|
312
|
+
query: new URLSearchParams({ with_components: "true" }),
|
|
313
|
+
}),
|
|
314
|
+
),
|
|
283
315
|
);
|
|
284
316
|
},
|
|
285
317
|
async remove(channelId, messageId, names) {
|
|
@@ -365,23 +397,26 @@ export function restPoster(
|
|
|
365
397
|
async editCard(channelId, messageId, card) {
|
|
366
398
|
checkSnowflake("a channel", channelId);
|
|
367
399
|
checkSnowflake("a message", messageId);
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
400
|
+
cardBody(card);
|
|
401
|
+
await keepingKind(card, async (sent) => {
|
|
402
|
+
const body = cardBody(sent);
|
|
403
|
+
const edit = () =>
|
|
404
|
+
rest.patch(Routes.channelMessage(channelId, messageId), { body });
|
|
405
|
+
try {
|
|
406
|
+
await edit();
|
|
407
|
+
} catch (error) {
|
|
408
|
+
// A forum post's first message has the post's own id. Discord refuses its edit while the
|
|
409
|
+
// post is archived (50083), so the post is opened again and the edit sent once more.
|
|
410
|
+
if (channelId !== messageId || !isCode(error, THREAD_ARCHIVED))
|
|
411
|
+
throw failure("editing a card", error);
|
|
412
|
+
await rest
|
|
413
|
+
.patch(Routes.channel(channelId), { body: { archived: false } })
|
|
414
|
+
.then(edit)
|
|
415
|
+
.catch((again: unknown) => {
|
|
416
|
+
throw failure("editing a card", again);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
});
|
|
385
420
|
},
|
|
386
421
|
async deleteAsBot(channelId, messageId) {
|
|
387
422
|
checkSnowflake("a channel", channelId);
|
|
@@ -131,6 +131,14 @@ export class ChannelRouter implements ConversationPort {
|
|
|
131
131
|
// A bot nobody lists is not heard, as it is not answered; a listed peer is, rounds or not.
|
|
132
132
|
if (!message.authorIsBot || message.peerBot || message.integration)
|
|
133
133
|
this.#options.observers?.notify(message);
|
|
134
|
+
const yieldedTo = peers?.yieldsTo(message);
|
|
135
|
+
if (yieldedTo) {
|
|
136
|
+
this.#options.logger.info(
|
|
137
|
+
{ channel: message.channel, bot: yieldedTo },
|
|
138
|
+
"message left to a peer bot: it also addresses a peer this host yields to",
|
|
139
|
+
);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
134
142
|
const started = await this.#inOrder(message.channel, async () => {
|
|
135
143
|
if (listed && peers?.exhausted(message.channel)) {
|
|
136
144
|
this.#options.logger.info(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
bool,
|
|
2
3
|
either,
|
|
3
4
|
type Field,
|
|
4
5
|
integer,
|
|
@@ -20,6 +21,14 @@ export interface PeerBotEntry {
|
|
|
20
21
|
guilds?: readonly string[];
|
|
21
22
|
/** Answer it only in these channels; left out, in every channel. */
|
|
22
23
|
channels?: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* Leave to this bot a person's message that addresses both: when a person mentions or replies to
|
|
26
|
+
* the host and in the same message mentions this bot or replies to it, the host starts no turn
|
|
27
|
+
* (observers still hear the message), so only this bot answers and it can hand over by mentioning
|
|
28
|
+
* the host. Where `guilds` and `channels` do not list the bot, the host does not yield to it.
|
|
29
|
+
* Set it on one side only: two hosts that yield to each other both stay silent.
|
|
30
|
+
*/
|
|
31
|
+
yields?: boolean;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
/**
|
|
@@ -58,6 +67,7 @@ const entryShape = shape({
|
|
|
58
67
|
id: snowflake,
|
|
59
68
|
guilds: optional(list(snowflake)),
|
|
60
69
|
channels: optional(list(snowflake)),
|
|
70
|
+
yields: optional(bool),
|
|
61
71
|
});
|
|
62
72
|
|
|
63
73
|
/** `discord.peerBots`, or the `peerBots` of the host's options. */
|
|
@@ -93,13 +103,35 @@ export class PeerBots {
|
|
|
93
103
|
/** Whether the host lists the bot that wrote this message, for this channel and server. */
|
|
94
104
|
lists(message: InboundMessage): boolean {
|
|
95
105
|
if (!message.authorIsBot || message.integration) return false;
|
|
106
|
+
return this.#listsHere(message.authorId, message) !== undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The peer bot a person's message addressed to the host is left to: one the host lists here with
|
|
111
|
+
* `yields` that the message also mentions or replies to; undefined when the host answers it.
|
|
112
|
+
*/
|
|
113
|
+
yieldsTo(message: InboundMessage): string | undefined {
|
|
114
|
+
// The peer cannot answer in a direct conversation, so the host never yields one.
|
|
115
|
+
if (message.authorIsBot || message.integration || message.isDirect)
|
|
116
|
+
return undefined;
|
|
117
|
+
if (!message.mentionsBot && !message.repliesToBot) return undefined;
|
|
118
|
+
const addressed = new Set(message.mentionedUserIds ?? []);
|
|
119
|
+
if (message.reference?.authorId) addressed.add(message.reference.authorId);
|
|
120
|
+
for (const id of addressed)
|
|
121
|
+
if (this.#listsHere(id, message)?.yields) return id;
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The bot's entry that covers this message's channel and server, preferring one that yields. */
|
|
126
|
+
#listsHere(botId: string, message: InboundMessage): PeerBotEntry | undefined {
|
|
96
127
|
const { surface, id: channelId } = parseChannelKey(message.channel);
|
|
97
|
-
if (surface !== this.#surface) return
|
|
98
|
-
|
|
128
|
+
if (surface !== this.#surface) return undefined;
|
|
129
|
+
const here = (this.#entries.get(botId) ?? []).filter(
|
|
99
130
|
(entry) =>
|
|
100
131
|
(!entry.guilds || entry.guilds.includes(message.space ?? "")) &&
|
|
101
132
|
(!entry.channels || entry.channels.includes(channelId)),
|
|
102
133
|
);
|
|
134
|
+
return here.find((entry) => entry.yields) ?? here[0];
|
|
103
135
|
}
|
|
104
136
|
|
|
105
137
|
/** Whether the channel has answered its rounds of peer bots since a person last posted. */
|
|
@@ -14,14 +14,20 @@ export function cardContent(card: DiscordCard): string {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* The card a message holds after an edit. A message keeps its kind: an edit with a `preview` of a
|
|
18
|
+
* Components V2 card stays one and drops the preview (as the real poster does), and an edit
|
|
19
|
+
* without one of a preview card is refused.
|
|
19
20
|
*/
|
|
20
|
-
export function
|
|
21
|
-
if (
|
|
21
|
+
export function editedCard(held: DiscordCard, edit: DiscordCard): DiscordCard {
|
|
22
|
+
if (held.preview !== undefined && edit.preview === undefined)
|
|
22
23
|
throw new DiscordPosterError(
|
|
23
24
|
"rejected",
|
|
24
25
|
"editing a card failed: the poster cannot change a message's kind, Components V2 or a preview card",
|
|
25
26
|
{ discordCode: 50035, status: 400 },
|
|
26
27
|
);
|
|
28
|
+
if (held.preview === undefined && edit.preview !== undefined) {
|
|
29
|
+
const { preview: _dropped, ...v2 } = edit;
|
|
30
|
+
return v2;
|
|
31
|
+
}
|
|
32
|
+
return edit;
|
|
27
33
|
}
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "../discord/poster/poster-checks.ts";
|
|
16
16
|
import { DiscordPosterError } from "../discord/poster/poster-errors.ts";
|
|
17
17
|
import { archivedThread } from "../discord/poster/poster-forum.ts";
|
|
18
|
-
import {
|
|
18
|
+
import { cardContent, editedCard } from "./fake-poster-cards.ts";
|
|
19
19
|
import type { FakeForum } from "./fake-poster-forum.ts";
|
|
20
20
|
import type {
|
|
21
21
|
FakePosterChannel,
|
|
@@ -292,9 +292,8 @@ class FakePosterImpl extends FakePosterReads implements FakeDiscordPoster {
|
|
|
292
292
|
"editing a card failed: that message is not a card",
|
|
293
293
|
{ discordCode: 50035, status: 400 },
|
|
294
294
|
);
|
|
295
|
-
|
|
296
|
-
message.
|
|
297
|
-
message.content = cardContent(card);
|
|
295
|
+
message.card = editedCard(message.card, card);
|
|
296
|
+
message.content = cardContent(message.card);
|
|
298
297
|
}
|
|
299
298
|
|
|
300
299
|
async remove(
|
|
@@ -425,9 +424,8 @@ class FakePosterImpl extends FakePosterReads implements FakeDiscordPoster {
|
|
|
425
424
|
"editing a card failed: that message is not a card",
|
|
426
425
|
{ discordCode: 50035, status: 400 },
|
|
427
426
|
);
|
|
428
|
-
|
|
429
|
-
message.
|
|
430
|
-
message.content = cardContent(card);
|
|
427
|
+
message.card = editedCard(message.card, card);
|
|
428
|
+
message.content = cardContent(message.card);
|
|
431
429
|
}
|
|
432
430
|
|
|
433
431
|
async deleteAsBot(channelId: string, messageId: string): Promise<void> {
|