pi-roundtable 0.26.3 → 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.
Files changed (84) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +23 -0
  3. package/README.zh-TW.md +31 -8
  4. package/docs/plugins.md +176 -7
  5. package/package.json +1 -1
  6. package/src/core/agents/agent-book.ts +34 -1
  7. package/src/core/agents/agent-claim.ts +25 -5
  8. package/src/core/agents/agent-dashboard.ts +23 -2
  9. package/src/core/agents/agent-messages.ts +28 -10
  10. package/src/core/agents/agent-ports.ts +10 -0
  11. package/src/core/agents/agent-prompt.ts +17 -1
  12. package/src/core/agents/agent-rules.ts +11 -1
  13. package/src/core/agents/agent-schema.ts +22 -0
  14. package/src/core/agents/agent-statements.ts +16 -2
  15. package/src/core/agents/agent-store.ts +30 -20
  16. package/src/core/agents/agent-team-fake-runtime.ts +48 -0
  17. package/src/core/agents/agent-team-fixture.ts +32 -44
  18. package/src/core/agents/agent-team.ts +69 -74
  19. package/src/core/agents/agent-tool-schema.ts +84 -0
  20. package/src/core/agents/agent-tools.ts +50 -85
  21. package/src/core/agents/group-turns.ts +132 -41
  22. package/src/core/agents/helper-create.ts +180 -0
  23. package/src/core/agents/helper-ops.ts +33 -0
  24. package/src/core/agents/helper-test-kit.ts +72 -0
  25. package/src/core/agents/helper-tools.ts +104 -0
  26. package/src/core/agents/helper-work.ts +101 -0
  27. package/src/core/agents/message-target.ts +15 -23
  28. package/src/core/agents/routes.ts +347 -0
  29. package/src/core/agents/team-approval.ts +32 -0
  30. package/src/core/agents/team-editing.ts +97 -32
  31. package/src/core/agents/team-helpers.ts +216 -0
  32. package/src/core/agents/team-lifecycle.ts +26 -1
  33. package/src/core/agents/team-options.ts +8 -0
  34. package/src/core/agents/team-prompt.ts +75 -0
  35. package/src/core/agents/team-selection.ts +31 -13
  36. package/src/core/agents/team-status.ts +6 -0
  37. package/src/core/agents/team-text.ts +20 -4
  38. package/src/core/agents/team-turn-types.ts +12 -1
  39. package/src/core/agents/team-turns.ts +25 -31
  40. package/src/core/builtin/agent-server.ts +37 -2
  41. package/src/core/builtin/modules.ts +4 -4
  42. package/src/core/config/adapters.ts +12 -0
  43. package/src/core/config/agent-team.ts +2 -0
  44. package/src/core/config/config-schema.ts +4 -0
  45. package/src/core/config/config.ts +48 -2
  46. package/src/core/config/helpers.ts +60 -0
  47. package/src/core/config/ops.ts +8 -0
  48. package/src/core/config/routing.ts +296 -0
  49. package/src/core/contract/channels.ts +73 -2
  50. package/src/core/contract/surface.ts +31 -1
  51. package/src/core/define-roundtable.ts +8 -1
  52. package/src/core/discord/agent-discord.ts +67 -6
  53. package/src/core/discord/discord-surface.ts +14 -16
  54. package/src/core/discord/message-changes.ts +66 -0
  55. package/src/core/discord/poster/poster-checks.ts +62 -8
  56. package/src/core/discord/poster/poster.ts +41 -0
  57. package/src/core/discord/poster/rest-poster-read.ts +130 -0
  58. package/src/core/discord/poster/rest-poster.ts +38 -120
  59. package/src/core/domain/round.ts +31 -0
  60. package/src/core/host-registration.ts +15 -0
  61. package/src/core/host.ts +6 -0
  62. package/src/core/i18n/conversation.ts +3 -0
  63. package/src/core/i18n/dashboard.ts +4 -0
  64. package/src/core/ops/error-reporter.ts +4 -0
  65. package/src/core/ops/ops-alerts.ts +102 -6
  66. package/src/core/plugin.ts +17 -0
  67. package/src/core/registry/contributions.ts +1 -26
  68. package/src/core/registry/surface-service.ts +41 -0
  69. package/src/core/routing/channel-router.ts +54 -3
  70. package/src/core/routing/claim-events.ts +42 -0
  71. package/src/core/routing/message-observers.ts +86 -0
  72. package/src/core/routing/peer-bots.ts +149 -0
  73. package/src/core/routing/surface-port.ts +4 -0
  74. package/src/core/services.ts +7 -6
  75. package/src/core/testing/fake-poster-guild.ts +1 -1
  76. package/src/core/testing/fake-poster-reads.ts +96 -0
  77. package/src/core/testing/fake-poster.ts +62 -94
  78. package/src/core/tool-set.snapshot.json +8 -0
  79. package/src/discord/index.ts +2 -0
  80. package/src/index.ts +18 -1
  81. package/src/kit/channels.ts +1 -0
  82. package/src/kit/index.ts +5 -0
  83. package/src/kit/ops-alerts.ts +4 -0
  84. 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 { ChatSurface, SurfaceThreads } from "../contract/surface.ts";
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, toInbound } from "./inbound-message.ts";
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(onMessage: (message: InboundMessage) => void): Promise<void> {
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.on(Events.MessageCreate, (message) => {
270
- toInbound(message, {
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 texts = [
159
- heading,
160
- ...card.sections.filter((section) => section !== ""),
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] = texts;
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
- { type: 10, content: first },
226
- ...(rest.length > 0 ? [{ type: 14, divider: true, spacing: 1 }] : []),
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
- async pin(channelId, messageId) {
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
+ }