pi-roundtable 0.1.0 → 0.2.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 (238) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +6 -4
  3. package/docs/plugins.md +1448 -66
  4. package/examples/echo-runtime.test.ts +122 -0
  5. package/examples/echo-runtime.ts +107 -0
  6. package/examples/events.test.ts +1 -0
  7. package/examples/events.ts +2 -2
  8. package/examples/fake-surface.test.ts +180 -0
  9. package/examples/fake-surface.ts +109 -0
  10. package/examples/interactions.test.ts +12 -3
  11. package/examples/interactions.ts +19 -19
  12. package/examples/shared-services.test.ts +74 -0
  13. package/examples/shared-services.ts +60 -0
  14. package/examples/study-room.test.ts +69 -0
  15. package/examples/study-room.ts +52 -0
  16. package/examples/support-desk.test.ts +34 -0
  17. package/examples/support-desk.ts +40 -0
  18. package/package.json +9 -2
  19. package/src/cli/checks/database.ts +11 -30
  20. package/src/cli/checks/images.ts +19 -0
  21. package/src/cli/doctor.ts +6 -0
  22. package/src/cli/main.ts +2 -1
  23. package/src/core/agents/agent-claim.ts +42 -16
  24. package/src/core/agents/agent-messages.ts +4 -4
  25. package/src/core/agents/agent-ports.ts +8 -21
  26. package/src/core/agents/agent-prompt.ts +3 -1
  27. package/src/core/agents/agent-store.ts +6 -4
  28. package/src/core/agents/agent-team-fixture.ts +389 -0
  29. package/src/core/agents/agent-team.ts +26 -25
  30. package/src/core/agents/agent-tools.ts +34 -15
  31. package/src/core/agents/avatar-studio.ts +40 -6
  32. package/src/core/agents/fallback-avatar.ts +85 -0
  33. package/src/core/agents/team-editing.ts +49 -10
  34. package/src/core/agents/team-keys.ts +22 -9
  35. package/src/core/agents/team-layout.ts +4 -3
  36. package/src/core/agents/team-lifecycle.ts +28 -2
  37. package/src/core/agents/team-options.ts +9 -5
  38. package/src/core/agents/team-status.ts +4 -4
  39. package/src/core/agents/team-text.ts +8 -6
  40. package/src/core/agents/team-turn-types.ts +4 -4
  41. package/src/core/agents/team-turns.ts +6 -5
  42. package/src/core/builtin/agent-server.ts +187 -121
  43. package/src/core/builtin/discord-admin.ts +41 -0
  44. package/src/core/builtin/discord.ts +65 -40
  45. package/src/core/builtin/modules.ts +67 -58
  46. package/src/core/builtin/session-tool.ts +24 -0
  47. package/src/core/builtin/skills.ts +72 -0
  48. package/src/core/builtin/stores.ts +55 -31
  49. package/src/core/config/config.ts +45 -4
  50. package/src/core/config/schema.ts +6 -0
  51. package/src/core/contract/channels.ts +54 -14
  52. package/src/core/contract/providers.ts +17 -0
  53. package/src/core/contract/runtime.ts +138 -0
  54. package/src/core/contract/services.ts +45 -0
  55. package/src/core/contract/surface.ts +88 -0
  56. package/src/core/db/migrations.ts +118 -3
  57. package/src/core/define-roundtable.ts +70 -46
  58. package/src/core/define.ts +2 -1
  59. package/src/core/discord/agent-commands.ts +39 -14
  60. package/src/core/discord/agent-panel.ts +66 -0
  61. package/src/core/discord/channel-executor.ts +6 -3
  62. package/src/core/discord/channel-operations.ts +14 -11
  63. package/src/core/discord/command-collection.ts +46 -0
  64. package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
  65. package/src/core/discord/connection.ts +35 -0
  66. package/src/core/discord/discord-surface.ts +32 -94
  67. package/src/core/discord/dispatch-threads.ts +2 -2
  68. package/src/core/discord/inbound-message.ts +99 -0
  69. package/src/core/discord/interaction-module.ts +57 -1
  70. package/src/core/discord/owner-cards.ts +4 -4
  71. package/src/core/discord/owner-command.ts +44 -11
  72. package/src/core/discord/owner-discord.ts +2 -2
  73. package/src/core/discord/schedule-commands.ts +21 -18
  74. package/src/core/discord/stop-button.ts +44 -1
  75. package/src/core/domain/attachment.ts +12 -8
  76. package/src/core/domain/conversation.ts +3 -4
  77. package/src/core/domain/owner-prompts.ts +5 -5
  78. package/src/core/domain/ports.ts +9 -53
  79. package/src/core/freeze.ts +9 -0
  80. package/src/core/host.ts +255 -82
  81. package/src/core/http/listeners.ts +55 -20
  82. package/src/core/i18n/agent-panel.ts +4 -0
  83. package/src/core/i18n/index.ts +12 -2
  84. package/src/core/i18n/owner.ts +7 -1
  85. package/src/core/i18n/schedules.ts +12 -6
  86. package/src/core/identity.ts +1 -1
  87. package/src/core/judging/effort-judge.ts +25 -3
  88. package/src/core/log.ts +21 -3
  89. package/src/core/models.ts +2 -2
  90. package/src/core/modules/background/background-turns.ts +6 -4
  91. package/src/core/modules/delegation/delegate.ts +3 -2
  92. package/src/core/modules/delegation/delegator.ts +15 -13
  93. package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
  94. package/src/core/modules/discord-admin/discord-admin.ts +6 -6
  95. package/src/core/modules/host-shell/shell-policy.ts +8 -3
  96. package/src/core/modules/memory/owner-memory-store.ts +27 -25
  97. package/src/core/modules/memory/owner-memory.ts +9 -13
  98. package/src/core/modules/schedules/recurrence.ts +5 -3
  99. package/src/core/modules/schedules/schedule-store.ts +11 -11
  100. package/src/core/modules/schedules/schedule-tools.ts +22 -20
  101. package/src/core/modules/schedules/scheduler.ts +7 -2
  102. package/src/core/modules/schedules/schedules.ts +9 -3
  103. package/src/core/modules/skills/skill-registry.ts +3 -2
  104. package/src/core/modules/skills/skill-store.ts +2 -15
  105. package/src/core/modules/skills/skill-tools.ts +10 -4
  106. package/src/core/ops/error-reporter.ts +1 -1
  107. package/src/core/plugin.ts +232 -28
  108. package/src/core/registry/contributions.ts +173 -14
  109. package/src/core/registry/providers.ts +35 -5
  110. package/src/core/registry/services.ts +229 -0
  111. package/src/core/routing/channel-queue.ts +5 -0
  112. package/src/core/routing/channel-router.ts +21 -7
  113. package/src/core/routing/conversation-turns.ts +139 -0
  114. package/src/core/routing/message-text.ts +9 -2
  115. package/src/core/routing/surface-port.ts +39 -0
  116. package/src/core/runtime/conversation-sessions.ts +4 -3
  117. package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
  118. package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
  119. package/src/core/runtime/mcp.ts +1 -1
  120. package/src/core/runtime/pending-confirmation-store.ts +10 -12
  121. package/src/core/runtime/pi-agent-runtime.ts +9 -6
  122. package/src/core/runtime/prompt-slot.ts +8 -3
  123. package/src/core/runtime/runtime-types.ts +9 -35
  124. package/src/core/runtime/session-factory.ts +21 -5
  125. package/src/core/runtime/text-tools.ts +1 -3
  126. package/src/core/services.ts +238 -90
  127. package/src/core/sessions.ts +3 -2
  128. package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
  129. package/src/core/shared/delegate-tool.ts +4 -3
  130. package/src/core/shared/schedule-tools.ts +22 -13
  131. package/src/core/shared/session-messages.ts +1 -1
  132. package/src/core/speakers.ts +6 -38
  133. package/src/core/testing/database.ts +1 -7
  134. package/src/core/testing/eager-catalog.ts +30 -0
  135. package/src/core/testing/locale.ts +23 -5
  136. package/src/core/testing/modules.ts +105 -41
  137. package/src/core/testing/test-host.ts +279 -0
  138. package/src/core/testing/tool-set.ts +64 -0
  139. package/src/core/time.ts +6 -1
  140. package/src/core/tool-set.snapshot.json +288 -0
  141. package/src/core/tool-tiers.ts +17 -57
  142. package/src/discord/index.ts +64 -0
  143. package/src/index.ts +181 -10
  144. package/src/kit/channels.ts +14 -0
  145. package/src/kit/domain.ts +18 -0
  146. package/src/kit/holds.ts +4 -0
  147. package/src/kit/index.ts +129 -0
  148. package/src/kit/judging.ts +10 -0
  149. package/src/kit/memory.ts +3 -0
  150. package/src/kit/mirror.ts +19 -0
  151. package/src/kit/presentation.ts +7 -0
  152. package/src/kit/shell.ts +7 -0
  153. package/src/kit/skills.ts +8 -0
  154. package/src/kit/support.ts +21 -0
  155. package/src/kit/threads.ts +7 -0
  156. package/src/kit/tools.ts +14 -0
  157. package/src/kit/worker.ts +16 -0
  158. package/src/testing.ts +394 -34
  159. package/examples/guide.test.ts +0 -52
  160. package/src/cli/add-plugin.test.ts +0 -107
  161. package/src/cli/checks/basic.test.ts +0 -276
  162. package/src/cli/checks/database.test.ts +0 -117
  163. package/src/cli/checks/discord.test.ts +0 -200
  164. package/src/cli/cli.test.ts +0 -122
  165. package/src/cli/config-edit.test.ts +0 -130
  166. package/src/cli/doctor.test.ts +0 -184
  167. package/src/cli/init.test.ts +0 -127
  168. package/src/cli/size.test.ts +0 -9
  169. package/src/cli/templates.test.ts +0 -129
  170. package/src/cli/testing/fixtures.ts +0 -106
  171. package/src/core/agents/agent-claim.test.ts +0 -142
  172. package/src/core/agents/agent-dashboard.test.ts +0 -120
  173. package/src/core/agents/agent-guild.test.ts +0 -124
  174. package/src/core/agents/agent-store.test.ts +0 -205
  175. package/src/core/agents/avatar-studio.test.ts +0 -88
  176. package/src/core/agents/group-round.test.ts +0 -143
  177. package/src/core/agents/owner-identity.test.ts +0 -172
  178. package/src/core/agents/team-turns.test.ts +0 -278
  179. package/src/core/attachments/attachments.test.ts +0 -85
  180. package/src/core/boundary.test.ts +0 -45
  181. package/src/core/builtin/modules.test.ts +0 -127
  182. package/src/core/config/config.test.ts +0 -135
  183. package/src/core/contract/discord.ts +0 -33
  184. package/src/core/db/migrations.test.ts +0 -237
  185. package/src/core/define-roundtable.test.ts +0 -134
  186. package/src/core/define.test.ts +0 -144
  187. package/src/core/discord/agent-commands.test.ts +0 -58
  188. package/src/core/discord/agent-discord.test.ts +0 -70
  189. package/src/core/discord/dispatch-thread-host.test.ts +0 -114
  190. package/src/core/discord/dispatch-threads.test.ts +0 -94
  191. package/src/core/discord/owner-cards.test.ts +0 -435
  192. package/src/core/discord/owner-discord.test.ts +0 -325
  193. package/src/core/domain/expression.ts +0 -21
  194. package/src/core/domain/profile.ts +0 -32
  195. package/src/core/drain.test.ts +0 -46
  196. package/src/core/events.test.ts +0 -81
  197. package/src/core/holds.test.ts +0 -54
  198. package/src/core/host.test.ts +0 -536
  199. package/src/core/http/listeners.test.ts +0 -164
  200. package/src/core/i18n/i18n.test.ts +0 -139
  201. package/src/core/identity.test.ts +0 -18
  202. package/src/core/judging/effort-judge.test.ts +0 -112
  203. package/src/core/judging/model-judge.test.ts +0 -126
  204. package/src/core/modules/delegation/delegator.test.ts +0 -170
  205. package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
  206. package/src/core/modules/schedules/schedule.test.ts +0 -358
  207. package/src/core/modules/skills/skill-kind.test.ts +0 -55
  208. package/src/core/modules/skills/skill-registry.test.ts +0 -376
  209. package/src/core/ops/error-reporter.test.ts +0 -283
  210. package/src/core/presentation/card-cadence.ts +0 -41
  211. package/src/core/presentation/presentation.test.ts +0 -166
  212. package/src/core/public-entry.test.ts +0 -59
  213. package/src/core/registry/contributions.test.ts +0 -215
  214. package/src/core/registry/interactions.test.ts +0 -87
  215. package/src/core/registry/providers.test.ts +0 -103
  216. package/src/core/routing/channel-queue.test.ts +0 -31
  217. package/src/core/routing/channel-router.test.ts +0 -326
  218. package/src/core/routing/conversation-kind.ts +0 -14
  219. package/src/core/routing/settle-turn.test.ts +0 -21
  220. package/src/core/runtime/compaction-tiers.test.ts +0 -227
  221. package/src/core/runtime/extensions/ask-user.test.ts +0 -114
  222. package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
  223. package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
  224. package/src/core/runtime/session-archive.test.ts +0 -20
  225. package/src/core/runtime/steerable-run.test.ts +0 -264
  226. package/src/core/runtime/text-tools.test.ts +0 -110
  227. package/src/core/runtime/turn-answer.test.ts +0 -72
  228. package/src/core/runtime/worker-task.test.ts +0 -82
  229. package/src/core/services.test.ts +0 -28
  230. package/src/core/sessions.test.ts +0 -85
  231. package/src/core/shared/unix-server.ts +0 -18
  232. package/src/core/size.test.ts +0 -9
  233. package/src/core/speakers.test.ts +0 -78
  234. package/src/core/testing/file-size.ts +0 -34
  235. package/src/core/time.test.ts +0 -166
  236. package/src/core/tool-tiers.test.ts +0 -117
  237. package/src/entries.test.ts +0 -112
  238. package/src/testing.test.ts +0 -156
