@addozhang/dsh-discord 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/cordis.patch.yml +14 -0
  4. package/lib/client/DiscordSettingsCard.d.ts +6 -0
  5. package/lib/client/PluginCard.d.ts +43 -0
  6. package/lib/client/card-controller.d.ts +22 -0
  7. package/lib/client/card-form.d.ts +111 -0
  8. package/lib/client/fields.d.ts +50 -0
  9. package/lib/client/index.d.ts +12 -0
  10. package/lib/client/nav-icon.d.ts +21 -0
  11. package/lib/client/settings-model.d.ts +49 -0
  12. package/lib/client/slot-contract.d.ts +57 -0
  13. package/lib/client/snapshot-store.d.ts +9 -0
  14. package/lib/client.js +1622 -0
  15. package/lib/compose.d.ts +158 -0
  16. package/lib/compose.js +227 -0
  17. package/lib/credential.d.ts +20 -0
  18. package/lib/credential.js +24 -0
  19. package/lib/discord/commands.d.ts +45 -0
  20. package/lib/discord/commands.js +87 -0
  21. package/lib/discord/components.d.ts +29 -0
  22. package/lib/discord/components.js +57 -0
  23. package/lib/discord/delivery.d.ts +21 -0
  24. package/lib/discord/delivery.js +31 -0
  25. package/lib/discord/interaction-lifecycle.d.ts +42 -0
  26. package/lib/discord/interaction-lifecycle.js +78 -0
  27. package/lib/discord/rest.d.ts +74 -0
  28. package/lib/discord/rest.js +149 -0
  29. package/lib/discord/selector.d.ts +39 -0
  30. package/lib/discord/selector.js +70 -0
  31. package/lib/discord/thread-port.d.ts +37 -0
  32. package/lib/discord/thread-port.js +58 -0
  33. package/lib/dsh/api-proxy-face.d.ts +253 -0
  34. package/lib/dsh/api-proxy-face.js +370 -0
  35. package/lib/features/adapter-status.d.ts +111 -0
  36. package/lib/features/adapter-status.js +103 -0
  37. package/lib/features/approval-expiry.d.ts +27 -0
  38. package/lib/features/approval-expiry.js +36 -0
  39. package/lib/features/approval-routing.d.ts +50 -0
  40. package/lib/features/approval-routing.js +57 -0
  41. package/lib/features/approval-store.d.ts +86 -0
  42. package/lib/features/approval-store.js +119 -0
  43. package/lib/features/approval-view.d.ts +47 -0
  44. package/lib/features/approval-view.js +48 -0
  45. package/lib/features/ask-wiring.d.ts +74 -0
  46. package/lib/features/ask-wiring.js +151 -0
  47. package/lib/features/host-status.d.ts +41 -0
  48. package/lib/features/host-status.js +24 -0
  49. package/lib/features/image-collection.d.ts +56 -0
  50. package/lib/features/image-collection.js +79 -0
  51. package/lib/features/image-download.d.ts +48 -0
  52. package/lib/features/image-download.js +77 -0
  53. package/lib/features/image-submission.d.ts +67 -0
  54. package/lib/features/image-submission.js +57 -0
  55. package/lib/features/interaction-router.d.ts +84 -0
  56. package/lib/features/interaction-router.js +655 -0
  57. package/lib/features/model-control.d.ts +79 -0
  58. package/lib/features/model-control.js +51 -0
  59. package/lib/features/preset-control.d.ts +84 -0
  60. package/lib/features/preset-control.js +43 -0
  61. package/lib/features/project-bind.d.ts +69 -0
  62. package/lib/features/project-bind.js +57 -0
  63. package/lib/features/project-info.d.ts +25 -0
  64. package/lib/features/project-info.js +22 -0
  65. package/lib/features/project-list.d.ts +79 -0
  66. package/lib/features/project-list.js +93 -0
  67. package/lib/features/prompt-submission.d.ts +53 -0
  68. package/lib/features/prompt-submission.js +47 -0
  69. package/lib/features/question-expiry.d.ts +53 -0
  70. package/lib/features/question-expiry.js +56 -0
  71. package/lib/features/question-routing.d.ts +108 -0
  72. package/lib/features/question-routing.js +162 -0
  73. package/lib/features/question-store.d.ts +124 -0
  74. package/lib/features/question-store.js +186 -0
  75. package/lib/features/question-view.d.ts +58 -0
  76. package/lib/features/question-view.js +81 -0
  77. package/lib/features/queue-control.d.ts +63 -0
  78. package/lib/features/queue-control.js +31 -0
  79. package/lib/features/reconcile-bindings.d.ts +75 -0
  80. package/lib/features/reconcile-bindings.js +54 -0
  81. package/lib/features/reconcile-deliveries.d.ts +29 -0
  82. package/lib/features/reconcile-deliveries.js +28 -0
  83. package/lib/features/reconcile-events.d.ts +49 -0
  84. package/lib/features/reconcile-events.js +50 -0
  85. package/lib/features/reconcile-intents.d.ts +32 -0
  86. package/lib/features/reconcile-intents.js +32 -0
  87. package/lib/features/reconcile-interactions.d.ts +59 -0
  88. package/lib/features/reconcile-interactions.js +54 -0
  89. package/lib/features/session-adopt.d.ts +89 -0
  90. package/lib/features/session-adopt.js +77 -0
  91. package/lib/features/session-creation.d.ts +49 -0
  92. package/lib/features/session-creation.js +43 -0
  93. package/lib/features/session-mainline.d.ts +75 -0
  94. package/lib/features/session-mainline.js +96 -0
  95. package/lib/features/session-resume.d.ts +44 -0
  96. package/lib/features/session-resume.js +40 -0
  97. package/lib/features/skill-run.d.ts +48 -0
  98. package/lib/features/skill-run.js +25 -0
  99. package/lib/features/steer-control.d.ts +37 -0
  100. package/lib/features/steer-control.js +28 -0
  101. package/lib/features/stop-control.d.ts +38 -0
  102. package/lib/features/stop-control.js +31 -0
  103. package/lib/features/task-admission.d.ts +29 -0
  104. package/lib/features/task-admission.js +21 -0
  105. package/lib/features/thread-continuation.d.ts +36 -0
  106. package/lib/features/thread-continuation.js +18 -0
  107. package/lib/features/thread-creation.d.ts +81 -0
  108. package/lib/features/thread-creation.js +81 -0
  109. package/lib/features/thread-routing.d.ts +49 -0
  110. package/lib/features/thread-routing.js +34 -0
  111. package/lib/features/turn-ownership.d.ts +39 -0
  112. package/lib/features/turn-ownership.js +40 -0
  113. package/lib/features/unbound-mention.d.ts +49 -0
  114. package/lib/features/unbound-mention.js +31 -0
  115. package/lib/features/workspace-channel.d.ts +41 -0
  116. package/lib/features/workspace-channel.js +47 -0
  117. package/lib/gateway/gateway.d.ts +50 -0
  118. package/lib/gateway/gateway.js +229 -0
  119. package/lib/gateway/inbound.d.ts +85 -0
  120. package/lib/gateway/inbound.js +186 -0
  121. package/lib/gateway/ingress.d.ts +15 -0
  122. package/lib/gateway/ingress.js +22 -0
  123. package/lib/i18n.d.ts +92 -0
  124. package/lib/i18n.js +180 -0
  125. package/lib/index.d.ts +17 -0
  126. package/lib/index.js +869 -0
  127. package/lib/lifecycle.d.ts +44 -0
  128. package/lib/lifecycle.js +84 -0
  129. package/lib/policy/authorization.d.ts +41 -0
  130. package/lib/policy/authorization.js +64 -0
  131. package/lib/policy/disclosure.d.ts +93 -0
  132. package/lib/policy/disclosure.js +153 -0
  133. package/lib/policy/guard.d.ts +31 -0
  134. package/lib/policy/guard.js +46 -0
  135. package/lib/policy/suppress.d.ts +13 -0
  136. package/lib/policy/suppress.js +20 -0
  137. package/lib/settings-namespace.d.ts +7 -0
  138. package/lib/settings-namespace.js +6 -0
  139. package/lib/settings.d.ts +37 -0
  140. package/lib/settings.js +102 -0
  141. package/lib/startup.d.ts +16 -0
  142. package/lib/startup.js +60 -0
  143. package/lib/state/bindings.d.ts +46 -0
  144. package/lib/state/bindings.js +55 -0
  145. package/lib/state/channel-bindings.d.ts +37 -0
  146. package/lib/state/channel-bindings.js +43 -0
  147. package/lib/state/domain.d.ts +82 -0
  148. package/lib/state/domain.js +86 -0
  149. package/lib/state/effect-machine.d.ts +37 -0
  150. package/lib/state/effect-machine.js +52 -0
  151. package/lib/state/fail-closed.d.ts +44 -0
  152. package/lib/state/fail-closed.js +57 -0
  153. package/lib/state/intents.d.ts +51 -0
  154. package/lib/state/intents.js +73 -0
  155. package/lib/state/records.d.ts +37 -0
  156. package/lib/state/records.js +30 -0
  157. package/lib/state/retention.d.ts +74 -0
  158. package/lib/state/retention.js +68 -0
  159. package/lib/state/session-owners.d.ts +50 -0
  160. package/lib/state/session-owners.js +50 -0
  161. package/lib/stream/finalizer.d.ts +49 -0
  162. package/lib/stream/finalizer.js +67 -0
  163. package/lib/stream/icons.d.ts +15 -0
  164. package/lib/stream/icons.js +36 -0
  165. package/lib/stream/live.d.ts +140 -0
  166. package/lib/stream/live.js +515 -0
  167. package/lib/stream/markdown.d.ts +24 -0
  168. package/lib/stream/markdown.js +95 -0
  169. package/lib/stream/outbound.d.ts +27 -0
  170. package/lib/stream/outbound.js +25 -0
  171. package/lib/stream/render-fence.d.ts +27 -0
  172. package/lib/stream/render-fence.js +37 -0
  173. package/lib/stream/render-model.d.ts +48 -0
  174. package/lib/stream/render-model.js +68 -0
  175. package/lib/stream/splitter.d.ts +8 -0
  176. package/lib/stream/splitter.js +58 -0
  177. package/lib/stream/tool-view.d.ts +40 -0
  178. package/lib/stream/tool-view.js +39 -0
  179. package/lib/stream/typing.d.ts +22 -0
  180. package/lib/stream/typing.js +58 -0
  181. package/lib/stream/update-scheduler.d.ts +20 -0
  182. package/lib/stream/update-scheduler.js +65 -0
  183. package/package.json +86 -0
