@addozhang/dsh-discord 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 (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/cordis.patch.yml +14 -0
  4. package/lib/client/DiscordSettingsCard.d.ts +6 -0
  5. package/lib/client/PluginCard.d.ts +43 -0
  6. package/lib/client/card-controller.d.ts +22 -0
  7. package/lib/client/card-form.d.ts +111 -0
  8. package/lib/client/fields.d.ts +50 -0
  9. package/lib/client/index.d.ts +12 -0
  10. package/lib/client/nav-icon.d.ts +21 -0
  11. package/lib/client/settings-model.d.ts +49 -0
  12. package/lib/client/slot-contract.d.ts +57 -0
  13. package/lib/client/snapshot-store.d.ts +9 -0
  14. package/lib/client.js +1622 -0
  15. package/lib/compose.d.ts +158 -0
  16. package/lib/compose.js +227 -0
  17. package/lib/credential.d.ts +20 -0
  18. package/lib/credential.js +24 -0
  19. package/lib/discord/commands.d.ts +45 -0
  20. package/lib/discord/commands.js +87 -0
  21. package/lib/discord/components.d.ts +29 -0
  22. package/lib/discord/components.js +57 -0
  23. package/lib/discord/delivery.d.ts +21 -0
  24. package/lib/discord/delivery.js +31 -0
  25. package/lib/discord/interaction-lifecycle.d.ts +42 -0
  26. package/lib/discord/interaction-lifecycle.js +78 -0
  27. package/lib/discord/rest.d.ts +74 -0
  28. package/lib/discord/rest.js +149 -0
  29. package/lib/discord/selector.d.ts +39 -0
  30. package/lib/discord/selector.js +70 -0
  31. package/lib/discord/thread-port.d.ts +37 -0
  32. package/lib/discord/thread-port.js +58 -0
  33. package/lib/dsh/api-proxy-face.d.ts +253 -0
  34. package/lib/dsh/api-proxy-face.js +370 -0
  35. package/lib/features/adapter-status.d.ts +111 -0
  36. package/lib/features/adapter-status.js +103 -0
  37. package/lib/features/approval-expiry.d.ts +27 -0
  38. package/lib/features/approval-expiry.js +36 -0
  39. package/lib/features/approval-routing.d.ts +50 -0
  40. package/lib/features/approval-routing.js +57 -0
  41. package/lib/features/approval-store.d.ts +86 -0
  42. package/lib/features/approval-store.js +119 -0
  43. package/lib/features/approval-view.d.ts +47 -0
  44. package/lib/features/approval-view.js +48 -0
  45. package/lib/features/ask-wiring.d.ts +74 -0
  46. package/lib/features/ask-wiring.js +151 -0
  47. package/lib/features/host-status.d.ts +41 -0
  48. package/lib/features/host-status.js +24 -0
  49. package/lib/features/image-collection.d.ts +56 -0
  50. package/lib/features/image-collection.js +79 -0
  51. package/lib/features/image-download.d.ts +48 -0
  52. package/lib/features/image-download.js +77 -0
  53. package/lib/features/image-submission.d.ts +67 -0
  54. package/lib/features/image-submission.js +57 -0
  55. package/lib/features/interaction-router.d.ts +84 -0
  56. package/lib/features/interaction-router.js +655 -0
  57. package/lib/features/model-control.d.ts +79 -0
  58. package/lib/features/model-control.js +51 -0
  59. package/lib/features/preset-control.d.ts +84 -0
  60. package/lib/features/preset-control.js +43 -0
  61. package/lib/features/project-bind.d.ts +69 -0
  62. package/lib/features/project-bind.js +57 -0
  63. package/lib/features/project-info.d.ts +25 -0
  64. package/lib/features/project-info.js +22 -0
  65. package/lib/features/project-list.d.ts +79 -0
  66. package/lib/features/project-list.js +93 -0
  67. package/lib/features/prompt-submission.d.ts +53 -0
  68. package/lib/features/prompt-submission.js +47 -0
  69. package/lib/features/question-expiry.d.ts +53 -0
  70. package/lib/features/question-expiry.js +56 -0
  71. package/lib/features/question-routing.d.ts +108 -0
  72. package/lib/features/question-routing.js +162 -0
  73. package/lib/features/question-store.d.ts +124 -0
  74. package/lib/features/question-store.js +186 -0
  75. package/lib/features/question-view.d.ts +58 -0
  76. package/lib/features/question-view.js +81 -0
  77. package/lib/features/queue-control.d.ts +63 -0
  78. package/lib/features/queue-control.js +31 -0
  79. package/lib/features/reconcile-bindings.d.ts +75 -0
  80. package/lib/features/reconcile-bindings.js +54 -0
  81. package/lib/features/reconcile-deliveries.d.ts +29 -0
  82. package/lib/features/reconcile-deliveries.js +28 -0
  83. package/lib/features/reconcile-events.d.ts +49 -0
  84. package/lib/features/reconcile-events.js +50 -0
  85. package/lib/features/reconcile-intents.d.ts +32 -0
  86. package/lib/features/reconcile-intents.js +32 -0
  87. package/lib/features/reconcile-interactions.d.ts +59 -0
  88. package/lib/features/reconcile-interactions.js +54 -0
  89. package/lib/features/session-adopt.d.ts +89 -0
  90. package/lib/features/session-adopt.js +77 -0
  91. package/lib/features/session-creation.d.ts +49 -0
  92. package/lib/features/session-creation.js +43 -0
  93. package/lib/features/session-mainline.d.ts +75 -0
  94. package/lib/features/session-mainline.js +96 -0
  95. package/lib/features/session-resume.d.ts +44 -0
  96. package/lib/features/session-resume.js +40 -0
  97. package/lib/features/skill-run.d.ts +48 -0
  98. package/lib/features/skill-run.js +25 -0
  99. package/lib/features/steer-control.d.ts +37 -0
  100. package/lib/features/steer-control.js +28 -0
  101. package/lib/features/stop-control.d.ts +38 -0
  102. package/lib/features/stop-control.js +31 -0
  103. package/lib/features/task-admission.d.ts +29 -0
  104. package/lib/features/task-admission.js +21 -0
  105. package/lib/features/thread-continuation.d.ts +36 -0
  106. package/lib/features/thread-continuation.js +18 -0
  107. package/lib/features/thread-creation.d.ts +81 -0
  108. package/lib/features/thread-creation.js +81 -0
  109. package/lib/features/thread-routing.d.ts +49 -0
  110. package/lib/features/thread-routing.js +34 -0
  111. package/lib/features/turn-ownership.d.ts +39 -0
  112. package/lib/features/turn-ownership.js +40 -0
  113. package/lib/features/unbound-mention.d.ts +49 -0
  114. package/lib/features/unbound-mention.js +31 -0
  115. package/lib/features/workspace-channel.d.ts +41 -0
  116. package/lib/features/workspace-channel.js +47 -0
  117. package/lib/gateway/gateway.d.ts +50 -0
  118. package/lib/gateway/gateway.js +229 -0
  119. package/lib/gateway/inbound.d.ts +85 -0
  120. package/lib/gateway/inbound.js +186 -0
  121. package/lib/gateway/ingress.d.ts +15 -0
  122. package/lib/gateway/ingress.js +22 -0
  123. package/lib/i18n.d.ts +92 -0
  124. package/lib/i18n.js +180 -0
  125. package/lib/index.d.ts +17 -0
  126. package/lib/index.js +869 -0
  127. package/lib/lifecycle.d.ts +44 -0
  128. package/lib/lifecycle.js +84 -0
  129. package/lib/policy/authorization.d.ts +41 -0
  130. package/lib/policy/authorization.js +64 -0
  131. package/lib/policy/disclosure.d.ts +93 -0
  132. package/lib/policy/disclosure.js +153 -0
  133. package/lib/policy/guard.d.ts +31 -0
  134. package/lib/policy/guard.js +46 -0
  135. package/lib/policy/suppress.d.ts +13 -0
  136. package/lib/policy/suppress.js +20 -0
  137. package/lib/settings-namespace.d.ts +7 -0
  138. package/lib/settings-namespace.js +6 -0
  139. package/lib/settings.d.ts +37 -0
  140. package/lib/settings.js +102 -0
  141. package/lib/startup.d.ts +16 -0
  142. package/lib/startup.js +60 -0
  143. package/lib/state/bindings.d.ts +46 -0
  144. package/lib/state/bindings.js +55 -0
  145. package/lib/state/channel-bindings.d.ts +37 -0
  146. package/lib/state/channel-bindings.js +43 -0
  147. package/lib/state/domain.d.ts +82 -0
  148. package/lib/state/domain.js +86 -0
  149. package/lib/state/effect-machine.d.ts +37 -0
  150. package/lib/state/effect-machine.js +52 -0
  151. package/lib/state/fail-closed.d.ts +44 -0
  152. package/lib/state/fail-closed.js +57 -0
  153. package/lib/state/intents.d.ts +51 -0
  154. package/lib/state/intents.js +73 -0
  155. package/lib/state/records.d.ts +37 -0
  156. package/lib/state/records.js +30 -0
  157. package/lib/state/retention.d.ts +74 -0
  158. package/lib/state/retention.js +68 -0
  159. package/lib/state/session-owners.d.ts +50 -0
  160. package/lib/state/session-owners.js +50 -0
  161. package/lib/stream/finalizer.d.ts +49 -0
  162. package/lib/stream/finalizer.js +67 -0
  163. package/lib/stream/icons.d.ts +15 -0
  164. package/lib/stream/icons.js +36 -0
  165. package/lib/stream/live.d.ts +140 -0
  166. package/lib/stream/live.js +515 -0
  167. package/lib/stream/markdown.d.ts +24 -0
  168. package/lib/stream/markdown.js +95 -0
  169. package/lib/stream/outbound.d.ts +27 -0
  170. package/lib/stream/outbound.js +25 -0
  171. package/lib/stream/render-fence.d.ts +27 -0
  172. package/lib/stream/render-fence.js +37 -0
  173. package/lib/stream/render-model.d.ts +48 -0
  174. package/lib/stream/render-model.js +68 -0
  175. package/lib/stream/splitter.d.ts +8 -0
  176. package/lib/stream/splitter.js +58 -0
  177. package/lib/stream/tool-view.d.ts +40 -0
  178. package/lib/stream/tool-view.js +39 -0
  179. package/lib/stream/typing.d.ts +22 -0
  180. package/lib/stream/typing.js +58 -0
  181. package/lib/stream/update-scheduler.d.ts +20 -0
  182. package/lib/stream/update-scheduler.js +65 -0
  183. package/package.json +86 -0
@@ -0,0 +1,655 @@
1
+ /**
2
+ * The interaction router (extracted from the Host composition root so the
3
+ * whole command/component surface is testable against the Discord API twin).
4
+ * Every path re-checks authorization where the event alone cannot prove it
5
+ * (workspace-administrator level, catalog freshness), answers interactions
6
+ * through the deferred-ack + ephemeral-followup pattern, and maps every DSH
7
+ * outcome onto caller-facing copy without ever retrying.
8
+ */
9
+ import { evaluateAuthorization, levelAtLeast } from '../policy/authorization.js';
10
+ import { OUTBOUND_EPHEMERAL_FLAGS } from '../policy/disclosure.js';
11
+ import { workspaceAutocompleteChoices, createProjectListView } from './project-list.js';
12
+ import { projectInfo } from './project-info.js';
13
+ import { planSteer } from './steer-control.js';
14
+ import { planStop } from './stop-control.js';
15
+ import { handleApprovalClick } from './approval-routing.js';
16
+ export function createInteractionRouter(deps) {
17
+ /** Authorize one interaction against the live policy table. */
18
+ function authorize(event) {
19
+ return evaluateAuthorization(deps.policy(), {
20
+ guildId: event.guildId,
21
+ userId: event.actorId,
22
+ roleIds: event.roleIds,
23
+ memberPermissions: event.memberPermissions,
24
+ isBot: event.isBot,
25
+ });
26
+ }
27
+ async function routeAutocomplete(event, interactionToken) {
28
+ if (event.commandName !== 'project')
29
+ return;
30
+ // Autocomplete has no deferred-ack form: whatever happens below, Discord
31
+ // must receive exactly one type-8 answer, even if only empty choices.
32
+ try {
33
+ const decision = authorize(event);
34
+ const choices = [];
35
+ if (decision.allowed) {
36
+ try {
37
+ const wireOptions = event.data['options'];
38
+ const sub = Array.isArray(wireOptions) ? wireOptions[0] : undefined;
39
+ const focused = Array.isArray(sub?.options) ? sub.options.find(option => option.focused === true) : undefined;
40
+ const query = typeof focused?.value === 'string' ? focused.value : '';
41
+ const catalog = await deps.catalogPort.listWorkspaces();
42
+ if (catalog.outcome === 'completed') {
43
+ choices.push(...workspaceAutocompleteChoices(catalog.workspaces, query).slice(0, 25));
44
+ }
45
+ }
46
+ catch (cause) {
47
+ // Malformed catalog rows must degrade to empty choices, never to
48
+ // an unanswered interaction.
49
+ deps.warn('discord_autocomplete_catalog_failed', String(cause));
50
+ }
51
+ }
52
+ const rest = await deps.rest();
53
+ if (rest === undefined) {
54
+ deps.warn('discord_autocomplete_failed', 'missing-token');
55
+ return;
56
+ }
57
+ const posted = await rest.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, {
58
+ type: 8,
59
+ data: { choices },
60
+ });
61
+ if (posted.outcome !== 'completed') {
62
+ deps.log('discord_autocomplete_failed', posted.outcome === 'rejected' ? `HTTP ${String(posted.status)}` : posted.reason);
63
+ }
64
+ }
65
+ catch (cause) {
66
+ deps.warn('discord_autocomplete_threw', String(cause));
67
+ const rest = await deps.rest();
68
+ if (rest !== undefined) {
69
+ await rest.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, {
70
+ type: 8,
71
+ data: { choices: [] },
72
+ }).catch(() => { });
73
+ }
74
+ }
75
+ }
76
+ async function routeSlashCommand(event, interactionToken) {
77
+ const rest = await deps.rest();
78
+ if (rest === undefined) {
79
+ deps.log('discord_ack_failed', 'missing-token');
80
+ return;
81
+ }
82
+ const ack = await rest.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, { type: 5, data: { flags: 64 } });
83
+ if (ack.outcome !== 'completed') {
84
+ deps.log('discord_ack_failed', ack.outcome);
85
+ return;
86
+ }
87
+ // Deferred-ack followups must never fail silently: the REST client
88
+ // resolves (never rejects) 4xx outcomes, so a void'ed call would drop
89
+ // the failure without a trace.
90
+ const followUp = async (content, components) => {
91
+ const posted = await rest.request('POST', `/webhooks/${deps.applicationId()}/${interactionToken}`, {
92
+ content,
93
+ flags: OUTBOUND_EPHEMERAL_FLAGS,
94
+ ...(components === undefined ? {} : { components }),
95
+ });
96
+ if (posted.outcome !== 'completed') {
97
+ deps.log('discord_followup_failed', posted.outcome === 'rejected' ? `HTTP ${String(posted.status)}` : posted.reason);
98
+ }
99
+ };
100
+ const buttonRow = (confirmId, cancelId) => [{
101
+ type: 1,
102
+ components: [
103
+ { type: 2, style: 3, label: deps.copy.bindConfirmButton, custom_id: confirmId },
104
+ { type: 2, style: 4, label: deps.copy.bindCancelButton, custom_id: cancelId },
105
+ ],
106
+ }];
107
+ try {
108
+ if (event.commandName === 'project') {
109
+ const options = event.data['options'];
110
+ const subcommand = Array.isArray(options) ? options[0] : undefined;
111
+ const subName = subcommand?.name;
112
+ if (subName === 'bind') {
113
+ // Add-project semantics: bind provisions (or reuses)
114
+ // the Workspace's home channel under the adapter category.
115
+ // The current channel — e.g. the control channel — is never
116
+ // captured; only the opaque ws: reference crosses the wire.
117
+ const decision = authorize(event);
118
+ if (!decision.allowed || !levelAtLeast(decision.level, 'workspace-administrator')) {
119
+ await followUp(deps.copy.bindAdminOnly);
120
+ return;
121
+ }
122
+ const wireOptions = Array.isArray(subcommand?.options) ? subcommand.options : [];
123
+ const reference = wireOptions.find(option => option.name === 'workspace')?.value;
124
+ if (typeof reference !== 'string' || reference === '') {
125
+ await followUp(deps.copy.bindUsage);
126
+ return;
127
+ }
128
+ const resolvedWorkspace = await deps.resolver.resolve(reference);
129
+ if (resolvedWorkspace.outcome !== 'found') {
130
+ await followUp(resolvedWorkspace.outcome === 'stale'
131
+ ? deps.copy.bindWorkspaceGone
132
+ : resolvedWorkspace.outcome === 'unknown'
133
+ ? deps.copy.bindWorkspaceUnknown
134
+ : deps.copy.bindWorkspaceUnavailable);
135
+ return;
136
+ }
137
+ const { id: workspaceId, title } = resolvedWorkspace.workspace;
138
+ const existing = deps.findBoundChannelFor(event.guildId, workspaceId);
139
+ if (existing !== undefined) {
140
+ // Idempotent: one workspace, one channel.
141
+ await followUp(deps.copy.bindChannelExists(title, existing));
142
+ return;
143
+ }
144
+ const expiresAtMs = Date.now() + 15 * 60 * 1000;
145
+ const confirmId = deps.registry.register({ kind: 'project-bind', action: 'confirm', workspaceId, workspaceTitle: title, guildId: event.guildId, actorId: event.actorId, expiresAtMs });
146
+ const cancelId = deps.registry.register({ kind: 'project-bind', action: 'cancel', workspaceId, workspaceTitle: title, guildId: event.guildId, actorId: event.actorId, expiresAtMs });
147
+ deps.log('discord_project_bind_planned', { interactionId: event.interactionId, workspaceId });
148
+ await followUp(deps.copy.bindConfirmPrompt(title), buttonRow(confirmId, cancelId));
149
+ return;
150
+ }
151
+ if (subName === 'list') {
152
+ deps.log('discord_project_list_start', { interactionId: event.interactionId });
153
+ const view = await createProjectListView(deps.catalogPort, { selectionId: event.interactionId });
154
+ if (view.outcome !== 'ok') {
155
+ await followUp(view.reason === 'workspace-catalog-unknown'
156
+ ? deps.copy.infoUnknown
157
+ : deps.copy.infoUnavailable);
158
+ return;
159
+ }
160
+ // Names only: the bind option autocompletes live candidates,
161
+ // so ids never need to be read, copied, or typed. Text surface,
162
+ // no navigation: over-large catalogs get an honest truncation
163
+ // line instead of a pager that leads nowhere.
164
+ const rows = view.items.map(item => `• ${item.label}`);
165
+ const truncation = view.totalCount > view.items.length
166
+ ? deps.copy.listTruncated(view.items.length, view.totalCount)
167
+ : '';
168
+ await followUp(rows.length === 0 ? deps.copy.listEmpty : [deps.copy.listHeader, ...rows].join('\n') + truncation);
169
+ return;
170
+ }
171
+ if (subName === 'info') {
172
+ // Info describes THIS channel's bound workspace. Any authorized
173
+ // member sees the identity plus the canonical path (amended
174
+ // design §3 / 16.1); the response is ephemeral either way.
175
+ // Deny-first: the refusal is decided before any DSH read.
176
+ const decision = authorize(event);
177
+ if (!decision.allowed) {
178
+ // Refuse identity disclosure to non-members even when bound.
179
+ await followUp(deps.copy.infoMemberOnly);
180
+ return;
181
+ }
182
+ const binding = deps.channelBinding(event.guildId, event.channelId);
183
+ if (binding === undefined) {
184
+ // Bind provisions the Workspace's home channel — most
185
+ // channels, including the control channel, are unbound.
186
+ await followUp(deps.copy.infoUnboundAllowed);
187
+ return;
188
+ }
189
+ const detail = await deps.dsh.readWorkspaceDetail(binding.workspaceId);
190
+ if (detail.outcome !== 'found') {
191
+ await followUp(detail.outcome === 'unknown'
192
+ ? deps.copy.infoUnknown
193
+ : detail.outcome === 'stale'
194
+ ? deps.copy.infoStale
195
+ : deps.copy.infoUnavailable);
196
+ return;
197
+ }
198
+ const view = projectInfo({
199
+ decision,
200
+ workspace: { id: detail.workspace.id, title: detail.workspace.title, path: detail.workspace.path },
201
+ });
202
+ if (view.outcome !== 'info') {
203
+ await followUp(deps.copy.infoMemberOnly);
204
+ return;
205
+ }
206
+ const lines = [
207
+ `**${view.workspace.label}**`,
208
+ deps.copy.infoRevision(String(binding.revision), binding.boundBy),
209
+ ];
210
+ if (view.workspace.path !== undefined)
211
+ lines.push(deps.copy.infoPath(view.workspace.path));
212
+ await followUp(lines.join('\n'));
213
+ return;
214
+ }
215
+ await followUp(deps.copy.unknownSubcommand);
216
+ return;
217
+ }
218
+ // ── Session control commands (session-control spec) ─────────────
219
+ // Every control path resolves the calling thread's session binding
220
+ // first; ownership comes from the turn tracker's request IDs, never
221
+ // from a session's running status.
222
+ if (event.commandName === 'stop' || event.commandName === 'steer') {
223
+ const sessionId = deps.sessionForThread(event.guildId, event.channelId);
224
+ if (sessionId === undefined) {
225
+ await followUp(deps.copy.stopNotSessionThread);
226
+ return;
227
+ }
228
+ if (event.commandName === 'stop') {
229
+ const result = await planStop({
230
+ cancel: async ({ sessionId: id }) => {
231
+ const cancelled = await deps.dsh.cancel(id);
232
+ return cancelled.outcome === 'accepted'
233
+ ? { outcome: 'accepted', pendingPreserved: true }
234
+ : cancelled.outcome === 'rejected'
235
+ ? { outcome: 'rejected', reason: cancelled.reason }
236
+ : { outcome: 'unknown' };
237
+ },
238
+ }, deps.turnTracker, { sessionId, threadId: event.channelId });
239
+ if (result.outcome === 'refused') {
240
+ await followUp(result.reason === 'no-active-turn'
241
+ ? deps.copy.stopNothingRunning
242
+ : deps.copy.stopNotOwner);
243
+ return;
244
+ }
245
+ if (result.outcome === 'cancelled') {
246
+ await followUp(result.pendingPreserved ? deps.copy.stopStoppedQueuePreserved : deps.copy.stopStopped);
247
+ return;
248
+ }
249
+ await followUp(result.outcome === 'rejected'
250
+ ? deps.copy.stopRejected
251
+ : deps.copy.stopUnknown);
252
+ return;
253
+ }
254
+ // /steer <prompt>
255
+ const steerText = typeof event.data['options'] === 'object'
256
+ ? event.data['options'].find(option => option.name === 'prompt')?.value
257
+ : undefined;
258
+ const prompt = typeof steerText === 'string' ? steerText.trim() : '';
259
+ if (prompt === '') {
260
+ await followUp(deps.copy.steerUsage);
261
+ return;
262
+ }
263
+ const result = await planSteer({
264
+ steer: async ({ sessionId: id }) => {
265
+ const steered = await deps.dsh.steer(id, prompt);
266
+ return steered.outcome === 'accepted'
267
+ ? { outcome: 'accepted' }
268
+ : steered.outcome === 'rejected'
269
+ ? { outcome: 'rejected', reason: steered.reason }
270
+ : { outcome: 'unknown' };
271
+ },
272
+ }, deps.turnTracker, { sessionId, threadId: event.channelId, prompt });
273
+ if (result.outcome === 'refused') {
274
+ await followUp(result.reason === 'no-active-turn'
275
+ ? deps.copy.steerNothingRunning
276
+ : deps.copy.steerNotOwner);
277
+ return;
278
+ }
279
+ await followUp(result.outcome === 'accepted'
280
+ ? deps.copy.steerQueued
281
+ : result.outcome === 'rejected'
282
+ ? deps.copy.steerRejected
283
+ : deps.copy.steerUnknown);
284
+ return;
285
+ }
286
+ if (event.commandName === 'queue') {
287
+ const sessionId = deps.sessionForThread(event.guildId, event.channelId);
288
+ if (sessionId === undefined) {
289
+ await followUp(deps.copy.queueNotSessionThread);
290
+ return;
291
+ }
292
+ const wireOptions = event.data['options'];
293
+ const sub = Array.isArray(wireOptions) ? wireOptions[0] : undefined;
294
+ if (sub?.name === 'remove') {
295
+ const raw = Array.isArray(sub.options) ? sub.options.find(option => option.name === 'item')?.value : undefined;
296
+ const reference = typeof raw === 'string' ? raw.trim() : '';
297
+ if (reference === '') {
298
+ await followUp(deps.copy.queueRemoveUsage);
299
+ return;
300
+ }
301
+ const snapshot = deps.queueSnapshots.get(sessionId);
302
+ const byPosition = /^\d+$/.test(reference)
303
+ ? snapshot?.[Number.parseInt(reference, 10) - 1]
304
+ : snapshot?.find(item => item.id === reference);
305
+ if (byPosition === undefined) {
306
+ await followUp(deps.copy.queueItemNotFound);
307
+ return;
308
+ }
309
+ const removed = await deps.dsh.removeQueueItem(sessionId, byPosition.id);
310
+ if (removed.outcome === 'accepted') {
311
+ await followUp(deps.copy.queueRemoved(byPosition.summary));
312
+ return;
313
+ }
314
+ await followUp(removed.outcome === 'rejected'
315
+ ? deps.copy.queueRemoveRejected
316
+ : deps.copy.queueRemoveUnknown);
317
+ return;
318
+ }
319
+ // /queue list — the mux snapshot cache is the data source.
320
+ const snapshot = deps.queueSnapshots.get(sessionId);
321
+ if (snapshot === undefined) {
322
+ await followUp(deps.copy.queueNoData);
323
+ return;
324
+ }
325
+ if (snapshot.length === 0) {
326
+ await followUp(deps.copy.queueEmpty);
327
+ return;
328
+ }
329
+ const rows = snapshot.map((item, index) => `${String(index + 1)}. ${item.summary}`);
330
+ await followUp([deps.copy.queueHeader, ...rows, '', deps.copy.queueRemoveHint].join('\n'));
331
+ return;
332
+ }
333
+ if (event.commandName === 'guild') {
334
+ const options = event.data['options'];
335
+ const subName = Array.isArray(options) ? options[0]?.name : undefined;
336
+ if (subName !== 'forget') {
337
+ await followUp(deps.copy.unknownSubcommand);
338
+ return;
339
+ }
340
+ // Guild forget mutates adapter-owned state for the whole guild:
341
+ // Host-operator authority ONLY (binding-state spec; decision 16.7).
342
+ const isOperator = deps.policy().hostOperatorUserIds.includes(event.actorId);
343
+ if (!isOperator) {
344
+ await followUp(deps.copy.forgetOperatorOnly);
345
+ return;
346
+ }
347
+ const expiresAtMs = Date.now() + 15 * 60 * 1000;
348
+ const confirmId = deps.registry.register({ kind: 'guild-forget', action: 'confirm', guildId: event.guildId, actorId: event.actorId, expiresAtMs });
349
+ await followUp(deps.copy.forgetConfirmPrompt, [{
350
+ type: 1,
351
+ components: [{ type: 2, style: 4, label: deps.copy.forgetConfirmButton, custom_id: confirmId }],
352
+ }]);
353
+ return;
354
+ }
355
+ // Terminal guard: a recognized interaction type with an unhandled
356
+ // command name must still answer, never strand on the deferred ack.
357
+ await followUp(deps.copy.unknownSubcommand);
358
+ }
359
+ catch (cause) {
360
+ deps.warn('discord_slash_handler_failed', String(cause));
361
+ await followUp(deps.copy.commandFailed).catch(() => { });
362
+ }
363
+ }
364
+ async function routeBindComponent(event, interactionToken) {
365
+ const customId = event.data['custom_id'];
366
+ if (typeof customId !== 'string')
367
+ return;
368
+ const resolved = deps.registry.resolve(customId, Date.now());
369
+ const bindContext = resolved.found ? resolved.context : undefined;
370
+ if (bindContext?.['kind'] !== 'project-bind')
371
+ return;
372
+ const rest = await deps.rest();
373
+ if (rest === undefined) {
374
+ deps.log('discord_ack_failed', 'missing-token');
375
+ return;
376
+ }
377
+ // Deferred update ack (type 6): keeps the ephemeral visible while the
378
+ // commit resolves; the result arrives as a fresh ephemeral followup.
379
+ const clicked = await rest.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, { type: 6 });
380
+ if (clicked.outcome !== 'completed') {
381
+ deps.log('discord_ack_failed', clicked.outcome);
382
+ return;
383
+ }
384
+ const followUpResult = async (content) => {
385
+ const posted = await rest.request('POST', `/webhooks/${deps.applicationId()}/${interactionToken}`, { content, flags: OUTBOUND_EPHEMERAL_FLAGS });
386
+ if (posted.outcome !== 'completed') {
387
+ deps.log('discord_followup_failed', posted.outcome === 'rejected' ? `HTTP ${String(posted.status)}` : posted.reason);
388
+ }
389
+ };
390
+ const owner = bindContext['actorId'];
391
+ const workspaceId = bindContext['workspaceId'];
392
+ const workspaceTitle = bindContext['workspaceTitle'];
393
+ const boundGuildId = bindContext['guildId'];
394
+ if (owner !== event.actorId
395
+ || typeof workspaceId !== 'string'
396
+ || typeof workspaceTitle !== 'string'
397
+ || typeof boundGuildId !== 'string') {
398
+ // Another member's button (or a malformed context): deny
399
+ // ephemerally and leave the control pending for its rightful owner.
400
+ await followUpResult(deps.copy.confirmNotOwner);
401
+ return;
402
+ }
403
+ if (bindContext['action'] !== 'confirm') {
404
+ await followUpResult(deps.copy.bindCancelled);
405
+ return;
406
+ }
407
+ // The write happens only now, on explicit confirmation: provision
408
+ // (or reuse) the Workspace's home channel and bind it — the channel
409
+ // the command was typed in is never captured.
410
+ const ensuredChannel = await deps.ensureWorkspaceChannel({
411
+ guildId: boundGuildId,
412
+ workspaceId,
413
+ title: workspaceTitle,
414
+ actorId: event.actorId,
415
+ }).catch((cause) => {
416
+ deps.log('discord_workspace_channel_ensure_threw', String(cause));
417
+ return undefined;
418
+ });
419
+ if (ensuredChannel === undefined) {
420
+ await followUpResult(deps.copy.bindChannelFailed);
421
+ return;
422
+ }
423
+ deps.log('discord_project_bind_commit', { workspaceId, channelId: ensuredChannel.channelId, created: ensuredChannel.created });
424
+ await followUpResult(ensuredChannel.created
425
+ ? deps.copy.bindChannelCreated(workspaceTitle, ensuredChannel.channelId)
426
+ : deps.copy.bindChannelExisting(workspaceTitle, ensuredChannel.channelId));
427
+ }
428
+ async function routeGuildForGetComponent(event, interactionToken) {
429
+ const customId = event.data['custom_id'];
430
+ if (typeof customId !== 'string')
431
+ return;
432
+ const resolved = deps.registry.resolve(customId, Date.now());
433
+ const context = resolved.found ? resolved.context : undefined;
434
+ if (context?.['kind'] !== 'guild-forget')
435
+ return;
436
+ const rest = await deps.rest();
437
+ if (rest === undefined) {
438
+ deps.log('discord_ack_failed', 'missing-token');
439
+ return;
440
+ }
441
+ const clicked = await rest.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, { type: 6 });
442
+ if (clicked.outcome !== 'completed') {
443
+ deps.log('discord_ack_failed', clicked.outcome);
444
+ return;
445
+ }
446
+ const owner = context['actorId'];
447
+ const guildId = context['guildId'];
448
+ if (owner !== event.actorId || typeof guildId !== 'string' || guildId !== event.guildId) {
449
+ const denied = await rest.request('POST', `/webhooks/${deps.applicationId()}/${interactionToken}`, { content: deps.copy.confirmNotOwner, flags: OUTBOUND_EPHEMERAL_FLAGS });
450
+ if (denied.outcome !== 'completed')
451
+ deps.log('discord_followup_failed', denied.outcome);
452
+ return;
453
+ }
454
+ await deps.forgetGuild(guildId);
455
+ deps.log('discord_guild_forget_commit', { guildId });
456
+ const posted = await rest.request('POST', `/webhooks/${deps.applicationId()}/${interactionToken}`, {
457
+ content: deps.copy.forgetDone,
458
+ flags: OUTBOUND_EPHEMERAL_FLAGS,
459
+ });
460
+ if (posted.outcome !== 'completed')
461
+ deps.log('discord_followup_failed', posted.outcome);
462
+ }
463
+ async function settleQuestionInteraction(event, interactionToken, outcome) {
464
+ if (outcome.outcome === 'modal-requested') {
465
+ const rest = await deps.rest();
466
+ if (rest === undefined)
467
+ return;
468
+ const modal = outcome.modal;
469
+ await rest.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, {
470
+ type: 9,
471
+ data: {
472
+ custom_id: modal.custom_id,
473
+ components: [{
474
+ type: 1,
475
+ components: [{
476
+ type: 4,
477
+ custom_id: 'answer',
478
+ style: 2,
479
+ label: modal.textInput.label,
480
+ min_length: modal.textInput.min_length,
481
+ max_length: modal.textInput.max_length,
482
+ required: true,
483
+ }],
484
+ }],
485
+ },
486
+ });
487
+ return;
488
+ }
489
+ // Every other outcome settles as a deferred update plus an ephemeral
490
+ // followup; the rendered controls retire on the submitted terminal
491
+ // outcome (question-routing) or via expiry/remote resolution — stray
492
+ // clicks meanwhile still answer honestly through the stores.
493
+ const rest = await deps.rest();
494
+ if (rest === undefined)
495
+ return;
496
+ await rest.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, { type: 6 });
497
+ const notice = outcome.outcome === 'submitted' ? deps.copy.questionSubmitted
498
+ : outcome.outcome === 'denied' ? deps.copy.questionDenied
499
+ : outcome.outcome === 'already-resolved' ? deps.copy.questionAlreadyResolved
500
+ : outcome.outcome === 'incomplete' ? deps.copy.questionIncomplete
501
+ : outcome.outcome === 'invalid-answer' ? deps.copy.questionInvalid(outcome.reason)
502
+ : outcome.outcome === 'unresolved' ? deps.copy.questionUnresolved
503
+ : outcome.outcome === 'resolved-elsewhere' ? deps.copy.questionResolvedElsewhere
504
+ : undefined;
505
+ if (notice !== undefined)
506
+ await deps.componentFollowUp(event.interactionId, interactionToken, notice);
507
+ }
508
+ return {
509
+ async route(event, interactionToken) {
510
+ deps.log('discord_slash_dispatch', {
511
+ interactionId: event.interactionId,
512
+ commandName: event.commandName,
513
+ hasToken: interactionToken !== undefined,
514
+ });
515
+ if (event.interactionType === 4 && interactionToken !== undefined) {
516
+ await routeAutocomplete(event, interactionToken);
517
+ return;
518
+ }
519
+ if (event.interactionType === 2 && interactionToken !== undefined) {
520
+ await routeSlashCommand(event, interactionToken);
521
+ return;
522
+ }
523
+ if (event.interactionType === 5 && interactionToken !== undefined) {
524
+ const modalCustomId = event.data['custom_id'];
525
+ if (typeof modalCustomId !== 'string')
526
+ return;
527
+ try {
528
+ const text = event.modalFields.find(field => field.customId === 'answer')?.value ?? '';
529
+ const outcome = deps.handleQuestionModal({
530
+ customId: modalCustomId,
531
+ userId: event.actorId,
532
+ threadId: event.channelId,
533
+ text,
534
+ });
535
+ deps.log('discord_question_modal_submit', { outcome: outcome.outcome, userId: event.actorId });
536
+ await settleQuestionInteraction(event, interactionToken, outcome);
537
+ }
538
+ catch (cause) {
539
+ deps.warn('discord_question_modal_failed', String(cause));
540
+ const rest = await deps.rest();
541
+ if (rest !== undefined) {
542
+ await rest.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, { type: 6 }).catch(() => { });
543
+ await deps.componentFollowUp(event.interactionId, interactionToken, deps.copy.commandFailed).catch(() => { });
544
+ }
545
+ }
546
+ return;
547
+ }
548
+ if (event.interactionType !== 3)
549
+ return;
550
+ const customId = event.data['custom_id'];
551
+ if (typeof customId !== 'string')
552
+ return;
553
+ if (interactionToken === undefined)
554
+ return;
555
+ const resolved = deps.registry.resolve(customId, Date.now());
556
+ const bindContext = resolved.found ? resolved.context : undefined;
557
+ if (bindContext?.['kind'] === 'project-bind') {
558
+ await routeBindComponent(event, interactionToken);
559
+ return;
560
+ }
561
+ if (bindContext?.['kind'] === 'guild-forget') {
562
+ await routeGuildForGetComponent(event, interactionToken);
563
+ return;
564
+ }
565
+ // A not-found custom_id means the control was never registered or has
566
+ // been retired by its TTL (approval/question registry entries stay
567
+ // resolvable until expiry; claimed-vs-resolved state lives in the
568
+ // stores). Kimaki discipline: an expired click still gets the
569
+ // deferred ack plus an explicit ephemeral rerun hint — never silence.
570
+ if (bindContext === undefined) {
571
+ const rest = await deps.rest();
572
+ if (rest !== undefined) {
573
+ await rest.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, { type: 6 }).then(acked => {
574
+ if (acked.outcome !== 'completed')
575
+ deps.log('discord_ack_failed', acked.outcome);
576
+ }).catch(() => { });
577
+ await deps.componentFollowUp(event.interactionId, interactionToken, deps.copy.expiredControl).catch(() => { });
578
+ }
579
+ return;
580
+ }
581
+ const isQuestionControl = 'questionRpcId' in bindContext;
582
+ const isApprovalControl = 'approvalId' in bindContext;
583
+ if (isQuestionControl) {
584
+ // A throw here must still ack the click, or Discord renders the
585
+ // interaction-failed state on the control message.
586
+ try {
587
+ const outcome = await deps.handleQuestionComponent({
588
+ customId,
589
+ userId: event.actorId,
590
+ threadId: event.channelId,
591
+ values: event.selectValues,
592
+ });
593
+ deps.log('discord_question_click', { outcome: outcome.outcome, userId: event.actorId, complete: outcome.outcome === 'recorded' ? outcome.complete : undefined });
594
+ await settleQuestionInteraction(event, interactionToken, outcome);
595
+ }
596
+ catch (cause) {
597
+ deps.warn('discord_question_click_failed', String(cause));
598
+ const rest = await deps.rest();
599
+ if (rest !== undefined) {
600
+ await rest.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, { type: 6 }).then(acked => {
601
+ if (acked.outcome !== 'completed')
602
+ deps.log('discord_ack_failed', acked.outcome);
603
+ }).catch(() => { });
604
+ await deps.componentFollowUp(event.interactionId, interactionToken, deps.copy.commandFailed).catch(() => { });
605
+ }
606
+ }
607
+ return;
608
+ }
609
+ if (isApprovalControl) {
610
+ // Ack the click BEFORE the DSH round-trip: component interactions
611
+ // must receive an initial response within 3s or every followup 404s.
612
+ const rest0 = await deps.rest();
613
+ if (rest0 !== undefined) {
614
+ const acked = await rest0.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, { type: 6 });
615
+ if (acked.outcome !== 'completed')
616
+ deps.log('discord_ack_failed', acked.outcome);
617
+ }
618
+ let outcome;
619
+ try {
620
+ outcome = await handleApprovalClick({
621
+ registry: deps.registry,
622
+ store: deps.approvals,
623
+ port: deps.approvalRespondPort,
624
+ nowMs: () => Date.now(),
625
+ }, { customId, userId: event.actorId, threadId: event.channelId });
626
+ }
627
+ catch (cause) {
628
+ deps.warn('discord_approval_click_failed', String(cause));
629
+ // The type-6 ack already went out: without a followup the user
630
+ // gets no failure state at all — answer like the sibling paths.
631
+ await deps.componentFollowUp(event.interactionId, interactionToken, deps.copy.commandFailed).catch(() => { });
632
+ return;
633
+ }
634
+ const approvalId = typeof bindContext['approvalId'] === 'string' ? bindContext['approvalId'] : '';
635
+ deps.log('discord_approval_click', { outcome: outcome.outcome, userId: event.actorId, approvalId });
636
+ // `unresolved` intentionally keeps the controls live: the store's
637
+ // claim accepts a re-click, so the user's own retry stays reachable
638
+ // until the expiry sweep retires the ask.
639
+ if (outcome.outcome === 'submitted' || outcome.outcome === 'already-resolved') {
640
+ await deps.disableControl(approvalId);
641
+ }
642
+ if (outcome.outcome === 'denied') {
643
+ await deps.componentFollowUp(event.interactionId, interactionToken, deps.copy.confirmNotOwner);
644
+ }
645
+ else if (outcome.outcome === 'unresolved') {
646
+ await deps.componentFollowUp(event.interactionId, interactionToken, deps.copy.unresolvedAck);
647
+ }
648
+ else if (outcome.outcome === 'submitted') {
649
+ await deps.componentFollowUp(event.interactionId, interactionToken, deps.copy.submittedAck);
650
+ }
651
+ return;
652
+ }
653
+ },
654
+ };
655
+ }