pi-roundtable 0.8.0 → 0.9.1

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 (158) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +35 -11
  3. package/README.zh-TW.md +27 -10
  4. package/docs/migration-0.9.md +247 -0
  5. package/docs/plugins.md +618 -86
  6. package/examples/channel-context.test.ts +88 -0
  7. package/examples/channel-context.ts +62 -0
  8. package/examples/echo-runtime.test.ts +1 -0
  9. package/examples/fake-surface.test.ts +69 -6
  10. package/examples/fake-surface.ts +73 -7
  11. package/examples/headless.ts +5 -1
  12. package/examples/prechecks.test.ts +1 -0
  13. package/examples/principals.test.ts +60 -0
  14. package/examples/principals.ts +48 -0
  15. package/examples/prompt.test.ts +1 -1
  16. package/examples/study-room.test.ts +29 -1
  17. package/examples/study-room.ts +16 -19
  18. package/examples/support-desk.test.ts +3 -1
  19. package/examples/tools.test.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/cli/checks/database.ts +11 -0
  22. package/src/cli/checks/identity.ts +225 -0
  23. package/src/cli/checks/model.ts +21 -3
  24. package/src/cli/cli.ts +115 -1
  25. package/src/cli/config-edit.ts +83 -13
  26. package/src/cli/doctor.ts +7 -0
  27. package/src/cli/line-diff.ts +72 -0
  28. package/src/cli/principal.ts +412 -0
  29. package/src/cli/project.ts +5 -0
  30. package/src/cli/report.ts +12 -3
  31. package/src/cli/upgrade-rewrite.ts +102 -0
  32. package/src/cli/upgrade-source.ts +430 -0
  33. package/src/cli/upgrade.ts +176 -0
  34. package/src/core/agents/agent-claim.ts +56 -34
  35. package/src/core/agents/team-turns.ts +8 -4
  36. package/src/core/builtin/agent-server.ts +9 -7
  37. package/src/core/builtin/discord-admin.ts +4 -1
  38. package/src/core/builtin/discord.ts +71 -13
  39. package/src/core/builtin/modules.ts +150 -86
  40. package/src/core/builtin/stores.ts +40 -21
  41. package/src/core/config/access.ts +229 -0
  42. package/src/core/config/adapters.ts +64 -1
  43. package/src/core/config/config.ts +87 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +82 -9
  46. package/src/core/contract/runtime.ts +23 -7
  47. package/src/core/contract/surface.ts +22 -6
  48. package/src/core/conversations/conversation-registry.ts +14 -0
  49. package/src/core/conversations/conversation-store.ts +13 -0
  50. package/src/core/define-roundtable.ts +42 -11
  51. package/src/core/discord/agent-panel.ts +2 -2
  52. package/src/core/discord/card-audience.ts +148 -0
  53. package/src/core/discord/channel-context.ts +348 -0
  54. package/src/core/discord/connection.ts +15 -3
  55. package/src/core/discord/direct-channel.ts +54 -0
  56. package/src/core/discord/discord-owners.ts +133 -0
  57. package/src/core/discord/discord-surface.ts +69 -14
  58. package/src/core/discord/fresh-marker.ts +24 -0
  59. package/src/core/discord/inbound-message.ts +21 -7
  60. package/src/core/discord/interaction-module.ts +11 -1
  61. package/src/core/discord/owner-cards-fixture.ts +80 -0
  62. package/src/core/discord/owner-cards.ts +58 -97
  63. package/src/core/discord/owner-command.ts +27 -13
  64. package/src/core/discord/stop-button.ts +4 -4
  65. package/src/core/domain/conversation.ts +7 -2
  66. package/src/core/domain/errors.ts +5 -0
  67. package/src/core/domain/ports.ts +27 -2
  68. package/src/core/drain.ts +56 -5
  69. package/src/core/errors.ts +13 -0
  70. package/src/core/host-background.ts +56 -0
  71. package/src/core/host.ts +52 -68
  72. package/src/core/i18n/cards.ts +8 -4
  73. package/src/core/i18n/conversation.ts +2 -0
  74. package/src/core/identity/access-policy.ts +227 -0
  75. package/src/core/identity/actor-facts.ts +65 -0
  76. package/src/core/identity/caching-principal-store.ts +111 -0
  77. package/src/core/identity/config-owner.ts +145 -0
  78. package/src/core/identity/contact.ts +27 -0
  79. package/src/core/identity/identity-plugin.ts +56 -0
  80. package/src/core/identity/identity-schema.ts +282 -0
  81. package/src/core/identity/identity-service.ts +489 -0
  82. package/src/core/identity/identity-view.ts +77 -0
  83. package/src/core/identity/plugin-identities.ts +109 -0
  84. package/src/core/identity/principal-reads.ts +38 -0
  85. package/src/core/identity/principal-store.ts +478 -0
  86. package/src/core/identity/principal-tables.ts +53 -0
  87. package/src/core/identity/seen-throttle.ts +57 -0
  88. package/src/core/identity/ulid.ts +36 -0
  89. package/src/core/identity.ts +34 -5
  90. package/src/core/interactions/prompts.ts +127 -0
  91. package/src/core/modules/background/background-turns.ts +56 -17
  92. package/src/core/modules/background/personal-target.ts +50 -0
  93. package/src/core/modules/delegation/delegate.ts +28 -14
  94. package/src/core/modules/delegation/delegator.ts +23 -7
  95. package/src/core/modules/memory/owner-memory.ts +123 -50
  96. package/src/core/modules/notify/notify.ts +53 -13
  97. package/src/core/modules/notify/session-notify.ts +110 -0
  98. package/src/core/modules/schedules/prechecks.ts +8 -2
  99. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  100. package/src/core/modules/schedules/schedule-store.ts +51 -20
  101. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  102. package/src/core/modules/schedules/scheduler.ts +56 -4
  103. package/src/core/modules/schedules/schedules.ts +43 -14
  104. package/src/core/plugin.ts +48 -0
  105. package/src/core/presence/direct-channels.ts +104 -0
  106. package/src/core/registry/contributions.ts +63 -3
  107. package/src/core/routing/channel-queue.ts +18 -1
  108. package/src/core/routing/channel-router.ts +274 -21
  109. package/src/core/routing/conversation-turns.ts +45 -10
  110. package/src/core/routing/surface-port.ts +32 -2
  111. package/src/core/routing/system-turns.ts +29 -0
  112. package/src/core/runtime/abort-guard.ts +58 -0
  113. package/src/core/runtime/bridge-guard.ts +225 -0
  114. package/src/core/runtime/compaction-tiers.ts +5 -1
  115. package/src/core/runtime/conversation-sessions.ts +139 -12
  116. package/src/core/runtime/extensions/ask-user.ts +1 -1
  117. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  118. package/src/core/runtime/extensions/private-memory.ts +447 -0
  119. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  120. package/src/core/runtime/pi-agent-runtime.ts +174 -190
  121. package/src/core/runtime/preflight-tools.ts +47 -0
  122. package/src/core/runtime/prompt-slot.ts +4 -4
  123. package/src/core/runtime/reader-history.ts +129 -0
  124. package/src/core/runtime/runtime-plugin.ts +51 -5
  125. package/src/core/runtime/runtime-types.ts +48 -12
  126. package/src/core/runtime/session-conversation.ts +143 -0
  127. package/src/core/runtime/session-factory.ts +115 -15
  128. package/src/core/runtime/session-scope.ts +78 -0
  129. package/src/core/runtime/steerable-run.ts +21 -3
  130. package/src/core/runtime/thinking-level.ts +47 -0
  131. package/src/core/runtime/tool-call-scope.ts +43 -0
  132. package/src/core/runtime/turn-answer.ts +30 -1
  133. package/src/core/runtime/turn-wrapup.ts +63 -0
  134. package/src/core/services.ts +27 -1
  135. package/src/core/sessions.ts +30 -0
  136. package/src/core/speakers.ts +21 -19
  137. package/src/core/testing/background.ts +31 -0
  138. package/src/core/testing/fixture-database.ts +55 -0
  139. package/src/core/testing/map-identity.ts +76 -0
  140. package/src/core/testing/module-sessions.ts +134 -0
  141. package/src/core/testing/modules.ts +108 -21
  142. package/src/core/testing/owner.ts +17 -0
  143. package/src/core/testing/surface-contract.ts +118 -41
  144. package/src/core/testing/test-host.ts +79 -16
  145. package/src/core/testing/tool-set.ts +32 -0
  146. package/src/core/tool-set.snapshot.json +4 -4
  147. package/src/core/tool-tiers.ts +57 -3
  148. package/src/discord/index.ts +8 -0
  149. package/src/index.ts +48 -8
  150. package/src/kit/compaction.ts +13 -0
  151. package/src/kit/index.ts +10 -0
  152. package/src/kit/support.ts +1 -1
  153. package/src/testing.ts +55 -16
  154. package/templates/.env.example +1 -1
  155. package/templates/adapters/web/README.md +7 -0
  156. package/templates/adapters/web/roundtable.config.ts +14 -10
  157. package/templates/roundtable.config.ts +12 -1
  158. package/src/core/domain/owner-prompts.ts +0 -45
