@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.
- package/LICENSE +21 -0
- package/README.md +68 -0
- package/cordis.patch.yml +14 -0
- package/lib/client/DiscordSettingsCard.d.ts +6 -0
- package/lib/client/PluginCard.d.ts +43 -0
- package/lib/client/card-controller.d.ts +22 -0
- package/lib/client/card-form.d.ts +111 -0
- package/lib/client/fields.d.ts +50 -0
- package/lib/client/index.d.ts +12 -0
- package/lib/client/nav-icon.d.ts +21 -0
- package/lib/client/settings-model.d.ts +49 -0
- package/lib/client/slot-contract.d.ts +57 -0
- package/lib/client/snapshot-store.d.ts +9 -0
- package/lib/client.js +1622 -0
- package/lib/compose.d.ts +158 -0
- package/lib/compose.js +227 -0
- package/lib/credential.d.ts +20 -0
- package/lib/credential.js +24 -0
- package/lib/discord/commands.d.ts +45 -0
- package/lib/discord/commands.js +87 -0
- package/lib/discord/components.d.ts +29 -0
- package/lib/discord/components.js +57 -0
- package/lib/discord/delivery.d.ts +21 -0
- package/lib/discord/delivery.js +31 -0
- package/lib/discord/interaction-lifecycle.d.ts +42 -0
- package/lib/discord/interaction-lifecycle.js +78 -0
- package/lib/discord/rest.d.ts +74 -0
- package/lib/discord/rest.js +149 -0
- package/lib/discord/selector.d.ts +39 -0
- package/lib/discord/selector.js +70 -0
- package/lib/discord/thread-port.d.ts +37 -0
- package/lib/discord/thread-port.js +58 -0
- package/lib/dsh/api-proxy-face.d.ts +253 -0
- package/lib/dsh/api-proxy-face.js +370 -0
- package/lib/features/adapter-status.d.ts +111 -0
- package/lib/features/adapter-status.js +103 -0
- package/lib/features/approval-expiry.d.ts +27 -0
- package/lib/features/approval-expiry.js +36 -0
- package/lib/features/approval-routing.d.ts +50 -0
- package/lib/features/approval-routing.js +57 -0
- package/lib/features/approval-store.d.ts +86 -0
- package/lib/features/approval-store.js +119 -0
- package/lib/features/approval-view.d.ts +47 -0
- package/lib/features/approval-view.js +48 -0
- package/lib/features/ask-wiring.d.ts +74 -0
- package/lib/features/ask-wiring.js +151 -0
- package/lib/features/host-status.d.ts +41 -0
- package/lib/features/host-status.js +24 -0
- package/lib/features/image-collection.d.ts +56 -0
- package/lib/features/image-collection.js +79 -0
- package/lib/features/image-download.d.ts +48 -0
- package/lib/features/image-download.js +77 -0
- package/lib/features/image-submission.d.ts +67 -0
- package/lib/features/image-submission.js +57 -0
- package/lib/features/interaction-router.d.ts +84 -0
- package/lib/features/interaction-router.js +655 -0
- package/lib/features/model-control.d.ts +79 -0
- package/lib/features/model-control.js +51 -0
- package/lib/features/preset-control.d.ts +84 -0
- package/lib/features/preset-control.js +43 -0
- package/lib/features/project-bind.d.ts +69 -0
- package/lib/features/project-bind.js +57 -0
- package/lib/features/project-info.d.ts +25 -0
- package/lib/features/project-info.js +22 -0
- package/lib/features/project-list.d.ts +79 -0
- package/lib/features/project-list.js +93 -0
- package/lib/features/prompt-submission.d.ts +53 -0
- package/lib/features/prompt-submission.js +47 -0
- package/lib/features/question-expiry.d.ts +53 -0
- package/lib/features/question-expiry.js +56 -0
- package/lib/features/question-routing.d.ts +108 -0
- package/lib/features/question-routing.js +162 -0
- package/lib/features/question-store.d.ts +124 -0
- package/lib/features/question-store.js +186 -0
- package/lib/features/question-view.d.ts +58 -0
- package/lib/features/question-view.js +81 -0
- package/lib/features/queue-control.d.ts +63 -0
- package/lib/features/queue-control.js +31 -0
- package/lib/features/reconcile-bindings.d.ts +75 -0
- package/lib/features/reconcile-bindings.js +54 -0
- package/lib/features/reconcile-deliveries.d.ts +29 -0
- package/lib/features/reconcile-deliveries.js +28 -0
- package/lib/features/reconcile-events.d.ts +49 -0
- package/lib/features/reconcile-events.js +50 -0
- package/lib/features/reconcile-intents.d.ts +32 -0
- package/lib/features/reconcile-intents.js +32 -0
- package/lib/features/reconcile-interactions.d.ts +59 -0
- package/lib/features/reconcile-interactions.js +54 -0
- package/lib/features/session-adopt.d.ts +89 -0
- package/lib/features/session-adopt.js +77 -0
- package/lib/features/session-creation.d.ts +49 -0
- package/lib/features/session-creation.js +43 -0
- package/lib/features/session-mainline.d.ts +75 -0
- package/lib/features/session-mainline.js +96 -0
- package/lib/features/session-resume.d.ts +44 -0
- package/lib/features/session-resume.js +40 -0
- package/lib/features/skill-run.d.ts +48 -0
- package/lib/features/skill-run.js +25 -0
- package/lib/features/steer-control.d.ts +37 -0
- package/lib/features/steer-control.js +28 -0
- package/lib/features/stop-control.d.ts +38 -0
- package/lib/features/stop-control.js +31 -0
- package/lib/features/task-admission.d.ts +29 -0
- package/lib/features/task-admission.js +21 -0
- package/lib/features/thread-continuation.d.ts +36 -0
- package/lib/features/thread-continuation.js +18 -0
- package/lib/features/thread-creation.d.ts +81 -0
- package/lib/features/thread-creation.js +81 -0
- package/lib/features/thread-routing.d.ts +49 -0
- package/lib/features/thread-routing.js +34 -0
- package/lib/features/turn-ownership.d.ts +39 -0
- package/lib/features/turn-ownership.js +40 -0
- package/lib/features/unbound-mention.d.ts +49 -0
- package/lib/features/unbound-mention.js +31 -0
- package/lib/features/workspace-channel.d.ts +41 -0
- package/lib/features/workspace-channel.js +47 -0
- package/lib/gateway/gateway.d.ts +50 -0
- package/lib/gateway/gateway.js +229 -0
- package/lib/gateway/inbound.d.ts +85 -0
- package/lib/gateway/inbound.js +186 -0
- package/lib/gateway/ingress.d.ts +15 -0
- package/lib/gateway/ingress.js +22 -0
- package/lib/i18n.d.ts +92 -0
- package/lib/i18n.js +180 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.js +869 -0
- package/lib/lifecycle.d.ts +44 -0
- package/lib/lifecycle.js +84 -0
- package/lib/policy/authorization.d.ts +41 -0
- package/lib/policy/authorization.js +64 -0
- package/lib/policy/disclosure.d.ts +93 -0
- package/lib/policy/disclosure.js +153 -0
- package/lib/policy/guard.d.ts +31 -0
- package/lib/policy/guard.js +46 -0
- package/lib/policy/suppress.d.ts +13 -0
- package/lib/policy/suppress.js +20 -0
- package/lib/settings-namespace.d.ts +7 -0
- package/lib/settings-namespace.js +6 -0
- package/lib/settings.d.ts +37 -0
- package/lib/settings.js +102 -0
- package/lib/startup.d.ts +16 -0
- package/lib/startup.js +60 -0
- package/lib/state/bindings.d.ts +46 -0
- package/lib/state/bindings.js +55 -0
- package/lib/state/channel-bindings.d.ts +37 -0
- package/lib/state/channel-bindings.js +43 -0
- package/lib/state/domain.d.ts +82 -0
- package/lib/state/domain.js +86 -0
- package/lib/state/effect-machine.d.ts +37 -0
- package/lib/state/effect-machine.js +52 -0
- package/lib/state/fail-closed.d.ts +44 -0
- package/lib/state/fail-closed.js +57 -0
- package/lib/state/intents.d.ts +51 -0
- package/lib/state/intents.js +73 -0
- package/lib/state/records.d.ts +37 -0
- package/lib/state/records.js +30 -0
- package/lib/state/retention.d.ts +74 -0
- package/lib/state/retention.js +68 -0
- package/lib/state/session-owners.d.ts +50 -0
- package/lib/state/session-owners.js +50 -0
- package/lib/stream/finalizer.d.ts +49 -0
- package/lib/stream/finalizer.js +67 -0
- package/lib/stream/icons.d.ts +15 -0
- package/lib/stream/icons.js +36 -0
- package/lib/stream/live.d.ts +140 -0
- package/lib/stream/live.js +515 -0
- package/lib/stream/markdown.d.ts +24 -0
- package/lib/stream/markdown.js +95 -0
- package/lib/stream/outbound.d.ts +27 -0
- package/lib/stream/outbound.js +25 -0
- package/lib/stream/render-fence.d.ts +27 -0
- package/lib/stream/render-fence.js +37 -0
- package/lib/stream/render-model.d.ts +48 -0
- package/lib/stream/render-model.js +68 -0
- package/lib/stream/splitter.d.ts +8 -0
- package/lib/stream/splitter.js +58 -0
- package/lib/stream/tool-view.d.ts +40 -0
- package/lib/stream/tool-view.js +39 -0
- package/lib/stream/typing.d.ts +22 -0
- package/lib/stream/typing.js +58 -0
- package/lib/stream/update-scheduler.d.ts +20 -0
- package/lib/stream/update-scheduler.js +65 -0
- 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';
|