@@ -8,24 +8,30 @@ import {
8
8
  type Interaction,
9
9
  type Message,
10
10
  MessageFlags,
11
- MessageReferenceType,
12
11
  Partials,
13
12
  } from "discord.js";
14
- import type { AttachmentRef } from "../domain/attachment.ts";
13
+ import type { AgentChannels, DashboardBoard } from "../agents/agent-ports.ts";
14
+ import type { ChatSurface } from "../contract/surface.ts";
15
15
  import type {
16
16
  ChannelKey,
17
17
  InboundMessage,
18
18
  OutboundReply,
19
19
  } from "../domain/conversation.ts";
20
- import type { ChatSurface, OwnerNotifier } from "../domain/ports.ts";
20
+ import type { OwnerPrompts } from "../domain/owner-prompts.ts";
21
+ import type { OwnerNotifier } from "../domain/ports.ts";
21
22
  import { messages } from "../i18n/index.ts";
22
23
  import type { Logger } from "../log.ts";
24
+ import type { OwnerOperations } from "../modules/discord-admin/discord-admin.ts";
23
25
  import { splitReply } from "../presentation/reply-splitter.ts";
24
- import type { ComposedInteractions } from "../registry/interactions.ts";
26
+ import type { Speaker } from "../speakers.ts";
25
27
  import { DiscordAgentChannels, DiscordDashboard } from "./agent-discord.ts";
