pi-roundtable 0.1.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 (315) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +140 -0
  4. package/docs/plugins.md +924 -0
  5. package/examples/channels.test.ts +13 -0
  6. package/examples/channels.ts +27 -0
  7. package/examples/dashboard.test.ts +11 -0
  8. package/examples/dashboard.ts +9 -0
  9. package/examples/events.test.ts +26 -0
  10. package/examples/events.ts +25 -0
  11. package/examples/guide.test.ts +52 -0
  12. package/examples/holds.test.ts +16 -0
  13. package/examples/holds.ts +30 -0
  14. package/examples/http.test.ts +12 -0
  15. package/examples/http.ts +17 -0
  16. package/examples/interactions.test.ts +25 -0
  17. package/examples/interactions.ts +28 -0
  18. package/examples/migrations.test.ts +31 -0
  19. package/examples/migrations.ts +42 -0
  20. package/examples/packages.test.ts +9 -0
  21. package/examples/packages.ts +9 -0
  22. package/examples/preflight.test.ts +11 -0
  23. package/examples/preflight.ts +19 -0
  24. package/examples/prompt.test.ts +25 -0
  25. package/examples/prompt.ts +20 -0
  26. package/examples/providers.test.ts +10 -0
  27. package/examples/providers.ts +17 -0
  28. package/examples/seeds.test.ts +12 -0
  29. package/examples/seeds.ts +18 -0
  30. package/examples/selection.test.ts +13 -0
  31. package/examples/selection.ts +14 -0
  32. package/examples/services.test.ts +20 -0
  33. package/examples/services.ts +30 -0
  34. package/examples/session-tools.test.ts +24 -0
  35. package/examples/session-tools.ts +34 -0
  36. package/examples/tools.test.ts +18 -0
  37. package/examples/tools.ts +28 -0
  38. package/package.json +55 -0
  39. package/src/cli/add-plugin.test.ts +107 -0
  40. package/src/cli/add-plugin.ts +75 -0
  41. package/src/cli/checks/basic.test.ts +276 -0
  42. package/src/cli/checks/bun.ts +26 -0
  43. package/src/cli/checks/configuration.ts +28 -0
  44. package/src/cli/checks/database.test.ts +117 -0
  45. package/src/cli/checks/database.ts +100 -0
  46. package/src/cli/checks/discord.test.ts +200 -0
  47. package/src/cli/checks/discord.ts +218 -0
  48. package/src/cli/checks/environment.ts +27 -0
  49. package/src/cli/checks/model.ts +28 -0
  50. package/src/cli/checks/public-url.ts +40 -0
  51. package/src/cli/cli.test.ts +122 -0
  52. package/src/cli/cli.ts +166 -0
  53. package/src/cli/config-edit.test.ts +130 -0
  54. package/src/cli/config-edit.ts +199 -0
  55. package/src/cli/discord-api.ts +96 -0
  56. package/src/cli/doctor.test.ts +184 -0
  57. package/src/cli/doctor.ts +92 -0
  58. package/src/cli/http.ts +23 -0
  59. package/src/cli/init.test.ts +127 -0
  60. package/src/cli/init.ts +82 -0
  61. package/src/cli/main.ts +13 -0
  62. package/src/cli/project.ts +123 -0
  63. package/src/cli/report.ts +68 -0
  64. package/src/cli/roundtable.mjs +22 -0
  65. package/src/cli/runtime.ts +53 -0
  66. package/src/cli/size.test.ts +9 -0
  67. package/src/cli/start.ts +33 -0
  68. package/src/cli/templates.test.ts +129 -0
  69. package/src/cli/templates.ts +119 -0
  70. package/src/cli/testing/fixtures.ts +106 -0
  71. package/src/core/agents/agent-claim.test.ts +142 -0
  72. package/src/core/agents/agent-claim.ts +170 -0
  73. package/src/core/agents/agent-dashboard.test.ts +120 -0
  74. package/src/core/agents/agent-dashboard.ts +185 -0
  75. package/src/core/agents/agent-guild.test.ts +124 -0
  76. package/src/core/agents/agent-messages.ts +189 -0
  77. package/src/core/agents/agent-ports.ts +103 -0
  78. package/src/core/agents/agent-prompt.ts +163 -0
  79. package/src/core/agents/agent-rules.ts +106 -0
  80. package/src/core/agents/agent-schema.ts +115 -0
  81. package/src/core/agents/agent-settings.ts +49 -0
  82. package/src/core/agents/agent-store.test.ts +205 -0
  83. package/src/core/agents/agent-store.ts +384 -0
  84. package/src/core/agents/agent-team.ts +445 -0
  85. package/src/core/agents/agent-tools.ts +386 -0
  86. package/src/core/agents/avatar-studio.test.ts +88 -0
  87. package/src/core/agents/avatar-studio.ts +147 -0
  88. package/src/core/agents/group-messages.ts +99 -0
  89. package/src/core/agents/group-round.test.ts +143 -0
  90. package/src/core/agents/group-round.ts +117 -0
  91. package/src/core/agents/group-turns.ts +132 -0
  92. package/src/core/agents/owner-identity.test.ts +172 -0
  93. package/src/core/agents/team-editing.ts +205 -0
  94. package/src/core/agents/team-keys.ts +40 -0
  95. package/src/core/agents/team-layout.ts +135 -0
  96. package/src/core/agents/team-lifecycle.ts +188 -0
  97. package/src/core/agents/team-options.ts +52 -0
  98. package/src/core/agents/team-status.ts +80 -0
  99. package/src/core/agents/team-text.ts +60 -0
  100. package/src/core/agents/team-turn-types.ts +107 -0
  101. package/src/core/agents/team-turns.test.ts +278 -0
  102. package/src/core/agents/team-turns.ts +323 -0
  103. package/src/core/assets/neutral.png +0 -0
  104. package/src/core/assets/prompts/shared-guest.md +11 -0
  105. package/src/core/assets/prompts/shared.md +11 -0
  106. package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
  107. package/src/core/attachments/attachment-dir.ts +15 -0
  108. package/src/core/attachments/attachment-fetcher.ts +83 -0
  109. package/src/core/attachments/attachments.test.ts +85 -0
  110. package/src/core/attachments/image-prep.ts +50 -0
  111. package/src/core/attachments/prompt-block.ts +37 -0
  112. package/src/core/attachments/turn-attachments.ts +79 -0
  113. package/src/core/boundary.test.ts +45 -0
  114. package/src/core/builtin/agent-server.ts +275 -0
  115. package/src/core/builtin/discord.ts +95 -0
  116. package/src/core/builtin/modules.test.ts +127 -0
  117. package/src/core/builtin/modules.ts +176 -0
  118. package/src/core/builtin/seeds.ts +10 -0
  119. package/src/core/builtin/stores.ts +42 -0
  120. package/src/core/config/config.test.ts +135 -0
  121. package/src/core/config/config.ts +285 -0
  122. package/src/core/config/schema.ts +181 -0
  123. package/src/core/contract/channels.ts +152 -0
  124. package/src/core/contract/discord.ts +33 -0
  125. package/src/core/contract/providers.ts +67 -0
  126. package/src/core/db/guild-scope.ts +53 -0
  127. package/src/core/db/migrations.test.ts +237 -0
  128. package/src/core/db/migrations.ts +37 -0
  129. package/src/core/define-roundtable.test.ts +134 -0
  130. package/src/core/define-roundtable.ts +195 -0
  131. package/src/core/define.test.ts +144 -0
  132. package/src/core/define.ts +154 -0
  133. package/src/core/discord/agent-commands.test.ts +58 -0
  134. package/src/core/discord/agent-commands.ts +385 -0
  135. package/src/core/discord/agent-discord.test.ts +70 -0
  136. package/src/core/discord/agent-discord.ts +367 -0
  137. package/src/core/discord/channel-executor.ts +404 -0
  138. package/src/core/discord/channel-operations.ts +329 -0
  139. package/src/core/discord/discord-surface.ts +431 -0
  140. package/src/core/discord/dispatch-thread-host.test.ts +114 -0
  141. package/src/core/discord/dispatch-thread-host.ts +72 -0
  142. package/src/core/discord/dispatch-threads.test.ts +94 -0
  143. package/src/core/discord/dispatch-threads.ts +180 -0
  144. package/src/core/discord/interaction-module.ts +1 -0
  145. package/src/core/discord/owner-cards.test.ts +435 -0
  146. package/src/core/discord/owner-cards.ts +463 -0
  147. package/src/core/discord/owner-command.ts +126 -0
  148. package/src/core/discord/owner-discord-access.ts +183 -0
  149. package/src/core/discord/owner-discord-threads.ts +160 -0
  150. package/src/core/discord/owner-discord.test.ts +325 -0
  151. package/src/core/discord/owner-discord.ts +282 -0
  152. package/src/core/discord/owner-panel.ts +103 -0
  153. package/src/core/discord/schedule-commands.ts +149 -0
  154. package/src/core/discord/stop-button.ts +20 -0
  155. package/src/core/domain/attachment.ts +39 -0
  156. package/src/core/domain/conversation.ts +42 -0
  157. package/src/core/domain/errors.ts +31 -0
  158. package/src/core/domain/expression.ts +21 -0
  159. package/src/core/domain/owner-prompts.ts +45 -0
  160. package/src/core/domain/ports.ts +91 -0
  161. package/src/core/domain/profile.ts +32 -0
  162. package/src/core/drain.test.ts +46 -0
  163. package/src/core/drain.ts +33 -0
  164. package/src/core/errors.ts +29 -0
  165. package/src/core/events.test.ts +81 -0
  166. package/src/core/events.ts +55 -0
  167. package/src/core/holds.test.ts +54 -0
  168. package/src/core/holds.ts +46 -0
  169. package/src/core/host.test.ts +536 -0
  170. package/src/core/host.ts +297 -0
  171. package/src/core/http/listeners.test.ts +164 -0
  172. package/src/core/http/listeners.ts +151 -0
  173. package/src/core/i18n/agent-panel.ts +96 -0
  174. package/src/core/i18n/cards.ts +64 -0
  175. package/src/core/i18n/channels.ts +28 -0
  176. package/src/core/i18n/conversation.ts +49 -0
  177. package/src/core/i18n/dashboard.ts +72 -0
  178. package/src/core/i18n/discord.ts +23 -0
  179. package/src/core/i18n/en.ts +27 -0
  180. package/src/core/i18n/i18n.test.ts +139 -0
  181. package/src/core/i18n/index.ts +58 -0
  182. package/src/core/i18n/owner.ts +35 -0
  183. package/src/core/i18n/schedules.ts +92 -0
  184. package/src/core/i18n/time-zones.ts +27 -0
  185. package/src/core/i18n/types.ts +7 -0
  186. package/src/core/i18n/zh-tw.ts +26 -0
  187. package/src/core/identity.test.ts +18 -0
  188. package/src/core/identity.ts +31 -0
  189. package/src/core/judging/confirmation-judge.ts +68 -0
  190. package/src/core/judging/effort-judge.test.ts +112 -0
  191. package/src/core/judging/effort-judge.ts +158 -0
  192. package/src/core/judging/model-judge.test.ts +126 -0
  193. package/src/core/judging/model-judge.ts +193 -0
  194. package/src/core/log.ts +45 -0
  195. package/src/core/models.ts +52 -0
  196. package/src/core/modules/background/background-turns.ts +78 -0
  197. package/src/core/modules/delegation/delegate.ts +54 -0
  198. package/src/core/modules/delegation/delegator.test.ts +170 -0
  199. package/src/core/modules/delegation/delegator.ts +191 -0
  200. package/src/core/modules/delegation/sol-worker.ts +78 -0
  201. package/src/core/modules/discord-admin/discord-admin.ts +288 -0
  202. package/src/core/modules/host-shell/shell-policy.ts +386 -0
  203. package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
  204. package/src/core/modules/memory/owner-memory-store.ts +228 -0
  205. package/src/core/modules/memory/owner-memory.ts +153 -0
  206. package/src/core/modules/notify/notify.ts +26 -0
  207. package/src/core/modules/schedules/recurrence.ts +206 -0
  208. package/src/core/modules/schedules/schedule-store.ts +213 -0
  209. package/src/core/modules/schedules/schedule-tools.ts +196 -0
  210. package/src/core/modules/schedules/schedule.test.ts +358 -0
  211. package/src/core/modules/schedules/scheduler.ts +121 -0
  212. package/src/core/modules/schedules/schedules.ts +87 -0
  213. package/src/core/modules/skills/repo-name.ts +11 -0
  214. package/src/core/modules/skills/skill-kind.test.ts +55 -0
  215. package/src/core/modules/skills/skill-link.ts +69 -0
  216. package/src/core/modules/skills/skill-listing.ts +128 -0
  217. package/src/core/modules/skills/skill-registry.test.ts +376 -0
  218. package/src/core/modules/skills/skill-registry.ts +323 -0
  219. package/src/core/modules/skills/skill-rules.ts +120 -0
  220. package/src/core/modules/skills/skill-store.ts +285 -0
  221. package/src/core/modules/skills/skill-tools.ts +207 -0
  222. package/src/core/ops/error-reporter.test.ts +283 -0
  223. package/src/core/ops/error-reporter.ts +271 -0
  224. package/src/core/plugin.ts +200 -0
  225. package/src/core/presentation/card-cadence.ts +41 -0
  226. package/src/core/presentation/headline.ts +34 -0
  227. package/src/core/presentation/presentation.test.ts +166 -0
  228. package/src/core/presentation/quiet-links.ts +36 -0
  229. package/src/core/presentation/reply-splitter.ts +110 -0
  230. package/src/core/presentation/thinking-line.ts +24 -0
  231. package/src/core/public-entry.test.ts +59 -0
  232. package/src/core/registry/contributions.test.ts +215 -0
  233. package/src/core/registry/contributions.ts +249 -0
  234. package/src/core/registry/interactions.test.ts +87 -0
  235. package/src/core/registry/interactions.ts +55 -0
  236. package/src/core/registry/providers.test.ts +103 -0
  237. package/src/core/registry/providers.ts +55 -0
  238. package/src/core/routing/channel-queue.test.ts +31 -0
  239. package/src/core/routing/channel-queue.ts +44 -0
  240. package/src/core/routing/channel-router.test.ts +326 -0
  241. package/src/core/routing/channel-router.ts +157 -0
  242. package/src/core/routing/conversation-kind.ts +14 -0
  243. package/src/core/routing/forward-join.ts +65 -0
  244. package/src/core/routing/message-text.ts +16 -0
  245. package/src/core/routing/settle-turn.test.ts +21 -0
  246. package/src/core/routing/settle-turn.ts +26 -0
  247. package/src/core/runtime/compaction-tiers.test.ts +227 -0
  248. package/src/core/runtime/compaction-tiers.ts +203 -0
  249. package/src/core/runtime/conversation-sessions.ts +162 -0
  250. package/src/core/runtime/extensions/agent-prompt.ts +16 -0
  251. package/src/core/runtime/extensions/ask-user.test.ts +114 -0
  252. package/src/core/runtime/extensions/ask-user.ts +98 -0
  253. package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
  254. package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
  255. package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
  256. package/src/core/runtime/mcp.ts +23 -0
  257. package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
  258. package/src/core/runtime/pending-confirmation-store.ts +75 -0
  259. package/src/core/runtime/pi-agent-runtime.ts +477 -0
  260. package/src/core/runtime/prompt-slot.ts +81 -0
  261. package/src/core/runtime/runtime-types.ts +150 -0
  262. package/src/core/runtime/session-archive.test.ts +20 -0
  263. package/src/core/runtime/session-archive.ts +17 -0
  264. package/src/core/runtime/session-factory.ts +291 -0
  265. package/src/core/runtime/steerable-run.test.ts +264 -0
  266. package/src/core/runtime/steerable-run.ts +109 -0
  267. package/src/core/runtime/text-tools.test.ts +110 -0
  268. package/src/core/runtime/text-tools.ts +66 -0
  269. package/src/core/runtime/turn-answer.test.ts +72 -0
  270. package/src/core/runtime/turn-answer.ts +79 -0
  271. package/src/core/runtime/worker-task.test.ts +82 -0
  272. package/src/core/runtime/worker-task.ts +65 -0
  273. package/src/core/services.test.ts +28 -0
  274. package/src/core/services.ts +107 -0
  275. package/src/core/sessions.test.ts +85 -0
  276. package/src/core/sessions.ts +149 -0
  277. package/src/core/shared/attachment-reader.ts +91 -0
  278. package/src/core/shared/delegate-tool.ts +15 -0
  279. package/src/core/shared/mcp-adapter.ts +58 -0
  280. package/src/core/shared/package-dir.ts +9 -0
  281. package/src/core/shared/profile-tools.ts +16 -0
  282. package/src/core/shared/read-attachment-tool.ts +43 -0
  283. package/src/core/shared/schedule-tools.ts +118 -0
  284. package/src/core/shared/session-messages.ts +50 -0
  285. package/src/core/shared/tool-result.ts +9 -0
  286. package/src/core/shared/unix-server.ts +18 -0
  287. package/src/core/size.test.ts +9 -0
  288. package/src/core/speakers.test.ts +78 -0
  289. package/src/core/speakers.ts +143 -0
  290. package/src/core/testing/database.ts +52 -0
  291. package/src/core/testing/file-size.ts +34 -0
  292. package/src/core/testing/locale.ts +8 -0
  293. package/src/core/testing/modules.ts +97 -0
  294. package/src/core/testing/owner.ts +15 -0
  295. package/src/core/testing/thread-host.ts +60 -0
  296. package/src/core/time.test.ts +166 -0
  297. package/src/core/time.ts +67 -0
  298. package/src/core/tool-tiers.test.ts +117 -0
  299. package/src/core/tool-tiers.ts +127 -0
  300. package/src/entries.test.ts +112 -0
  301. package/src/index.ts +28 -0
  302. package/src/testing.test.ts +156 -0
  303. package/src/testing.ts +182 -0
  304. package/templates/.env.example +22 -0
  305. package/templates/README.md +11 -0
  306. package/templates/_gitignore +3 -0
  307. package/templates/agents.ts +12 -0
  308. package/templates/biome.json.tmpl +7 -0
  309. package/templates/docker-compose.yml +16 -0
  310. package/templates/package.json.tmpl +24 -0
  311. package/templates/persona/shared.md +2 -0
  312. package/templates/plugin/plugin.test.ts.tmpl +13 -0
  313. package/templates/plugin/plugin.ts +18 -0
  314. package/templates/roundtable.config.ts +24 -0
  315. package/templates/tsconfig.json.tmpl +16 -0