@@ -18,20 +18,24 @@ import type {
18
18
  OutboundReply,
19
19
  } from "../domain/conversation.ts";
20
20
  import type { InterimPosts } from "../domain/interim.ts";
21
- import type { OwnerPrompts } from "../domain/owner-prompts.ts";
22
21
  import type { OwnerNotifier } from "../domain/ports.ts";
23
22
  import { messages } from "../i18n/index.ts";
23
+ import type { PromptScope, Prompts } from "../interactions/prompts.ts";
24
24
  import type { Logger } from "../log.ts";
25
25
  import type { OwnerOperations } from "../modules/discord-admin/discord-admin.ts";
26
26
  import { splitReply } from "../presentation/reply-splitter.ts";
27
- import type { Speaker } from "../speakers.ts";
28
27
  import { DiscordAgentChannels, DiscordDashboard } from "./agent-discord.ts";
28
+ import {
29
+ type ContextSourceMessage,
30
+ contextSourceOf,
31
+ } from "./channel-context.ts";
29
32
  import { discordChannelExecutor } from "./channel-executor.ts";
30
33
  import type { ChannelExecutor } from "./channel-operations.ts";
31
34
  import type { ComposedCommands } from "./compose-commands.ts";
32
35
  import type { ChannelInfo, DiscordConnection } from "./connection.ts";