26
28
  import { discordChannelExecutor } from "./channel-executor.ts";
27
29
  import type { ChannelExecutor } from "./channel-operations.ts";
30
+ import type { ComposedCommands } from "./compose-commands.ts";
31
+ import type { ChannelInfo, DiscordConnection } from "./connection.ts";
28
32
  import { DiscordThreadHost } from "./dispatch-thread-host.ts";
33
+ import type { ThreadHost } from "./dispatch-threads.ts";
34
+ import { toInbound } from "./inbound-message.ts";
29
35
  import type { CardChannel } from "./owner-cards.ts";
30
36
  import { DiscordOwnerOps } from "./owner-discord.ts";
31
37
  import { stopPanel } from "./stop-button.ts";
@@ -34,9 +40,6 @@ const PREFIX = "discord:";
34
40
  /** Discord's error code for a channel that does not exist or cannot be seen. */
35
41
  const UNKNOWN_CHANNEL = 10003;
36
42
 
37
- export type ChannelInfo =
38
- | { kind: "dm"; name: string }
39
- | { kind: "guild"; name: string; guild: string; guildId: string };
40
43
  const TYPING_REFRESH_MS = 8_000;
41
44
  /** A turn shorter than this shows no stop button, so quick answers do not flicker one. */
42
45
  const STOP_PANEL_DELAY_MS = 5_000;
