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