@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,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending question records and answer drafts (design.md §8, task 14.1). One
|
|
3
|
+
* record binds the ownership facts — Session, Discord Thread, owning
|
|
4
|
+
* adapter-submitted request ID, the answerable question rpcId, originating
|
|
5
|
+
* user — plus the full question list and one draft answer per question. DSH
|
|
6
|
+
* answers an ask() as a whole batch or not at all, so drafts may accumulate
|
|
7
|
+
* but never submit until every question is answered and valid.
|
|
8
|
+
*
|
|
9
|
+
* Bounds are Discord's component limits, enforced at open as fail-closed
|
|
10
|
+
* values: a batch the adapter cannot render is refused rather than silently
|
|
11
|
+
* truncated. Records are process-local by design — a Host restart invalidates
|
|
12
|
+
* pending questions, which reconciliation (15.7) treats as unresolved.
|
|
13
|
+
*/
|
|
14
|
+
/** Discord action rows per message — one select each. */
|
|
15
|
+
export const MAX_QUESTIONS_PER_BATCH = 5;
|
|
16
|
+
/** Discord string-select option bound. */
|
|
17
|
+
export const MAX_OPTIONS_PER_QUESTION = 25;
|
|
18
|
+
/** Discord select option label bound (characters). */
|
|
19
|
+
export const MAX_LABEL_LENGTH = 100;
|
|
20
|
+
/** The adapter's own custom-text bound (inside Discord's 4000). */
|
|
21
|
+
export const MAX_CUSTOM_LENGTH = 1000;
|
|
22
|
+
/** Validate one draft answer against its question (pure; also used by routing). */
|
|
23
|
+
export function validateDraftAnswer(question, draft) {
|
|
24
|
+
if (question === undefined || draft.id !== question.id)
|
|
25
|
+
return { ok: false, error: 'unknown-question' };
|
|
26
|
+
const labels = new Set((question.options ?? []).map(option => option.label));
|
|
27
|
+
if (!draft.selected.every(label => labels.has(label)))
|
|
28
|
+
return { ok: false, error: 'invalid-label' };
|
|
29
|
+
if (!question.multiSelect && draft.selected.length > 1)
|
|
30
|
+
return { ok: false, error: 'invalid-label' };
|
|
31
|
+
if (draft.custom !== undefined && draft.custom.length > MAX_CUSTOM_LENGTH) {
|
|
32
|
+
return { ok: false, error: 'custom-too-long' };
|
|
33
|
+
}
|
|
34
|
+
return { ok: true };
|
|
35
|
+
}
|
|
36
|
+
/** A batch is complete exactly when every question carries a draft. */
|
|
37
|
+
export function isCompleteDraft(questions, drafts) {
|
|
38
|
+
return questions.every(question => drafts.has(question.id));
|
|
39
|
+
}
|
|
40
|
+
/** Encode the complete draft into the DSH answer batch (pure). */
|
|
41
|
+
export function encodeAnswer(questions, drafts) {
|
|
42
|
+
return {
|
|
43
|
+
answers: questions.map((question) => {
|
|
44
|
+
const draft = drafts.get(question.id);
|
|
45
|
+
const selected = draft?.selected ?? [];
|
|
46
|
+
return {
|
|
47
|
+
id: question.id,
|
|
48
|
+
selected,
|
|
49
|
+
...(draft?.custom === undefined ? {} : { custom: draft.custom }),
|
|
50
|
+
};
|
|
51
|
+
}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function toRecord(entry) {
|
|
55
|
+
return {
|
|
56
|
+
...entry.batch,
|
|
57
|
+
state: entry.state,
|
|
58
|
+
...(entry.resolvedOutcome === undefined ? {} : { resolvedOutcome: entry.resolvedOutcome }),
|
|
59
|
+
...(entry.resolvedBy === undefined ? {} : { resolvedBy: entry.resolvedBy }),
|
|
60
|
+
...(entry.expiredCancel === undefined ? {} : { expiredCancel: entry.expiredCancel }),
|
|
61
|
+
draft: questionId => entry.drafts.get(questionId),
|
|
62
|
+
drafts: () => [...entry.batch.questions.flatMap(question => {
|
|
63
|
+
const draft = entry.drafts.get(question.id);
|
|
64
|
+
return draft === undefined ? [] : [draft];
|
|
65
|
+
})],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function createQuestionStore() {
|
|
69
|
+
const entries = new Map();
|
|
70
|
+
const chains = new Map();
|
|
71
|
+
function entry(rpcId) {
|
|
72
|
+
return entries.get(rpcId);
|
|
73
|
+
}
|
|
74
|
+
/** Per-key serialization: operations on one batch never interleave. */
|
|
75
|
+
function serialized(rpcId, op) {
|
|
76
|
+
const tail = chains.get(rpcId) ?? Promise.resolve();
|
|
77
|
+
const run = tail.then(op, op);
|
|
78
|
+
chains.set(rpcId, run.then(() => undefined, () => undefined));
|
|
79
|
+
return run;
|
|
80
|
+
}
|
|
81
|
+
function mutate(rpcId, change) {
|
|
82
|
+
return serialized(rpcId, () => {
|
|
83
|
+
const found = entries.get(rpcId);
|
|
84
|
+
if (found === undefined)
|
|
85
|
+
return;
|
|
86
|
+
change(found);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
get(rpcId) {
|
|
91
|
+
const found = entry(rpcId);
|
|
92
|
+
return found === undefined ? undefined : toRecord(found);
|
|
93
|
+
},
|
|
94
|
+
open(candidate) {
|
|
95
|
+
const existing = entries.get(candidate.questionRpcId);
|
|
96
|
+
if (existing !== undefined)
|
|
97
|
+
return { ok: true };
|
|
98
|
+
if (candidate.questions.length > MAX_QUESTIONS_PER_BATCH)
|
|
99
|
+
return { ok: false, error: 'too-many-questions' };
|
|
100
|
+
for (const question of candidate.questions) {
|
|
101
|
+
const options = question.options ?? [];
|
|
102
|
+
if (options.length === 0)
|
|
103
|
+
return { ok: false, error: 'options-required' };
|
|
104
|
+
if (options.length > MAX_OPTIONS_PER_QUESTION)
|
|
105
|
+
return { ok: false, error: 'too-many-options' };
|
|
106
|
+
if (options.some(option => option.label.length > MAX_LABEL_LENGTH)) {
|
|
107
|
+
return { ok: false, error: 'option-label-too-long' };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
entries.set(candidate.questionRpcId, {
|
|
111
|
+
batch: candidate,
|
|
112
|
+
state: 'pending',
|
|
113
|
+
drafts: new Map(),
|
|
114
|
+
});
|
|
115
|
+
return { ok: true };
|
|
116
|
+
},
|
|
117
|
+
setDraft(rpcId, draft) {
|
|
118
|
+
const found = entry(rpcId);
|
|
119
|
+
if (found === undefined)
|
|
120
|
+
return { ok: false, error: 'unknown-question' };
|
|
121
|
+
const question = found.batch.questions.find(item => item.id === draft.id);
|
|
122
|
+
const verdict = validateDraftAnswer(question, draft);
|
|
123
|
+
if (!verdict.ok)
|
|
124
|
+
return verdict;
|
|
125
|
+
found.drafts.set(draft.id, draft);
|
|
126
|
+
return { ok: true, complete: isCompleteDraft(found.batch.questions, found.drafts) };
|
|
127
|
+
},
|
|
128
|
+
takeDrafts(rpcId) {
|
|
129
|
+
const found = entry(rpcId);
|
|
130
|
+
if (found === undefined)
|
|
131
|
+
return [];
|
|
132
|
+
return found.batch.questions.flatMap(question => {
|
|
133
|
+
const draft = found.drafts.get(question.id);
|
|
134
|
+
return draft === undefined ? [] : [draft];
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
encodeSubmitted(rpcId) {
|
|
138
|
+
const found = entry(rpcId);
|
|
139
|
+
if (found === undefined)
|
|
140
|
+
return { answers: [] };
|
|
141
|
+
return encodeAnswer(found.batch.questions, found.drafts);
|
|
142
|
+
},
|
|
143
|
+
claim(rpcId) {
|
|
144
|
+
return serialized(rpcId, () => {
|
|
145
|
+
const found = entries.get(rpcId);
|
|
146
|
+
if (found === undefined)
|
|
147
|
+
return { outcome: 'unknown' };
|
|
148
|
+
const claimable = found.state === 'pending' || found.state === 'unresolved';
|
|
149
|
+
if (!claimable)
|
|
150
|
+
return { outcome: 'not-claimable', record: toRecord(found) };
|
|
151
|
+
found.state = 'submitting';
|
|
152
|
+
return { outcome: 'claimed', record: toRecord(found) };
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
markResolved(rpcId, outcome, by, _atMs) {
|
|
156
|
+
return mutate(rpcId, (found) => {
|
|
157
|
+
// First resolution wins: a remote frame arriving after the user's own
|
|
158
|
+
// recorded answer retires controls elsewhere, never rewrites history.
|
|
159
|
+
if (found.state === 'resolved')
|
|
160
|
+
return;
|
|
161
|
+
found.state = 'resolved';
|
|
162
|
+
found.resolvedOutcome = outcome;
|
|
163
|
+
found.resolvedBy = by;
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
markUnresolved(rpcId, _atMs) {
|
|
167
|
+
return mutate(rpcId, (found) => {
|
|
168
|
+
found.state = 'unresolved';
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
listPendingExpired(atMs) {
|
|
172
|
+
const expired = [];
|
|
173
|
+
for (const entry of entries.values()) {
|
|
174
|
+
if (entry.state === 'pending' && atMs >= entry.batch.expiresAtMs)
|
|
175
|
+
expired.push(toRecord(entry));
|
|
176
|
+
}
|
|
177
|
+
return expired;
|
|
178
|
+
},
|
|
179
|
+
markExpired(rpcId, cancel, _atMs) {
|
|
180
|
+
return mutate(rpcId, (found) => {
|
|
181
|
+
found.state = 'expired';
|
|
182
|
+
found.expiredCancel = cancel;
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Question control rendering (design.md §8, task 14.1). One pending ask()
|
|
3
|
+
* batch renders as one string select per question — Discord's min/max values
|
|
4
|
+
* carry the multi-select semantics — plus a Submit button when the five-row
|
|
5
|
+
* budget allows (a full batch auto-submits when its last answer lands). Every
|
|
6
|
+
* custom_id is an opaque registry key: rpc ids, question ids, session, and
|
|
7
|
+
* labels never ride the Discord wire. Option values are the host's own
|
|
8
|
+
* labels, validated against the batch before any answer is recorded.
|
|
9
|
+
*/
|
|
10
|
+
import type { ComponentRegistry } from '../discord/components.js';
|
|
11
|
+
import { DISCORD_SUPPRESS_NOTIFICATIONS_FLAG } from '../policy/disclosure.js';
|
|
12
|
+
import { type QuestionBatch } from './question-store.js';
|
|
13
|
+
/** Discord interactive-component ids (action row = 1, button = 2, string select = 3). */
|
|
14
|
+
declare const ACTION_ROW = 1;
|
|
15
|
+
declare const BUTTON = 2;
|
|
16
|
+
declare const STRING_SELECT = 3;
|
|
17
|
+
declare const BUTTON_STYLE_PRIMARY = 1;
|
|
18
|
+
/** The reserved select value that routes a question to the custom-text modal. */
|
|
19
|
+
export declare const CUSTOM_ANSWER_VALUE = "__custom__";
|
|
20
|
+
export interface QuestionViewInput {
|
|
21
|
+
registry: ComponentRegistry;
|
|
22
|
+
batch: QuestionBatch;
|
|
23
|
+
}
|
|
24
|
+
export interface QuestionSelectComponent {
|
|
25
|
+
type: typeof STRING_SELECT;
|
|
26
|
+
custom_id: string;
|
|
27
|
+
options: Array<{
|
|
28
|
+
label: string;
|
|
29
|
+
value: string;
|
|
30
|
+
description?: string | undefined;
|
|
31
|
+
}>;
|
|
32
|
+
min_values: number;
|
|
33
|
+
max_values: number;
|
|
34
|
+
placeholder?: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
export interface QuestionButtonComponent {
|
|
37
|
+
type: typeof BUTTON;
|
|
38
|
+
style: typeof BUTTON_STYLE_PRIMARY;
|
|
39
|
+
label: string;
|
|
40
|
+
custom_id: string;
|
|
41
|
+
}
|
|
42
|
+
export interface QuestionRow {
|
|
43
|
+
type: typeof ACTION_ROW;
|
|
44
|
+
components: Array<QuestionSelectComponent | QuestionButtonComponent>;
|
|
45
|
+
}
|
|
46
|
+
export interface QuestionViewPayload {
|
|
47
|
+
content: string;
|
|
48
|
+
flags: typeof DISCORD_SUPPRESS_NOTIFICATIONS_FLAG;
|
|
49
|
+
components: QuestionRow[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Render one pending question batch. Selects carry a trailing "Other…"
|
|
53
|
+
* option (value `__custom__`) whose selection opens the custom-text modal —
|
|
54
|
+
* see question-routing's modal flow. Registry contexts expose
|
|
55
|
+
* `{ questionRpcId, action: 'select' | 'submit', questionId?, expiresAtMs }`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function renderQuestionControls(input: QuestionViewInput): QuestionViewPayload;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Question control rendering (design.md §8, task 14.1). One pending ask()
|
|
3
|
+
* batch renders as one string select per question — Discord's min/max values
|
|
4
|
+
* carry the multi-select semantics — plus a Submit button when the five-row
|
|
5
|
+
* budget allows (a full batch auto-submits when its last answer lands). Every
|
|
6
|
+
* custom_id is an opaque registry key: rpc ids, question ids, session, and
|
|
7
|
+
* labels never ride the Discord wire. Option values are the host's own
|
|
8
|
+
* labels, validated against the batch before any answer is recorded.
|
|
9
|
+
*/
|
|
10
|
+
import { DISCORD_SUPPRESS_NOTIFICATIONS_FLAG } from '../policy/disclosure.js';
|
|
11
|
+
import { suppressMentionSyntax } from '../policy/suppress.js';
|
|
12
|
+
import { MAX_CUSTOM_LENGTH, MAX_LABEL_LENGTH, } from './question-store.js';
|
|
13
|
+
/** Discord interactive-component ids (action row = 1, button = 2, string select = 3). */
|
|
14
|
+
const ACTION_ROW = 1;
|
|
15
|
+
const BUTTON = 2;
|
|
16
|
+
const STRING_SELECT = 3;
|
|
17
|
+
const BUTTON_STYLE_PRIMARY = 1;
|
|
18
|
+
/** The reserved select value that routes a question to the custom-text modal. */
|
|
19
|
+
export const CUSTOM_ANSWER_VALUE = '__custom__';
|
|
20
|
+
function boundedQuestionText(text) {
|
|
21
|
+
const neutralized = suppressMentionSyntax(text);
|
|
22
|
+
return neutralized.length <= MAX_LABEL_LENGTH ? neutralized : `${neutralized.slice(0, MAX_LABEL_LENGTH)}…`;
|
|
23
|
+
}
|
|
24
|
+
function selectFor(registry, batch, question) {
|
|
25
|
+
const options = (question.options ?? []).map(option => ({
|
|
26
|
+
label: option.label,
|
|
27
|
+
value: option.label,
|
|
28
|
+
...(option.description === undefined ? {} : { description: option.description }),
|
|
29
|
+
}));
|
|
30
|
+
const customLabel = `Other — tell us in your own words`;
|
|
31
|
+
options.push({ label: customLabel, value: CUSTOM_ANSWER_VALUE });
|
|
32
|
+
return {
|
|
33
|
+
type: STRING_SELECT,
|
|
34
|
+
custom_id: registry.register({
|
|
35
|
+
questionRpcId: batch.questionRpcId,
|
|
36
|
+
action: 'select',
|
|
37
|
+
questionId: question.id,
|
|
38
|
+
expiresAtMs: batch.expiresAtMs,
|
|
39
|
+
}),
|
|
40
|
+
options,
|
|
41
|
+
min_values: 1,
|
|
42
|
+
max_values: question.multiSelect ? Math.min(options.length, 25) : 1,
|
|
43
|
+
...(question.header === undefined ? {} : { placeholder: boundedQuestionText(question.header) }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Render one pending question batch. Selects carry a trailing "Other…"
|
|
48
|
+
* option (value `__custom__`) whose selection opens the custom-text modal —
|
|
49
|
+
* see question-routing's modal flow. Registry contexts expose
|
|
50
|
+
* `{ questionRpcId, action: 'select' | 'submit', questionId?, expiresAtMs }`.
|
|
51
|
+
*/
|
|
52
|
+
export function renderQuestionControls(input) {
|
|
53
|
+
const { registry, batch } = input;
|
|
54
|
+
const rows = batch.questions.map(question => ({
|
|
55
|
+
type: ACTION_ROW,
|
|
56
|
+
components: [selectFor(registry, batch, question)],
|
|
57
|
+
}));
|
|
58
|
+
if (batch.questions.length < 5) {
|
|
59
|
+
rows.push({
|
|
60
|
+
type: ACTION_ROW,
|
|
61
|
+
components: [{
|
|
62
|
+
type: BUTTON,
|
|
63
|
+
style: BUTTON_STYLE_PRIMARY,
|
|
64
|
+
label: 'Submit answers',
|
|
65
|
+
custom_id: registry.register({
|
|
66
|
+
questionRpcId: batch.questionRpcId,
|
|
67
|
+
action: 'submit',
|
|
68
|
+
expiresAtMs: batch.expiresAtMs,
|
|
69
|
+
}),
|
|
70
|
+
}],
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const header = batch.questions.length === 1
|
|
74
|
+
? boundedQuestionText(batch.questions[0]?.question ?? 'Questions')
|
|
75
|
+
: `${String(batch.questions.length)} questions need answers`;
|
|
76
|
+
return {
|
|
77
|
+
content: `${header} (answers stay editable until you submit; custom text up to ${String(MAX_CUSTOM_LENGTH)} characters)`,
|
|
78
|
+
flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG,
|
|
79
|
+
components: rows,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/queue list` and `/queue remove <item>` (design.md §13, task 8.6). The
|
|
3
|
+
* list renders the session's pending inbox in queue order with stable
|
|
4
|
+
* positions; removal targets exactly one item. Every DSH outcome maps to a
|
|
5
|
+
* value, and unknown removal outcomes stay unknown — reconciliation, not
|
|
6
|
+
* optimism, decides whether the item is really gone.
|
|
7
|
+
*/
|
|
8
|
+
export interface DshQueuePort {
|
|
9
|
+
snapshot(request: {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
}): Promise<{
|
|
12
|
+
outcome: 'completed';
|
|
13
|
+
items: Array<{
|
|
14
|
+
itemId: string;
|
|
15
|
+
summary: string;
|
|
16
|
+
requestedBy: string;
|
|
17
|
+
}>;
|
|
18
|
+
} | {
|
|
19
|
+
outcome: 'rejected';
|
|
20
|
+
reason: string;
|
|
21
|
+
} | {
|
|
22
|
+
outcome: 'unknown';
|
|
23
|
+
}>;
|
|
24
|
+
remove(request: {
|
|
25
|
+
sessionId: string;
|
|
26
|
+
itemId: string;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
outcome: 'completed';
|
|
29
|
+
removed: boolean;
|
|
30
|
+
} | {
|
|
31
|
+
outcome: 'rejected';
|
|
32
|
+
reason: string;
|
|
33
|
+
} | {
|
|
34
|
+
outcome: 'unknown';
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
export type QueueListView = {
|
|
38
|
+
outcome: 'ok';
|
|
39
|
+
items: Array<{
|
|
40
|
+
itemId: string;
|
|
41
|
+
position: number;
|
|
42
|
+
summary: string;
|
|
43
|
+
}>;
|
|
44
|
+
} | {
|
|
45
|
+
outcome: 'failed';
|
|
46
|
+
reason: 'queue-unavailable' | 'queue-unknown';
|
|
47
|
+
};
|
|
48
|
+
export declare function listQueue(queue: DshQueuePort, request: {
|
|
49
|
+
sessionId: string;
|
|
50
|
+
}): Promise<QueueListView>;
|
|
51
|
+
export type QueueRemoveResult = {
|
|
52
|
+
outcome: 'removed';
|
|
53
|
+
} | {
|
|
54
|
+
outcome: 'not-found';
|
|
55
|
+
} | {
|
|
56
|
+
outcome: 'failed';
|
|
57
|
+
} | {
|
|
58
|
+
outcome: 'unknown';
|
|
59
|
+
};
|
|
60
|
+
export declare function removeQueueItem(queue: DshQueuePort, request: {
|
|
61
|
+
sessionId: string;
|
|
62
|
+
itemId: string;
|
|
63
|
+
}): Promise<QueueRemoveResult>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/queue list` and `/queue remove <item>` (design.md §13, task 8.6). The
|
|
3
|
+
* list renders the session's pending inbox in queue order with stable
|
|
4
|
+
* positions; removal targets exactly one item. Every DSH outcome maps to a
|
|
5
|
+
* value, and unknown removal outcomes stay unknown — reconciliation, not
|
|
6
|
+
* optimism, decides whether the item is really gone.
|
|
7
|
+
*/
|
|
8
|
+
export async function listQueue(queue, request) {
|
|
9
|
+
const snapshot = await queue.snapshot({ sessionId: request.sessionId });
|
|
10
|
+
if (snapshot.outcome === 'rejected')
|
|
11
|
+
return { outcome: 'failed', reason: 'queue-unavailable' };
|
|
12
|
+
if (snapshot.outcome === 'unknown')
|
|
13
|
+
return { outcome: 'failed', reason: 'queue-unknown' };
|
|
14
|
+
return {
|
|
15
|
+
outcome: 'ok',
|
|
16
|
+
items: snapshot.items.map((item, index) => ({
|
|
17
|
+
itemId: item.itemId,
|
|
18
|
+
position: index + 1,
|
|
19
|
+
summary: item.summary,
|
|
20
|
+
})),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export async function removeQueueItem(queue, request) {
|
|
24
|
+
const removed = await queue.remove({ sessionId: request.sessionId, itemId: request.itemId });
|
|
25
|
+
if (removed.outcome === 'completed') {
|
|
26
|
+
return removed.removed ? { outcome: 'removed' } : { outcome: 'not-found' };
|
|
27
|
+
}
|
|
28
|
+
if (removed.outcome === 'unknown')
|
|
29
|
+
return { outcome: 'unknown' };
|
|
30
|
+
return { outcome: 'failed' };
|
|
31
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding reconciliation (design.md §11 steps 1–2, 8–9; task 15.1/15.2). At
|
|
3
|
+
* startup every persisted mapping is checked against the DSH Workspace and
|
|
4
|
+
* Session baseline and the Discord channel surface. Retirement ever removes
|
|
5
|
+
* only the adapter's own record — Host Workspaces and Sessions are never
|
|
6
|
+
* deleted by the adapter — and a mapping the adapter cannot verify right now
|
|
7
|
+
* stays in a blocked state for a later sweep instead of being guessed away.
|
|
8
|
+
*/
|
|
9
|
+
import type { ChannelBinding, ThreadBinding } from '../state/records.js';
|
|
10
|
+
/** The DSH baseline the adapter reconciles against (workspace.list / session list). */
|
|
11
|
+
export interface DshBaseline {
|
|
12
|
+
workspaces: ReadonlyArray<{
|
|
13
|
+
workspaceId: string;
|
|
14
|
+
title: string;
|
|
15
|
+
path: string;
|
|
16
|
+
}>;
|
|
17
|
+
sessionIds: ReadonlySet<string> | readonly string[];
|
|
18
|
+
}
|
|
19
|
+
/** Discord channel reachability as observed through the REST surface. */
|
|
20
|
+
export type DiscordChannelStatus = 'ok' | 'missing' | 'unknown';
|
|
21
|
+
export interface DiscordChannelFacts {
|
|
22
|
+
channels: Readonly<Record<string, DiscordChannelStatus>>;
|
|
23
|
+
}
|
|
24
|
+
export type ChannelBindingAction = {
|
|
25
|
+
channelId: string;
|
|
26
|
+
action: 'keep';
|
|
27
|
+
} | {
|
|
28
|
+
channelId: string;
|
|
29
|
+
action: 'keep-blocked';
|
|
30
|
+
reason: 'discord-unverified';
|
|
31
|
+
} | {
|
|
32
|
+
channelId: string;
|
|
33
|
+
action: 'retire';
|
|
34
|
+
reason: 'workspace-missing' | 'discord-deleted';
|
|
35
|
+
} | {
|
|
36
|
+
channelId: string;
|
|
37
|
+
action: 'update-metadata';
|
|
38
|
+
metadata: {
|
|
39
|
+
title: string;
|
|
40
|
+
path: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
export type ThreadBindingAction = {
|
|
44
|
+
threadId: string;
|
|
45
|
+
action: 'keep';
|
|
46
|
+
} | {
|
|
47
|
+
threadId: string;
|
|
48
|
+
action: 'keep-blocked';
|
|
49
|
+
reason: 'discord-unverified';
|
|
50
|
+
} | {
|
|
51
|
+
threadId: string;
|
|
52
|
+
action: 'retire';
|
|
53
|
+
reason: 'workspace-missing' | 'session-missing' | 'discord-deleted';
|
|
54
|
+
};
|
|
55
|
+
export interface BindingReconciliationInput {
|
|
56
|
+
channelBindings: ReadonlyArray<ChannelBinding & {
|
|
57
|
+
channelId: string;
|
|
58
|
+
}>;
|
|
59
|
+
threadBindings: ReadonlyArray<ThreadBinding & {
|
|
60
|
+
threadId: string;
|
|
61
|
+
}>;
|
|
62
|
+
baseline: DshBaseline;
|
|
63
|
+
discord: DiscordChannelFacts;
|
|
64
|
+
/** The adapter's cached Workspace labels; a mismatch plans a refresh. */
|
|
65
|
+
cachedWorkspaceMetadata?: Readonly<Record<string, {
|
|
66
|
+
title: string;
|
|
67
|
+
path: string;
|
|
68
|
+
}>> | undefined;
|
|
69
|
+
}
|
|
70
|
+
export interface BindingReconciliationPlan {
|
|
71
|
+
channelActions: ChannelBindingAction[];
|
|
72
|
+
threadActions: ThreadBindingAction[];
|
|
73
|
+
}
|
|
74
|
+
/** Plan one reconciliation pass. Pure: callers decide how actions are applied. */
|
|
75
|
+
export declare function planBindingReconciliation(input: BindingReconciliationInput): BindingReconciliationPlan;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding reconciliation (design.md §11 steps 1–2, 8–9; task 15.1/15.2). At
|
|
3
|
+
* startup every persisted mapping is checked against the DSH Workspace and
|
|
4
|
+
* Session baseline and the Discord channel surface. Retirement ever removes
|
|
5
|
+
* only the adapter's own record — Host Workspaces and Sessions are never
|
|
6
|
+
* deleted by the adapter — and a mapping the adapter cannot verify right now
|
|
7
|
+
* stays in a blocked state for a later sweep instead of being guessed away.
|
|
8
|
+
*/
|
|
9
|
+
function workspaceSet(baseline) {
|
|
10
|
+
return new Map(baseline.workspaces.map(workspace => [workspace.workspaceId, { title: workspace.title, path: workspace.path }]));
|
|
11
|
+
}
|
|
12
|
+
function sessionSet(baseline) {
|
|
13
|
+
return new Set(baseline.sessionIds);
|
|
14
|
+
}
|
|
15
|
+
/** Plan one reconciliation pass. Pure: callers decide how actions are applied. */
|
|
16
|
+
export function planBindingReconciliation(input) {
|
|
17
|
+
const workspaces = workspaceSet(input.baseline);
|
|
18
|
+
const sessions = sessionSet(input.baseline);
|
|
19
|
+
const channelActions = input.channelBindings.map((binding) => {
|
|
20
|
+
const workspace = workspaces.get(binding.workspaceId);
|
|
21
|
+
if (workspace === undefined) {
|
|
22
|
+
return { channelId: binding.channelId, action: 'retire', reason: 'workspace-missing' };
|
|
23
|
+
}
|
|
24
|
+
const status = input.discord.channels[binding.channelId];
|
|
25
|
+
if (status === 'missing') {
|
|
26
|
+
return { channelId: binding.channelId, action: 'retire', reason: 'discord-deleted' };
|
|
27
|
+
}
|
|
28
|
+
if (status === 'unknown') {
|
|
29
|
+
return { channelId: binding.channelId, action: 'keep-blocked', reason: 'discord-unverified' };
|
|
30
|
+
}
|
|
31
|
+
const cached = input.cachedWorkspaceMetadata?.[binding.workspaceId];
|
|
32
|
+
if (cached !== undefined && (cached.title !== workspace.title || cached.path !== workspace.path)) {
|
|
33
|
+
return { channelId: binding.channelId, action: 'update-metadata', metadata: workspace };
|
|
34
|
+
}
|
|
35
|
+
return { channelId: binding.channelId, action: 'keep' };
|
|
36
|
+
});
|
|
37
|
+
const threadActions = input.threadBindings.map((binding) => {
|
|
38
|
+
if (!workspaces.has(binding.workspaceId)) {
|
|
39
|
+
return { threadId: binding.threadId, action: 'retire', reason: 'workspace-missing' };
|
|
40
|
+
}
|
|
41
|
+
if (!sessions.has(binding.sessionId)) {
|
|
42
|
+
return { threadId: binding.threadId, action: 'retire', reason: 'session-missing' };
|
|
43
|
+
}
|
|
44
|
+
const status = input.discord.channels[binding.threadId];
|
|
45
|
+
if (status === 'missing') {
|
|
46
|
+
return { threadId: binding.threadId, action: 'retire', reason: 'discord-deleted' };
|
|
47
|
+
}
|
|
48
|
+
if (status === 'unknown') {
|
|
49
|
+
return { threadId: binding.threadId, action: 'keep-blocked', reason: 'discord-unverified' };
|
|
50
|
+
}
|
|
51
|
+
return { threadId: binding.threadId, action: 'keep' };
|
|
52
|
+
});
|
|
53
|
+
return { channelActions, threadActions };
|
|
54
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord delivery reconciliation (design.md §11 step 7, task 15.6). An
|
|
3
|
+
* outbound delivery parked in an unknown state is resolved by evidence: one
|
|
4
|
+
* bounded GET against Discord — present proves delivery, a confirmed 404
|
|
5
|
+
* proves failure — and an unreachable probe leaves the record unknown for a
|
|
6
|
+
* later sweep. The reconciliation deps carry no send face: recovery never
|
|
7
|
+
* resends, it only settles what already happened.
|
|
8
|
+
*/
|
|
9
|
+
export interface DeliveryLookupInput {
|
|
10
|
+
deliveryId: string;
|
|
11
|
+
channelId: string;
|
|
12
|
+
messageId: string;
|
|
13
|
+
}
|
|
14
|
+
export interface DeliveryReconcileDeps {
|
|
15
|
+
resolve(deliveryId: string, outcome: 'succeeded' | 'failed' | 'unknown', atMs: number): Promise<void>;
|
|
16
|
+
messageProbe(channelId: string, messageId: string): Promise<'present' | 'missing' | 'unknown'>;
|
|
17
|
+
nowMs: () => number;
|
|
18
|
+
}
|
|
19
|
+
export interface DeliveryReconcileInput {
|
|
20
|
+
deliveries: readonly DeliveryLookupInput[];
|
|
21
|
+
/** Bound per sweep; leftovers wait for the next one. */
|
|
22
|
+
maxLookups: number;
|
|
23
|
+
}
|
|
24
|
+
export interface DeliveryReconcileResult {
|
|
25
|
+
delivered: string[];
|
|
26
|
+
failed: string[];
|
|
27
|
+
unresolved: string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function reconcileDeliveries(deps: DeliveryReconcileDeps, input: DeliveryReconcileInput): Promise<DeliveryReconcileResult>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord delivery reconciliation (design.md §11 step 7, task 15.6). An
|
|
3
|
+
* outbound delivery parked in an unknown state is resolved by evidence: one
|
|
4
|
+
* bounded GET against Discord — present proves delivery, a confirmed 404
|
|
5
|
+
* proves failure — and an unreachable probe leaves the record unknown for a
|
|
6
|
+
* later sweep. The reconciliation deps carry no send face: recovery never
|
|
7
|
+
* resends, it only settles what already happened.
|
|
8
|
+
*/
|
|
9
|
+
export async function reconcileDeliveries(deps, input) {
|
|
10
|
+
const delivered = [];
|
|
11
|
+
const failed = [];
|
|
12
|
+
const unresolved = [];
|
|
13
|
+
for (const record of input.deliveries.slice(0, input.maxLookups)) {
|
|
14
|
+
const probe = await deps.messageProbe(record.channelId, record.messageId);
|
|
15
|
+
if (probe === 'present') {
|
|
16
|
+
await deps.resolve(record.deliveryId, 'succeeded', deps.nowMs());
|
|
17
|
+
delivered.push(record.deliveryId);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (probe === 'missing') {
|
|
21
|
+
await deps.resolve(record.deliveryId, 'failed', deps.nowMs());
|
|
22
|
+
failed.push(record.deliveryId);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
unresolved.push(record.deliveryId);
|
|
26
|
+
}
|
|
27
|
+
return { delivered, failed, unresolved };
|
|
28
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History replay after reconnect (design.md §11 steps 4–7, tasks 15.3/15.4).
|
|
3
|
+
* Because DSH 0.1.1-rc.2 ignores `events.mux.since`, durable history reads —
|
|
4
|
+
* not the live stream — are the recovery source. Per active session the
|
|
5
|
+
* replay walks bounded history pages, folds events past the committed
|
|
6
|
+
* watermark oldest-first, skips events whose live delivery is already known,
|
|
7
|
+
* and advances the watermark strictly after the delivery bookkeeping for that
|
|
8
|
+
* event resolves. The page budget bounds recovery work; hitting it yields an
|
|
9
|
+
* explicitly incomplete result rather than an unbounded fetch loop.
|
|
10
|
+
*/
|
|
11
|
+
export interface HistoryEvent {
|
|
12
|
+
seq: number;
|
|
13
|
+
id: string;
|
|
14
|
+
}
|
|
15
|
+
/** One history page: events ascending within the page, newest page first. */
|
|
16
|
+
export interface HistoryPage {
|
|
17
|
+
events: HistoryEvent[];
|
|
18
|
+
hasMore: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface EventHistoryPort {
|
|
21
|
+
/** `beforeSeq === undefined` reads the newest page; older pages chain down. */
|
|
22
|
+
history(sessionId: string, beforeSeq: number | undefined): Promise<HistoryPage>;
|
|
23
|
+
}
|
|
24
|
+
export interface ReplayDeliverPort {
|
|
25
|
+
/** Deliver one missed event; the return is the delivery bookkeeping. */
|
|
26
|
+
deliver(event: HistoryEvent): Promise<'recorded' | 'duplicate'>;
|
|
27
|
+
}
|
|
28
|
+
export interface ReplayWatermarkStore {
|
|
29
|
+
watermark(sessionId: string): number | undefined;
|
|
30
|
+
commit(sessionId: string, seq: number): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export interface ReplayDeps {
|
|
33
|
+
historyPort: EventHistoryPort;
|
|
34
|
+
deliver: ReplayDeliverPort;
|
|
35
|
+
watermarkStore: ReplayWatermarkStore;
|
|
36
|
+
}
|
|
37
|
+
export interface ReplayInput {
|
|
38
|
+
sessionId: string;
|
|
39
|
+
/** Ids already delivered live across the reconnect gap. */
|
|
40
|
+
liveSeen?: ReadonlySet<string> | undefined;
|
|
41
|
+
/** Page budget; recovery reports incomplete past this instead of looping. */
|
|
42
|
+
maxPages?: number | undefined;
|
|
43
|
+
}
|
|
44
|
+
export interface ReplayResult {
|
|
45
|
+
delivered: string[];
|
|
46
|
+
committedThrough?: number | undefined;
|
|
47
|
+
incomplete: boolean;
|
|
48
|
+
}
|
|
49
|
+
export declare function replayHistory(deps: ReplayDeps, input: ReplayInput): Promise<ReplayResult>;
|