@@ -0,0 +1,463 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ ActionRowBuilder,
4
+ ButtonBuilder,
5
+ ButtonStyle,
6
+ type Interaction,
7
+ LabelBuilder,
8
+ MessageFlags,
9
+ ModalBuilder,
10
+ StringSelectMenuBuilder,
11
+ TextDisplayBuilder,
12
+ TextInputBuilder,
13
+ TextInputStyle,
14
+ } from "discord.js";
15
+ import type { ChannelKey } from "../domain/conversation.ts";
16
+ import type {
17
+ Approval,
18
+ OwnerAnswer,
19
+ OwnerPrompts,
20
+ OwnerQuestion,
21
+ } from "../domain/owner-prompts.ts";
22
+ import { messages } from "../i18n/index.ts";
23
+ import type { Logger } from "../log.ts";
24
+ import {
25
+ type Speaker,
26
+ type SpeakerPolicy,
27
+ type Tier,
28
+ tierAtLeast,
29
+ } from "../speakers.ts";
30
+ import type { InteractionModule } from "./interaction-module.ts";
31
+ import { ownerPanel, type PanelContent, plain } from "./owner-panel.ts";
32
+
33
+ export const CARD_PREFIX = "roundtable:card:";
34
+ /** An unanswered card expires after this long. */
35
+ export const CARD_TIMEOUT_MS = 30 * 60_000;
36
+ const OTHER = "other";
37
+ const TEXT_FIELD = "text";
38
+
39
+ /** A card's message: the panel, led by a mention of the owner in a thread. */
40
+ export type CardPayload = Omit<
41
+ ReturnType<typeof ownerPanel>,
42
+ "components" | "allowedMentions"
43
+ > & {
44
+ components: (
45
+ | ReturnType<typeof ownerPanel>["components"][number]
46
+ | TextDisplayBuilder
47
+ )[];
48
+ allowedMentions: { parse?: never[]; users?: string[] };
49
+ };
50
+
51
+ /** The posted card, which is edited to show how it ended. */
52
+ export interface CardMessage {
53
+ edit(payload: CardPayload): Promise<unknown>;
54
+ }
55
+
56
+ /** Where a channel's cards are posted. */
57
+ export interface CardChannel {
58
+ 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
+ thread?: boolean;
61
+ }
62
+
63
+ export interface OwnerCardsOptions {
64
+ ownerId: string;
65
+ /** Decides who holds a tier, for cards that lower tiers may answer; without it only the owner answers. */
66
+ speakers?: SpeakerPolicy;
67
+ /** The Discord channel by id; throws when it cannot take messages. */
68
+ channel(channelId: string): Promise<CardChannel>;
69
+ logger: Logger;
70
+ /** CARD_TIMEOUT_MS by default. */
71
+ timeoutMs?: number;
72
+ }
73
+
74
+ type Rows = NonNullable<Parameters<typeof ownerPanel>[0]["rows"]>;
75
+
76
+ /** One open card: how it looks, and how it ends. */
77
+ interface OpenCard {
78
+ title: string;
79
+ sections: string[];
80
+ /** The card's controls, disabled once it has ended. */
81
+ rows(disabled: boolean): Rows;
82
+ /** Ends the card with its answer; the answering interaction shows the outcome. */
83
+ end(value: unknown): void;
84
+ question?: OwnerQuestion;
85
+ /** Options chosen together with "Other…", kept while its form is open. */
86
+ picked?: string[];
87
+ /** Posted in a thread, so it leads with the mention of who may answer. */
88
+ mention?: boolean;
89
+ /** Who may answer, and what to tell someone else who presses it. */
90
+ audience: Audience;
91
+ }
92
+
93
+ /** The people a card is for. */
94
+ interface Audience {
95
+ allows(user: { id: string; roleIds: readonly string[] }): boolean;
96
+ /** The user the card mentions in a thread. */
97
+ mentionId: string;
98
+ /** Shown on the card. */
99
+ note: string;
100
+ refusal: string;
101
+ }
102
+
103
+ /**
104
+ * The owner's cards in the assistant's channels: approvals of held actions and ask_user questions,
105
+ * answered with buttons, a menu, or a form. Only the owner may answer; an unanswered card
106
+ * expires after 30 minutes and a stopped turn cancels it. Open cards live in memory, so a
107
+ * restart abandons them; pressing one then says it no longer works.
108
+ */
109
+ export class OwnerCards implements InteractionModule {
110
+ readonly #options: OwnerCardsOptions;
111
+ readonly #open = new Map<string, OpenCard>();
112
+
113
+ constructor(options: OwnerCardsOptions) {
114
+ this.#options = options;
115
+ }
116
+
117
+ commands() {
118
+ return [];
119
+ }
120
+
121
+ /** The channel's cards; undefined for a conversation outside Discord. */
122
+ prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined {
123
+ if (!channel.startsWith("discord:")) return undefined;
124
+ const channelId = channel.slice("discord:".length);
125
+ return {
126
+ confirm: (title, message, signal, minTier = "owner") =>
127
+ this.#post<Approval>(channelId, signal, "expired", "cancelled", {
128
+ audience: this.#approvers(minTier, speaker),
129
+ title,
130
+ sections: [message],
131
+ rows: (id, disabled) => [
132
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
133
+ new ButtonBuilder()
134
+ .setCustomId(`${CARD_PREFIX}${id}:yes`)
135
+ .setLabel(messages().cardRunLabel)
136
+ .setEmoji("✅")
137
+ .setStyle(ButtonStyle.Success)
138
+ .setDisabled(disabled),
139
+ new ButtonBuilder()
140
+ .setCustomId(`${CARD_PREFIX}${id}:no`)
141
+ .setLabel(messages().cardCancelLabel)
142
+ .setEmoji("❌")
143
+ .setStyle(ButtonStyle.Secondary)
144
+ .setDisabled(disabled),
145
+ ),
146
+ ],
147
+ footer: messages().cardApprovalFooter,
148
+ }),
149
+ ask: (title, question, signal) =>
150
+ this.#post<OwnerAnswer | undefined>(
151
+ channelId,
152
+ signal,
153
+ undefined,
154
+ undefined,
155
+ {
156
+ audience: this.#asker(speaker),
157
+ title,
158
+ sections: [question.question],
159
+ question,
160
+ rows: (id, disabled) => askRows(id, question, disabled),
161
+ footer: messages().cardQuestionFooter,
162
+ },
163
+ ),
164
+ };
165
+ }
166
+
167
+ /** Posts a card and resolves with its answer, `expired`, or `cancelled`. */
168
+ #post<T>(
169
+ channelId: string,
170
+ signal: AbortSignal | undefined,
171
+ expired: T,
172
+ cancelled: T,
173
+ card: {
174
+ audience: Audience;
175
+ title: string;
176
+ sections: string[];
177
+ question?: OwnerQuestion;
178
+ rows(id: string, disabled: boolean): Rows;
179
+ footer: string;
180
+ },
181
+ ): Promise<T> {
182
+ const { logger, timeoutMs = CARD_TIMEOUT_MS } = this.#options;
183
+ if (signal?.aborted) return Promise.resolve(cancelled);
184
+ const id = randomUUID();
185
+ const sections = [
186
+ ...card.sections,
187
+ ...(card.audience.note ? [card.audience.note] : []),
188
+ ];
189
+ return new Promise<T>((resolve) => {
190
+ let message: CardMessage | undefined;
191
+ let finish: string | undefined;
192
+ const settle = (value: T, outcome: string | undefined) => {
193
+ if (!this.#open.delete(id)) return;
194
+ clearTimeout(timer);
195
+ signal?.removeEventListener("abort", abort);
196
+ // A card ended by an answer is edited by the answering interaction.
197
+ if (outcome !== undefined) {
198
+ finish = outcome;
199
+ void message
200
+ ?.edit(view(outcome))
201
+ .catch((error: unknown) =>
202
+ logger.warn({ channelId, err: error }, "card not closed"),
203
+ );
204
+ }
205
+ resolve(value);
206
+ };
207
+ const view = (footer: string, disabled = true) =>
208
+ this.#panel(
209
+ {
210
+ title: card.title,
211
+ sections,
212
+ rows: card.rows(id, disabled),
213
+ footer,
214
+ },
215
+ open.mention === true,
216
+ card.audience.mentionId,
217
+ );
218
+ const abort = () => settle(cancelled, messages().cardStopped);
219
+ const timer = setTimeout(
220
+ () => settle(expired, messages().cardExpired),
221
+ timeoutMs,
222
+ );
223
+ signal?.addEventListener("abort", abort, { once: true });
224
+ const open: OpenCard = {
225
+ title: card.title,
226
+ sections,
227
+ rows: (disabled) => card.rows(id, disabled),
228
+ audience: card.audience,
229
+ end: (value) => settle(value as T, undefined),
230
+ ...(card.question ? { question: card.question } : {}),
231
+ };
232
+ this.#open.set(id, open);
233
+ this.#options
234
+ .channel(channelId)
235
+ .then((channel) => {
236
+ open.mention = channel.thread === true;
237
+ return channel.send(view(card.footer, false));
238
+ })
239
+ .then((posted) => {
240
+ message = posted;
241
+ // Ended while it was being posted.
242
+ if (finish !== undefined) void posted.edit(view(finish));
243
+ })
244
+ .catch((error: unknown) => {
245
+ logger.warn({ channelId, err: error }, "card not posted");
246
+ settle(expired, undefined);
247
+ });
248
+ });
249
+ }
250
+
251
+ /** The card's panel; in a thread it leads with the mention of who may answer. */
252
+ #panel(content: PanelContent, mention: boolean, userId: string): CardPayload {
253
+ const panel = ownerPanel(content);
254
+ if (!mention) return panel;
255
+ return {
256
+ ...panel,
257
+ components: [
258
+ new TextDisplayBuilder().setContent(`<@${userId}>`),
259
+ ...panel.components,
260
+ ],
261
+ allowedMentions: { users: [userId] },
262
+ };
263
+ }
264
+
265
+ /** An approval is for the speakers whose tier holds the tool, the owner alone by default. */
266
+ #approvers(minTier: Tier, speaker: Speaker | undefined): Audience {
267
+ const { ownerId, speakers } = this.#options;
268
+ const mentionId =
269
+ speaker && tierAtLeast(speaker.tier, minTier) ? speaker.id : ownerId;
270
+ if (minTier === "owner" || !speakers)
271
+ return {
272
+ allows: (user) => user.id === ownerId,
273
+ mentionId: ownerId,
274
+ note: "",
275
+ refusal: messages().cardOwnerOnly,
276
+ };
277
+ return {
278
+ allows: (user) => {
279
+ const who = speakers.resolve({ ...user, name: "" });
280
+ return who !== undefined && tierAtLeast(who.tier, minTier);
281
+ },
282
+ mentionId,
283
+ note: messages().cardApproversNote(minTier),
284
+ refusal: messages().cardApproversRefusal(minTier),
285
+ };
286
+ }
287
+
288
+ /** A question is for the speaker whose turn asks it, and for the owner. */
289
+ #asker(speaker: Speaker | undefined): Audience {
290
+ const { ownerId } = this.#options;
291
+ if (!speaker || speaker.id === ownerId)
292
+ return {
293
+ allows: (user) => user.id === ownerId,
294
+ mentionId: ownerId,
295
+ note: "",
296
+ refusal: messages().cardOwnerOnly,
297
+ };
298
+ return {
299
+ allows: (user) => user.id === speaker.id || user.id === ownerId,
300
+ mentionId: speaker.id,
301
+ note: messages().cardAskerNote(speaker.id),
302
+ refusal: messages().cardAskerRefusal,
303
+ };
304
+ }
305
+
306
+ async handle(interaction: Interaction): Promise<boolean> {
307
+ if (
308
+ !(
309
+ interaction.isButton() ||
310
+ interaction.isStringSelectMenu() ||
311
+ interaction.isModalSubmit()
312
+ ) ||
313
+ !interaction.customId.startsWith(CARD_PREFIX)
314
+ )
315
+ return false;
316
+ const [id = "", action = ""] = interaction.customId
317
+ .slice(CARD_PREFIX.length)
318
+ .split(":");
319
+ const card = this.#open.get(id);
320
+ if (!card) {
321
+ await interaction.reply({
322
+ content: messages().cardInactive,
323
+ flags: MessageFlags.Ephemeral,
324
+ });
325
+ return true;
326
+ }
327
+ const { member } = interaction;
328
+ const roleIds =
329
+ member && "cache" in member.roles ? [...member.roles.cache.keys()] : [];
330
+ if (!card.audience.allows({ id: interaction.user.id, roleIds })) {
331
+ await interaction.reply({
332
+ content: card.audience.refusal,
333
+ flags: MessageFlags.Ephemeral,
334
+ });
335
+ return true;
336
+ }
337
+ const closed = (outcome: string) =>
338
+ this.#panel(
339
+ {
340
+ title: card.title,
341
+ sections: card.sections,
342
+ rows: card.rows(true),
343
+ footer: outcome,
344
+ },
345
+ card.mention === true,
346
+ card.audience.mentionId,
347
+ );
348
+ if (interaction.isButton() && (action === "yes" || action === "no")) {
349
+ const approved = action === "yes";
350
+ card.end(approved ? "approved" : "declined");
351
+ await interaction.update(
352
+ closed(approved ? messages().cardApproved : messages().cardDeclined),
353
+ );
354
+ return true;
355
+ }
356
+ const question = card.question;
357
+ if (!question) return true;
358
+ if (interaction.isButton() && action === "write") {
359
+ card.picked = [];
360
+ await interaction.showModal(answerModal(id));
361
+ return true;
362
+ }
363
+ if (interaction.isStringSelectMenu() && action === "pick") {
364
+ const values = interaction.values;
365
+ const choices = question.options
366
+ .filter((_option, i) => values.includes(String(i)))
367
+ .map((option) => option.label);
368
+ if (values.includes(OTHER)) {
369
+ card.picked = choices;
370
+ await interaction.showModal(answerModal(id));
371
+ return true;
372
+ }
373
+ const answer = { choices };
374
+ card.end(answer);
375
+ await interaction.update(closed(answeredLine(answer)));
376
+ return true;
377
+ }
378
+ if (interaction.isModalSubmit() && action === "text") {
379
+ const answer = {
380
+ choices: card.picked ?? [],
381
+ text: interaction.fields.getTextInputValue(TEXT_FIELD),
382
+ };
383
+ card.end(answer);
384
+ if (interaction.isFromMessage())
385
+ await interaction.update(closed(answeredLine(answer)));
386
+ else await interaction.deferUpdate();
387
+ return true;
388
+ }
389
+ return true;
390
+ }
391
+ }
392
+
393
+ /** A question's controls: a menu of its options, or a button that opens the answer form. */
394
+ function askRows(id: string, question: OwnerQuestion, disabled: boolean): Rows {
395
+ if (question.options.length === 0)
396
+ return [
397
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
398
+ new ButtonBuilder()
399
+ .setCustomId(`${CARD_PREFIX}${id}:write`)
400
+ .setLabel(messages().cardAnswerLabel)
401
+ .setStyle(ButtonStyle.Primary)
402
+ .setDisabled(disabled),
403
+ ),
404
+ ];
405
+ // Discord menus take 25 options; "Other…" takes the last place when the options fill it.
406
+ const options = question.options
407
+ .slice(0, question.allowOther ? 24 : 25)
408
+ .map((option, i) => ({
409
+ label: option.label.slice(0, 100),
410
+ value: String(i),
411
+ ...(option.description
412
+ ? { description: option.description.slice(0, 100) }
413
+ : {}),
414
+ }));
415
+ if (question.allowOther)
416
+ options.push({
417
+ label: messages().cardOtherLabel,
418
+ value: OTHER,
419
+ description: messages().cardOtherDescription,
420
+ });
421
+ return [
422
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
423
+ new StringSelectMenuBuilder()
424
+ .setCustomId(`${CARD_PREFIX}${id}:pick`)
425
+ .setPlaceholder(
426
+ question.multi
427
+ ? messages().cardPickManyPlaceholder
428
+ : messages().cardPickOnePlaceholder,
429
+ )
430
+ .setMinValues(1)
431
+ .setMaxValues(question.multi ? options.length : 1)
432
+ .addOptions(options)
433
+ .setDisabled(disabled),
434
+ ),
435
+ ];
436
+ }
437
+
438
+ function answerModal(id: string): ModalBuilder {
439
+ return new ModalBuilder()
440
+ .setCustomId(`${CARD_PREFIX}${id}:text`)
441
+ .setTitle(messages().cardModalTitle)
442
+ .addLabelComponents(
443
+ new LabelBuilder()
444
+ .setLabel(messages().cardModalField)
445
+ .setTextInputComponent(
446
+ new TextInputBuilder()
447
+ .setCustomId(TEXT_FIELD)
448
+ .setStyle(TextInputStyle.Paragraph)
449
+ .setRequired(true)
450
+ .setMaxLength(1000),
451
+ ),
452
+ );
453
+ }
454
+
455
+ function answeredLine(answer: OwnerAnswer): string {
456
+ const parts = [
457
+ ...answer.choices.map(plain),
458
+ ...(answer.text !== undefined
459
+ ? [messages().cardQuote(plain(answer.text))]
460
+ : []),
461
+ ];
462
+ return messages().cardAnswered(parts);
463
+ }
@@ -0,0 +1,126 @@
1
+ import {
2
+ type AutocompleteInteraction,
3
+ type ChatInputCommandInteraction,
4
+ type Interaction,
5
+ InteractionContextType,
6
+ MessageFlags,
7
+ SlashCommandSubcommandGroupBuilder,
8
+ } from "discord.js";
9
+ import type { CommandRoot, RootOption } from "../contract/discord.ts";
10
+ import { messages } from "../i18n/index.ts";
11
+ import type { Logger } from "../log.ts";
12
+ import type { InteractionModule } from "./interaction-module.ts";
13
+ import { ephemeralPanel, OwnerFacingError, ownerPanel } from "./owner-panel.ts";
14
+
15
+ /** The owner's single control command, such as `/roundtable`, every feature adds its subcommands to. */
16
+ export function ownerRootCommand(name: string): CommandRoot {
17
+ return {
18
+ name,
19
+ description: messages().ownerRootDescription,
20
+ contexts: [InteractionContextType.Guild, InteractionContextType.BotDM],
21
+ };
22
+ }
23
+
24
+ /** A subcommand group as the JSON the root command carries. */
25
+ export const groupOption = (
26
+ build: (
27
+ group: SlashCommandSubcommandGroupBuilder,
28
+ ) => SlashCommandSubcommandGroupBuilder,
29
+ ): RootOption => build(new SlashCommandSubcommandGroupBuilder()).toJSON();
30
+
31
+ /**
32
+ * Only the owner may use the root command, checked by user ID; every answer is visible only to him,
33
+ * and a failure becomes a panel instead of a hanging interaction.
34
+ */
35
+ // pi-lens-ignore: large-class — two methods: the owner check and the failure panel
36
+ export class OwnerGuard {
37
+ readonly #ownerId: string;
38
+ readonly #logger: Logger;
39
+ /** The name of the root command the guard serves, without the slash. */
40
+ readonly root: string;
41
+
42
+ constructor(ownerId: string, logger: Logger, root: string) {
43
+ this.#ownerId = ownerId;
44
+ this.#logger = logger;
45
+ this.root = root;
46
+ }
47
+
48
+ isOwner(interaction: Interaction): boolean {
49
+ return interaction.user.id === this.#ownerId;
50
+ }
51
+
52
+ /** Runs a handler; failures become a panel instead of a hanging interaction. */
53
+ async run(interaction: Interaction, run: () => Promise<void>): Promise<void> {
54
+ try {
55
+ await run();
56
+ } catch (error) {
57
+ const owned = error instanceof OwnerFacingError;
58
+ if (!owned) this.#logger.error({ err: error }, "owner command failed");
59
+ if (!interaction.isRepliable()) return;
60
+ const text = messages();
61
+ const panel = {
62
+ title: owned ? text.ownerFailedTitle : text.ownerErrorTitle,
63
+ sections: [owned ? error.message : text.ownerErrorBody],
64
+ };
65
+ if (interaction.deferred || interaction.replied)
66
+ await interaction.editReply(ownerPanel(panel));
67
+ else await interaction.reply(ephemeralPanel(panel));
68
+ }
69
+ }
70
+ }
71
+
72
+ /** One feature's part of the root command. */
73
+ export interface OwnerCommandHandlers {
74
+ /** Whether a root subcommand, in its group or null at the top level, is this feature's. */
75
+ owns(group: string | null, subcommand: string): boolean;
76
+ autocomplete?(interaction: AutocompleteInteraction): Promise<void>;
77
+ /** An answer that must be the interaction's first, such as a form; true when it answered. */
78
+ open?(interaction: ChatInputCommandInteraction): Promise<boolean>;
79
+ /** Answers a subcommand; the reply is already deferred and ephemeral. */
80
+ command(interaction: ChatInputCommandInteraction): Promise<void>;
81
+ /** Buttons, menus, and forms the feature posted; true when it handled one. */
82
+ component?(interaction: Interaction): Promise<boolean>;
83
+ }
84
+
85
+ const refusal = () =>
86
+ ephemeralPanel({
87
+ title: messages().ownerRefusalTitle,
88
+ sections: [messages().ownerRefusalBody],
89
+ });
90
+
91
+ /** Answers a feature's root subcommands and components, for the owner only. */
92
+ export function ownerCommandModule(
93
+ guard: OwnerGuard,
94
+ handlers: OwnerCommandHandlers,
95
+ ): InteractionModule {
96
+ const owned = (
97
+ interaction: AutocompleteInteraction | ChatInputCommandInteraction,
98
+ ) =>
99
+ interaction.commandName === guard.root &&
100
+ handlers.owns(
101
+ interaction.options.getSubcommandGroup(),
102
+ interaction.options.getSubcommand(false) ?? "",
103
+ );
104
+ return {
105
+ commands: () => [],
106
+ async handle(interaction) {
107
+ if (interaction.isAutocomplete()) {
108
+ if (!owned(interaction)) return false;
109
+ if (!guard.isOwner(interaction)) await interaction.respond([]);
110
+ else await handlers.autocomplete?.(interaction);
111
+ return true;
112
+ }
113
+ if (await handlers.component?.(interaction)) return true;
114
+ if (!interaction.isChatInputCommand() || !owned(interaction))
115
+ return false;
116
+ if (!guard.isOwner(interaction)) {
117
+ await interaction.reply(refusal());
118
+ return true;
119
+ }
120
+ if (await handlers.open?.(interaction)) return true;
121
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
122
+ await guard.run(interaction, () => handlers.command(interaction));
123
+ return true;
124
+ },
125
+ };
126
+ }