@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
package/lib/index.js ADDED
@@ -0,0 +1,869 @@
1
+ import { DEFAULT_DISCORD_SETTINGS, DiscordSettingsSchema, installDiscordSettings, normalizeDiscordSettings, } from './settings.js';
2
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings';
3
+ import { installCancellationRoot } from './lifecycle.js';
4
+ import { ALLOWED_MENTIONS_NONE, DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, OUTBOUND_EPHEMERAL_FLAGS } from './policy/disclosure.js';
5
+ import { validateHostCapabilities } from './startup.js';
6
+ import { createAdapterStatusTracker, installAdapterStatusRpc, } from './features/adapter-status.js';
7
+ import { DISCORD_BOT_TOKEN_REF, describeDiscordCredential, resolveDiscordBotToken } from './credential.js';
8
+ import { createSharedRestClient } from './discord/rest.js';
9
+ import { createRestThreadPort } from './discord/thread-port.js';
10
+ import { createComponentRegistry } from './discord/components.js';
11
+ import { buildCommandRegistrations } from './discord/commands.js';
12
+ import { startDiscordAdapter } from './compose.js';
13
+ import { createWorkspaceCatalogPort, createWorkspaceResolver, readWorkspaceDetail, promptSession, createSessionViaProxy, cancelSessionViaProxy, steerSession, removeQueueItemViaProxy } from './dsh/api-proxy-face.js';
14
+ import { createApprovalStore } from './features/approval-store.js';
15
+ import { createAskWiring } from './features/ask-wiring.js';
16
+ import { sweepExpiredApprovals } from './features/approval-expiry.js';
17
+ import { sweepExpiredQuestions } from './features/question-expiry.js';
18
+ import { createQuestionStore } from './features/question-store.js';
19
+ import { handleSelectInput, handleModalSubmit } from './features/question-routing.js';
20
+ import { createCopy } from './i18n.js';
21
+ import { createInteractionRouter } from './features/interaction-router.js';
22
+ import { channelBindingKey, parseChannelBindingKey, threadBindingKey, parseThreadBindingKey, discordDomainSpec, CHANNEL_BINDINGS_TABLE, THREAD_BINDINGS_TABLE, INTENTS_TABLE } from './state/domain.js';
23
+ import { planBindingReconciliation } from './features/reconcile-bindings.js';
24
+ import { guildKeysToForget, sweepExpired } from './state/retention.js';
25
+ import { listSessionIds, createClientRespondPort } from './dsh/api-proxy-face.js';
26
+ import { createBindingStore } from './state/bindings.js';
27
+ import { createIntentStore } from './state/intents.js';
28
+ import { createTurnTracker } from './features/turn-ownership.js';
29
+ import { createThreadCreationFlow } from './features/thread-creation.js';
30
+ import { createSessionCreationFlow } from './features/session-creation.js';
31
+ import { createPromptSubmissionFlow } from './features/prompt-submission.js';
32
+ import { createSessionMainline, requestIdFor } from './features/session-mainline.js';
33
+ import { startLiveRender } from './stream/live.js';
34
+ import { planWorkspaceChannel, workspaceChannelName } from './features/workspace-channel.js';
35
+ /** Stable Cordis plugin name for diagnostics. */
36
+ export const name = 'dsh-discord';
37
+ /** Host services required by the complete embedded adapter. */
38
+ export const inject = ['apiProxy', 'credentials', 'settings', 'storageDomain', 'connection'];
39
+ export const Config = DiscordSettingsSchema;
40
+ /**
41
+ * Fixed Milestone 1 Gateway intents: guilds, messages, content. Member data
42
+ * rides MESSAGE_CREATE/INTERACTION_CREATE payloads, so the privileged
43
+ * GUILD_MEMBERS bit (1 << 1) is not requested — without the Dev Portal
44
+ * toggle Discord rejects identify with 4014, which is terminal for us.
45
+ */
46
+ const GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 15);
47
+ /**
48
+ * Emit through the Host's logger service only when it is actually wired:
49
+ * bare or fake Cordis contexts must not turn an error path into an
50
+ * unhandled rejection.
51
+ */
52
+ function emitLog(ctx, level, message) {
53
+ const logger = ctx.logger;
54
+ const emit = logger?.[level];
55
+ if (typeof emit === 'function')
56
+ emit.call(logger, message);
57
+ }
58
+ /**
59
+ * Mount the embedded Discord adapter. Composition of the runtime path
60
+ * (Gateway → ingress → command/mention dispatch → Discord REST, plus the
61
+ * reconciliation sweeps) lives in `src/compose.ts` and is started from here.
62
+ */
63
+ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
64
+ validateHostCapabilities(name => ctx.get(name));
65
+ installCancellationRoot(ctx);
66
+ let current = normalizeDiscordSettings(config);
67
+ /**
68
+ * Discord-visible copy, re-resolved on every access so a language change
69
+ * on the settings card applies without rebuilding the composition. The
70
+ * 'auto' preference follows the DSH locale (the `locale` namespace the
71
+ * Host app registers); non-Chinese locales fall back to English.
72
+ */
73
+ const resolveLanguage = () => {
74
+ if (current.language !== 'auto')
75
+ return current.language;
76
+ const locale = ctx.get('settings').get(settingsNamespace('locale'));
77
+ return typeof locale?.preference === 'string' && locale.preference.startsWith('zh') ? 'zh' : 'en';
78
+ };
79
+ const copy = new Proxy({}, {
80
+ get: (_target, key) => createCopy(resolveLanguage())[key],
81
+ });
82
+ // Reassigned once the async composition has built the real registrar.
83
+ let registerCommands = async () => { };
84
+ const onGuildsChanged = () => { void registerCommands(); };
85
+ installDiscordSettings(ctx, current, (next) => {
86
+ const guildsChanged = next.allowedGuildIds.join(',') !== current.allowedGuildIds.join(',');
87
+ current = next;
88
+ if (guildsChanged)
89
+ onGuildsChanged();
90
+ emitLog(ctx, 'debug', {
91
+ event: 'discord_settings_applied',
92
+ enabled: current.enabled,
93
+ allowedGuildCount: current.allowedGuildIds.length,
94
+ });
95
+ });
96
+ // The settings card's status surface: credential presence seeded now, the
97
+ // Gateway observation fed by the adapter composition as it starts.
98
+ // Services resolve through the same accessor the startup boundary probes.
99
+ const statusTracker = createAdapterStatusTracker();
100
+ // Late-bound so the management channel can reach the runtime built inside
101
+ // the composition IIFE below (the card's Connect button).
102
+ const runtimeRef = { current: undefined };
103
+ installAdapterStatusRpc(ctx.get('connection'), statusTracker, {
104
+ tracker: statusTracker,
105
+ setToken: async (value) => {
106
+ await ctx.get('credentials').set(DISCORD_BOT_TOKEN_REF, value);
107
+ statusTracker.setCredential({ configured: true });
108
+ },
109
+ connect: () => { runtimeRef.current?.connect(); },
110
+ disconnect: () => { runtimeRef.current?.disconnect(); },
111
+ });
112
+ void describeDiscordCredential(ctx.get('credentials'))
113
+ .then((view) => { statusTracker.setCredential(view); })
114
+ .catch((cause) => {
115
+ emitLog(ctx, 'warn', { event: 'discord_credential_probe_failed', cause: String(cause) });
116
+ statusTracker.setCredential({ configured: false });
117
+ });
118
+ const credentials = ctx.get('credentials');
119
+ // The in-process apiProxy face: domain methods resolve RpcRequest →
120
+ // RpcResponse and never throw business errors; boundedness and outcome
121
+ // logging live in src/dsh/api-proxy-face.ts.
122
+ const apiProxy = ctx.get('apiProxy');
123
+ // Every apiProxy outcome is visible on stderr: the live profile pass reads
124
+ // this channel, and a silent call can never again be misread as a hang.
125
+ const rpcLog = (event, detail) => {
126
+ console.error(`[dsh-discord] ${event}:`, typeof detail === 'string' ? detail : JSON.stringify(detail ?? null));
127
+ emitLog(ctx, 'debug', { event, detail: typeof detail === 'string' ? detail : JSON.stringify(detail ?? null) });
128
+ };
129
+ void (async () => {
130
+ // The durable domain gates the whole composition: no adapter starts
131
+ // without its bindings/intents tables, and close() drains queued writes
132
+ // on teardown (fail-closed across restarts, design §10).
133
+ const domain = await ctx.storageDomain.open(discordDomainSpec);
134
+ ctx.effect(() => () => { void domain.close(); }, 'discord durable domain');
135
+ const catalogPort = createWorkspaceCatalogPort(apiProxy, { log: rpcLog });
136
+ const policy = () => ({
137
+ allowedGuildIds: [...current.allowedGuildIds],
138
+ memberUserIds: [...current.memberUserIds],
139
+ memberRoleIds: [...current.memberRoleIds],
140
+ administratorUserIds: [...current.administratorUserIds],
141
+ administratorRoleIds: [...current.administratorRoleIds],
142
+ deniedUserIds: [...current.deniedUserIds],
143
+ deniedRoleIds: [...current.deniedRoleIds],
144
+ hostOperatorUserIds: [...current.hostOperatorUserIds],
145
+ });
146
+ // Channel→Workspace and Thread→Session bindings, inbound-intent records,
147
+ // and the adapter-owned turn tracker. The first three are DURABLE through
148
+ // the storage-domain tables opened above (design §10); reads are
149
+ // synchronous from the domain's in-memory projection, writes queue on
150
+ // its durable chain. queueSnapshots stays process-local by design. The
151
+ // Discord application id equals the bot user id, resolved at start.
152
+ const applicationIdRef = { current: 'dsh-discord' };
153
+ const channelTable = domain.table(CHANNEL_BINDINGS_TABLE);
154
+ const threadTable = domain.table(THREAD_BINDINGS_TABLE);
155
+ const intentTable = domain.table(INTENTS_TABLE);
156
+ const bindingStore = createBindingStore(channelTable);
157
+ const threadBindingStore = createBindingStore(threadTable);
158
+ const intents = createIntentStore(intentTable);
159
+ const turnTracker = createTurnTracker();
160
+ // Approvals: process-local backing (minutes-lived; DSH replays pending
161
+ // asks on mux reopen). turnActors maps submitted request ids to their
162
+ // Discord authors — the ownership fact for approval/question clicks.
163
+ const approvalRows = new Map();
164
+ const approvalsStore = createApprovalStore({
165
+ get: key => approvalRows.get(key),
166
+ put: (key, record) => {
167
+ approvalRows.set(key, record);
168
+ return Promise.resolve();
169
+ },
170
+ });
171
+ const questionsStore = createQuestionStore();
172
+ const turnActors = new Map();
173
+ const sharedRegistry = createComponentRegistry();
174
+ const questionCancelPort = {
175
+ cancel: async ({ sessionId }) => {
176
+ const cancelled = await cancelSessionViaProxy(apiProxy, { sessionId }, { log: rpcLog });
177
+ const turn = turnTracker.active(sessionId);
178
+ if (turn !== undefined)
179
+ turnTracker.complete(turn.requestId);
180
+ return cancelled.outcome === 'accepted'
181
+ ? { outcome: 'accepted' }
182
+ : cancelled.outcome === 'rejected'
183
+ ? { outcome: 'rejected', reason: cancelled.reason }
184
+ : { outcome: 'unknown' };
185
+ },
186
+ };
187
+ const askWiring = createAskWiring({
188
+ registry: sharedRegistry,
189
+ approvals: approvalsStore,
190
+ questions: questionsStore,
191
+ cancelPort: questionCancelPort,
192
+ nowMs: () => Date.now(),
193
+ log: rpcLog,
194
+ activeTurnRequestId: sessionId => turnTracker.active(sessionId)?.requestId,
195
+ turnActor: requestId => turnActors.get(requestId),
196
+ threadOwner: threadId => {
197
+ for (const key of threadTable.keys()) {
198
+ if (!key.endsWith(`:${threadId}`))
199
+ continue;
200
+ const createdBy = threadTable.get(key)?.createdBy;
201
+ if (typeof createdBy === 'string' && createdBy !== '')
202
+ return createdBy;
203
+ }
204
+ return undefined;
205
+ },
206
+ postMessage: async (threadId, payload) => {
207
+ const rest = await sharedRest();
208
+ if (rest === undefined)
209
+ return { stored: false, reason: 'rest unavailable' };
210
+ const sent = await rest.request('POST', `/channels/${threadId}/messages`, payload);
211
+ if (sent.outcome !== 'completed') {
212
+ return {
213
+ stored: false,
214
+ reason: sent.outcome === 'rejected'
215
+ ? `HTTP ${String(sent.status)} ${sent.error.message}`
216
+ : sent.reason,
217
+ };
218
+ }
219
+ if (typeof sent.body?.id !== 'string')
220
+ return { stored: false, reason: `HTTP ${String(sent.status)}: response missing message id` };
221
+ return { stored: true, messageId: sent.body.id };
222
+ },
223
+ });
224
+ const componentFollowUp = async (_interactionId, interactionToken, content) => {
225
+ const rest = await sharedRest();
226
+ if (rest === undefined)
227
+ return;
228
+ const posted = await rest.request('POST', `/webhooks/${applicationIdRef.current}/${interactionToken}`, { content, flags: OUTBOUND_EPHEMERAL_FLAGS });
229
+ if (posted.outcome !== 'completed') {
230
+ rpcLog('discord_followup_failed', posted.outcome === 'rejected' ? `HTTP ${String(posted.status)}` : posted.reason);
231
+ }
232
+ };
233
+ const bindings = {
234
+ workspaceForChannel: (guildId, channelId) => bindingStore.get(channelBindingKey({
235
+ applicationId: applicationIdRef.current, guildId, channelId,
236
+ }))?.workspaceId,
237
+ sessionForThread: (guildId, threadId) => threadBindingStore.get(threadBindingKey({
238
+ applicationId: applicationIdRef.current, guildId, threadId,
239
+ }))?.sessionId,
240
+ };
241
+ // ── Session mainline: mention → thread → session → prompt → turn ──────
242
+ const restThreadPort = (() => {
243
+ const port = createRestThreadPort({
244
+ request: async (method, path, body) => {
245
+ const rest = await sharedRest();
246
+ if (rest === undefined)
247
+ return { outcome: 'unknown', reason: 'network-unreachable' };
248
+ return rest.request(method, path, body);
249
+ },
250
+ }, { autoArchiveMinutes: () => current.threadAutoArchiveMinutes });
251
+ return {
252
+ ...port,
253
+ // The join only affects sidebar visibility, but a silent refusal is
254
+ // still indistinguishable from a working one — log it (runbook rule).
255
+ joinThread: async (request) => {
256
+ const joined = await port.joinThread(request);
257
+ if (joined.outcome !== 'completed') {
258
+ rpcLog('discord_thread_join_failed', request);
259
+ }
260
+ return joined;
261
+ },
262
+ };
263
+ })();
264
+ const dshSessionPort = {
265
+ createSession: request => createSessionViaProxy(apiProxy, request, { log: rpcLog }),
266
+ };
267
+ const dshPromptPort = {
268
+ submit: request => promptSession(apiProxy, { sessionId: request.sessionId, prompt: request.prompt }, { log: rpcLog, rpcId: request.requestId }),
269
+ };
270
+ const composedMainline = createSessionMainline({
271
+ threads: createThreadCreationFlow({
272
+ intents,
273
+ discord: restThreadPort,
274
+ nowMs: () => Date.now(),
275
+ }),
276
+ sessions: createSessionCreationFlow({
277
+ sessions: dshSessionPort,
278
+ threadBindings: threadBindingStore,
279
+ newSessionId: () => crypto.randomUUID(),
280
+ }),
281
+ prompts: createPromptSubmissionFlow({
282
+ prompts: dshPromptPort,
283
+ intents,
284
+ nowMs: () => Date.now(),
285
+ }),
286
+ turns: turnTracker,
287
+ });
288
+ /** Mainline outcome → copy key; the string resolves live per language. */
289
+ const MAINLINE_FAILURE_KEYS = {
290
+ 'thread-conflict': 'mainlineThreadConflict',
291
+ 'thread-failed': 'mainlineThreadFailed',
292
+ 'session-rejected': 'mainlineSessionRejected',
293
+ 'session-unknown': 'mainlineSessionUnknown',
294
+ 'prompt-rejected': 'mainlinePromptRejected',
295
+ 'prompt-unknown': 'mainlinePromptUnknown',
296
+ };
297
+ const mainline = {
298
+ admitMention: async (request) => {
299
+ const result = await composedMainline.admitMention(request);
300
+ if (result.outcome === 'admitted') {
301
+ // Ownership fact: this request id's turn belongs to this author —
302
+ // approval/question clicks authorize against it.
303
+ turnActors.set(requestIdFor(request.messageId), request.authorId);
304
+ rpcLog('discord_mention_admitted', { messageId: request.messageId, threadId: result.threadId, sessionId: result.sessionId });
305
+ }
306
+ else {
307
+ rpcLog('discord_mention_not_admitted', { messageId: request.messageId, outcome: result.outcome });
308
+ const notice = copy[MAINLINE_FAILURE_KEYS[result.outcome]];
309
+ void withRest(rest => rest.request('POST', `/channels/${request.channelId}/messages`, { content: notice, flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, allowed_mentions: ALLOWED_MENTIONS_NONE }))
310
+ .catch((cause) => { rpcLog('discord_mainline_notice_failed', String(cause)); });
311
+ }
312
+ return result;
313
+ },
314
+ continueInThread: async (request) => {
315
+ const result = await composedMainline.continueInThread(request);
316
+ if (result.outcome === 'rejected' || result.outcome === 'unknown') {
317
+ rpcLog('discord_continuation_not_queued', { messageId: request.messageId, outcome: result.outcome });
318
+ const notice = result.outcome === 'rejected' ? copy.continuationRejected : copy.continuationUnknown;
319
+ void withRest(rest => rest.request('POST', `/channels/${request.threadId}/messages`, { content: notice, flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, allowed_mentions: ALLOWED_MENTIONS_NONE }))
320
+ .catch((cause) => { rpcLog('discord_mainline_notice_failed', String(cause)); });
321
+ }
322
+ return result;
323
+ },
324
+ };
325
+ // Bind provision resolves the selection against the live catalog; the
326
+ // Discord confirm button carries only opaque registry ids between the
327
+ // command and the write.
328
+ const resolver = createWorkspaceResolver(apiProxy, { log: rpcLog });
329
+ // Adapter-owned guild surfaces: the "DeepSeek Harness" category, the
330
+ // general control channel (both provisioned on READY), and — on bind —
331
+ // one channel per Workspace, named after its display title. Nothing
332
+ // outside the category is ever touched.
333
+ const CATEGORY_NAME = 'DeepSeek Harness';
334
+ const ensureCategory = async (rest, guildId) => {
335
+ const listed = await rest.request('GET', `/guilds/${guildId}/channels`);
336
+ if (listed.outcome !== 'completed')
337
+ return undefined;
338
+ const channels = listed.body;
339
+ let category = channels.find((c) => c.type === 4 && c.name.toLowerCase() === CATEGORY_NAME.toLowerCase());
340
+ if (category === undefined) {
341
+ const made = await rest.request('POST', `/guilds/${guildId}/channels`, { name: CATEGORY_NAME, type: 4 });
342
+ if (made.outcome !== 'completed')
343
+ return undefined;
344
+ category = made.body;
345
+ }
346
+ return { channels, categoryId: category.id };
347
+ };
348
+ /**
349
+ * ONE process-wide REST client, rebuilt only when the resolved token
350
+ * changes. Per-route serialization lives inside the client, so concurrent
351
+ * sends/edits/typing into one channel queue behind each other instead of
352
+ * racing the bucket; every composition path goes through here.
353
+ */
354
+ let sharedRestCache;
355
+ const sharedRest = async () => {
356
+ const token = (await resolveDiscordBotToken(credentials)) ?? '';
357
+ if (token === '')
358
+ return undefined;
359
+ if (sharedRestCache?.token !== token) {
360
+ const apiBase = process.env['DSH_DISCORD_REST_BASE'];
361
+ sharedRestCache = {
362
+ token,
363
+ client: createSharedRestClient({ token, ...(apiBase === undefined ? {} : { apiBase }) }),
364
+ };
365
+ }
366
+ return sharedRestCache.client;
367
+ };
368
+ const withRest = async (run) => {
369
+ const rest = await sharedRest();
370
+ if (rest === undefined)
371
+ return undefined;
372
+ return run(rest);
373
+ };
374
+ /**
375
+ * Reconcile an unobservable message send (rest.ts contract): the send
376
+ * carried a nonce, so a message that actually landed is findable among
377
+ * the channel's recent messages — authored by this bot. `undefined`
378
+ * means the outcome stays unobservable (no evidence either way).
379
+ */
380
+ const findSentMessageByNonce = async (rest, channelId, nonce) => {
381
+ // The list body is untrusted wire data: narrow every row by shape.
382
+ const listed = await rest.request('GET', `/channels/${channelId}/messages?limit=50`);
383
+ if (listed.outcome !== 'completed' || !Array.isArray(listed.body))
384
+ return undefined;
385
+ for (const row of listed.body) {
386
+ if (typeof row !== 'object' || row === null)
387
+ continue;
388
+ const record = row;
389
+ if (record['nonce'] !== nonce)
390
+ continue;
391
+ const author = record['author'];
392
+ if (typeof author !== 'object' || author === null)
393
+ continue;
394
+ if (author['id'] !== applicationIdRef.current)
395
+ continue;
396
+ if (typeof record['id'] === 'string')
397
+ return record['id'];
398
+ }
399
+ return undefined;
400
+ };
401
+ const bindChannelKey = (guildId, channelId) => channelBindingKey({ applicationId: applicationIdRef.current, guildId, channelId });
402
+ /** The guild channel already serving this Workspace, if any (one-project-one-channel lookup). */
403
+ const findBoundChannelFor = (guildId, workspaceId) => {
404
+ for (const [key, binding] of channelTable.entries()) {
405
+ if (binding.workspaceId !== workspaceId)
406
+ continue;
407
+ const scope = parseChannelBindingKey(key);
408
+ if (scope?.guildId === guildId)
409
+ return scope.channelId;
410
+ }
411
+ return undefined;
412
+ };
413
+ /**
414
+ * Ensure the Workspace's home channel exists under the adapter category
415
+ * and serves this Workspace (one-project-one-channel: the
416
+ * Workspace's existing bound channel wins outright). A same-name channel
417
+ * is reused only when unbound; a channel serving another Workspace is
418
+ * never stolen — a `-2` sibling is created instead.
419
+ */
420
+ const ensureWorkspaceChannel = async (options) => {
421
+ return withRest(async (rest) => {
422
+ const ensured = await ensureCategory(rest, options.guildId);
423
+ if (ensured === undefined)
424
+ return undefined;
425
+ // The control channel (general, the control-channel analog) is
426
+ // the command surface — it must never become a Workspace home.
427
+ const controlChannelId = ensured.channels.find((c) => c.type === 0 && c.name.toLowerCase() === 'general' && c.parent_id === ensured.categoryId)?.id;
428
+ const bindingOf = (channelId) => {
429
+ if (channelId === controlChannelId)
430
+ return 'other-workspace';
431
+ const bound = bindingStore.get(bindChannelKey(options.guildId, channelId));
432
+ if (bound === undefined)
433
+ return 'unbound';
434
+ return bound.workspaceId === options.workspaceId ? 'this-workspace' : 'other-workspace';
435
+ };
436
+ const existing = findBoundChannelFor(options.guildId, options.workspaceId);
437
+ const record = { workspaceId: options.workspaceId, boundBy: options.actorId, boundAtMs: Date.now() };
438
+ const placement = planWorkspaceChannel({
439
+ channels: ensured.channels.map((c) => ({ id: c.id, name: c.name, parentId: c.parent_id })),
440
+ categoryId: ensured.categoryId,
441
+ desiredName: workspaceChannelName(options.title),
442
+ bindingOf,
443
+ existingForWorkspace: existing,
444
+ });
445
+ if (placement.outcome === 'reuse') {
446
+ if (placement.needsBind) {
447
+ await bindingStore.bind(bindChannelKey(options.guildId, placement.channelId), record);
448
+ }
449
+ return { channelId: placement.channelId, created: false };
450
+ }
451
+ const made = await rest.request('POST', `/guilds/${options.guildId}/channels`, {
452
+ name: placement.name,
453
+ type: 0,
454
+ parent_id: ensured.categoryId,
455
+ });
456
+ if (made.outcome !== 'completed') {
457
+ rpcLog('discord_workspace_channel_create_failed', made.outcome === 'rejected' ? `HTTP ${String(made.status)}` : made.reason);
458
+ return undefined;
459
+ }
460
+ await bindingStore.bind(bindChannelKey(options.guildId, made.body.id), record);
461
+ return { channelId: made.body.id, created: true };
462
+ });
463
+ };
464
+ // ── Reconciliation (reconciliation spec): on READY, verify every
465
+ // persisted mapping against the DSH baseline and Discord's live
466
+ // surface. Retirement ever deletes only adapter records; unverifiable
467
+ // mappings stay blocked (logged) rather than guessed away.
468
+ let reconciling = false;
469
+ const reconcileOnReady = async () => {
470
+ if (reconciling)
471
+ return;
472
+ reconciling = true;
473
+ try {
474
+ const rest = await sharedRest();
475
+ if (rest === undefined)
476
+ return;
477
+ const catalog = await catalogPort.listWorkspaces();
478
+ if (catalog.outcome !== 'completed') {
479
+ rpcLog('discord_reconcile_blocked', 'workspace-catalog-unavailable');
480
+ return;
481
+ }
482
+ const sessionIds = await listSessionIds(apiProxy, { log: rpcLog });
483
+ if (sessionIds.outcome !== 'completed') {
484
+ rpcLog('discord_reconcile_blocked', 'session-list-unavailable');
485
+ return;
486
+ }
487
+ for (const guildId of current.allowedGuildIds) {
488
+ const facts = {};
489
+ const channels = await rest.request('GET', `/guilds/${guildId}/channels`);
490
+ if (channels.outcome === 'completed') {
491
+ for (const channel of channels.body)
492
+ facts[channel.id] = 'ok';
493
+ const threads = await rest.request('GET', `/guilds/${guildId}/threads/active`);
494
+ if (threads.outcome === 'completed' && Array.isArray(threads.body?.threads)) {
495
+ for (const thread of threads.body.threads)
496
+ facts[thread.id] = 'ok';
497
+ }
498
+ }
499
+ const plan = planBindingReconciliation({
500
+ channelBindings: [...channelTable.entries()].map(([key, record]) => {
501
+ const scope = parseChannelBindingKey(key);
502
+ return { channelId: scope?.channelId ?? '', ...record };
503
+ }).filter(binding => binding.channelId !== ''),
504
+ threadBindings: [...threadTable.entries()].map(([key, record]) => {
505
+ const scope = parseThreadBindingKey(key);
506
+ return { threadId: scope?.threadId ?? '', ...record };
507
+ }).filter(binding => binding.threadId !== ''),
508
+ baseline: {
509
+ workspaces: catalog.workspaces.map(workspace => ({ workspaceId: workspace.id, title: workspace.title, path: '' })),
510
+ sessionIds: sessionIds.ids,
511
+ },
512
+ discord: { channels: facts },
513
+ });
514
+ for (const action of plan.channelActions) {
515
+ if (action.action !== 'retire')
516
+ continue;
517
+ const scope = parseChannelBindingKey(channelBindingKey({ applicationId: applicationIdRef.current, guildId, channelId: action.channelId }));
518
+ if (scope === undefined)
519
+ continue;
520
+ await channelTable.delete(channelBindingKey(scope));
521
+ rpcLog('discord_reconcile_channel_retired', { channelId: action.channelId, reason: action.reason });
522
+ }
523
+ for (const action of plan.threadActions) {
524
+ if (action.action !== 'retire')
525
+ continue;
526
+ const scope = parseThreadBindingKey(threadBindingKey({ applicationId: applicationIdRef.current, guildId, threadId: action.threadId }));
527
+ if (scope === undefined)
528
+ continue;
529
+ await threadTable.delete(threadBindingKey(scope));
530
+ rpcLog('discord_reconcile_thread_retired', { threadId: action.threadId, reason: action.reason });
531
+ }
532
+ }
533
+ rpcLog('discord_reconcile_done', {});
534
+ }
535
+ finally {
536
+ reconciling = false;
537
+ }
538
+ };
539
+ // ── Retention sweep: completed intents age out on the durable table;
540
+ // interaction records are still process-local stubs and thus omitted.
541
+ const retentionTimer = setInterval(() => {
542
+ const plan = sweepExpired({ intents: [...intentTable.entries()].map(([key, record]) => [key, record]), resolvedInteractions: [] }, { nowMs: Date.now() });
543
+ for (const key of plan.intentKeys) {
544
+ void intentTable.delete(key).then(() => {
545
+ rpcLog('discord_retention_intent_expired', { key });
546
+ });
547
+ }
548
+ }, 6 * 60 * 60_000);
549
+ ctx.effect(() => () => { clearInterval(retentionTimer); }, 'discord retention sweep');
550
+ // Approval/question expiry: 30s sweep — approvals auto-reject before
551
+ // their controls expire; question expiry cancels the owning turn.
552
+ const expiryTimer = setInterval(() => {
553
+ void sweepExpiredApprovals({
554
+ store: approvalsStore,
555
+ port: approvalRespondPort,
556
+ controls: { disable: key => askWiring.disableControl(key) },
557
+ nowMs: () => Date.now(),
558
+ }).catch((cause) => { rpcLog('discord_approval_expiry_threw', String(cause)); });
559
+ void sweepExpiredQuestions({
560
+ store: questionsStore,
561
+ cancelPort: questionCancelPort,
562
+ controls: { disable: key => askWiring.disableControl(key) },
563
+ nowMs: () => Date.now(),
564
+ }).catch((cause) => { rpcLog('discord_question_expiry_threw', String(cause)); });
565
+ }, 30_000);
566
+ ctx.effect(() => () => { clearInterval(expiryTimer); }, 'discord interaction expiry sweep');
567
+ // DSH approval respond face: the client-response echoes the ask's rpcId.
568
+ const clientRespond = createClientRespondPort(apiProxy, { log: rpcLog });
569
+ const approvalRespondPort = {
570
+ respond: async ({ rpcId, sessionId, approvalId, outcome }) => {
571
+ const receipt = await clientRespond.respond(rpcId, { sessionId, approvalId, outcome });
572
+ rpcLog('discord_approval_respond_receipt', { rpcId, approvalId, outcome, receipt });
573
+ return receipt;
574
+ },
575
+ };
576
+ const queueSnapshots = new Map();
577
+ /** Delete every adapter-owned record for one guild (DSH untouched). */
578
+ const forgetGuild = async (guildId) => {
579
+ const plan = guildKeysToForget({
580
+ guildId,
581
+ channelBindingKeys: [...channelTable.keys()],
582
+ threadBindingKeys: [...threadTable.keys()],
583
+ });
584
+ for (const key of plan.channelKeys)
585
+ await channelTable.delete(key);
586
+ for (const key of plan.threadKeys)
587
+ await threadTable.delete(key);
588
+ rpcLog('discord_guild_forget_deleted', { guildId, channels: plan.channelKeys.length, threads: plan.threadKeys.length });
589
+ };
590
+ const questionRespondPort = {
591
+ respond: async (input) => {
592
+ const receipt = await clientRespond.respond(input.rpcId, { sessionId: input.sessionId, answer: input.answer });
593
+ rpcLog('discord_question_respond_receipt', { rpcId: input.rpcId, receipt });
594
+ return receipt;
595
+ },
596
+ };
597
+ const questionRoutingDeps = {
598
+ registry: sharedRegistry,
599
+ store: questionsStore,
600
+ port: questionRespondPort,
601
+ nowMs: () => Date.now(),
602
+ controls: { disable: key => askWiring.disableControl(key) },
603
+ };
604
+ const interactionRouter = createInteractionRouter({
605
+ policy,
606
+ applicationId: () => applicationIdRef.current,
607
+ registry: sharedRegistry,
608
+ copy,
609
+ approvals: approvalsStore,
610
+ approvalRespondPort,
611
+ turnTracker,
612
+ queueSnapshots,
613
+ forgetGuild,
614
+ componentFollowUp,
615
+ disableControl: key => askWiring.disableControl(key),
616
+ handleQuestionComponent: input => handleSelectInput(questionRoutingDeps, input),
617
+ handleQuestionModal: input => handleModalSubmit(questionRoutingDeps, input),
618
+ dsh: {
619
+ cancel: sessionId => cancelSessionViaProxy(apiProxy, { sessionId }, { log: rpcLog }),
620
+ steer: (sessionId, prompt) => steerSession(apiProxy, { sessionId, prompt }, { log: rpcLog }),
621
+ removeQueueItem: (sessionId, itemId) => removeQueueItemViaProxy(apiProxy, { sessionId, itemId }, { log: rpcLog }),
622
+ readWorkspaceDetail: reference => readWorkspaceDetail(apiProxy, reference, { log: rpcLog }),
623
+ },
624
+ catalogPort,
625
+ resolver,
626
+ channelBinding: (guildId, channelId) => bindingStore.get(bindChannelKey(guildId, channelId)),
627
+ findBoundChannelFor,
628
+ sessionForThread: (guildId, threadId) => bindings.sessionForThread(guildId, threadId),
629
+ ensureWorkspaceChannel,
630
+ rest: sharedRest,
631
+ log: rpcLog,
632
+ warn: (event, detail) => {
633
+ emitLog(ctx, 'warn', { event, detail: typeof detail === 'string' ? detail : JSON.stringify(detail ?? null) });
634
+ },
635
+ });
636
+ runtimeRef.current = startDiscordAdapter({
637
+ tokenProvider: async () => (await resolveDiscordBotToken(credentials)) ?? undefined,
638
+ socketFactory: (url) => {
639
+ const ws = new WebSocket(url);
640
+ // Translate the browser-style WebSocket event API (undici) into the
641
+ // adapter's plain-callback contract. onclose MUST pass the numeric
642
+ // close code: terminal codes drive the fail-closed status surface.
643
+ const socket = {
644
+ url,
645
+ onopen: null,
646
+ onmessage: null,
647
+ onclose: null,
648
+ onerror: null,
649
+ close: (code) => { ws.close(code ?? 1000); },
650
+ terminate: () => { ws.close(1000); },
651
+ send: (data) => { ws.send(data); },
652
+ };
653
+ ws.onopen = () => { socket.onopen?.(); };
654
+ ws.onmessage = (ev) => { socket.onmessage?.(typeof ev.data === 'string' ? ev.data : String(ev.data)); };
655
+ ws.onclose = (ev) => { socket.onclose?.(ev.code); };
656
+ ws.onerror = () => { socket.onerror?.(new Error('socket error')); };
657
+ return socket;
658
+ },
659
+ policy,
660
+ selfUserIdProvider: async () => {
661
+ const rest = await sharedRest();
662
+ if (rest === undefined)
663
+ throw new TypeError('discord bot token unavailable');
664
+ const me = await rest.request('GET', '/users/@me');
665
+ if (me.outcome !== 'completed')
666
+ throw new TypeError('cannot resolve bot identity');
667
+ return me.body.id;
668
+ },
669
+ intents: GATEWAY_INTENTS,
670
+ // Twin-E2E seams: both overrides default to the production surfaces and
671
+ // exist only so a local Discord API twin can host the real protocol.
672
+ ...(process.env['DSH_DISCORD_GATEWAY_URL'] === undefined
673
+ ? {}
674
+ : { gatewayUrl: process.env['DSH_DISCORD_GATEWAY_URL'] }),
675
+ allowedGuildIds: [...current.allowedGuildIds],
676
+ applicationId: () => applicationIdRef.current,
677
+ mainline,
678
+ bindings,
679
+ unboundNotice: (request) => {
680
+ const content = request.audience === 'administrator'
681
+ ? copy.unboundNoticeAdministrator
682
+ : copy.unboundNoticeMember;
683
+ void withRest(async (rest) => {
684
+ const sent = await rest.request('POST', `/channels/${request.channelId}/messages`, { content, flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, allowed_mentions: ALLOWED_MENTIONS_NONE });
685
+ if (sent.outcome !== 'completed') {
686
+ rpcLog('discord_unbound_notice_send_failed', sent.outcome === 'rejected' ? `HTTP ${String(sent.status)}` : sent.reason);
687
+ }
688
+ }).catch((cause) => {
689
+ rpcLog('discord_unbound_notice_threw', String(cause));
690
+ });
691
+ },
692
+ ensureGuildChannels: async (guildId) => {
693
+ await withRest(async (rest) => {
694
+ const ensured = await ensureCategory(rest, guildId);
695
+ if (ensured === undefined)
696
+ return;
697
+ // Never touch channels outside our category: create our own only.
698
+ const hasControl = ensured.channels.some((c) => c.type === 0 && c.name.toLowerCase() === 'general' && c.parent_id === ensured.categoryId);
699
+ if (hasControl)
700
+ return;
701
+ await rest.request('POST', `/guilds/${guildId}/channels`, { name: 'general', type: 0, parent_id: ensured.categoryId });
702
+ });
703
+ },
704
+ approvals: approvalsStore,
705
+ questions: questionsStore,
706
+ status: statusTracker,
707
+ registry: sharedRegistry,
708
+ onReady: () => reconcileOnReady(),
709
+ routeInteraction: (event, token) => {
710
+ // The router speaks the interaction dialect only (compose forwards
711
+ // interactions exclusively).
712
+ if (event.kind !== 'interaction')
713
+ return;
714
+ return interactionRouter.route(event, token);
715
+ },
716
+ logger: {
717
+ warn: (event, detail) => {
718
+ emitLog(ctx, 'warn', { event, detail: typeof detail === 'string' ? detail : JSON.stringify(detail ?? null) });
719
+ },
720
+ },
721
+ });
722
+ // Register the Milestone 1 command set per allowed Guild (instant
723
+ // propagation, unlike global commands) once the bot identity is known.
724
+ // Re-runs whenever the allowlist changes — apply-time settings may still
725
+ // be loading, so the first non-empty list wins.
726
+ let registering = false;
727
+ registerCommands = async () => {
728
+ if (registering || current.allowedGuildIds.length === 0)
729
+ return;
730
+ registering = true;
731
+ try {
732
+ const rest = await sharedRest();
733
+ if (rest === undefined)
734
+ return;
735
+ const me = await rest.request('GET', '/users/@me');
736
+ if (me.outcome !== 'completed')
737
+ return;
738
+ applicationIdRef.current = me.body.id;
739
+ const registrations = buildCommandRegistrations();
740
+ for (const guildId of current.allowedGuildIds) {
741
+ const result = await rest.request('PUT', `/applications/${me.body.id}/guilds/${guildId}/commands`, registrations);
742
+ if (result.outcome !== 'completed') {
743
+ emitLog(ctx, 'warn', { event: 'discord_command_register_failed', guildId });
744
+ }
745
+ }
746
+ }
747
+ finally {
748
+ registering = false;
749
+ }
750
+ };
751
+ void registerCommands().catch((cause) => {
752
+ emitLog(ctx, 'warn', { event: 'discord_command_register_failed', cause: String(cause) });
753
+ });
754
+ // ── Live streaming: DSH events.mux → per-thread Discord rendering ─────
755
+ // The queue snapshot cache (declared with the interaction router) is the
756
+ // /queue surface's data source: DSH 0.1.1 has no queue-list RPC, only the
757
+ // authoritative whole-snapshot mux frame.
758
+ const threadForSession = (sessionId) => {
759
+ for (const [key, record] of threadTable.entries()) {
760
+ if (record.sessionId !== sessionId)
761
+ continue;
762
+ const scope = parseThreadBindingKey(key);
763
+ if (scope !== undefined)
764
+ return scope.threadId;
765
+ }
766
+ return undefined;
767
+ };
768
+ const liveRef = { current: undefined };
769
+ liveRef.current = startLiveRender({
770
+ frames: (signal) => {
771
+ const events = apiProxy.events;
772
+ if (events === undefined)
773
+ throw new TypeError('apiProxy.events is unavailable on this Host');
774
+ return events.mux({ rpcId: crypto.randomUUID(), payload: {} }, signal);
775
+ },
776
+ threadForSession,
777
+ delivery: {
778
+ send: async (request) => {
779
+ const rest = await sharedRest();
780
+ if (rest === undefined)
781
+ return { outcome: 'failed' };
782
+ // The nonce lets an unobservable send be reconciled instead of
783
+ // blindly retried (rest.ts contract): a message that actually
784
+ // landed is found by its nonce and reported completed.
785
+ const nonce = crypto.randomUUID();
786
+ const sent = await rest.request('POST', `/channels/${request.channelId}/messages`, {
787
+ content: request.content,
788
+ flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG,
789
+ allowed_mentions: ALLOWED_MENTIONS_NONE,
790
+ nonce,
791
+ });
792
+ if (sent.outcome === 'completed' && typeof sent.body?.id === 'string') {
793
+ return { outcome: 'completed', messageId: sent.body.id };
794
+ }
795
+ if (sent.outcome === 'unknown') {
796
+ const reconciled = await findSentMessageByNonce(rest, request.channelId, nonce);
797
+ if (reconciled !== undefined)
798
+ return { outcome: 'completed', messageId: reconciled };
799
+ rpcLog('discord_live_send_unknown', { channelId: request.channelId });
800
+ return { outcome: 'unknown' };
801
+ }
802
+ rpcLog('discord_live_send_failed', { channelId: request.channelId, outcome: sent.outcome });
803
+ return { outcome: 'failed' };
804
+ },
805
+ edit: async (request) => {
806
+ const rest = await sharedRest();
807
+ if (rest === undefined)
808
+ return { outcome: 'failed' };
809
+ const edited = await rest.request('PATCH', `/channels/${request.channelId}/messages/${request.messageId}`, {
810
+ content: request.content,
811
+ flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG,
812
+ allowed_mentions: ALLOWED_MENTIONS_NONE,
813
+ });
814
+ return edited.outcome === 'completed' ? { outcome: 'completed' } : { outcome: 'failed' };
815
+ },
816
+ typing: async (channelId) => {
817
+ const rest = await sharedRest();
818
+ if (rest === undefined)
819
+ return;
820
+ await rest.request('POST', `/channels/${channelId}/typing`);
821
+ },
822
+ delete: async (request) => {
823
+ const rest = await sharedRest();
824
+ if (rest === undefined)
825
+ return { outcome: 'failed' };
826
+ const deleted = await rest.request('DELETE', `/channels/${request.channelId}/messages/${request.messageId}`);
827
+ return deleted.outcome === 'completed' ? { outcome: 'completed' } : { outcome: 'failed' };
828
+ },
829
+ renameThread: async (request) => {
830
+ const rest = await sharedRest();
831
+ if (rest === undefined)
832
+ return { outcome: 'failed' };
833
+ const renamed = await rest.request('PATCH', `/channels/${request.channelId}`, { name: request.name });
834
+ return renamed.outcome === 'completed' ? { outcome: 'completed' } : { outcome: 'failed' };
835
+ },
836
+ },
837
+ threadName: async (channelId) => {
838
+ const rest = await sharedRest();
839
+ if (rest === undefined)
840
+ return undefined;
841
+ const got = await rest.request('GET', `/channels/${channelId}`);
842
+ return got.outcome === 'completed' && typeof got.body?.name === 'string' ? got.body.name : undefined;
843
+ },
844
+ updateIntervalMs: current.streamUpdateIntervalMs,
845
+ typingIntervalMs: current.typingIntervalMs,
846
+ approvalTimeoutMs: current.approvalTimeoutMs,
847
+ questionTimeoutMs: current.questionTimeoutMs,
848
+ verbosity: current.defaultVerbosity,
849
+ log: rpcLog,
850
+ interruptedMarker: () => copy.interruptedMarker,
851
+ onQueueSnapshot: (sessionId, items) => { queueSnapshots.set(sessionId, items); },
852
+ onTurnEnded: (sessionId) => {
853
+ const turn = turnTracker.active(sessionId);
854
+ if (turn !== undefined)
855
+ turnTracker.complete(turn.requestId);
856
+ rpcLog('discord_turn_ended', { sessionId, hadActiveTurn: turn !== undefined });
857
+ },
858
+ requests: askWiring,
859
+ });
860
+ ctx.effect(() => () => { liveRef.current?.dispose(); }, 'dsh-discord live render');
861
+ ctx.effect(() => () => { runtimeRef.current?.dispose(); }, 'dsh-discord composed runtime');
862
+ })().catch((cause) => {
863
+ console.error('[dsh-discord] composition failed:', cause);
864
+ emitLog(ctx, 'warn', { event: 'discord_compose_failed', cause: String(cause) });
865
+ statusTracker.setGateway('disconnected');
866
+ });
867
+ }
868
+ export { DEFAULT_DISCORD_SETTINGS, DISCORD_SETTINGS_NAMESPACE, DiscordSettingsSchema, normalizeDiscordSettings, validateDiscordSettings, } from './settings.js';
869
+ export { DISCORD_BOT_TOKEN_REF, describeDiscordCredential, resolveDiscordBotToken, } from './credential.js';