@modesto_ma/dsh-channels 0.0.0-stage → 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/README.md +302 -2
- package/cordis.patch.yml +3 -0
- package/docs/access-control-design.md +550 -0
- package/docs/assets/web-settings-panel.png +0 -0
- package/docs/assets/web-settings-restart-pending.png +0 -0
- package/docs/feasibility-and-design.md +213 -0
- package/docs/feishu-onboarding.md +268 -0
- package/docs/persistence.md +199 -0
- package/docs/refactor-plan.md +56 -0
- package/docs/review-dsh-0.2.0.md +389 -0
- package/docs/slash-commands-implementation-plan.md +410 -0
- package/docs/web-display-only-surface-roadmap.md +363 -0
- package/lib/client.js +2226 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +10217 -0
- package/lib/index.js.map +1 -0
- package/package.json +98 -4
- package/src/access/cards.ts +188 -0
- package/src/access/controller.ts +201 -0
- package/src/access/index.ts +13 -0
- package/src/access/policy.ts +103 -0
- package/src/access/requests.ts +512 -0
- package/src/access/store.ts +220 -0
- package/src/approvals.ts +8 -0
- package/src/cards/approval.ts +114 -0
- package/src/cards/index.ts +11 -0
- package/src/cards/primitives.ts +82 -0
- package/src/cards/question.ts +141 -0
- package/src/cards/streaming.ts +563 -0
- package/src/cards.ts +8 -0
- package/src/cleanup.ts +398 -0
- package/src/client/api.ts +262 -0
- package/src/client/catalog.ts +92 -0
- package/src/client/index.ts +61 -0
- package/src/client/panel.tsx +1475 -0
- package/src/client/styles.ts +98 -0
- package/src/commands/registry.ts +155 -0
- package/src/commands.ts +335 -0
- package/src/config.ts +206 -0
- package/src/domain.ts +428 -0
- package/src/dsh-types.ts +11 -0
- package/src/feishu/agent-context.ts +62 -0
- package/src/feishu/delivery.ts +188 -0
- package/src/feishu/inbound-attachments.ts +436 -0
- package/src/feishu-channel.ts +339 -0
- package/src/health.ts +233 -0
- package/src/host/agent.ts +181 -0
- package/src/host/commands.ts +63 -0
- package/src/host/core.ts +345 -0
- package/src/host/index.ts +12 -0
- package/src/host/interaction.ts +101 -0
- package/src/host/services.ts +320 -0
- package/src/index.ts +122 -0
- package/src/interactions/approvals.ts +154 -0
- package/src/interactions/pending.ts +162 -0
- package/src/interactions/questions.ts +274 -0
- package/src/questions.ts +8 -0
- package/src/routing/inbound.ts +524 -0
- package/src/service.ts +2138 -0
- package/src/session/manager.ts +352 -0
- package/src/session/repair.ts +157 -0
- package/src/session/request-policy.ts +192 -0
- package/src/session/routes.ts +165 -0
- package/src/session-commands.ts +310 -0
- package/src/session-export.ts +112 -0
- package/src/settings/catalog.ts +309 -0
- package/src/settings/contract.ts +508 -0
- package/src/settings/overlay.ts +164 -0
- package/src/settings/routes.ts +409 -0
- package/src/settings/surface.ts +773 -0
- package/src/stats.ts +485 -0
- package/src/streamer.ts +1108 -0
- package/src/workspaces.ts +199 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared lifecycle of a pending interaction card.
|
|
3
|
+
*
|
|
4
|
+
* Both HITL surfaces (approvals and `ask_user_question`) need the same
|
|
5
|
+
* mechanics: a token-keyed registry, owner-gated card-action lookup, delivery
|
|
6
|
+
* with a withdrawn-race guard, a local timeout, settle-once cleanup, and
|
|
7
|
+
* shutdown withdrawal. Only the rendered card and the resolution semantics
|
|
8
|
+
* differ, so those stay in the surface modules and everything else lives here.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-channels/interactions/pending
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CardActionEvent, FeishuChannel } from '../feishu-channel.ts'
|
|
14
|
+
|
|
15
|
+
/** Where one session's interaction cards are placed on Feishu. */
|
|
16
|
+
export interface InteractionRoute {
|
|
17
|
+
/** Feishu chat the cards live in. */
|
|
18
|
+
chatId: string
|
|
19
|
+
/** Reply anchor: the session's latest streaming card message id. */
|
|
20
|
+
cardMessageId: string
|
|
21
|
+
/** Whether the anchor lives in a real topic (replyInThread). */
|
|
22
|
+
inThread: boolean
|
|
23
|
+
/** The session owner (open_id) — the only operator allowed to decide. */
|
|
24
|
+
senderOpenId: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Fields every pending interaction card carries. */
|
|
28
|
+
export interface PendingCardEntry {
|
|
29
|
+
/** Opaque token binding the card to one pending interaction. */
|
|
30
|
+
readonly token: string
|
|
31
|
+
/** DSH session the interaction belongs to (routing + error logs). */
|
|
32
|
+
readonly sessionId: string
|
|
33
|
+
/** Feishu placement of this card. */
|
|
34
|
+
readonly route: InteractionRoute
|
|
35
|
+
/** Card message id once it lands; undefined while sending. */
|
|
36
|
+
messageId: string | undefined
|
|
37
|
+
/** Settle-once guard: a late click/signal/timeout is discarded. */
|
|
38
|
+
settled: boolean
|
|
39
|
+
/** Local timeout handle (absent for host-owned deadlines). */
|
|
40
|
+
timer: ReturnType<typeof setTimeout> | undefined
|
|
41
|
+
/** The ask's own abort signal, when it has one. */
|
|
42
|
+
readonly signal: AbortSignal | undefined
|
|
43
|
+
/** Listener registered for that signal, removed on close. */
|
|
44
|
+
onAbort: (() => void) | undefined
|
|
45
|
+
/** The owner-only notice was already patched onto the card. */
|
|
46
|
+
unauthorizedNoted: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DeliverHandlers<E extends PendingCardEntry> {
|
|
50
|
+
/** The pending card to send as a topic/thread reply. */
|
|
51
|
+
card: object
|
|
52
|
+
/** The card landed and its message id is recorded (arm timeouts here). */
|
|
53
|
+
onDelivered?(entry: E): void
|
|
54
|
+
/** The request withdrew while the card was in flight. */
|
|
55
|
+
onWithdrawn(entry: E): void
|
|
56
|
+
/** Delivery failed: fail the request closed. */
|
|
57
|
+
onFailure(entry: E, error: unknown): void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The pending-card table: registry, delivery, owner gating, and cleanup.
|
|
62
|
+
* Concrete surfaces supply rendering and the terminal action.
|
|
63
|
+
*/
|
|
64
|
+
export class PendingCardTable<E extends PendingCardEntry> {
|
|
65
|
+
private readonly entries = new Map<string, E>()
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly channel: FeishuChannel,
|
|
69
|
+
private readonly onError: (sessionId: string, error: unknown) => void,
|
|
70
|
+
) {}
|
|
71
|
+
|
|
72
|
+
add(entry: E): void {
|
|
73
|
+
this.entries.set(entry.token, entry)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
count(): number {
|
|
77
|
+
return this.entries.size
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every pending entry (diagnostics and inbound text routing). */
|
|
81
|
+
values(): IterableIterator<E> {
|
|
82
|
+
return this.entries.values()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Owner-gated lookup for one card action, containing the owner-only notice.
|
|
87
|
+
* Returns `undefined` for unknown cards, settled entries, and non-owner
|
|
88
|
+
* clicks (which call `onUnauthorized` once).
|
|
89
|
+
*/
|
|
90
|
+
resolveAction(event: CardActionEvent, onUnauthorized: (entry: E) => void): E | undefined {
|
|
91
|
+
let entry: E | undefined
|
|
92
|
+
for (const candidate of this.entries.values()) {
|
|
93
|
+
if (candidate.messageId === event.messageId) {
|
|
94
|
+
entry = candidate
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (entry === undefined || entry.settled) return undefined
|
|
99
|
+
if (event.operator.openId !== entry.route.senderOpenId) {
|
|
100
|
+
onUnauthorized(entry)
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
return entry
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Send the pending card and contain every outcome. */
|
|
107
|
+
async deliver(entry: E, handlers: DeliverHandlers<E>): Promise<void> {
|
|
108
|
+
try {
|
|
109
|
+
const res = await this.channel.send(
|
|
110
|
+
entry.route.chatId,
|
|
111
|
+
{ card: handlers.card },
|
|
112
|
+
{ replyTo: entry.route.cardMessageId, replyInThread: entry.route.inThread },
|
|
113
|
+
)
|
|
114
|
+
// Record the id before the withdrawn check: a request that settled
|
|
115
|
+
// while the card was in flight still needs its card patched terminal.
|
|
116
|
+
entry.messageId = res.messageId
|
|
117
|
+
if (entry.settled) {
|
|
118
|
+
handlers.onWithdrawn(entry)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
handlers.onDelivered?.(entry)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
this.onError(entry.sessionId, error)
|
|
124
|
+
handlers.onFailure(entry, error)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Full-replacement patch of one delivered card; failures stay contained. */
|
|
129
|
+
patch(entry: E, card: object): void {
|
|
130
|
+
if (entry.messageId === undefined) return
|
|
131
|
+
void this.channel.updateCard(entry.messageId, card)
|
|
132
|
+
.catch((error: unknown) => { this.onError(entry.sessionId, error) })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Arm the local deadline for a request the host does not time itself. */
|
|
136
|
+
armTimeout(entry: E, ms: number, onTimeout: () => void): void {
|
|
137
|
+
entry.timer = setTimeout(onTimeout, ms)
|
|
138
|
+
entry.timer.unref?.()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Close one entry exactly once: clear the timer and abort listener, drop it
|
|
143
|
+
* from the registry, then run the terminal action (usually a card patch).
|
|
144
|
+
* @returns false when it was already settled.
|
|
145
|
+
*/
|
|
146
|
+
close(entry: E, terminal: () => void): boolean {
|
|
147
|
+
if (entry.settled) return false
|
|
148
|
+
entry.settled = true
|
|
149
|
+
if (entry.timer !== undefined) clearTimeout(entry.timer)
|
|
150
|
+
if (entry.signal !== undefined && entry.onAbort !== undefined) {
|
|
151
|
+
entry.signal.removeEventListener('abort', entry.onAbort)
|
|
152
|
+
}
|
|
153
|
+
this.entries.delete(entry.token)
|
|
154
|
+
terminal()
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Withdraw every pending entry (plugin shutdown). */
|
|
159
|
+
withdrawAll(withdraw: (entry: E) => void): void {
|
|
160
|
+
for (const entry of [...this.entries.values()]) withdraw(entry)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HITL structured-question answerer: turns the host's
|
|
3
|
+
* `user-questions/request` waterfall events (the model's `ask_user_question`
|
|
4
|
+
* tool) into standalone Feishu cards.
|
|
5
|
+
*
|
|
6
|
+
* The host's only shipped answerer is the Web client, so a Feishu-only
|
|
7
|
+
* session otherwise fails every `ask_user_question` call with `NO_PROVIDER`.
|
|
8
|
+
* This answerer is registered with `prepend: true` on the plugin context:
|
|
9
|
+
* requests for a session this channel routes are claimed here, everything else
|
|
10
|
+
* delegates through `next()`.
|
|
11
|
+
*
|
|
12
|
+
* Rendering lives in `cards/question.ts`; the shared pending-card lifecycle
|
|
13
|
+
* lives in `interactions/pending.ts`.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately unsupported in v1: questions that declare no options (a card
|
|
16
|
+
* cannot ask for them); such a request fails with an explicit error instead of
|
|
17
|
+
* hanging.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-channels/interactions/questions
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { randomUUID } from 'node:crypto'
|
|
23
|
+
import { buildQuestionCard, buildQuestionSettledCard } from '../cards/question.ts'
|
|
24
|
+
import type { QuestionCardInput, QuestionCardItem, QuestionCardState } from '../cards/question.ts'
|
|
25
|
+
import type { AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequestEvent } from '../dsh-types.ts'
|
|
26
|
+
import type { CardActionEvent, FeishuChannel } from '../feishu-channel.ts'
|
|
27
|
+
import { PendingCardTable } from './pending.ts'
|
|
28
|
+
import type { InteractionRoute, PendingCardEntry } from './pending.ts'
|
|
29
|
+
|
|
30
|
+
export interface QuestionHooks {
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the Feishu placement for a session. `undefined` means the session
|
|
33
|
+
* is not owned by this channel — the answerer delegates through `next()`.
|
|
34
|
+
*/
|
|
35
|
+
routeFor(sessionId: string): InteractionRoute | undefined
|
|
36
|
+
/** Log a non-fatal error (delivery/update failures). */
|
|
37
|
+
onError(sessionId: string, error: unknown): void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One in-flight question set: card content, partial answers, resolver. */
|
|
41
|
+
interface PendingQuestions extends PendingCardEntry {
|
|
42
|
+
questions: QuestionCardItem[]
|
|
43
|
+
/** Multi-select toggles not yet submitted, by question id. */
|
|
44
|
+
selected: Map<string, string[]>
|
|
45
|
+
/** Committed answers, by question id. */
|
|
46
|
+
answered: Map<string, string[]>
|
|
47
|
+
/** Free-text answers supplied by a plain reply, by question id. */
|
|
48
|
+
custom: Map<string, string>
|
|
49
|
+
/** Host owns the deadline (timed wait): no local timer is armed. */
|
|
50
|
+
hostTimed: boolean
|
|
51
|
+
resolve(answer: AskUserQuestionAnswer): void
|
|
52
|
+
reject(error: unknown): void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Normalize one host question into its renderable form. */
|
|
56
|
+
function normalizeQuestion(item: AskUserQuestionItem): QuestionCardItem {
|
|
57
|
+
const intent = item.intent
|
|
58
|
+
return {
|
|
59
|
+
id: item.id,
|
|
60
|
+
question: item.question,
|
|
61
|
+
...(item.detail === undefined ? {} : { detail: item.detail }),
|
|
62
|
+
...(item.header === undefined ? {} : { header: item.header }),
|
|
63
|
+
options: (item.options ?? []).map(option => ({
|
|
64
|
+
label: option.label,
|
|
65
|
+
...(option.description === undefined ? {} : { description: option.description }),
|
|
66
|
+
})),
|
|
67
|
+
multiSelect: item.multiSelect === true,
|
|
68
|
+
...(intent?.kind === 'plan-review' ? { intentApproved: intent.approve } : {}),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class QuestionAnswerer {
|
|
73
|
+
private readonly table: PendingCardTable<PendingQuestions>
|
|
74
|
+
|
|
75
|
+
constructor(
|
|
76
|
+
channel: FeishuChannel,
|
|
77
|
+
private readonly hooks: QuestionHooks,
|
|
78
|
+
private readonly timeoutMs: number,
|
|
79
|
+
) {
|
|
80
|
+
this.table = new PendingCardTable(channel, hooks.onError)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** In-flight question-set count (diagnostics/tests). */
|
|
84
|
+
pendingCount(): number {
|
|
85
|
+
return this.table.count()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The `user-questions/request` waterfall listener. Non-owned sessions and
|
|
90
|
+
* questions without options delegate through `next()`.
|
|
91
|
+
*/
|
|
92
|
+
onRequest(
|
|
93
|
+
request: AskUserQuestionRequestEvent,
|
|
94
|
+
next: () => Promise<AskUserQuestionAnswer>,
|
|
95
|
+
): Promise<AskUserQuestionAnswer> {
|
|
96
|
+
const agent = request.agent
|
|
97
|
+
if (agent === undefined) return next()
|
|
98
|
+
const route = this.hooks.routeFor(agent.session.id)
|
|
99
|
+
if (route === undefined) return next()
|
|
100
|
+
|
|
101
|
+
const questions = request.questions.map(normalizeQuestion)
|
|
102
|
+
if (questions.length === 0) return next()
|
|
103
|
+
|
|
104
|
+
const token = randomUUID()
|
|
105
|
+
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
|
|
106
|
+
const entry: PendingQuestions = {
|
|
107
|
+
token,
|
|
108
|
+
sessionId: agent.session.id,
|
|
109
|
+
route,
|
|
110
|
+
messageId: undefined,
|
|
111
|
+
settled: false,
|
|
112
|
+
timer: undefined,
|
|
113
|
+
signal: request.signal,
|
|
114
|
+
onAbort: undefined,
|
|
115
|
+
unauthorizedNoted: false,
|
|
116
|
+
questions,
|
|
117
|
+
selected: new Map(),
|
|
118
|
+
answered: new Map(),
|
|
119
|
+
custom: new Map(),
|
|
120
|
+
hostTimed: request.wait?.timed === true,
|
|
121
|
+
resolve,
|
|
122
|
+
reject,
|
|
123
|
+
}
|
|
124
|
+
const onAbort = (): void => {
|
|
125
|
+
const reason = request.signal?.reason
|
|
126
|
+
entry.reject(reason instanceof Error ? reason : new Error('dsh-channels: 提问已被取消。'))
|
|
127
|
+
this.close(entry, 'cancelled')
|
|
128
|
+
}
|
|
129
|
+
entry.onAbort = onAbort
|
|
130
|
+
if (request.signal?.aborted === true) {
|
|
131
|
+
reject(new Error('dsh-channels: 提问已被取消。'))
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
request.signal?.addEventListener('abort', onAbort, { once: true })
|
|
135
|
+
this.table.add(entry)
|
|
136
|
+
void this.table.deliver(entry, {
|
|
137
|
+
card: buildQuestionCard(this.cardInput(entry)),
|
|
138
|
+
onWithdrawn: e => this.table.patch(e, buildQuestionSettledCard(this.cardInput(e), 'cancelled')),
|
|
139
|
+
// A timed wait is owned by the host: its signal settles the request at
|
|
140
|
+
// the deadline (rejection with ASK_TIMED_OUT becomes a continued
|
|
141
|
+
// question). Only blocking asks need a local timer.
|
|
142
|
+
onDelivered: e => {
|
|
143
|
+
if (e.hostTimed) return
|
|
144
|
+
this.table.armTimeout(e, this.timeoutMs, () => {
|
|
145
|
+
e.reject(new Error('dsh-channels: ask_user_question 等待超时,未收到回答。'))
|
|
146
|
+
this.close(e, 'expired')
|
|
147
|
+
})
|
|
148
|
+
},
|
|
149
|
+
onFailure: (e, error) => {
|
|
150
|
+
this.table.close(e, () => {})
|
|
151
|
+
e.reject(error instanceof Error ? error : new Error(String(error)))
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Snapshot the current selections for rendering. */
|
|
158
|
+
private cardInput(entry: PendingQuestions): QuestionCardInput {
|
|
159
|
+
const selected: Record<string, string[]> = {}
|
|
160
|
+
for (const [id, labels] of entry.selected) selected[id] = [...labels]
|
|
161
|
+
return {
|
|
162
|
+
token: entry.token,
|
|
163
|
+
questions: entry.questions,
|
|
164
|
+
timeoutMs: this.timeoutMs,
|
|
165
|
+
sessionId: entry.sessionId,
|
|
166
|
+
selected,
|
|
167
|
+
answered: [...entry.answered.keys()],
|
|
168
|
+
...(entry.unauthorizedNoted ? { unauthorizedNote: true } : {}),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Route one card button-callback event to its pending question set.
|
|
174
|
+
* Unknown cards and non-owner operators are ignored (a non-owner click adds
|
|
175
|
+
* the owner-only notice once); owner decisions settle the request.
|
|
176
|
+
*/
|
|
177
|
+
onCardAction(event: CardActionEvent): void {
|
|
178
|
+
const entry = this.table.resolveAction(event, candidate => {
|
|
179
|
+
if (candidate.unauthorizedNoted) return
|
|
180
|
+
candidate.unauthorizedNoted = true
|
|
181
|
+
this.table.patch(candidate, buildQuestionCard(this.cardInput(candidate)))
|
|
182
|
+
})
|
|
183
|
+
if (entry === undefined) return
|
|
184
|
+
const value = event.action.value as
|
|
185
|
+
| { decision?: unknown; id?: unknown; value?: unknown }
|
|
186
|
+
| undefined
|
|
187
|
+
const decision = value?.decision
|
|
188
|
+
const id = typeof value?.id === 'string' ? value.id : undefined
|
|
189
|
+
const option = typeof value?.value === 'string' ? value.value : undefined
|
|
190
|
+
if (id === undefined) return
|
|
191
|
+
const question = entry.questions.find(item => item.id === id)
|
|
192
|
+
if (question === undefined || entry.answered.has(id)) return
|
|
193
|
+
|
|
194
|
+
if (decision === 'question-answer' && option !== undefined) {
|
|
195
|
+
entry.answered.set(id, [option])
|
|
196
|
+
this.table.patch(entry, buildQuestionCard(this.cardInput(entry)))
|
|
197
|
+
this.resolveIfComplete(entry)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
if (decision === 'question-toggle' && option !== undefined) {
|
|
201
|
+
const current = new Set(entry.selected.get(id) ?? [])
|
|
202
|
+
if (current.has(option)) current.delete(option)
|
|
203
|
+
else current.add(option)
|
|
204
|
+
entry.selected.set(id, [...current])
|
|
205
|
+
this.table.patch(entry, buildQuestionCard(this.cardInput(entry)))
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
if (decision === 'question-submit') {
|
|
209
|
+
const chosen = entry.selected.get(id) ?? []
|
|
210
|
+
if (chosen.length === 0) return
|
|
211
|
+
entry.answered.set(id, [...chosen])
|
|
212
|
+
this.table.patch(entry, buildQuestionCard(this.cardInput(entry)))
|
|
213
|
+
this.resolveIfComplete(entry)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Complete the waterfall once every question has a committed answer. */
|
|
218
|
+
private resolveIfComplete(entry: PendingQuestions): void {
|
|
219
|
+
if (entry.questions.some(question => !entry.answered.has(question.id))) return
|
|
220
|
+
const answer: AskUserQuestionAnswer = {
|
|
221
|
+
answers: entry.questions.map(question => ({
|
|
222
|
+
id: question.id,
|
|
223
|
+
selected: entry.answered.get(question.id) ?? [],
|
|
224
|
+
...(entry.custom.has(question.id) ? { custom: entry.custom.get(question.id) } : {}),
|
|
225
|
+
})),
|
|
226
|
+
}
|
|
227
|
+
const closed = this.table.close(entry, () => {
|
|
228
|
+
this.table.patch(entry, buildQuestionSettledCard(this.cardInput(entry), 'answered'))
|
|
229
|
+
})
|
|
230
|
+
if (closed) entry.resolve(answer)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Feed one plain reply as a free-text answer.
|
|
235
|
+
*
|
|
236
|
+
* A session blocked on `ask_user_question` has no other use for an inbound
|
|
237
|
+
* text message, so the next unanswered question consumes it as its "Other"
|
|
238
|
+
* answer. Returns false when the session has no pending question.
|
|
239
|
+
*/
|
|
240
|
+
submitText(sessionId: string, text: string): boolean {
|
|
241
|
+
const trimmed = text.trim()
|
|
242
|
+
if (trimmed === '') return false
|
|
243
|
+
const entry = [...this.table.values()].find(candidate =>
|
|
244
|
+
candidate.sessionId === sessionId && !candidate.settled)
|
|
245
|
+
if (entry === undefined) return false
|
|
246
|
+
const question = entry.questions.find(candidate => !entry.answered.has(candidate.id))
|
|
247
|
+
if (question === undefined) return false
|
|
248
|
+
entry.answered.set(question.id, [])
|
|
249
|
+
entry.custom.set(question.id, trimmed)
|
|
250
|
+
this.table.patch(entry, buildQuestionCard(this.cardInput(entry)))
|
|
251
|
+
this.resolveIfComplete(entry)
|
|
252
|
+
return true
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Whether a session currently has an unanswered question set. */
|
|
256
|
+
hasPendingFor(sessionId: string): boolean {
|
|
257
|
+
return [...this.table.values()].some(candidate => candidate.sessionId === sessionId && !candidate.settled)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Withdraw every in-flight question (plugin shutdown). */
|
|
261
|
+
dispose(): void {
|
|
262
|
+
this.table.withdrawAll(entry => {
|
|
263
|
+
entry.reject(new Error('dsh-channels: 插件已关闭,提问未完成。'))
|
|
264
|
+
this.close(entry, 'cancelled')
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Settle once and patch the card to its terminal state. */
|
|
269
|
+
private close(entry: PendingQuestions, state: QuestionCardState): void {
|
|
270
|
+
this.table.close(entry, () => {
|
|
271
|
+
this.table.patch(entry, buildQuestionSettledCard(this.cardInput(entry), state))
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
}
|