@@ -46,6 +49,8 @@ export interface DiscordSurfaceOptions {
46
49
  ownerId: string;
47
50
  /** How the owner is named in audit-log reasons and refusals. */
48
51
  ownerName: string;
52
+ /** The owner's cards in a Discord channel; the surface hands them out as its `prompts`. */
53
+ prompts: (channel: ChannelKey, speaker?: Speaker) => OwnerPrompts | undefined;
49
54
  logger: Logger;
50
55
  }
51
56
 
@@ -55,7 +60,10 @@ export interface DiscordSurfaceOptions {
55
60
  * server's channels answer the owner without a mention; elsewhere the assistant still answers only
56
61
  * DMs and messages that mention it.
57
62
  */
58
- export class DiscordSurface implements ChatSurface, OwnerNotifier {
63
+ export class DiscordSurface
64
+ implements ChatSurface, OwnerNotifier, DiscordConnection
65
+ {
66
+ readonly surface = "discord";
59
67
  readonly #options: DiscordSurfaceOptions;
60
68
  readonly #client = new Client({
61
69
  intents: [
@@ -68,15 +76,15 @@ export class DiscordSurface implements ChatSurface, OwnerNotifier {
68
76
  allowedMentions: { parse: [] },
69
77
  });
70
78
 
71
- #interactions: ComposedInteractions = { commands: [], modules: [] };
79
+ #interactions: ComposedCommands = { commands: [], modules: [] };
72
80
 
73
81
  constructor(options: DiscordSurfaceOptions) {
74
82
  this.#options = options;
75
83
  }
76
84
 
77
85
  /** Slash commands and the modules that answer them; the commands are registered globally at startup. */
78
- useInteractions(interactions: ComposedInteractions): void {
79
- this.#interactions = interactions;
86
+ setCommands(composed: ComposedCommands): void {
87
+ this.#interactions = composed;
80
88
  }
81
89
 
82
90
  /** Channel operations for outside agents; undefined until the connection is ready. */
@@ -87,7 +95,7 @@ export class DiscordSurface implements ChatSurface, OwnerNotifier {
87
95
  }
88
96
 
89
97
  /** Discord reading and management for the owner's agent; calls fail until the connection is ready. */
90
- ownerDiscord(): DiscordOwnerOps {
98
+ ownerOperations(): OwnerOperations {
91
99
  return new DiscordOwnerOps(
92
100
  this.#client,
93
101
  this.#options.ownerId,
@@ -96,17 +104,17 @@ export class DiscordSurface implements ChatSurface, OwnerNotifier {
96
104
  }
97
105
 
98
106
  /** The agent server's channels and webhooks. */
99
- agentChannels(guildId: string): DiscordAgentChannels {
107
+ agentChannels(guildId: string): AgentChannels {
100
108
  return new DiscordAgentChannels(this.#client, guildId);
101
109
  }
102
110
 
103
111
  /** Threads for background dispatches, in the channel that started each. */
104
- threadHost(): DiscordThreadHost {
112
+ threadHost(): ThreadHost {
105
113
  return new DiscordThreadHost(this.#client);
106
114
  }
107
115
 
108
116
  /** The agent server's `#dashboard` and its pinned status message. */
109
- agentDashboard(guildId: string): DiscordDashboard {
117
+ agentDashboard(guildId: string): DashboardBoard {
110
118
  return new DiscordDashboard(this.#client, guildId, this.#options.logger);
111
119
  }
112
120
 
@@ -145,10 +153,18 @@ export class DiscordSurface implements ChatSurface, OwnerNotifier {
145
153
  this.#client.on(Events.ChannelDelete, (channel) => handler(channel.id));
146
154
  }
147
155
 
156
+ /** The owner's approval and question cards in a Discord channel. */
157
+ prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined {
158
+ return this.#options.prompts(channel, speaker);
159
+ }
160
+
148
161
  async start(onMessage: (message: InboundMessage) => void): Promise<void> {
149
162
  const { logger, token } = this.#options;
150
163
  this.#client.on(Events.MessageCreate, (message) => {
151
- this.#toInbound(message)
164
+ toInbound(message, {
165
+ botId: this.#client.user?.id,
166
+ applicationId: this.#client.application?.id,
167
+ })
152
168
  .then((inbound) => {
153
169
  if (inbound) onMessage(inbound);
154
170
  })
@@ -317,73 +333,6 @@ export class DiscordSurface implements ChatSurface, OwnerNotifier {
317
333
  await this.#client.destroy();
318
334
  }
319
335
 
320
- async #toInbound(message: Message): Promise<InboundMessage | undefined> {
321
- const botId = this.#client.user?.id;
322
- if (!botId || message.author.id === botId) return undefined;
323
- const mention = new RegExp(`<@!?${botId}>`, "g");
324
- const reference = message.reference;
325
- // A forward also has a reference, to a message elsewhere; its copy is the snapshot.
326
- const forward =
327
- reference?.type === MessageReferenceType.Forward
328
- ? message.messageSnapshots.first()
329
- : undefined;
330
- const referenced =
331
- reference?.messageId && !forward
332
- ? await message.fetchReference().catch(() => undefined)
333
- : undefined;
334
- return {
335
- channel: `${PREFIX}${message.channelId}`,
336
- messageId: message.id,
337
- authorId: message.author.id,
338
- authorName:
339
- message.member?.displayName ??
340
- message.author.globalName ??
341
- message.author.username,
342
- authorIsBot: message.author.bot,
343
- ...(message.member
344
- ? { authorRoleIds: [...message.member.roles.cache.keys()] }
345
- : {}),
346
- ...(message.webhookId
347
- ? {
348
- webhookId: message.webhookId,
349
- // Webhooks the assistant created carry its application on every message they post.
350
- ownWebhook:
351
- message.applicationId !== null &&
352
- message.applicationId === (this.#client.application?.id ?? botId),
353
- }
354
- : {}),
355
- isDirect: message.channel.isDMBased(),
356
- ...(message.guildId ? { guildId: message.guildId } : {}),
357
- mentionsBot: message.mentions.users.has(botId),
358
- repliesToBot: referenced?.author.id === botId,
359
- text: message.content.replace(mention, "").trim(),
360
- attachments: [
361
- ...attachmentRefs(message),
362
- ...(forward ? attachmentRefs(forward) : []),
363
- ],
364
- ...(forward && reference?.channelId
365
- ? {
366
- forwarded: {
367
- text: forward.content ?? "",
368
- channelMention: `<#${reference.channelId}>`,
369
- url: `https://discord.com/channels/${reference.guildId ?? "@me"}/${reference.channelId}/${reference.messageId ?? ""}`,
370
- },
371
- }
372
- : {}),
373
- ...(referenced
374
- ? {
375
- reference: {
376
- text: referenced.content.replace(mention, "").trim(),
377
- attachments: attachmentRefs(referenced),
378
- ...(referenced.webhookId
379
- ? { webhookName: referenced.author.username }
380
- : {}),
381
- },
382
- }
383
- : {}),
384
- };
385
- }
386
-
387
336
  async #dispatch(interaction: Interaction): Promise<void> {
388
337
  const { logger } = this.#options;
389
338
  try {
@@ -418,14 +367,3 @@ export class DiscordSurface implements ChatSurface, OwnerNotifier {
418
367
  return target;
419
368
  }
420
369
  }
421
-
422
- function attachmentRefs(
423
- message: Pick<Message, "attachments">,
424
- ): AttachmentRef[] {
425
- return [...message.attachments.values()].map((attachment) => ({
426
- url: attachment.url,
427
- name: attachment.name,
428
- ...(attachment.contentType ? { contentType: attachment.contentType } : {}),
429
- size: attachment.size,
430
- }));
431
- }
@@ -12,7 +12,7 @@ import type { Logger } from "../log.ts";
12
12
 
13
13
  const PREFIX = "discord:";
14
14
  /** Discord's limit on a thread name. */
15
- export const THREAD_NAME_LIMIT = 100;
15
+ const THREAD_NAME_LIMIT = 100;
16
16
 
17
17
  /** The chat side of dispatch threads; the Discord surface implements it. */
18
18
  export interface ThreadHost {
@@ -48,7 +48,7 @@ export interface DispatchThreadsOptions {
48
48
  host: ThreadHost;
49
49
  /** Threads still open, so a start after a crash can archive them. */
50
50
  ledgerPath: string;
51
- /** Channels that keep posting in place, such as party channels. */
51
+ /** Channels whose claims keep reports in place rather than opening threads. */
52
52
  excluded?: (channel: ChannelKey) => boolean;
53
53
  logger: Logger;
54
54
  }
@@ -0,0 +1,99 @@
1
+ import { type Message, MessageReferenceType } from "discord.js";
2
+ import type { InboundMessage } from "../contract/channels.ts";
3
+ import { channelKey } from "../contract/surface.ts";
4
+ import type { AttachmentRef } from "../domain/attachment.ts";
5
+
6
+ /** Who the bot is on Discord, as far as the client knows it. */
7
+ export interface BotIdentity {
8
+ /** The bot user's id; undefined until the client is ready. */
9
+ botId: string | undefined;
10
+ /** The application's id, which the webhooks the assistant created carry. */
11
+ applicationId: string | undefined;
12
+ }
13
+
14
+ function attachmentRefs(
15
+ message: Pick<Message, "attachments">,
16
+ ): AttachmentRef[] {
17
+ return [...message.attachments.values()].map((attachment) => ({
18
+ url: attachment.url,
19
+ name: attachment.name,
20
+ ...(attachment.contentType ? { contentType: attachment.contentType } : {}),
21
+ size: attachment.size,
22
+ }));
23
+ }
24
+
25
+ /**
26
+ * The facts of one Discord message, as the neutral `InboundMessage`; undefined for the bot's own
27
+ * messages and before the client is ready. A forward reports the channel it came from as a
28
+ * channel key, and a webhook's post as an integration.
29
+ */
30
+ export async function toInbound(
31
+ message: Message,
32
+ { botId, applicationId }: BotIdentity,
33
+ ): Promise<InboundMessage | undefined> {
34
+ if (!botId || message.author.id === botId) return undefined;
35
+ const mention = new RegExp(`<@!?${botId}>`, "g");
36
+ const reference = message.reference;
37
+ // A forward also has a reference, to a message elsewhere; its copy is the snapshot.
38
+ const forward =
39
+ reference?.type === MessageReferenceType.Forward
40
+ ? message.messageSnapshots.first()
41
+ : undefined;
42
+ const referenced =
43
+ reference?.messageId && !forward
44
+ ? await message.fetchReference().catch(() => undefined)
45
+ : undefined;
46
+ return {
47
+ channel: channelKey("discord", message.channelId),
48
+ messageId: message.id,
49
+ authorId: message.author.id,
50
+ authorName:
51
+ message.member?.displayName ??
52
+ message.author.globalName ??
53
+ message.author.username,
54
+ authorIsBot: message.author.bot,
55
+ ...(message.member
56
+ ? { authorRoleIds: [...message.member.roles.cache.keys()] }
57
+ : {}),
58
+ ...(message.webhookId
59
+ ? {
60
+ integration: {
61
+ id: message.webhookId,
62
+ // Webhooks the assistant created carry its application on every message they post.
63
+ own:
64
+ message.applicationId !== null &&
65
+ message.applicationId === (applicationId ?? botId),
66
+ },
67
+ }
68
+ : {}),
69
+ isDirect: message.channel.isDMBased(),
70
+ ...(message.guildId ? { space: message.guildId } : {}),
71
+ mentionsBot: message.mentions.users.has(botId),
72
+ repliesToBot: referenced?.author.id === botId,
73
+ text: message.content.replace(mention, "").trim(),
74
+ attachments: [
75
+ ...attachmentRefs(message),
76
+ ...(forward ? attachmentRefs(forward) : []),
77
+ ],
78
+ ...(forward && reference?.channelId
79
+ ? {
80
+ forwarded: {
81
+ text: forward.content ?? "",
82
+ source: channelKey("discord", reference.channelId),
83
+ url: `https://discord.com/channels/${reference.guildId ?? "@me"}/${reference.channelId}/${reference.messageId ?? ""}`,
84
+ },
85
+ }
86
+ : {}),
87
+ ...(referenced
88
+ ? {
89
+ reference: {
90
+ text: referenced.content.replace(mention, "").trim(),
91
+ attachments: attachmentRefs(referenced),
92
+ ...(referenced.webhookId
93
+ ? { webhookName: referenced.author.username }
94
+ : {}),
95
+ },
96
+ }
97
+ : {}),
98
+ };
99
+ }
@@ -1 +1,57 @@
1
- export type { InteractionModule } from "../contract/discord.ts";
1
+ import type {
2
+ APIApplicationCommandSubcommandGroupOption,
3
+ APIApplicationCommandSubcommandOption,
4
+ Interaction,
5
+ InteractionContextType,
6
+ RESTPostAPIChatInputApplicationCommandsJSONBody,
7
+ } from "discord.js";
8
+
9
+ /** A group of slash commands and the components they post, handled together. */
10
+ export interface InteractionModule {
11
+ /** Top-level commands of its own; never the configured root command. */
12
+ commands(): RESTPostAPIChatInputApplicationCommandsJSONBody[];
13
+ /** Resolves true when this module handled the interaction. */
14
+ handle(interaction: Interaction): Promise<boolean>;
15
+ }
16
+
17
+ /** A subcommand or subcommand group under the root command. */
18
+ export type RootOption =
19
+ | APIApplicationCommandSubcommandOption
20
+ | APIApplicationCommandSubcommandGroupOption;
21
+
22
+ /** A plugin's interactions: its module, and the subcommands it adds under the root command. */
23
+ export interface InteractionContribution {
24
+ module: InteractionModule;
25
+ rootOptions?: readonly RootOption[];
26
+ }
27
+
28
+ /** The one root command every plugin's subcommands go under, such as `/roundtable`. */
29
+ export interface CommandRoot {
30
+ name: string;
31
+ description: string;
32
+ contexts: readonly InteractionContextType[];
33
+ }
34
+
35
+ /**
36
+ * Takes the slash commands and components of the plugins. Plugins add theirs while they set up;
37
+ * the Discord plugin composes them in its preflight, so a clash fails before any service starts.
38
+ */
39
+ export interface CommandRegistrar {
40
+ /** Throws PluginError once the Discord plugin's preflight has composed the commands. */
41
+ add(contribution: InteractionContribution): void;
42
+ }
43
+
44
+ /** Who may use the owner's commands, and how a failing command answers. */
45
+ export interface CommandGuard {
46
+ /** The name of the root command the guard serves, without the slash. */
47
+ readonly root: string;
48
+ /**
49
+ * Text appended as it is to the refusal a non-owner gets, such as a pointer to the commands
50
+ * anyone may use; include the space or punctuation your language needs before it.
51
+ */
52
+ readonly refusalHint?: string;
53
+ /** Whether the actor is the owner: an interaction, or anything with `user.id`, such as a test's. */
54
+ isOwner(actor: { user: { id: string } }): boolean;
55
+ /** Runs a handler; a failure becomes a panel instead of a hanging interaction. */
56
+ run(interaction: Interaction, run: () => Promise<void>): Promise<void>;
57
+ }
@@ -12,6 +12,7 @@ import {
12
12
  TextInputBuilder,
13
13
  TextInputStyle,
14
14
  } from "discord.js";
15
+ import { parseChannelKey } from "../contract/surface.ts";
15
16
  import type { ChannelKey } from "../domain/conversation.ts";
16
17
  import type {
17
18
  Approval,
@@ -56,7 +57,7 @@ export interface CardMessage {
56
57
  /** Where a channel's cards are posted. */
57
58
  export interface CardChannel {
58
59
  send(payload: CardPayload): Promise<CardMessage>;
59
- /** A thread, where the owner is mentioned so he hears of the card; a channel never mentions him. */
60
+ /** A thread, where the owner is mentioned so they hear of the card; a channel never mentions them. */
60
61
  thread?: boolean;
61
62
  }
62
63
 
@@ -118,10 +119,9 @@ export class OwnerCards implements InteractionModule {
118
119
  return [];
119
120
  }
120
121
 
121
- /** The channel's cards; undefined for a conversation outside Discord. */
122
+ /** The channel's cards; the chat surface port sends only Discord keys here. */
122
123
  prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined {
123
- if (!channel.startsWith("discord:")) return undefined;
124
- const channelId = channel.slice("discord:".length);
124
+ const channelId = parseChannelKey(channel).id;
125
125
  return {
126
126
  confirm: (title, message, signal, minTier = "owner") =>
127
127
  this.#post<Approval>(channelId, signal, "expired", "cancelled", {
@@ -6,10 +6,14 @@ import {
6
6
  MessageFlags,
7
7
  SlashCommandSubcommandGroupBuilder,
8
8
  } from "discord.js";
9
- import type { CommandRoot, RootOption } from "../contract/discord.ts";
10
9
  import { messages } from "../i18n/index.ts";
11
10
  import type { Logger } from "../log.ts";
12
- import type { InteractionModule } from "./interaction-module.ts";
11
+ import type {
12
+ CommandGuard,
13
+ CommandRoot,
14
+ InteractionModule,
15
+ RootOption,
16
+ } from "./interaction-module.ts";
13
17
  import { ephemeralPanel, OwnerFacingError, ownerPanel } from "./owner-panel.ts";
14
18
 
15
19
  /** The owner's single control command, such as `/roundtable`, every feature adds its subcommands to. */
@@ -29,24 +33,31 @@ export const groupOption = (
29
33
  ): RootOption => build(new SlashCommandSubcommandGroupBuilder()).toJSON();
30
34
 
31
35
  /**
32
- * Only the owner may use the root command, checked by user ID; every answer is visible only to him,
36
+ * Only the owner may use the root command, checked by user ID; every answer is visible only to them,
33
37
  * and a failure becomes a panel instead of a hanging interaction.
34
38
  */
35
39
  // pi-lens-ignore: large-class — two methods: the owner check and the failure panel
36
- export class OwnerGuard {
40
+ export class OwnerGuard implements CommandGuard {
37
41
  readonly #ownerId: string;
38
42
  readonly #logger: Logger;
39
43
  /** The name of the root command the guard serves, without the slash. */
40
44
  readonly root: string;
45
+ readonly refusalHint: string | undefined;
41
46
 
42
- constructor(ownerId: string, logger: Logger, root: string) {
47
+ constructor(
48
+ ownerId: string,
49
+ logger: Logger,
50
+ root: string,
51
+ refusalHint?: string,
52
+ ) {
43
53
  this.#ownerId = ownerId;
44
54
  this.#logger = logger;
45
55
  this.root = root;
56
+ this.refusalHint = refusalHint;
46
57
  }
47
58
 
48
- isOwner(interaction: Interaction): boolean {
49
- return interaction.user.id === this.#ownerId;
59
+ isOwner(actor: { user: { id: string } }): boolean {
60
+ return actor.user.id === this.#ownerId;
50
61
  }
51
62
 
52
63
  /** Runs a handler; failures become a panel instead of a hanging interaction. */
@@ -69,6 +80,28 @@ export class OwnerGuard {
69
80
  }
70
81
  }
71
82
 
83
+ /** What `commandGuard` needs. */
84
+ export interface CommandGuardOptions {
85
+ /** The one user who may use the owner's commands. */
86
+ ownerId: string;
87
+ /** The name of the root command the guard serves, without the slash. */
88
+ root: string;
89
+ /** Where a failing command is logged. */
90
+ logger: Logger;
91
+ /** Text appended as it is to the refusal a non-owner gets. */
92
+ refusalHint?: string;
93
+ }
94
+
95
+ /** A guard for the owner's commands, such as the one a test hands a module; the Discord plugin makes its own. */
96
+ export function commandGuard(options: CommandGuardOptions): CommandGuard {
97
+ return new OwnerGuard(
98
+ options.ownerId,
99
+ options.logger,
100
+ options.root,
101
+ options.refusalHint,
102
+ );
103
+ }
104
+
72
105
  /** One feature's part of the root command. */
73
106
  export interface OwnerCommandHandlers {
74
107
  /** Whether a root subcommand, in its group or null at the top level, is this feature's. */
@@ -82,15 +115,15 @@ export interface OwnerCommandHandlers {
82
115
  component?(interaction: Interaction): Promise<boolean>;
83
116
  }
84
117
 
85
- const refusal = () =>
118
+ const refusal = (hint: string | undefined) =>
86
119
  ephemeralPanel({
87
120
  title: messages().ownerRefusalTitle,
88
- sections: [messages().ownerRefusalBody],
121
+ sections: [`${messages().ownerRefusalBody}${hint ?? ""}`],
89
122
  });
90
123
 
91
124
  /** Answers a feature's root subcommands and components, for the owner only. */
92
125
  export function ownerCommandModule(
93
- guard: OwnerGuard,
126
+ guard: CommandGuard,
94
127
  handlers: OwnerCommandHandlers,
95
128
  ): InteractionModule {
96
129
  const owned = (
@@ -114,7 +147,7 @@ export function ownerCommandModule(
114
147
  if (!interaction.isChatInputCommand() || !owned(interaction))
115
148
  return false;
116
149
  if (!guard.isOwner(interaction)) {
117
- await interaction.reply(refusal());
150
+ await interaction.reply(refusal(guard.refusalHint));
118
151
  return true;
119
152
  }
120
153
  if (await handlers.open?.(interaction)) return true;
@@ -5,7 +5,7 @@ import {
5
5
  PermissionFlagsBits,
6
6
  } from "discord.js";
7
7
  import { messages } from "../i18n/index.ts";
8
- import type { OwnerDiscord } from "../modules/discord-admin/discord-admin.ts";
8
+ import type { OwnerOperations } from "../modules/discord-admin/discord-admin.ts";
9
9
  import {
10
10
  discordChannelExecutor,
11
11
  fetchOwnerChannel,
@@ -29,7 +29,7 @@ export { outranksMember, outranksRole } from "./owner-discord-access.ts";
29
29
  * Discord operations for the owner's agent over the bot's connection. Every call is bounded
30
30
  * by what both the owner and the bot may do where it acts, read fresh from Discord.
31
31
  */
32
- export class DiscordOwnerOps implements OwnerDiscord {
32
+ export class DiscordOwnerOps implements OwnerOperations {
33
33
  readonly #client: Client;
34
34
  readonly #ownerId: string;
35
35
  /** The audit-log reason of an action taken without one of its own. */
@@ -3,18 +3,15 @@ import type {
3
3
  ChatInputCommandInteraction,
4
4
  SlashCommandSubcommandGroupBuilder,
5
5
  } from "discord.js";
6
- import type { InteractionContribution } from "../contract/discord.ts";
7
6
  import { messages } from "../i18n/index.ts";
8
7
  import { describeRecurrence } from "../modules/schedules/recurrence.ts";
8
+ import type { Schedule } from "../modules/schedules/schedule-store.ts";
9
+ import type { ScheduleStore } from "../services.ts";
9
10
  import type {
10
- Schedule,
11
- ScheduleStore,
12
- } from "../modules/schedules/schedule-store.ts";
13
- import {
14
- groupOption,
15
- type OwnerGuard,
16
- ownerCommandModule,
17
- } from "./owner-command.ts";
11
+ CommandGuard,
12
+ InteractionContribution,
13
+ } from "./interaction-module.ts";
14
+ import { groupOption, ownerCommandModule } from "./owner-command.ts";
18
15
  import {
19
16
  OwnerFacingError,
20
17
  ownerPanel,
@@ -52,7 +49,10 @@ function channelMention(schedule: Schedule): string {
52
49
 
53
50
  const unix = (at: Date) => Math.floor(at.getTime() / 1000);
54
51
 
55
- function section(schedule: Schedule): string {
52
+ /** How a schedule's target reads in a list; the target's name when no plugin contributes it. */
53
+ export type TargetLabel = (target: string) => string;
54
+
55
+ function section(schedule: Schedule, label: TargetLabel): string {
56
56
  const prompt =
57
57
  schedule.prompt.length > PROMPT_PREVIEW
58
58
  ? `${schedule.prompt.slice(0, PROMPT_PREVIEW)}…`
@@ -64,14 +64,12 @@ function section(schedule: Schedule): string {
64
64
  plain(schedule.lastStatus ?? "?"),
65
65
  )
66
66
  : "";
67
- const mode =
68
- schedule.mode === "owner" ? text.scheduleModeOwner : text.scheduleModeParty;
69
67
  return [
70
68
  text.scheduleHead(
71
69
  schedule.id,
72
70
  plain(schedule.title),
73
71
  channelMention(schedule),
74
- mode,
72
+ plain(label(schedule.target)),
75
73
  ),
76
74
  text.scheduleNextRun(
77
75
  describeRecurrence(schedule.recurrence),
@@ -85,9 +83,11 @@ function section(schedule: Schedule): string {
85
83
  /** `/<root> schedule`: the owner's view over every channel's schedules. */
86
84
  export class ScheduleCommands {
87
85
  readonly #store: ScheduleStore;
86
+ readonly #label: TargetLabel;
88
87
 
89
- constructor(store: ScheduleStore) {
88
+ constructor(store: ScheduleStore, label: TargetLabel) {
90
89
  this.#store = store;
90
+ this.#label = label;
91
91
  }
92
92
 
93
93
  async autocomplete(interaction: AutocompleteInteraction): Promise<void> {
@@ -110,7 +110,9 @@ export class ScheduleCommands {
110
110
  const all = await this.#store.all();
111
111
  await replyWithPanels(interaction, {
112
112
  title: messages().scheduleTitle,
113
- sections: all.length ? all.map(section) : [messages().scheduleNone],
113
+ sections: all.length
114
+ ? all.map((s) => section(s, this.#label))
115
+ : [messages().scheduleNone],
114
116
  footer: messages().scheduleFooter,
115
117
  });
116
118
  }
@@ -126,7 +128,7 @@ export class ScheduleCommands {
126
128
  await interaction.editReply(
127
129
  ownerPanel({
128
130
  title: messages().scheduleCancelled,
129
- sections: [section(removed)],
131
+ sections: [section(removed, this.#label)],
130
132
  }),
131
133
  );
132
134
  }
@@ -134,10 +136,11 @@ export class ScheduleCommands {
134
136
 
135
137
  /** `/<root> schedule list|cancel`. */
136
138
  export function scheduleCommands(
137
- guard: OwnerGuard,
139
+ guard: CommandGuard,
138
140
  store: ScheduleStore,
141
+ label: TargetLabel,
139
142
  ): InteractionContribution {
140
- const commands = new ScheduleCommands(store);
143
+ const commands = new ScheduleCommands(store, label);
141
144
  return {
142
145
  module: ownerCommandModule(guard, {
143
146
  owns: (group) => group === "schedule",