pi-roundtable 0.26.2 → 0.27.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 +53 -0
- package/README.md +23 -0
- package/README.zh-TW.md +31 -8
- package/docs/plugins.md +176 -7
- package/package.json +1 -1
- package/src/core/agents/agent-book.ts +34 -1
- package/src/core/agents/agent-claim.ts +25 -5
- package/src/core/agents/agent-dashboard.ts +23 -2
- package/src/core/agents/agent-messages.ts +28 -10
- package/src/core/agents/agent-ports.ts +10 -0
- package/src/core/agents/agent-prompt.ts +17 -1
- package/src/core/agents/agent-rules.ts +11 -1
- package/src/core/agents/agent-schema.ts +22 -0
- package/src/core/agents/agent-statements.ts +16 -2
- package/src/core/agents/agent-store.ts +30 -20
- package/src/core/agents/agent-team-fake-runtime.ts +48 -0
- package/src/core/agents/agent-team-fixture.ts +32 -44
- package/src/core/agents/agent-team.ts +69 -74
- package/src/core/agents/agent-tool-schema.ts +84 -0
- package/src/core/agents/agent-tools.ts +50 -85
- package/src/core/agents/group-turns.ts +132 -41
- package/src/core/agents/helper-create.ts +180 -0
- package/src/core/agents/helper-ops.ts +33 -0
- package/src/core/agents/helper-test-kit.ts +72 -0
- package/src/core/agents/helper-tools.ts +104 -0
- package/src/core/agents/helper-work.ts +101 -0
- package/src/core/agents/message-target.ts +15 -23
- package/src/core/agents/routes.ts +347 -0
- package/src/core/agents/team-approval.ts +32 -0
- package/src/core/agents/team-editing.ts +97 -32
- package/src/core/agents/team-helpers.ts +216 -0
- package/src/core/agents/team-lifecycle.ts +26 -1
- package/src/core/agents/team-options.ts +8 -0
- package/src/core/agents/team-prompt.ts +75 -0
- package/src/core/agents/team-selection.ts +31 -13
- package/src/core/agents/team-status.ts +6 -0
- package/src/core/agents/team-text.ts +20 -4
- package/src/core/agents/team-turn-types.ts +12 -1
- package/src/core/agents/team-turns.ts +25 -31
- package/src/core/builtin/agent-server.ts +37 -2
- package/src/core/builtin/modules.ts +4 -4
- package/src/core/config/adapters.ts +12 -0
- package/src/core/config/agent-team.ts +2 -0
- package/src/core/config/config-schema.ts +4 -0
- package/src/core/config/config.ts +48 -2
- package/src/core/config/helpers.ts +60 -0
- package/src/core/config/ops.ts +8 -0
- package/src/core/config/routing.ts +296 -0
- package/src/core/contract/channels.ts +73 -2
- package/src/core/contract/surface.ts +31 -1
- package/src/core/define-roundtable.ts +8 -1
- package/src/core/discord/agent-discord.ts +67 -6
- package/src/core/discord/discord-surface.ts +14 -16
- package/src/core/discord/message-changes.ts +66 -0
- package/src/core/discord/poster/poster-checks.ts +62 -8
- package/src/core/discord/poster/poster.ts +41 -0
- package/src/core/discord/poster/rest-poster-read.ts +130 -0
- package/src/core/discord/poster/rest-poster.ts +38 -120
- package/src/core/domain/round.ts +31 -0
- package/src/core/host-registration.ts +15 -0
- package/src/core/host.ts +6 -0
- package/src/core/i18n/conversation.ts +3 -0
- package/src/core/i18n/dashboard.ts +4 -0
- package/src/core/ops/error-reporter.ts +4 -0
- package/src/core/ops/ops-alerts.ts +102 -6
- package/src/core/plugin.ts +17 -0
- package/src/core/registry/contributions.ts +1 -26
- package/src/core/registry/surface-service.ts +41 -0
- package/src/core/routing/channel-router.ts +54 -3
- package/src/core/routing/claim-events.ts +42 -0
- package/src/core/routing/message-observers.ts +86 -0
- package/src/core/routing/peer-bots.ts +149 -0
- package/src/core/routing/surface-port.ts +4 -0
- package/src/core/services.ts +7 -6
- package/src/core/testing/fake-poster-guild.ts +1 -1
- package/src/core/testing/fake-poster-reads.ts +96 -0
- package/src/core/testing/fake-poster.ts +62 -94
- package/src/core/tool-set.snapshot.json +8 -0
- package/src/discord/index.ts +2 -0
- package/src/index.ts +18 -1
- package/src/kit/channels.ts +1 -0
- package/src/kit/index.ts +5 -0
- package/src/kit/ops-alerts.ts +4 -0
- package/src/testing.ts +18 -1
|
@@ -16,7 +16,11 @@ import type {
|
|
|
16
16
|
ChannelContext,
|
|
17
17
|
ChannelContextOptions,
|
|
18
18
|
} from "../contract/channel-context.ts";
|
|
19
|
-
import type {
|
|
19
|
+
import type {
|
|
20
|
+
ChatSurface,
|
|
21
|
+
SurfaceMessageEvents,
|
|
22
|
+
SurfaceThreads,
|
|
23
|
+
} from "../contract/surface.ts";
|
|
20
24
|
import type {
|
|
21
25
|
ChannelKey,
|
|
22
26
|
InboundMessage,
|
|
@@ -42,7 +46,8 @@ import type { ChannelInfo, DiscordConnection } from "./connection.ts";
|
|
|
42
46
|
import { DiscordThreadHost } from "./dispatch-thread-host.ts";
|
|
43
47
|
import { DISCORD_FORMATTING, pickFormatting } from "./formatting.ts";
|
|
44
48
|
import { DEFAULT_FRESH_MARKER, postFreshMarker } from "./fresh-marker.ts";
|
|
45
|
-
import { lateAnswerMessage
|
|
49
|
+
import { lateAnswerMessage } from "./inbound-message.ts";
|
|
50
|
+
import { listenForMessages, reportChanges } from "./message-changes.ts";
|
|
46
51
|
import type { CardChannel, LateTurn } from "./owner-cards.ts";
|
|
47
52
|
import { DiscordOwnerOps } from "./owner-discord.ts";
|
|
48
53
|
import { stopPanel } from "./stop-button.ts";
|
|
@@ -103,7 +108,7 @@ export class DiscordSurface
|
|
|
103
108
|
GatewayIntentBits.DirectMessages,
|
|
104
109
|
GatewayIntentBits.MessageContent,
|
|
105
110
|
],
|
|
106
|
-
partials: [Partials.Channel],
|
|
111
|
+
partials: [Partials.Channel, Partials.Message],
|
|
107
112
|
allowedMentions: { parse: [] },
|
|
108
113
|
});
|
|
109
114
|
|
|
@@ -263,21 +268,14 @@ export class DiscordSurface
|
|
|
263
268
|
onMessage(lateAnswerMessage(turn));
|
|
264
269
|
}
|
|
265
270
|
|
|
266
|
-
async start(
|
|
271
|
+
async start(
|
|
272
|
+
onMessage: (message: InboundMessage) => void,
|
|
273
|
+
events?: SurfaceMessageEvents,
|
|
274
|
+
): Promise<void> {
|
|
267
275
|
const { logger, token } = this.#options;
|
|
268
276
|
this.#onMessage = onMessage;
|
|
269
|
-
this.#client
|
|
270
|
-
|
|
271
|
-
botId: this.#client.user?.id,
|
|
272
|
-
applicationId: this.#client.application?.id,
|
|
273
|
-
})
|
|
274
|
-
.then((inbound) => {
|
|
275
|
-
if (inbound) onMessage(inbound);
|
|
276
|
-
})
|
|
277
|
-
.catch((error: unknown) =>
|
|
278
|
-
logger.error({ err: error }, "inbound message could not be read"),
|
|
279
|
-
);
|
|
280
|
-
});
|
|
277
|
+
if (events) reportChanges(this.#client, events, logger);
|
|
278
|
+
listenForMessages(this.#client, onMessage, logger);
|
|
281
279
|
this.#client.on(Events.InteractionCreate, (interaction) => {
|
|
282
280
|
void this.#dispatch(interaction);
|
|
283
281
|
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type Client, Events } from "discord.js";
|
|
2
|
+
import type { InboundMessage } from "../contract/channels.ts";
|
|
3
|
+
import type { SurfaceMessageEvents } from "../contract/surface.ts";
|
|
4
|
+
import { channelKey } from "../contract/surface.ts";
|
|
5
|
+
import type { Logger } from "../log.ts";
|
|
6
|
+
import { toInbound } from "./inbound-message.ts";
|
|
7
|
+
|
|
8
|
+
/** Hands every message the client sees to `deliver` as the neutral message; the bot's own are left out. */
|
|
9
|
+
export function listenForMessages(
|
|
10
|
+
client: Client,
|
|
11
|
+
deliver: (message: InboundMessage) => void,
|
|
12
|
+
logger: Logger,
|
|
13
|
+
): void {
|
|
14
|
+
client.on(Events.MessageCreate, (message) => {
|
|
15
|
+
toInbound(message, {
|
|
16
|
+
botId: client.user?.id,
|
|
17
|
+
applicationId: client.application?.id,
|
|
18
|
+
})
|
|
19
|
+
.then((inbound) => {
|
|
20
|
+
if (inbound) deliver(inbound);
|
|
21
|
+
})
|
|
22
|
+
.catch((error: unknown) =>
|
|
23
|
+
logger.error({ err: error }, "inbound message could not be read"),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reports deleted and edited messages to the claims. The partial-message setting of the client
|
|
30
|
+
* lets a delete or an edit of a message it never cached arrive; an edit of one is fetched to read
|
|
31
|
+
* it. A delete carries only ids, so it is reported for the bot's own messages too.
|
|
32
|
+
*/
|
|
33
|
+
export function reportChanges(
|
|
34
|
+
client: Client,
|
|
35
|
+
events: SurfaceMessageEvents,
|
|
36
|
+
logger: Logger,
|
|
37
|
+
): void {
|
|
38
|
+
const deleted = (channelId: string, id: string, guildId: string | null) =>
|
|
39
|
+
events.deleted(channelKey("discord", channelId), id, guildId ?? undefined);
|
|
40
|
+
client.on(Events.MessageDelete, (message) =>
|
|
41
|
+
deleted(message.channelId, message.id, message.guildId),
|
|
42
|
+
);
|
|
43
|
+
client.on(Events.MessageBulkDelete, (messages, channel) => {
|
|
44
|
+
for (const id of messages.keys()) deleted(channel.id, id, channel.guildId);
|
|
45
|
+
});
|
|
46
|
+
client.on(Events.MessageUpdate, (before, after) => {
|
|
47
|
+
void (async () => {
|
|
48
|
+
const current = after.partial ? await after.fetch() : after;
|
|
49
|
+
// An unfurled link, a pin or an edit by the bot itself is not what a claim keeps track of.
|
|
50
|
+
if (
|
|
51
|
+
!before.partial &&
|
|
52
|
+
before.content === current.content &&
|
|
53
|
+
before.embeds.length === current.embeds.length &&
|
|
54
|
+
before.attachments.size === current.attachments.size
|
|
55
|
+
)
|
|
56
|
+
return;
|
|
57
|
+
const inbound = await toInbound(current, {
|
|
58
|
+
botId: client.user?.id,
|
|
59
|
+
applicationId: client.application?.id,
|
|
60
|
+
});
|
|
61
|
+
if (inbound) events.edited(inbound);
|
|
62
|
+
})().catch((error: unknown) =>
|
|
63
|
+
logger.warn({ err: error }, "an edited message could not be read"),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type CardPicture,
|
|
2
3
|
DISCORD_LIMITS,
|
|
3
4
|
type DiscordCard,
|
|
5
|
+
type WebhookCardPost,
|
|
4
6
|
type WebhookPost,
|
|
5
7
|
} from "./poster.ts";
|
|
6
8
|
import { DiscordPosterError } from "./poster-errors.ts";
|
|
@@ -14,6 +16,9 @@ const AVATAR_URL_MAX = 2_048;
|
|
|
14
16
|
/** The longest address Discord accepts on a link button. */
|
|
15
17
|
const LINK_BUTTON_URL_MAX = 512;
|
|
16
18
|
|
|
19
|
+
/** The longest alt text Discord takes for a thumbnail or a gallery item. */
|
|
20
|
+
const PICTURE_DESCRIPTION_MAX = 1_024;
|
|
21
|
+
|
|
17
22
|
/** Discord's style number for a button that opens an address. */
|
|
18
23
|
const LINK_BUTTON_STYLE = 5;
|
|
19
24
|
|
|
@@ -93,6 +98,21 @@ export function checkedText(
|
|
|
93
98
|
};
|
|
94
99
|
}
|
|
95
100
|
|
|
101
|
+
/** The checked body of a webhook's card post or edit: the card, and for a post the name and picture it shows. */
|
|
102
|
+
export function checkedWebhookCard(post: WebhookCardPost) {
|
|
103
|
+
const webhook = checkWebhookName(post.webhook);
|
|
104
|
+
const username = checkUsername(post.username);
|
|
105
|
+
const card = cardBody(post.card);
|
|
106
|
+
return {
|
|
107
|
+
webhook,
|
|
108
|
+
body: {
|
|
109
|
+
username,
|
|
110
|
+
...(isHttps(post.avatarUrl) ? { avatar_url: post.avatarUrl } : {}),
|
|
111
|
+
...card,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
96
116
|
/** The checked body of a webhook post, its webhook name, and the files to send with it. */
|
|
97
117
|
export function checkedPost(post: WebhookPost) {
|
|
98
118
|
const webhook = checkWebhookName(post.webhook);
|
|
@@ -120,6 +140,26 @@ export function checkedPost(post: WebhookPost) {
|
|
|
120
140
|
};
|
|
121
141
|
}
|
|
122
142
|
|
|
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
|
+
|
|
123
163
|
function isHttps(url: string | undefined): url is string {
|
|
124
164
|
if (url === undefined || url.length > AVATAR_URL_MAX) return false;
|
|
125
165
|
try {
|
|
@@ -155,11 +195,9 @@ export function checkChannelName(label: string, name: string): void {
|
|
|
155
195
|
*/
|
|
156
196
|
export function cardBody(card: DiscordCard) {
|
|
157
197
|
const heading = `### ${card.title}`;
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
...(card.footer ? [`-# ${card.footer}`] : []),
|
|
162
|
-
];
|
|
198
|
+
const main = [heading, ...card.sections.filter((section) => section !== "")];
|
|
199
|
+
const footer = card.footer ? [`-# ${card.footer}`] : [];
|
|
200
|
+
const texts = [...main, ...footer];
|
|
163
201
|
const size = texts.reduce((sum, text) => sum + [...text].length, 0);
|
|
164
202
|
if (size > DISCORD_LIMITS.cardTextChars)
|
|
165
203
|
throw invalid(
|
|
@@ -167,7 +205,13 @@ export function cardBody(card: DiscordCard) {
|
|
|
167
205
|
);
|
|
168
206
|
if (texts.some((text) => text.trim() === ""))
|
|
169
207
|
throw invalid("a card has no empty text");
|
|
170
|
-
const [first, ...rest] =
|
|
208
|
+
const [first, ...rest] = main;
|
|
209
|
+
const thumbnail = card.thumbnail
|
|
210
|
+
? checkPicture("a card's thumbnail", card.thumbnail)
|
|
211
|
+
: undefined;
|
|
212
|
+
const image = card.image
|
|
213
|
+
? checkPicture("a card's image", card.image)
|
|
214
|
+
: undefined;
|
|
171
215
|
const buttons = card.buttons ?? [];
|
|
172
216
|
const links = card.links ?? [];
|
|
173
217
|
if (buttons.length + links.length > DISCORD_LIMITS.cardButtons)
|
|
@@ -222,9 +266,19 @@ export function cardBody(card: DiscordCard) {
|
|
|
222
266
|
type: 17,
|
|
223
267
|
...(card.accent === undefined ? {} : { accent_color: card.accent }),
|
|
224
268
|
components: [
|
|
225
|
-
|
|
226
|
-
|
|
269
|
+
thumbnail
|
|
270
|
+
? {
|
|
271
|
+
type: 9,
|
|
272
|
+
components: [{ type: 10, content: first }],
|
|
273
|
+
accessory: { type: 11, ...thumbnail },
|
|
274
|
+
}
|
|
275
|
+
: { type: 10, content: first },
|
|
276
|
+
...(texts.length > 1
|
|
277
|
+
? [{ type: 14, divider: true, spacing: 1 }]
|
|
278
|
+
: []),
|
|
227
279
|
...rest.map((content) => ({ type: 10, content })),
|
|
280
|
+
...(image ? [{ type: 12, items: [image] }] : []),
|
|
281
|
+
...footer.map((content) => ({ type: 10, content })),
|
|
228
282
|
...selects.map((select) => ({
|
|
229
283
|
type: 1,
|
|
230
284
|
components: [
|
|
@@ -39,6 +39,17 @@ export interface WebhookPost {
|
|
|
39
39
|
mentions?: readonly string[];
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** A card a channel's webhook posts, under a name and picture the plugin chooses. */
|
|
43
|
+
export interface WebhookCardPost {
|
|
44
|
+
/** The webhook that posts it; the same rule and meaning as `WebhookPost.webhook`. */
|
|
45
|
+
webhook: string;
|
|
46
|
+
/** The name shown for this message; the same rule as `WebhookPost.username`. */
|
|
47
|
+
username: string;
|
|
48
|
+
/** An `https` address of at most 2,048 characters for the picture beside the name; anything else is left out. */
|
|
49
|
+
avatarUrl?: string;
|
|
50
|
+
card: DiscordCard;
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
/** A message the poster posted, to edit, delete or pin later. */
|
|
43
54
|
export interface PostedMessage {
|
|
44
55
|
channelId: string;
|
|
@@ -73,6 +84,14 @@ export interface RecentMessage {
|
|
|
73
84
|
referencedMessageId?: string;
|
|
74
85
|
}
|
|
75
86
|
|
|
87
|
+
/** A picture on a card: an address Discord fetches, and the text a screen reader gives for it. */
|
|
88
|
+
export interface CardPicture {
|
|
89
|
+
/** An `https` address of at most 2,048 characters (`invalid` otherwise). */
|
|
90
|
+
url: string;
|
|
91
|
+
/** Alt text of 1 to 1,024 characters. */
|
|
92
|
+
description?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
76
95
|
/**
|
|
77
96
|
* A card the bot posts: a Components V2 container with a heading, text sections, a quiet last line
|
|
78
97
|
* and an optional colour bar. Plain data, so a fake can hold and compare it. It carries no
|
|
@@ -83,6 +102,10 @@ export interface DiscordCard {
|
|
|
83
102
|
accent?: number;
|
|
84
103
|
title: string;
|
|
85
104
|
sections: readonly string[];
|
|
105
|
+
/** A small picture beside the heading (a Section with a Thumbnail); the heading is the section's text. */
|
|
106
|
+
thumbnail?: CardPicture;
|
|
107
|
+
/** A picture under the text sections, above the quiet last line (a Media Gallery of one item). */
|
|
108
|
+
image?: CardPicture;
|
|
86
109
|
footer?: string;
|
|
87
110
|
/** Up to five buttons in one row under the text; a press comes to the Discord plugin's interactions by `customId`. */
|
|
88
111
|
buttons?: readonly CardButton[];
|
|
@@ -178,6 +201,24 @@ export interface DiscordPoster {
|
|
|
178
201
|
edit: { content: string; mentions?: readonly string[] },
|
|
179
202
|
webhooks: readonly string[],
|
|
180
203
|
): Promise<void>;
|
|
204
|
+
/**
|
|
205
|
+
* Posts a card through the channel's webhook of that name, found or made on first use, so the
|
|
206
|
+
* card shows the webhook's name and picture instead of the bot's. The webhook is the bot's own,
|
|
207
|
+
* so the card's buttons and menus reach the Discord plugin's interactions as the bot's own cards'
|
|
208
|
+
* do. It pings nobody.
|
|
209
|
+
*/
|
|
210
|
+
postCard(channelId: string, post: WebhookCardPost): Promise<PostedMessage>;
|
|
211
|
+
/**
|
|
212
|
+
* Replaces a card a webhook posted, asking each named webhook in order (a webhook edits only its
|
|
213
|
+
* own messages); makes none. `message_gone` when none of them posted it; a message that is not a
|
|
214
|
+
* card cannot become one (`rejected`).
|
|
215
|
+
*/
|
|
216
|
+
editWebhookCard(
|
|
217
|
+
channelId: string,
|
|
218
|
+
messageId: string,
|
|
219
|
+
card: DiscordCard,
|
|
220
|
+
webhooks: readonly string[],
|
|
221
|
+
): Promise<void>;
|
|
181
222
|
/**
|
|
182
223
|
* Deletes a message one of the named webhooks posted, else as the bot (Manage Messages, for one
|
|
183
224
|
* an earlier webhook of the same name posted); makes none. `message_gone` when it does not exist.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Routes } from "discord.js";
|
|
2
|
+
import type { DiscordMessage, DiscordPoster, RecentMessage } from "./poster.ts";
|
|
3
|
+
import { checkSnowflake } from "./poster-checks.ts";
|
|
4
|
+
import { DiscordPosterError, failure } from "./poster-errors.ts";
|
|
5
|
+
import { type DiscordRest, object, text } from "./rest-poster-core.ts";
|
|
6
|
+
|
|
7
|
+
/** How many pins Discord lists in a page, and how many pages the poster reads (250 pins, the most a channel keeps). */
|
|
8
|
+
const PIN_PAGE = 50;
|
|
9
|
+
const PIN_PAGES = 5;
|
|
10
|
+
|
|
11
|
+
/** The poster's pin calls and the reads of messages. */
|
|
12
|
+
export type ReadOperations = Pick<
|
|
13
|
+
DiscordPoster,
|
|
14
|
+
"pin" | "unpin" | "pinnedIds" | "message" | "recentMessages"
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
export function readOperations(rest: DiscordRest): ReadOperations {
|
|
18
|
+
return {
|
|
19
|
+
async pin(channelId, messageId) {
|
|
20
|
+
checkSnowflake("a channel", channelId);
|
|
21
|
+
checkSnowflake("a message", messageId);
|
|
22
|
+
await rest
|
|
23
|
+
.put(Routes.channelMessagesPin(channelId, messageId))
|
|
24
|
+
.catch((error: unknown) => {
|
|
25
|
+
throw failure("pinning a message", error);
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
async unpin(channelId, messageId) {
|
|
29
|
+
checkSnowflake("a channel", channelId);
|
|
30
|
+
checkSnowflake("a message", messageId);
|
|
31
|
+
await rest
|
|
32
|
+
.delete(Routes.channelMessagesPin(channelId, messageId))
|
|
33
|
+
.catch((error: unknown) => {
|
|
34
|
+
throw failure("unpinning a message", error);
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
async pinnedIds(channelId) {
|
|
38
|
+
checkSnowflake("a channel", channelId);
|
|
39
|
+
// Discord keeps at most 250 pins in a channel and lists 50 a page, newest first; `before`
|
|
40
|
+
// takes the `pinned_at` of the last one read. Five pages cover a channel.
|
|
41
|
+
const ids: string[] = [];
|
|
42
|
+
let before: string | undefined;
|
|
43
|
+
for (let page = 0; page < PIN_PAGES; page += 1) {
|
|
44
|
+
const query = new URLSearchParams({ limit: String(PIN_PAGE) });
|
|
45
|
+
if (before) query.set("before", before);
|
|
46
|
+
const listed = object(
|
|
47
|
+
await rest
|
|
48
|
+
.get(Routes.channelMessagesPins(channelId), { query })
|
|
49
|
+
.catch((error: unknown) => {
|
|
50
|
+
throw failure("listing a channel's pins", error);
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
const items = Array.isArray(listed.items) ? listed.items : [];
|
|
54
|
+
for (const entry of items) {
|
|
55
|
+
const id = text(object(object(entry).message).id);
|
|
56
|
+
if (id) ids.push(id);
|
|
57
|
+
}
|
|
58
|
+
const last = text(object(items.at(-1)).pinned_at);
|
|
59
|
+
if (listed.has_more !== true || !last) break;
|
|
60
|
+
before = last;
|
|
61
|
+
}
|
|
62
|
+
return ids;
|
|
63
|
+
},
|
|
64
|
+
async message(channelId, messageId): Promise<DiscordMessage> {
|
|
65
|
+
checkSnowflake("a channel", channelId);
|
|
66
|
+
checkSnowflake("a message", messageId);
|
|
67
|
+
const read = object(
|
|
68
|
+
await rest
|
|
69
|
+
.get(Routes.channelMessage(channelId, messageId))
|
|
70
|
+
.catch((error: unknown) => {
|
|
71
|
+
throw failure("reading a message", error);
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
const id = text(read.id);
|
|
75
|
+
if (!id)
|
|
76
|
+
throw new DiscordPosterError("rejected", "Discord sent no message");
|
|
77
|
+
const webhookId = text(read.webhook_id);
|
|
78
|
+
const files = (
|
|
79
|
+
Array.isArray(read.attachments) ? read.attachments : []
|
|
80
|
+
).flatMap((entry) => {
|
|
81
|
+
const file = object(entry);
|
|
82
|
+
const name = text(file.filename);
|
|
83
|
+
const url = text(file.url);
|
|
84
|
+
return name && url
|
|
85
|
+
? [
|
|
86
|
+
{
|
|
87
|
+
name,
|
|
88
|
+
url,
|
|
89
|
+
contentType: text(file.content_type) ?? "",
|
|
90
|
+
size: typeof file.size === "number" ? file.size : 0,
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
: [];
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
id,
|
|
97
|
+
authorId: text(object(read.author).id) ?? "",
|
|
98
|
+
...(webhookId ? { webhookId } : {}),
|
|
99
|
+
content: typeof read.content === "string" ? read.content : "",
|
|
100
|
+
files,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
async recentMessages(channelId, limit): Promise<RecentMessage[]> {
|
|
104
|
+
checkSnowflake("a channel", channelId);
|
|
105
|
+
const listed = await rest
|
|
106
|
+
.get(Routes.channelMessages(channelId), {
|
|
107
|
+
query: new URLSearchParams({
|
|
108
|
+
limit: String(Math.min(50, Math.max(1, Math.trunc(limit)))),
|
|
109
|
+
}),
|
|
110
|
+
})
|
|
111
|
+
.catch((error: unknown) => {
|
|
112
|
+
throw failure("listing a channel's messages", error);
|
|
113
|
+
});
|
|
114
|
+
return (Array.isArray(listed) ? listed : []).flatMap((entry) => {
|
|
115
|
+
const message = object(entry);
|
|
116
|
+
const id = text(message.id);
|
|
117
|
+
if (!id) return [];
|
|
118
|
+
const referenced = text(object(message.message_reference).message_id);
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
id,
|
|
122
|
+
type: typeof message.type === "number" ? message.type : 0,
|
|
123
|
+
authorId: text(object(message.author).id) ?? "",
|
|
124
|
+
...(referenced ? { referencedMessageId: referenced } : {}),
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import { RESTJSONErrorCodes, Routes } from "discord.js";
|
|
2
|
-
import type {
|
|
3
|
-
DiscordMessage,
|
|
4
|
-
DiscordPoster,
|
|
5
|
-
PosterFile,
|
|
6
|
-
RecentMessage,
|
|
7
|
-
} from "./poster.ts";
|
|
2
|
+
import type { DiscordPoster, PosterFile } from "./poster.ts";
|
|
8
3
|
import {
|
|
9
4
|
cardBody,
|
|
10
5
|
checkAvatar,
|
|
11
6
|
checkedPost,
|
|
12
7
|
checkedText,
|
|
8
|
+
checkedWebhookCard,
|
|
13
9
|
checkSnowflake,
|
|
14
10
|
checkWebhookName,
|
|
15
11
|
invalid,
|
|
@@ -22,14 +18,11 @@ import {
|
|
|
22
18
|
text,
|
|
23
19
|
} from "./rest-poster-core.ts";
|
|
24
20
|
import { guildOperations } from "./rest-poster-guild.ts";
|
|
21
|
+
import { readOperations } from "./rest-poster-read.ts";
|
|
25
22
|
|
|
26
23
|
export type { DiscordRest, RestOptions } from "./rest-poster-core.ts";
|
|
27
24
|
export { restOf } from "./rest-poster-core.ts";
|
|
28
25
|
|
|
29
|
-
/** How many pins Discord lists in a page, and how many pages the poster reads (250 pins, the most a channel keeps). */
|
|
30
|
-
const PIN_PAGE = 50;
|
|
31
|
-
const PIN_PAGES = 5;
|
|
32
|
-
|
|
33
26
|
interface Webhook {
|
|
34
27
|
id: string;
|
|
35
28
|
token: string;
|
|
@@ -252,6 +245,40 @@ export function restPoster(
|
|
|
252
245
|
}),
|
|
253
246
|
);
|
|
254
247
|
},
|
|
248
|
+
async postCard(channelId, post) {
|
|
249
|
+
checkSnowflake("a channel", channelId);
|
|
250
|
+
const { webhook: name, body } = checkedWebhookCard(post);
|
|
251
|
+
const sent = await through(
|
|
252
|
+
"posting a card",
|
|
253
|
+
channelId,
|
|
254
|
+
name,
|
|
255
|
+
(hook) =>
|
|
256
|
+
rest.post(Routes.webhook(hook.id, hook.token), {
|
|
257
|
+
auth: false,
|
|
258
|
+
body,
|
|
259
|
+
// Without this Discord drops the components of a webhook's post.
|
|
260
|
+
query: new URLSearchParams({
|
|
261
|
+
wait: "true",
|
|
262
|
+
with_components: "true",
|
|
263
|
+
}),
|
|
264
|
+
}),
|
|
265
|
+
true,
|
|
266
|
+
);
|
|
267
|
+
return { channelId, messageId: messageIdOf(sent) };
|
|
268
|
+
},
|
|
269
|
+
async editWebhookCard(channelId, messageId, card, names) {
|
|
270
|
+
checkSnowflake("a channel", channelId);
|
|
271
|
+
checkSnowflake("a message", messageId);
|
|
272
|
+
const body = cardBody(card);
|
|
273
|
+
for (const name of names) checkWebhookName(name);
|
|
274
|
+
await eachWebhook("editing a card", channelId, names, (hook) =>
|
|
275
|
+
rest.patch(Routes.webhookMessage(hook.id, hook.token, messageId), {
|
|
276
|
+
auth: false,
|
|
277
|
+
body,
|
|
278
|
+
query: new URLSearchParams({ with_components: "true" }),
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
},
|
|
255
282
|
async remove(channelId, messageId, names) {
|
|
256
283
|
checkSnowflake("a channel", channelId);
|
|
257
284
|
checkSnowflake("a message", messageId);
|
|
@@ -373,116 +400,7 @@ export function restPoster(
|
|
|
373
400
|
throw failure("sending a direct message", error);
|
|
374
401
|
}
|
|
375
402
|
},
|
|
376
|
-
|
|
377
|
-
checkSnowflake("a channel", channelId);
|
|
378
|
-
checkSnowflake("a message", messageId);
|
|
379
|
-
await rest
|
|
380
|
-
.put(Routes.channelMessagesPin(channelId, messageId))
|
|
381
|
-
.catch((error: unknown) => {
|
|
382
|
-
throw failure("pinning a message", error);
|
|
383
|
-
});
|
|
384
|
-
},
|
|
385
|
-
async unpin(channelId, messageId) {
|
|
386
|
-
checkSnowflake("a channel", channelId);
|
|
387
|
-
checkSnowflake("a message", messageId);
|
|
388
|
-
await rest
|
|
389
|
-
.delete(Routes.channelMessagesPin(channelId, messageId))
|
|
390
|
-
.catch((error: unknown) => {
|
|
391
|
-
throw failure("unpinning a message", error);
|
|
392
|
-
});
|
|
393
|
-
},
|
|
394
|
-
async pinnedIds(channelId) {
|
|
395
|
-
checkSnowflake("a channel", channelId);
|
|
396
|
-
// Discord keeps at most 250 pins in a channel and lists 50 a page, newest first; `before`
|
|
397
|
-
// takes the `pinned_at` of the last one read. Five pages cover a channel.
|
|
398
|
-
const ids: string[] = [];
|
|
399
|
-
let before: string | undefined;
|
|
400
|
-
for (let page = 0; page < PIN_PAGES; page += 1) {
|
|
401
|
-
const query = new URLSearchParams({ limit: String(PIN_PAGE) });
|
|
402
|
-
if (before) query.set("before", before);
|
|
403
|
-
const listed = object(
|
|
404
|
-
await rest
|
|
405
|
-
.get(Routes.channelMessagesPins(channelId), { query })
|
|
406
|
-
.catch((error: unknown) => {
|
|
407
|
-
throw failure("listing a channel's pins", error);
|
|
408
|
-
}),
|
|
409
|
-
);
|
|
410
|
-
const items = Array.isArray(listed.items) ? listed.items : [];
|
|
411
|
-
for (const entry of items) {
|
|
412
|
-
const id = text(object(object(entry).message).id);
|
|
413
|
-
if (id) ids.push(id);
|
|
414
|
-
}
|
|
415
|
-
const last = text(object(items.at(-1)).pinned_at);
|
|
416
|
-
if (listed.has_more !== true || !last) break;
|
|
417
|
-
before = last;
|
|
418
|
-
}
|
|
419
|
-
return ids;
|
|
420
|
-
},
|
|
421
|
-
async message(channelId, messageId): Promise<DiscordMessage> {
|
|
422
|
-
checkSnowflake("a channel", channelId);
|
|
423
|
-
checkSnowflake("a message", messageId);
|
|
424
|
-
const read = object(
|
|
425
|
-
await rest
|
|
426
|
-
.get(Routes.channelMessage(channelId, messageId))
|
|
427
|
-
.catch((error: unknown) => {
|
|
428
|
-
throw failure("reading a message", error);
|
|
429
|
-
}),
|
|
430
|
-
);
|
|
431
|
-
const id = text(read.id);
|
|
432
|
-
if (!id)
|
|
433
|
-
throw new DiscordPosterError("rejected", "Discord sent no message");
|
|
434
|
-
const webhookId = text(read.webhook_id);
|
|
435
|
-
const files = (
|
|
436
|
-
Array.isArray(read.attachments) ? read.attachments : []
|
|
437
|
-
).flatMap((entry) => {
|
|
438
|
-
const file = object(entry);
|
|
439
|
-
const name = text(file.filename);
|
|
440
|
-
const url = text(file.url);
|
|
441
|
-
return name && url
|
|
442
|
-
? [
|
|
443
|
-
{
|
|
444
|
-
name,
|
|
445
|
-
url,
|
|
446
|
-
contentType: text(file.content_type) ?? "",
|
|
447
|
-
size: typeof file.size === "number" ? file.size : 0,
|
|
448
|
-
},
|
|
449
|
-
]
|
|
450
|
-
: [];
|
|
451
|
-
});
|
|
452
|
-
return {
|
|
453
|
-
id,
|
|
454
|
-
authorId: text(object(read.author).id) ?? "",
|
|
455
|
-
...(webhookId ? { webhookId } : {}),
|
|
456
|
-
content: typeof read.content === "string" ? read.content : "",
|
|
457
|
-
files,
|
|
458
|
-
};
|
|
459
|
-
},
|
|
460
|
-
async recentMessages(channelId, limit): Promise<RecentMessage[]> {
|
|
461
|
-
checkSnowflake("a channel", channelId);
|
|
462
|
-
const listed = await rest
|
|
463
|
-
.get(Routes.channelMessages(channelId), {
|
|
464
|
-
query: new URLSearchParams({
|
|
465
|
-
limit: String(Math.min(50, Math.max(1, Math.trunc(limit)))),
|
|
466
|
-
}),
|
|
467
|
-
})
|
|
468
|
-
.catch((error: unknown) => {
|
|
469
|
-
throw failure("listing a channel's messages", error);
|
|
470
|
-
});
|
|
471
|
-
return (Array.isArray(listed) ? listed : []).flatMap((entry) => {
|
|
472
|
-
const message = object(entry);
|
|
473
|
-
const id = text(message.id);
|
|
474
|
-
if (!id) return [];
|
|
475
|
-
const referenced = text(object(message.message_reference).message_id);
|
|
476
|
-
return [
|
|
477
|
-
{
|
|
478
|
-
id,
|
|
479
|
-
type: typeof message.type === "number" ? message.type : 0,
|
|
480
|
-
authorId: text(object(message.author).id) ?? "",
|
|
481
|
-
...(referenced ? { referencedMessageId: referenced } : {}),
|
|
482
|
-
},
|
|
483
|
-
];
|
|
484
|
-
});
|
|
485
|
-
},
|
|
403
|
+
...readOperations(rest),
|
|
486
404
|
botUserId: () => core.bot(),
|
|
487
405
|
};
|
|
488
406
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a group's round stands, for a surface that shows it: who speaks now, who is next and who
|
|
3
|
+
* has spoken. A round is the members' answers to one owner message, one after another; this is what
|
|
4
|
+
* the core tells a surface at each step, and a surface that shows no rounds never hears it.
|
|
5
|
+
*/
|
|
6
|
+
export type RoundEnd =
|
|
7
|
+
/** Every member in the order has spoken. */
|
|
8
|
+
| "done"
|
|
9
|
+
/** The round reached the most replies a round holds, with members still waiting. */
|
|
10
|
+
| "limit"
|
|
11
|
+
/** The owner pressed Stop: the running turn ended and the members waiting did not speak. */
|
|
12
|
+
| "stopped";
|
|
13
|
+
|
|
14
|
+
export interface RoundProgress {
|
|
15
|
+
/** The group message id of the line that opened the round; one round has one id. */
|
|
16
|
+
id: number;
|
|
17
|
+
state: "running" | "ended";
|
|
18
|
+
/** The member speaking now, by name. */
|
|
19
|
+
current?: string;
|
|
20
|
+
/** The members still to speak, in order, a handoff appended. */
|
|
21
|
+
next: string[];
|
|
22
|
+
/** The members who have replied so far, in order. */
|
|
23
|
+
spoken: string[];
|
|
24
|
+
/** The replies made so far, and the most a round holds. */
|
|
25
|
+
replies: number;
|
|
26
|
+
limit: number;
|
|
27
|
+
/** Why a round that has ended ended. */
|
|
28
|
+
ended?: RoundEnd;
|
|
29
|
+
/** The owner's messages waiting in the channel for the next round. */
|
|
30
|
+
queued?: number;
|
|
31
|
+
}
|