33
36
  import { DiscordThreadHost } from "./dispatch-thread-host.ts";
34
37
  import type { ThreadHost } from "./dispatch-threads.ts";
38
+ import { DEFAULT_FRESH_MARKER, postFreshMarker } from "./fresh-marker.ts";
35
39
  import { toInbound } from "./inbound-message.ts";
36
40
  import type { CardChannel } from "./owner-cards.ts";
37
41
  import { DiscordOwnerOps } from "./owner-discord.ts";
@@ -47,12 +51,15 @@ const STOP_PANEL_DELAY_MS = 5_000;
47
51
 
48
52
  export interface DiscordSurfaceOptions {
49
53
  token: string;
54
+ /** The primary owner's Discord user id, whom the admin tools act as and the deprecated owner channel reaches. */
50
55
  ownerId: string;
51
- /** How the owner is named in audit-log reasons and refusals. */
56
+ /** How the primary owner is named in audit-log reasons and refusals. */
52
57
  ownerName: string;
53
- /** The owner's cards in a Discord channel; the surface hands them out as its `prompts`. */
54
- prompts: (channel: ChannelKey, speaker?: Speaker) => OwnerPrompts | undefined;
58
+ /** The cards in a Discord channel; the surface hands them out as its `prompts`. */
59
+ prompts: (channel: ChannelKey, scope?: PromptScope) => Prompts | undefined;
55
60
  logger: Logger;
61
+ /** The divider posted in a server channel when its conversation starts over; `false` posts none. Default: `DEFAULT_FRESH_MARKER`. */
62
+ freshMarker?: string | false;
56
63
  }
57
64
 
58
65
  /**
@@ -96,7 +103,7 @@ export class DiscordSurface
96
103
  : undefined;
97
104
  }
98
105
 
99
- /** Discord reading and management for the owner's agent; calls fail until the connection is ready. */
106
+ /** Discord reading and management for the owner's agent, as the primary owner may; calls fail until the connection is ready. */
100
107
  ownerOperations(): OwnerOperations {
101
108
  return new DiscordOwnerOps(
102
109
  this.#client,
@@ -150,14 +157,37 @@ export class DiscordSurface
150
157
  };
151
158
  }
152
159
 