@@ -0,0 +1,47 @@
1
+ /**
2
+ * At-most-once prompt submission (design.md §5, §10, task 8.4). Every prompt
3
+ * carries a stable request id claimed before the DSH call. A same-id/same-
4
+ * hash delivery never resubmits; a different hash conflicts. A rejection ends
5
+ * that intent — an explicit user retry mints a NEW intent. An unknown
6
+ * admission is recorded as `unknown` and NEVER retried automatically; the
7
+ * reconciler checks DSH history/queue evidence, and an explicit retry (new
8
+ * intent) is the user's deliberate act.
9
+ */
10
+ import { hashPayload } from '../state/intents.js';
11
+ export function createPromptSubmissionFlow(deps) {
12
+ async function submit(requestId, request) {
13
+ const contentHash = await hashPayload({ sessionId: request.sessionId, prompt: request.prompt });
14
+ const claim = await deps.intents.claim({
15
+ messageId: requestId,
16
+ contentHash,
17
+ claimedAtMs: deps.nowMs(),
18
+ });
19
+ if (claim.outcome === 'conflict')
20
+ return { outcome: 'conflict' };
21
+ if (claim.outcome === 'duplicate') {
22
+ return { outcome: 'already-submitted' };
23
+ }
24
+ const submitted = await deps.prompts.submit({
25
+ requestId,
26
+ sessionId: request.sessionId,
27
+ prompt: request.prompt,
28
+ mode: 'queue',
29
+ });
30
+ if (submitted.outcome === 'accepted') {
31
+ await deps.intents.resolve(requestId, 'succeeded', deps.nowMs());
32
+ return { outcome: 'accepted' };
33
+ }
34
+ if (submitted.outcome === 'rejected') {
35
+ await deps.intents.resolve(requestId, 'failed', deps.nowMs());
36
+ return { outcome: 'rejected' };
37
+ }
38
+ await deps.intents.resolve(requestId, 'unknown', deps.nowMs());
39
+ return { outcome: 'unknown' };
40
+ }
41
+ return {
42
+ submitOnce: request => submit(request.requestId, request),
43
+ retry(request, { newRequestId }) {
44
+ return submit(newRequestId, request);
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Question expiry sweep (design.md §8, task 14.4). An ask left pending past
3
+ * its deadline must not strand DSH's tool call forever, and the adapter has
4
+ * no answer to give: the sweep atomically claims the batch — losing to any
5
+ * user click, which then owns the question — requests cancellation of the
6
+ * owning adapter-controlled turn (the port is session-scoped: DSH 0.1.1
7
+ * cancels a session's active turn), records whether the cancellation was
8
+ * accepted, and only then expires the Discord controls. The sweep's
9
+ * dependencies carry no answer port, so it structurally cannot synthesize a
10
+ * response.
11
+ */
12
+ import type { QuestionStore } from './question-store.js';
13
+ /** The DSH cancellation face for the owning adapter-controlled turn. */
14
+ export interface DshTurnCancelPort {
15
+ cancel(input: {
16
+ sessionId: string;
17
+ requestId: string;
18
+ }): Promise<{
19
+ outcome: 'accepted';
20
+ } | {
21
+ outcome: 'rejected';
22
+ reason: string;
23
+ } | {
24
+ outcome: 'unknown';
25
+ }>;
26
+ }
27
+ /** Face that retires the rendered select menus and submit button. */
28
+ export interface QuestionControls {
29
+ disable(questionRpcId: string): Promise<void>;
30
+ }
31
+ export interface QuestionExpiryDeps {
32
+ store: QuestionStore;
33
+ cancelPort: DshTurnCancelPort;
34
+ controls: QuestionControls;
35
+ nowMs: () => number;
36
+ }
37
+ export interface QuestionExpiryResult {
38
+ /** Question rpc ids this sweep cancelled and retired. */
39
+ handled: string[];
40
+ }
41
+ export declare function sweepExpiredQuestions(deps: QuestionExpiryDeps): Promise<QuestionExpiryResult>;
42
+ export interface AbandonQuestionDeps {
43
+ store: Pick<QuestionStore, 'claim' | 'get' | 'markExpired'>;
44
+ cancelPort: DshTurnCancelPort;
45
+ nowMs: () => number;
46
+ }
47
+ /**
48
+ * The controls never reached Discord, so nobody can ever answer: cancel the
49
+ * owning turn immediately instead of letting the sweep wait out the deadline
50
+ * with DSH's tool call hanging. Same claim discipline as the sweep — a user
51
+ * click that somehow won the race owns the question and this is a no-op.
52
+ */
53
+ export declare function abandonUnrenderableQuestion(deps: AbandonQuestionDeps, questionRpcId: string): Promise<void>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Question expiry sweep (design.md §8, task 14.4). An ask left pending past
3
+ * its deadline must not strand DSH's tool call forever, and the adapter has
4
+ * no answer to give: the sweep atomically claims the batch — losing to any
5
+ * user click, which then owns the question — requests cancellation of the
6
+ * owning adapter-controlled turn (the port is session-scoped: DSH 0.1.1
7
+ * cancels a session's active turn), records whether the cancellation was
8
+ * accepted, and only then expires the Discord controls. The sweep's
9
+ * dependencies carry no answer port, so it structurally cannot synthesize a
10
+ * response.
11
+ */
12
+ export async function sweepExpiredQuestions(deps) {
13
+ const nowMs = deps.nowMs();
14
+ const handled = [];
15
+ for (const record of deps.store.listPendingExpired(nowMs)) {
16
+ // Atomic claim: a user click that beat the sweep owns the answer.
17
+ const claim = await deps.store.claim(record.questionRpcId);
18
+ if (claim.outcome !== 'claimed')
19
+ continue;
20
+ const cancelled = await deps.cancelPort.cancel({
21
+ sessionId: record.sessionId,
22
+ requestId: record.requestId,
23
+ });
24
+ const outcome = cancelled.outcome === 'accepted'
25
+ ? 'accepted'
26
+ : cancelled.outcome === 'rejected'
27
+ ? 'rejected'
28
+ : 'unknown';
29
+ await deps.store.markExpired(record.questionRpcId, outcome, deps.nowMs());
30
+ // Controls expire only after the cancellation outcome is on the record.
31
+ await deps.controls.disable(record.questionRpcId);
32
+ handled.push(record.questionRpcId);
33
+ }
34
+ return { handled };
35
+ }
36
+ /**
37
+ * The controls never reached Discord, so nobody can ever answer: cancel the
38
+ * owning turn immediately instead of letting the sweep wait out the deadline
39
+ * with DSH's tool call hanging. Same claim discipline as the sweep — a user
40
+ * click that somehow won the race owns the question and this is a no-op.
41
+ */
42
+ export async function abandonUnrenderableQuestion(deps, questionRpcId) {
43
+ const claim = await deps.store.claim(questionRpcId);
44
+ if (claim.outcome !== 'claimed')
45
+ return;
46
+ const cancelled = await deps.cancelPort.cancel({
47
+ sessionId: claim.record.sessionId,
48
+ requestId: claim.record.requestId,
49
+ });
50
+ const outcome = cancelled.outcome === 'accepted'
51
+ ? 'accepted'
52
+ : cancelled.outcome === 'rejected'
53
+ ? 'rejected'
54
+ : 'unknown';
55
+ await deps.store.markExpired(questionRpcId, outcome, deps.nowMs());
56
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Question interaction routing (design.md §8, tasks 14.2/14.3). Select
3
+ * answers record against the pending batch through the opaque registry;
4
+ * choosing the reserved "Other" value opens the custom-text modal instead of
5
+ * recording; modal submits record the text. Labels outside the offered
6
+ * options, unknown question ids, and oversized text are refused as values —
7
+ * never silently dropped. Only the originating user on the owning thread may
8
+ * answer, and an incomplete batch never reaches DSH: one complete response
9
+ * or nothing.
10
+ */
11
+ import type { ComponentRegistry } from '../discord/components.js';
12
+ import { CUSTOM_ANSWER_VALUE } from './question-view.js';
13
+ import { type DraftAnswerError, type EncodedQuestionAnswer, type QuestionStore } from './question-store.js';
14
+ export { CUSTOM_ANSWER_VALUE };
15
+ /** The DSH respond face for questions: the answer echoes the ask's rpcId. */
16
+ export interface DshQuestionRespondPort {
17
+ respond(input: {
18
+ rpcId: string;
19
+ sessionId: string;
20
+ answer: EncodedQuestionAnswer;
21
+ }): Promise<{
22
+ outcome: 'confirmed';
23
+ } | {
24
+ outcome: 'rejected';
25
+ reason: string;
26
+ } | {
27
+ outcome: 'unknown';
28
+ }>;
29
+ }
30
+ export interface QuestionRoutingDeps {
31
+ registry: ComponentRegistry;
32
+ store: QuestionStore;
33
+ port: DshQuestionRespondPort;
34
+ nowMs: () => number;
35
+ /** Retires the rendered controls on a submitted (terminal) outcome. */
36
+ controls: {
37
+ disable(questionRpcId: string): Promise<void>;
38
+ };
39
+ }
40
+ export interface QuestionInteractionInput {
41
+ customId: string;
42
+ userId: string;
43
+ threadId: string;
44
+ }
45
+ /** The modal descriptor the wire layer turns into a Discord modal callback. */
46
+ export interface ModalDescriptor {
47
+ /** Bounded title; Discord allows 45 characters. */
48
+ title: string;
49
+ custom_id: string;
50
+ textInput: {
51
+ label: string;
52
+ min_length: number;
53
+ max_length: number;
54
+ required: true;
55
+ style: 'paragraph';
56
+ };
57
+ }
58
+ export type QuestionInteractionOutcome = {
59
+ outcome: 'recorded';
60
+ complete: boolean;
61
+ } | {
62
+ outcome: 'modal-requested';
63
+ modal: ModalDescriptor;
64
+ } | {
65
+ outcome: 'submitted';
66
+ } | {
67
+ outcome: 'already-resolved';
68
+ } | {
69
+ outcome: 'denied';
70
+ } | {
71
+ outcome: 'unknown-control';
72
+ } | {
73
+ outcome: 'invalid-answer';
74
+ reason: DraftAnswerError;
75
+ } | {
76
+ outcome: 'incomplete';
77
+ } | {
78
+ outcome: 'unresolved';
79
+ } | {
80
+ outcome: 'resolved-elsewhere';
81
+ };
82
+ export declare function handleSelectInput(deps: QuestionRoutingDeps, input: QuestionInteractionInput & {
83
+ values: string[];
84
+ }): Promise<QuestionInteractionOutcome>;
85
+ export declare function handleModalSubmit(deps: QuestionRoutingDeps, input: QuestionInteractionInput & {
86
+ text: string;
87
+ }): QuestionInteractionOutcome;
88
+ /**
89
+ * A `question/resolved` frame arrived from the Host: the ask was answered or
90
+ * cancelled by another client. The adapter retires its controls and never
91
+ * submits afterwards; a resolution the adapter already recorded (the user's
92
+ * own answer) stays first — the remote frame only retires the controls.
93
+ */
94
+ export interface RemoteResolutionDeps {
95
+ store: Pick<QuestionStore, 'get' | 'markResolved'>;
96
+ controls: {
97
+ disable(questionRpcId: string): Promise<void>;
98
+ };
99
+ nowMs: () => number;
100
+ }
101
+ export declare function handleRemoteResolution(deps: RemoteResolutionDeps, input: {
102
+ questionRpcId: string;
103
+ outcome: 'answered' | 'cancelled';
104
+ }): Promise<{
105
+ outcome: 'resolved-elsewhere';
106
+ } | {
107
+ outcome: 'unknown-control';
108
+ }>;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Question interaction routing (design.md §8, tasks 14.2/14.3). Select
3
+ * answers record against the pending batch through the opaque registry;
4
+ * choosing the reserved "Other" value opens the custom-text modal instead of
5
+ * recording; modal submits record the text. Labels outside the offered
6
+ * options, unknown question ids, and oversized text are refused as values —
7
+ * never silently dropped. Only the originating user on the owning thread may
8
+ * answer, and an incomplete batch never reaches DSH: one complete response
9
+ * or nothing.
10
+ */
11
+ import { CUSTOM_ANSWER_VALUE } from './question-view.js';
12
+ import { MAX_CUSTOM_LENGTH, } from './question-store.js';
13
+ export { CUSTOM_ANSWER_VALUE };
14
+ function parseContext(context) {
15
+ const questionRpcId = context['questionRpcId'];
16
+ const action = context['action'];
17
+ if (typeof questionRpcId !== 'string')
18
+ return undefined;
19
+ if (action !== 'select' && action !== 'submit' && action !== 'modal')
20
+ return undefined;
21
+ const questionId = context['questionId'];
22
+ return {
23
+ questionRpcId,
24
+ action,
25
+ ...(typeof questionId === 'string' ? { questionId } : {}),
26
+ };
27
+ }
28
+ /** Milestone 1 question authorization: the originating user, own thread, point. */
29
+ function authorize(record, input) {
30
+ return input.userId === record.actorUserId && input.threadId === record.threadId;
31
+ }
32
+ function resolveContext(deps, customId) {
33
+ const resolution = deps.registry.resolve(customId, deps.nowMs());
34
+ if (!resolution.found)
35
+ return { outcome: 'unknown-control' };
36
+ const context = parseContext(resolution.context);
37
+ if (context === undefined)
38
+ return { outcome: 'unknown-control' };
39
+ const record = deps.store.get(context.questionRpcId);
40
+ if (record === undefined)
41
+ return { outcome: 'unknown-control' };
42
+ return { context, record };
43
+ }
44
+ function modalTitle(question) {
45
+ const title = question.length <= 45 ? question : `${question.slice(0, 44)}…`;
46
+ return title;
47
+ }
48
+ export async function handleSelectInput(deps, input) {
49
+ const resolved = resolveContext(deps, input.customId);
50
+ if ('outcome' in resolved)
51
+ return resolved;
52
+ const { context, record } = resolved;
53
+ if (!authorize(record, input))
54
+ return { outcome: 'denied' };
55
+ if (record.state === 'resolved' || record.state === 'submitting' || record.state === 'expired') {
56
+ return { outcome: 'already-resolved' };
57
+ }
58
+ if (context.action === 'submit') {
59
+ const drafts = record.drafts();
60
+ const complete = record.questions.every(question => drafts.some(draft => draft.id === question.id));
61
+ if (!complete)
62
+ return { outcome: 'incomplete' };
63
+ // Atomic claim: pending or an explicit retry after an unconfirmed submit.
64
+ const claim = await deps.store.claim(context.questionRpcId);
65
+ if (claim.outcome !== 'claimed')
66
+ return { outcome: 'already-resolved' };
67
+ // A port that THROWS (distinct from an unknown outcome) is still an
68
+ // unconfirmed submit: park unresolved so the expiry sweeps and the
69
+ // user's own retry stay available — never leave the ask in submitting.
70
+ let submitted;
71
+ try {
72
+ submitted = await deps.port.respond({
73
+ rpcId: record.questionRpcId,
74
+ sessionId: record.sessionId,
75
+ answer: deps.store.encodeSubmitted(context.questionRpcId),
76
+ });
77
+ }
78
+ catch {
79
+ await deps.store.markUnresolved(context.questionRpcId, deps.nowMs());
80
+ return { outcome: 'unresolved' };
81
+ }
82
+ if (submitted.outcome === 'confirmed') {
83
+ await deps.store.markResolved(context.questionRpcId, 'answered', 'user', deps.nowMs());
84
+ // Terminal: retire the controls so stray clicks cannot follow.
85
+ // (disableControl never throws; the guard keeps the settled outcome.)
86
+ await deps.controls.disable(context.questionRpcId).catch(() => { });
87
+ return { outcome: 'submitted' };
88
+ }
89
+ await deps.store.markUnresolved(context.questionRpcId, deps.nowMs());
90
+ return { outcome: 'unresolved' };
91
+ }
92
+ if (context.action !== 'select' || context.questionId === undefined)
93
+ return { outcome: 'unknown-control' };
94
+ // The reserved Other value opens the custom-text modal instead of
95
+ // recording a label answer.
96
+ if (input.values.includes(CUSTOM_ANSWER_VALUE)) {
97
+ const question = record.questions.find(item => item.id === context.questionId);
98
+ if (question === undefined)
99
+ return { outcome: 'unknown-control' };
100
+ const customId = deps.registry.register({
101
+ questionRpcId: context.questionRpcId,
102
+ action: 'modal',
103
+ questionId: question.id,
104
+ expiresAtMs: record.expiresAtMs,
105
+ });
106
+ return {
107
+ outcome: 'modal-requested',
108
+ modal: {
109
+ title: modalTitle(question.question),
110
+ custom_id: customId,
111
+ textInput: {
112
+ label: question.header ?? 'Your answer',
113
+ min_length: 1,
114
+ max_length: MAX_CUSTOM_LENGTH,
115
+ required: true,
116
+ style: 'paragraph',
117
+ },
118
+ },
119
+ };
120
+ }
121
+ const verdict = deps.store.setDraft(context.questionRpcId, {
122
+ id: context.questionId,
123
+ selected: input.values,
124
+ });
125
+ if (!verdict.ok)
126
+ return { outcome: 'invalid-answer', reason: verdict.error };
127
+ return { outcome: 'recorded', complete: verdict.complete };
128
+ }
129
+ export function handleModalSubmit(deps, input) {
130
+ const resolved = resolveContext(deps, input.customId);
131
+ if ('outcome' in resolved)
132
+ return resolved;
133
+ const { context, record } = resolved;
134
+ if (context.action !== 'modal' || context.questionId === undefined)
135
+ return { outcome: 'unknown-control' };
136
+ if (!authorize(record, input))
137
+ return { outcome: 'denied' };
138
+ if (record.state === 'resolved' || record.state === 'submitting' || record.state === 'expired') {
139
+ return { outcome: 'already-resolved' };
140
+ }
141
+ if (input.text.length === 0 || input.text.length > MAX_CUSTOM_LENGTH) {
142
+ return { outcome: 'invalid-answer', reason: 'custom-too-long' };
143
+ }
144
+ const verdict = deps.store.setDraft(context.questionRpcId, {
145
+ id: context.questionId,
146
+ selected: [],
147
+ custom: input.text,
148
+ });
149
+ if (!verdict.ok)
150
+ return { outcome: 'invalid-answer', reason: verdict.error };
151
+ return { outcome: 'recorded', complete: verdict.complete };
152
+ }
153
+ export async function handleRemoteResolution(deps, input) {
154
+ const record = deps.store.get(input.questionRpcId);
155
+ if (record === undefined)
156
+ return { outcome: 'unknown-control' };
157
+ if (record.state !== 'resolved') {
158
+ await deps.store.markResolved(input.questionRpcId, input.outcome, 'remote', deps.nowMs());
159
+ }
160
+ await deps.controls.disable(input.questionRpcId);
161
+ return { outcome: 'resolved-elsewhere' };
162
+ }
@@ -0,0 +1,124 @@
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
+ /** One question as the adapter carries it (labels only; details render). */
15
+ export interface QuestionItemView {
16
+ id: string;
17
+ question: string;
18
+ header?: string | undefined;
19
+ options?: ReadonlyArray<{
20
+ label: string;
21
+ description?: string | undefined;
22
+ }> | undefined;
23
+ multiSelect: boolean;
24
+ }
25
+ /** The full pending batch with its ownership facts. */
26
+ export interface QuestionBatch {
27
+ questionRpcId: string;
28
+ sessionId: string;
29
+ threadId: string;
30
+ requestId: string;
31
+ actorUserId: string;
32
+ /** The ask deadline; the expiry sweep cancels the owning turn past it. */
33
+ expiresAtMs: number;
34
+ questions: readonly QuestionItemView[];
35
+ }
36
+ /** One validated draft answer. */
37
+ export interface QuestionDraft {
38
+ id: string;
39
+ selected: string[];
40
+ custom?: string | undefined;
41
+ }
42
+ /** The DSH answer shape (AskUserQuestionAnswer) the adapter submits. */
43
+ export interface EncodedQuestionAnswer {
44
+ answers: Array<{
45
+ id: string;
46
+ selected: string[];
47
+ custom?: string | undefined;
48
+ }>;
49
+ }
50
+ /** Discord action rows per message — one select each. */
51
+ export declare const MAX_QUESTIONS_PER_BATCH = 5;
52
+ /** Discord string-select option bound. */
53
+ export declare const MAX_OPTIONS_PER_QUESTION = 25;
54
+ /** Discord select option label bound (characters). */
55
+ export declare const MAX_LABEL_LENGTH = 100;
56
+ /** The adapter's own custom-text bound (inside Discord's 4000). */
57
+ export declare const MAX_CUSTOM_LENGTH = 1000;
58
+ export type OpenBatchError = 'too-many-questions' | 'too-many-options' | 'option-label-too-long' | 'options-required' | 'duplicate';
59
+ export type DraftAnswerError = 'unknown-question' | 'invalid-label' | 'custom-too-long';
60
+ export type ValidateResult = {
61
+ ok: true;
62
+ } | {
63
+ ok: false;
64
+ error: DraftAnswerError;
65
+ };
66
+ /** Validate one draft answer against its question (pure; also used by routing). */
67
+ export declare function validateDraftAnswer(question: QuestionItemView | undefined, draft: QuestionDraft): ValidateResult;
68
+ /** A batch is complete exactly when every question carries a draft. */
69
+ export declare function isCompleteDraft(questions: readonly QuestionItemView[], drafts: ReadonlyMap<string, QuestionDraft>): boolean;
70
+ /** Encode the complete draft into the DSH answer batch (pure). */
71
+ export declare function encodeAnswer(questions: readonly QuestionItemView[], drafts: ReadonlyMap<string, QuestionDraft>): EncodedQuestionAnswer;
72
+ export type OpenBatchResult = {
73
+ ok: true;
74
+ } | {
75
+ ok: false;
76
+ error: OpenBatchError;
77
+ };
78
+ /** One pending record as callers observe it. */
79
+ export interface QuestionRecord extends QuestionBatch {
80
+ state: 'pending' | 'submitting' | 'resolved' | 'unresolved' | 'expired';
81
+ resolvedOutcome?: 'answered' | 'cancelled' | undefined;
82
+ resolvedBy?: 'user' | 'remote' | undefined;
83
+ expiredCancel?: 'accepted' | 'rejected' | 'unknown' | undefined;
84
+ draft(questionId: string): QuestionDraft | undefined;
85
+ drafts(): QuestionDraft[];
86
+ }
87
+ export interface QuestionStore {
88
+ get(questionRpcId: string): QuestionRecord | undefined;
89
+ open(batch: QuestionBatch): OpenBatchResult;
90
+ /** Validate then persist one question's draft; reports batch completion. */
91
+ setDraft(questionRpcId: string, draft: QuestionDraft): {
92
+ ok: true;
93
+ complete: boolean;
94
+ } | {
95
+ ok: false;
96
+ error: DraftAnswerError;
97
+ };
98
+ /** Snapshot the drafts (the submitter reads them under the claim). */
99
+ takeDrafts(questionRpcId: string): QuestionDraft[];
100
+ /** Encode the current drafts into the DSH answer batch. */
101
+ encodeSubmitted(questionRpcId: string): EncodedQuestionAnswer;
102
+ /**
103
+ * Atomically claim the batch for submission: pending (or an explicit retry
104
+ * after unresolved) flips to submitting exactly once per key.
105
+ */
106
+ claim(questionRpcId: string): Promise<{
107
+ outcome: 'claimed';
108
+ record: QuestionRecord;
109
+ } | {
110
+ outcome: 'not-claimable';
111
+ record: QuestionRecord;
112
+ } | {
113
+ outcome: 'unknown';
114
+ }>;
115
+ /** Record a DSH-confirmed user answer; terminal. */
116
+ markResolved(questionRpcId: string, outcome: 'answered' | 'cancelled', by: 'user' | 'remote', atMs: number): Promise<void>;
117
+ /** Retain the batch in an explicit unresolved state when DSH is silent. */
118
+ markUnresolved(questionRpcId: string, atMs: number): Promise<void>;
119
+ /** Pending batches whose deadline has passed, for the expiry sweep (14.4). */
120
+ listPendingExpired(atMs: number): QuestionRecord[];
121
+ /** Record the expiry: the owning turn's cancellation outcome, controls gone. */
122
+ markExpired(questionRpcId: string, cancel: 'accepted' | 'rejected' | 'unknown', atMs: number): Promise<void>;
123
+ }
124
+ export declare function createQuestionStore(): QuestionStore;