160
+ /**
161
+ * A server text channel's messages before one, as channel context reads them; undefined for a
162
+ * direct message or a channel that holds no messages. Throws while the connection is not ready
163
+ * or when Discord refuses, such as for a channel the bot cannot read.
164
+ */
165
+ async messagesBefore(
166
+ channelId: string,
167
+ before: string,
168
+ limit: number,
169
+ ): Promise<ContextSourceMessage[] | undefined> {
170
+ if (!this.#client.isReady()) throw new Error("Discord is not connected");
171
+ const channel = await this.#client.channels.fetch(channelId);
172
+ if (!channel?.isTextBased() || channel.isDMBased()) return undefined;
173
+ const fetched = await channel.messages.fetch({ before, limit });
174
+ const self = {
175
+ botId: this.#client.user.id,
176
+ applicationId: this.#client.application.id,
177
+ };
178
+ return [...fetched.values()].map((message) =>
179
+ contextSourceOf(message, self),
180
+ );
181
+ }
182
+
153
183
  /** Reports deleted server channels by id; register before start. */
154
184
  onChannelDeleted(handler: (channelId: string) => void): void {
155
185
  this.#client.on(Events.ChannelDelete, (channel) => handler(channel.id));
156
186
  }
157
187
 
158
- /** The owner's approval and question cards in a Discord channel. */
159
- prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined {
160
- return this.#options.prompts(channel, speaker);
188
+ /** The approval and question cards in a Discord channel, for those the scope names. */
189
+ prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
190
+ return this.#options.prompts(channel, scope);
161
191
  }
162
192
 
163
193
  /** The channel's interim posts: ordinary messages, edited in place for the progress line. */
@@ -244,6 +274,20 @@ export class DiscordSurface
244
274
  }
245
275
  }
246
276
 
277
+ /** The divider that ends channel context's window; see `postFreshMarker`. */
278
+ async markFresh(channel: ChannelKey): Promise<void> {
279
+ await postFreshMarker(
280
+ this.#options.freshMarker ?? DEFAULT_FRESH_MARKER,
281
+ async () => {
282
+ const target = await this.#sendable(channel);
283
+ return {
284
+ dm: target.isDMBased(),
285
+ send: (text) => target.send({ content: text }),
286
+ };
287
+ },
288
+ );
289
+ }
290
+
247
291
  startTyping(channel: ChannelKey): () => void {
248
292
  let stopped = false;
249
293
  const tick = () => {
@@ -339,15 +383,26 @@ export class DiscordSurface
339
383
  };
340
384
  }
341
385
 
342
- /** The owner's direct-message channel. */
386
+ /** A Discord user's direct-message channel. */
387
+ async directChannel(userId: string): Promise<ChannelKey> {
388
+ const user = await this.#client.users.fetch(userId);
389
+ return `${PREFIX}${(await user.createDM()).id}`;
390
+ }
391
+
392
+ /** Sends a Discord user a direct message, split as a reply is. */
393
+ async sendDirect(userId: string, text: string): Promise<void> {
394
+ const user = await this.#client.users.fetch(userId);
395
+ for (const chunk of splitReply(text)) await user.send({ content: chunk });
396
+ }
397
+
398
+ /** @deprecated The primary owner's direct-message channel; see `DiscordConnection.ownerChannel`. */
343
399
  async ownerChannel(): Promise<ChannelKey> {
344
- const owner = await this.#client.users.fetch(this.#options.ownerId);
345
- return `${PREFIX}${(await owner.createDM()).id}`;
400
+ return this.directChannel(this.#options.ownerId);
346
401
  }
347
402
 
403
+ /** @deprecated Sends the primary owner a direct message; see `DiscordConnection.notifyOwner`. */
348
404
  async notifyOwner(text: string): Promise<void> {
349
- const owner = await this.#client.users.fetch(this.#options.ownerId);
350
- for (const chunk of splitReply(text)) await owner.send({ content: chunk });
405
+ await this.sendDirect(this.#options.ownerId, text);
351
406
  }
352
407
 
353
408
  async stop(): Promise<void> {
@@ -0,0 +1,24 @@
1
+ /** The divider a guild channel gets when its conversation starts over. */
2
+ export const DEFAULT_FRESH_MARKER = "─── new conversation ───";
3
+
4
+ /** A channel as the divider needs it: whether it is a direct message, and a way to post. */
5
+ export interface MarkerTarget {
6
+ dm: boolean;
7
+ send(text: string): Promise<unknown>;
8
+ }
9
+
10
+ /**
11
+ * Posts the divider that ends channel context's window after a conversation started over: a
12
+ * public post of the assistant in a server channel or thread, never in a direct message, and
13
+ * nothing for `false`. A channel that cannot be found or posted to is left as it is; a Discord
14
+ * error rejects, for the caller to log.
15
+ */
16
+ export async function postFreshMarker(
17
+ marker: string | false,
18
+ find: () => Promise<MarkerTarget | undefined>,
19
+ ): Promise<void> {
20
+ if (marker === false) return;
21
+ const target = await find();
22
+ if (!target || target.dm) return;
23
+ await target.send(marker);
24
+ }
@@ -55,18 +55,32 @@ export async function toInbound(
55
55
  reference?.messageId && !forward
56
56
  ? await message.fetchReference().catch(() => undefined)
57
57
  : undefined;
58
+ const authorName =
59
+ message.member?.displayName ??
60
+ message.author.globalName ??
61
+ message.author.username;
62
+ const roleIds = message.member
63
+ ? [...message.member.roles.cache.keys()]
64
+ : undefined;
58
65
  return {
59
66
  channel: channelKey("discord", message.channelId),
60
67
  messageId: message.id,
68
+ // A DM has no member, so its author's roles are unknown, not none.
69
+ actor: {
70
+ provider: "discord",
71
+ subject: message.author.id,
72
+ name: authorName,
73
+ surface: "discord",
74
+ ...(roleIds
75
+ ? { roles: roleIds.map((role) => `discord:role:${role}`) }
76
+ : {}),
77
+ ...(message.guildId ? { space: message.guildId } : {}),
78
+ legacyId: message.author.id,
79
+ },
61
80
  authorId: message.author.id,
62
- authorName:
63
- message.member?.displayName ??
64
- message.author.globalName ??
65
- message.author.username,
81
+ authorName,
66
82
  authorIsBot: message.author.bot,
67
- ...(message.member
68
- ? { authorRoleIds: [...message.member.roles.cache.keys()] }
69
- : {}),
83
+ ...(roleIds ? { authorRoleIds: roleIds } : {}),
70
84
  ...(message.webhookId
71
85
  ? {
72
86
  integration: {
@@ -5,6 +5,7 @@ import type {
5
5
  InteractionContextType,
6
6
  RESTPostAPIChatInputApplicationCommandsJSONBody,
7
7
  } from "discord.js";
8
+ import type { DiscordActor } from "./discord-owners.ts";
8
9
 
9
10
  /** A group of slash commands and the components they post, handled together. */
10
11
  export interface InteractionModule {
@@ -50,7 +51,16 @@ export interface CommandGuard {
50
51
  * anyone may use; include the space or punctuation your language needs before it.
51
52
  */
52
53
  readonly refusalHint?: string;
53
- /** Whether the actor is the owner: an interaction, or anything with `user.id`, such as a test's. */
54
+ /**
55
+ * Whether the actor may use the owner's commands now: an owner, the primary owner by their user
56
+ * id and any other by their Discord identity, checked again each time. The actor is an
57
+ * interaction, or anything with `user.id`, such as a test's.
58
+ */
59
+ allows(actor: DiscordActor): Promise<boolean>;
60
+ /**
61
+ * Whether the actor is the primary owner, by user id.
62
+ * @deprecated Since 0.9 every owner uses the owner's commands; ask `allows`. Goes away in 1.0.
63
+ */
54
64
  isOwner(actor: { user: { id: string } }): boolean;
55
65
  /** Runs a handler; a failure becomes a panel instead of a hanging interaction. */
56
66
  run(interaction: Interaction, run: () => Promise<void>): Promise<void>;
@@ -0,0 +1,80 @@
1
+ import type { Interaction } from "discord.js";
2
+ import { CARD_PREFIX, type CardPayload } from "./owner-cards.ts";
3
+
4
+ /** The primary owner of the cards' tests. */
5
+ export const OWNER = "100000000000000001";
6
+
7
+ /** The card's JSON, where its text, custom ids, and disabled flags can be read. */
8
+ export const json = (payload: CardPayload | undefined) =>
9
+ JSON.stringify(payload?.components.map((c) => c.toJSON()));
10
+
11
+ /** A channel, or a thread, that records the cards sent to it and every edit of them. */
12
+ export function fakeChannel(thread = false) {
13
+ const sent: CardPayload[] = [];
14
+ const edits: CardPayload[] = [];
15
+ return {
16
+ sent,
17
+ edits,
18
+ /** The id of the latest card sent. */
19
+ cardId: () =>
20
+ json(sent.at(-1)).match(new RegExp(`${CARD_PREFIX}([0-9a-f-]+):`))?.[1] ??
21
+ "",
22
+ channel: async () => ({
23
+ thread,
24
+ send: async (payload: CardPayload) => {
25
+ sent.push(payload);
26
+ return {
27
+ edit: async (change: CardPayload) => {
28
+ edits.push(change);
29
+ },
30
+ };
31
+ },
32
+ }),
33
+ };
34
+ }
35
+
36
+ export type Kind = "button" | "select" | "modal";
37
+
38
+ /** A press of a card's control, recording how it was answered. */
39
+ export function press(
40
+ kind: Kind,
41
+ customId: string,
42
+ options: {
43
+ user?: string;
44
+ roles?: string[];
45
+ values?: string[];
46
+ text?: string;
47
+ } = {},
48
+ ) {
49
+ const replies: string[] = [];
50
+ const updates: CardPayload[] = [];
51
+ const modals: unknown[] = [];
52
+ // SAFETY: the cards read only these members of an interaction.
53
+ const interaction = {
54
+ isButton: () => kind === "button",
55
+ isStringSelectMenu: () => kind === "select",
56
+ isModalSubmit: () => kind === "modal",
57
+ isFromMessage: () => true,
58
+ customId,
59
+ user: { id: options.user ?? OWNER },
60
+ member: {
61
+ roles: { cache: new Map((options.roles ?? []).map((r) => [r, r])) },
62
+ },
63
+ values: options.values ?? [],
64
+ fields: { getTextInputValue: () => options.text ?? "" },
65
+ reply: async (answer: { content: string }) => {
66
+ replies.push(answer.content);
67
+ },
68
+ update: async (payload: CardPayload) => {
69
+ updates.push(payload);
70
+ },
71
+ showModal: async (modal: unknown) => {
72
+ modals.push(modal);
73
+ },
74
+ deferUpdate: async () => undefined,
75
+ } as unknown as Interaction;
76
+ return { interaction, replies, updates, modals };
77
+ }
78
+
79
+ /** Lets a posted card settle. */
80
+ export const tick = () => Bun.sleep(1);
@@ -14,20 +14,21 @@ import {
14
14
  } from "discord.js";
15
15
  import { parseChannelKey } from "../contract/surface.ts";
16
16
  import type { ChannelKey } from "../domain/conversation.ts";
17
+ import { messages } from "../i18n/index.ts";
17
18
  import type {
18
19
  Approval,
19
20
  OwnerAnswer,
20
- OwnerPrompts,
21
21
  OwnerQuestion,
22
- } from "../domain/owner-prompts.ts";
23
- import { messages } from "../i18n/index.ts";
22
+ PromptScope,
23
+ Prompts,
24
+ } from "../interactions/prompts.ts";
24
25
  import type { Logger } from "../log.ts";
25
26
  import {
26
- type Speaker,
27
- type SpeakerPolicy,
28
- type Tier,
29
- tierAtLeast,
30
- } from "../speakers.ts";
27
+ type Audience,
28
+ type CardAudienceOptions,
29
+ CardAudiences,
30
+ } from "./card-audience.ts";
31
+ import { DiscordOwners, discordUser } from "./discord-owners.ts";
31
32
  import type { InteractionModule } from "./interaction-module.ts";
32
33
  import { ownerPanel, type PanelContent, plain } from "./owner-panel.ts";
33
34
 
@@ -37,7 +38,7 @@ export const CARD_TIMEOUT_MS = 30 * 60_000;
37
38
  const OTHER = "other";
38
39
  const TEXT_FIELD = "text";
39
40
 
40
- /** A card's message: the panel, led by a mention of the owner in a thread. */
41
+ /** A card's message: the panel, led in a thread by a mention of those it is for. */
41
42
  export type CardPayload = Omit<
42
43
  ReturnType<typeof ownerPanel>,
43
44
  "components" | "allowedMentions"
@@ -57,14 +58,18 @@ export interface CardMessage {
57
58
  /** Where a channel's cards are posted. */
58
59
  export interface CardChannel {
59
60
  send(payload: CardPayload): Promise<CardMessage>;
60
- /** A thread, where the owner is mentioned so they hear of the card; a channel never mentions them. */
61
+ /** A thread, where those a card is for are mentioned so they hear of it; a channel mentions no one. */
61
62
  thread?: boolean;
62
63
  }
63
64
 
64
65
  export interface OwnerCardsOptions {
66
+ /** The primary owner's Discord user id. */
65
67
  ownerId: string;
66
- /** Decides who holds a tier, for cards that lower tiers may answer; without it only the owner answers. */
67
- speakers?: SpeakerPolicy;
68
+ /**
69
+ * Who holds a tier now, and which owners have a Discord identity, for cards that others than
70
+ * the primary owner may answer; without it only the primary owner answers.
71
+ */
72
+ identity?: CardAudienceOptions["identity"];
68
73
  /** The Discord channel by id; throws when it cannot take messages. */
69
74
  channel(channelId: string): Promise<CardChannel>;
70
75
  logger: Logger;
@@ -91,42 +96,41 @@ interface OpenCard {
91
96
  audience: Audience;
92
97
  }
93
98
 
94
- /** The people a card is for. */
95
- interface Audience {
96
- allows(user: { id: string; roleIds: readonly string[] }): boolean;
97
- /** The user the card mentions in a thread. */
98
- mentionId: string;
99
- /** Shown on the card. */
100
- note: string;
101
- refusal: string;
102
- }
103
-
104
99
  /**
105
- * The owner's cards in the assistant's channels: approvals of held actions and ask_user questions,
106
- * answered with buttons, a menu, or a form. The owner may answer, and so may the speaker whose
107
- * turn asks, when the card allows (a question always; an approval at its tier); an unanswered card
108
- * expires after 30 minutes and a stopped turn cancels it. Open cards live in memory, so a
109
- * restart abandons them; pressing one then says it no longer works.
100
+ * The cards in the assistant's channels: approvals of held actions and ask_user questions,
101
+ * answered with buttons, a menu, or a form, by those the turn's prompt scope names (see
102
+ * `CardAudiences`): the speaker whose turn asks, when the card allows (a question always; an
103
+ * approval at its tier), and, in a shared conversation, the owners. An unanswered card expires
104
+ * after 30 minutes and a stopped turn cancels it. Open cards live in memory, so a restart
105
+ * abandons them; pressing one then says it no longer works.
110
106
  */
111
107
  export class OwnerCards implements InteractionModule {
112
108
  readonly #options: OwnerCardsOptions;
109
+ readonly #audiences: CardAudiences;
113
110
  readonly #open = new Map<string, OpenCard>();
114
111
 
115
112
  constructor(options: OwnerCardsOptions) {
116
113
  this.#options = options;
114
+ this.#audiences = new CardAudiences(options, new DiscordOwners(options));
117
115
  }
118
116
 
119
117
  commands() {
120
118
  return [];
121
119
  }
122
120
 
123
- /** The channel's cards; the chat surface port sends only Discord keys here. */
124
- prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined {
121
+ /**
122
+ * The channel's cards for the scope's turn; without a scope they are the owners'. The chat
123
+ * surface port sends only Discord keys here.
124
+ */
125
+ prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
125
126
  const channelId = parseChannelKey(channel).id;
126
127
  return {
127
- confirm: (title, message, signal, minTier = "owner") =>
128
- this.#post<Approval>(channelId, signal, "expired", "cancelled", {
129
- audience: this.#approvers(minTier, speaker),
128
+ confirm: async (title, message, signal, minTier = "owner") => {
129
+ const audience = await this.#audiences.approval(scope, minTier);
130
+ // A private conversation's call above its person's tier is no one's to approve.
131
+ if (!audience) return "expired";
132
+ return this.#post<Approval>(channelId, signal, "expired", "cancelled", {
133
+ audience,
130
134
  title,
131
135
  sections: [message],
132
136
  rows: (id, disabled) => [
@@ -146,22 +150,26 @@ export class OwnerCards implements InteractionModule {
146
150
  ),
147
151
  ],
148
152
  footer: messages().cardApprovalFooter,
149
- }),
150
- ask: (title, question, signal) =>
151
- this.#post<OwnerAnswer | undefined>(
153
+ });
154
+ },
155
+ ask: async (title, question, signal) => {
156
+ const audience = await this.#audiences.question(scope);
157
+ if (!audience) return undefined;
158
+ return this.#post<OwnerAnswer | undefined>(
152
159
  channelId,
153
160
  signal,
154
161
  undefined,
155
162
  undefined,
156
163
  {
157
- audience: this.#asker(speaker),
164
+ audience,
158
165
  title,
159
166
  sections: [question.question],
160
167
  question,
161
168
  rows: (id, disabled) => askRows(id, question, disabled),
162
169
  footer: messages().cardQuestionFooter,
163
170
  },
164
- ),
171
+ );
172
+ },
165
173
  };
166
174
  }
167
175
 
@@ -214,7 +222,7 @@ export class OwnerCards implements InteractionModule {
214
222
  footer,
215
223
  },
216
224
  open.mention === true,
217
- card.audience.mentionId,
225
+ card.audience.mentions,
218
226
  );
219
227
  const abort = () => settle(cancelled, messages().cardStopped);
220
228
  const timer = setTimeout(
@@ -250,67 +258,22 @@ export class OwnerCards implements InteractionModule {
250
258
  }
251
259
 
252
260
  /** The card's panel; in a thread it leads with the mention of who may answer. */
253
- #panel(content: PanelContent, mention: boolean, userId: string): CardPayload {
261
+ #panel(
262
+ content: PanelContent,
263
+ mention: boolean,
264
+ userIds: readonly string[],
265
+ ): CardPayload {
254
266
  const panel = ownerPanel(content);
255
267
  if (!mention) return panel;
256
268
  return {
257
269
  ...panel,
258
270
  components: [
259
- new TextDisplayBuilder().setContent(`<@${userId}>`),
271
+ new TextDisplayBuilder().setContent(
272
+ userIds.map((id) => `<@${id}>`).join(" "),
273
+ ),
260
274
  ...panel.components,
261
275
  ],
262
- allowedMentions: { users: [userId] },
263
- };
264
- }
265
-
266
- /**
267
- * An approval is for the speaker whose turn held the call, when their tier holds it, and for
268
- * the owner; the owner's alone by default. Nobody else in the channel may approve it.
269
- */
270
- #approvers(minTier: Tier, speaker: Speaker | undefined): Audience {
271
- const { ownerId, speakers } = this.#options;
272
- if (
273
- minTier === "owner" ||
274
- !speakers ||
275
- !speaker ||
276
- speaker.id === ownerId ||
277
- !tierAtLeast(speaker.tier, minTier)
278
- )
279
- return {
280
- allows: (user) => user.id === ownerId,
281
- mentionId: ownerId,
282
- note: "",
283
- refusal: messages().cardOwnerOnly,
284
- };
285
- return {
286
- allows: (user) => {
287
- if (user.id === ownerId) return true;
288
- if (user.id !== speaker.id) return false;
289
- // Their tier when they press, in case it was lowered during the turn.
290
- const who = speakers.resolve({ ...user, name: "" });
291
- return who !== undefined && tierAtLeast(who.tier, minTier);
292
- },
293
- mentionId: speaker.id,
294
- note: messages().cardApproversNote(speaker.id),
295
- refusal: messages().cardApproversRefusal,
296
- };
297
- }
298
-
299
- /** A question is for the speaker whose turn asks it, and for the owner. */
300
- #asker(speaker: Speaker | undefined): Audience {
301
- const { ownerId } = this.#options;
302
- if (!speaker || speaker.id === ownerId)
303
- return {
304
- allows: (user) => user.id === ownerId,
305
- mentionId: ownerId,
306
- note: "",
307
- refusal: messages().cardOwnerOnly,
308
- };
309
- return {
310
- allows: (user) => user.id === speaker.id || user.id === ownerId,
311
- mentionId: speaker.id,
312
- note: messages().cardAskerNote(speaker.id),
313
- refusal: messages().cardAskerRefusal,
276
+ allowedMentions: { users: [...userIds] },
314
277
  };
315
278
  }
316
279
 
@@ -335,10 +298,8 @@ export class OwnerCards implements InteractionModule {
335
298
  });
336
299
  return true;
337
300
  }
338
- const { member } = interaction;
339
- const roleIds =
340
- member && "cache" in member.roles ? [...member.roles.cache.keys()] : [];
341
- if (!card.audience.allows({ id: interaction.user.id, roleIds })) {
301
+ const user = discordUser(interaction);
302
+ if (!(await card.audience.allows(user))) {
342
303
  await interaction.reply({
343
304
  content: card.audience.refusal,
344
305
  flags: MessageFlags.Ephemeral,
@@ -354,7 +315,7 @@ export class OwnerCards implements InteractionModule {
354
315
  footer: outcome,
355
316
  },
356
317
  card.mention === true,
357
- card.audience.mentionId,
318
+ card.audience.mentions,
358
319
  );
359
320
  if (interaction.isButton() && (action === "yes" || action === "no")) {
360
321
  const approved = action === "yes";