@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,524 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound routing: dedup, admission, the route decision tree, and the
|
|
3
|
+
* session-establishment / continuation flow.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is channel-plane policy; the command plane (slash commands)
|
|
6
|
+
* and the answerers stay in the service and are reached through narrow ports.
|
|
7
|
+
* Dependency reads go through accessors so the moved logic stays readable.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-channels/routing/inbound
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomUUID } from 'node:crypto'
|
|
13
|
+
import type { AgentHandle, HostContext, SessionId, UserMessage, Workspace } from '../dsh-types.ts'
|
|
14
|
+
import type { FeishuConfig } from '../config.ts'
|
|
15
|
+
import { buildCard, buildOnboardingCard, initialStreamingCard } from '../cards.ts'
|
|
16
|
+
import { RouteOperationQueue } from '../commands.ts'
|
|
17
|
+
import { chatRouteKey } from '../domain.ts'
|
|
18
|
+
import type { FeishuDomainHandle, RouteRecord } from '../domain.ts'
|
|
19
|
+
import type { CardDelivery } from '../feishu/delivery.ts'
|
|
20
|
+
import {
|
|
21
|
+
adoptStagedAttachments, attachmentsDir, hasAttachmentPayload,
|
|
22
|
+
} from '../feishu/inbound-attachments.ts'
|
|
23
|
+
import { AttachmentIntake } from '../feishu/inbound-attachments.ts'
|
|
24
|
+
import type { AttachmentArchiveResult, AttachmentFailure } from '../feishu/inbound-attachments.ts'
|
|
25
|
+
import type { NormalizedMessage } from '../feishu-channel.ts'
|
|
26
|
+
import type { QuestionAnswerer } from '../interactions/questions.ts'
|
|
27
|
+
import type { InflightRepair } from '../session/repair.ts'
|
|
28
|
+
import type { SessionManager } from '../session/manager.ts'
|
|
29
|
+
import type { RouteStore } from '../session/routes.ts'
|
|
30
|
+
import type { TurnStreamer } from '../streamer.ts'
|
|
31
|
+
import { ensureWorkspace, stagingPathFor, topicTokenFor } from '../workspaces.ts'
|
|
32
|
+
|
|
33
|
+
/** The command plane the inbound router delegates admission-time work to. */
|
|
34
|
+
export interface CommandPlanePort {
|
|
35
|
+
/** Slash-command admission + dispatch. */
|
|
36
|
+
handleSlash(msg: NormalizedMessage, line: string): Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Channel-plane admission port (structurally satisfied by the access
|
|
41
|
+
* controller). Kept as a local interface so routing has no dependency on the
|
|
42
|
+
* access module's implementation details.
|
|
43
|
+
*/
|
|
44
|
+
export interface InboundAccessPort {
|
|
45
|
+
/** Full admission for a non-slash message; false = handled (deny/notice). */
|
|
46
|
+
admit(msg: NormalizedMessage): Promise<boolean>
|
|
47
|
+
/**
|
|
48
|
+
* Route ownership: only the sender who created a topic may continue it.
|
|
49
|
+
* Also guards against cross-chat id confusion.
|
|
50
|
+
*/
|
|
51
|
+
ownsRoute(msg: NormalizedMessage, record: RouteRecord): boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface InboundDeps {
|
|
55
|
+
ctx: HostContext
|
|
56
|
+
config: FeishuConfig
|
|
57
|
+
started(): boolean
|
|
58
|
+
domain(): FeishuDomainHandle | undefined
|
|
59
|
+
routes: RouteStore
|
|
60
|
+
repair: InflightRepair
|
|
61
|
+
delivery: CardDelivery
|
|
62
|
+
sessions: SessionManager
|
|
63
|
+
attachments: AttachmentIntake
|
|
64
|
+
/** Channel-plane access control (administrator + whitelist gates). */
|
|
65
|
+
access: InboundAccessPort
|
|
66
|
+
/** Shared per-route FIFO admission (commands and turns must not interleave). */
|
|
67
|
+
queue: RouteOperationQueue
|
|
68
|
+
streamer(): TurnStreamer | undefined
|
|
69
|
+
questionAnswerer(): QuestionAnswerer | undefined
|
|
70
|
+
commands: CommandPlanePort
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* User-facing text for attachments that could not be archived. Delivered as a
|
|
75
|
+
* plain reply (never a card) so the user learns why the Agent cannot read a
|
|
76
|
+
* file instead of silently getting an answer about older attachments.
|
|
77
|
+
*/
|
|
78
|
+
export function buildAttachmentFailureNotice(failures: readonly AttachmentFailure[]): string {
|
|
79
|
+
const lines = failures.map(failure => `• ${failure.name}:${failure.reason}`)
|
|
80
|
+
return [
|
|
81
|
+
'⚠️ 附件保存失败,Agent 无法读取:',
|
|
82
|
+
...lines,
|
|
83
|
+
'可尝试压缩/拆分后重发,或把文件直接放到工作区目录。',
|
|
84
|
+
].join('\n')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build one durable user message from inbound Feishu text. */
|
|
88
|
+
export function feishuUserMessage(text: string): UserMessage {
|
|
89
|
+
return {
|
|
90
|
+
id: randomUUID(),
|
|
91
|
+
role: 'user',
|
|
92
|
+
content: [{ type: 'text', text }],
|
|
93
|
+
source: { kind: 'user' },
|
|
94
|
+
} as unknown as UserMessage
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class InboundRouter {
|
|
98
|
+
/**
|
|
99
|
+
* In-process dedup guard: message id → accept timestamp. Set synchronously
|
|
100
|
+
* at acceptance so a same-process redelivery can never slip through the
|
|
101
|
+
* window between the persisted dedup check and its `put` landing.
|
|
102
|
+
*/
|
|
103
|
+
private readonly accepted = new Map<string, number>()
|
|
104
|
+
/**
|
|
105
|
+
* chat-scoped key → in-flight session establishment. Concurrent first
|
|
106
|
+
* messages of one (chat, user) pair all observe the same promise: they wait
|
|
107
|
+
* for the establishment to settle, then either continue the just-created
|
|
108
|
+
* session (same topic, or degraded chat route) or fall through to start
|
|
109
|
+
* their own — never racing each other into duplicate sessions, route
|
|
110
|
+
* overwrites, or the "no route, no mention" drop window.
|
|
111
|
+
*/
|
|
112
|
+
private readonly establishing = new Map<string, Promise<RouteRecord>>()
|
|
113
|
+
|
|
114
|
+
constructor(private readonly deps: InboundDeps) {}
|
|
115
|
+
|
|
116
|
+
private get ctx(): HostContext { return this.deps.ctx }
|
|
117
|
+
private get config(): FeishuConfig { return this.deps.config }
|
|
118
|
+
private get started(): boolean { return this.deps.started() }
|
|
119
|
+
private get routes(): RouteStore { return this.deps.routes }
|
|
120
|
+
private get repair(): InflightRepair { return this.deps.repair }
|
|
121
|
+
private get delivery(): CardDelivery { return this.deps.delivery }
|
|
122
|
+
private get sessions(): SessionManager { return this.deps.sessions }
|
|
123
|
+
private get attachments(): AttachmentIntake { return this.deps.attachments }
|
|
124
|
+
private get routeOperations(): RouteOperationQueue { return this.deps.queue }
|
|
125
|
+
private get streamer(): TurnStreamer | undefined { return this.deps.streamer() }
|
|
126
|
+
private get questionAnswerer(): QuestionAnswerer | undefined { return this.deps.questionAnswerer() }
|
|
127
|
+
private get domain(): FeishuDomainHandle | undefined { return this.deps.domain() }
|
|
128
|
+
private get access(): InboundAccessPort { return this.deps.access }
|
|
129
|
+
|
|
130
|
+
/** Retire dedup entries older than the retention cutoff (GC sweep). */
|
|
131
|
+
pruneAccepted(cutoff: number): void {
|
|
132
|
+
for (const [messageId, acceptedAt] of this.accepted) {
|
|
133
|
+
if (acceptedAt < cutoff) this.accepted.delete(messageId)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Send the one-time onboarding card for a private chat, then remember the
|
|
139
|
+
* chat in the domain global. Best-effort: a storage failure only risks a
|
|
140
|
+
* repeated card, never a blocked session.
|
|
141
|
+
*/
|
|
142
|
+
private async sendOnboardingCard(msg: NormalizedMessage): Promise<void> {
|
|
143
|
+
const domain = this.domain
|
|
144
|
+
if (domain === undefined) return
|
|
145
|
+
try {
|
|
146
|
+
const global = domain.getGlobal()
|
|
147
|
+
const welcomed = global.welcomedChats ?? []
|
|
148
|
+
if (welcomed.includes(msg.chatId)) return
|
|
149
|
+
await this.delivery.standaloneReply(msg, buildOnboardingCard(), { createThread: false })
|
|
150
|
+
// Bounded: keep the most recent 500 chats.
|
|
151
|
+
const next = [...welcomed, msg.chatId].slice(-500)
|
|
152
|
+
await domain.setGlobal({ ...global, welcomedChats: next })
|
|
153
|
+
} catch (error) {
|
|
154
|
+
this.ctx.logger?.warn?.('dsh-channels: onboarding card failed: %s', String(error))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Drop all in-memory routing state (shutdown). */
|
|
159
|
+
clear(): void {
|
|
160
|
+
this.accepted.clear()
|
|
161
|
+
this.establishing.clear()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Entry point: one normalized inbound message. */
|
|
165
|
+
async handle(msg: NormalizedMessage): Promise<void> {
|
|
166
|
+
const botId = this.delivery.requireChannel().botOpenId()
|
|
167
|
+
if (botId !== undefined && msg.senderId === botId) return
|
|
168
|
+
if (msg.chatType !== 'p2p' && msg.chatType !== 'group') return
|
|
169
|
+
let content = (msg.content ?? '').trim()
|
|
170
|
+
if (content === '') return
|
|
171
|
+
|
|
172
|
+
if (msg.chatType === 'p2p' && this.config.dmMode === 'disabled') return
|
|
173
|
+
|
|
174
|
+
// 0. Idempotency gate: Feishu redelivers un-acked events on WS reconnect.
|
|
175
|
+
// The in-process guard closes the window between the persisted check
|
|
176
|
+
// and its `put` landing; the durable record covers restarts. Recording
|
|
177
|
+
// happens BEFORE any side effect so a redelivery is dropped, never
|
|
178
|
+
// double-processed (at-most-once per message id).
|
|
179
|
+
if (this.accepted.has(msg.messageId)) return
|
|
180
|
+
if (this.domain?.processed.get(msg.messageId) !== undefined) return
|
|
181
|
+
this.accepted.set(msg.messageId, Date.now())
|
|
182
|
+
try {
|
|
183
|
+
await this.domain?.processed.put(msg.messageId, {
|
|
184
|
+
key: msg.messageId,
|
|
185
|
+
processedAt: Date.now(),
|
|
186
|
+
})
|
|
187
|
+
this.accepted.delete(msg.messageId)
|
|
188
|
+
} catch (error) {
|
|
189
|
+
// Best-effort: the memory guard still covers this process; the failed
|
|
190
|
+
// record only widens the crash window (pruned by the GC sweep later).
|
|
191
|
+
this.ctx.logger?.warn?.('dsh-channels: recording processed message %s failed: %s', msg.messageId, String(error))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Slash admission stays behind message-id dedup but ahead of every route,
|
|
195
|
+
// mention, workspace, Session, card-stream, and model side effect. The
|
|
196
|
+
// command plane applies its own administrator/bootstrap rules.
|
|
197
|
+
if (content.startsWith('/')) {
|
|
198
|
+
await this.deps.commands.handleSlash(msg, content)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Channel-plane admission (administrator / group whitelist / user
|
|
203
|
+
// whitelist). Nothing below this point may run for a denied sender: no
|
|
204
|
+
// TTL tombstone, attachment archive, workspace, Session, or model call.
|
|
205
|
+
if (!(await this.access.admit(msg))) return
|
|
206
|
+
|
|
207
|
+
// Enforce TTL at admission as well as in the periodic sweep. A message that
|
|
208
|
+
// arrives after the deadline must not get one chance to revive the route
|
|
209
|
+
// merely because the background GC has not run yet.
|
|
210
|
+
const expiredThread = msg.threadId === undefined
|
|
211
|
+
? undefined
|
|
212
|
+
: await this.routes.expireThread(msg.threadId)
|
|
213
|
+
|
|
214
|
+
// A TTL-retired topic is permanently closed on the channel plane. Keeping
|
|
215
|
+
// a durable tombstone avoids silently forking a fresh Session in a topic
|
|
216
|
+
// whose visible history still belongs to the old Session.
|
|
217
|
+
if (expiredThread !== undefined) {
|
|
218
|
+
await this.delivery.expiredRouteCard(msg)
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Attachments are archived before session work: an attachment-only message
|
|
223
|
+
// produces no model turn, and a caption mixed with attachments keeps only
|
|
224
|
+
// the caption as the prompt. The Agent reads the files on demand later.
|
|
225
|
+
const archived = await this.archiveAttachments(msg)
|
|
226
|
+
if (archived !== undefined) {
|
|
227
|
+
if (archived.failures.length > 0) {
|
|
228
|
+
await this.delivery.noticeText(msg, buildAttachmentFailureNotice(archived.failures))
|
|
229
|
+
}
|
|
230
|
+
if (archived.attachmentOnly) return
|
|
231
|
+
content = archived.text
|
|
232
|
+
if (content === '') return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// A session blocked on `ask_user_question` has no other use for an inbound
|
|
236
|
+
// text message: the next reply becomes the free-text answer.
|
|
237
|
+
if (await this.tryAnswerPendingQuestion(msg, content)) return
|
|
238
|
+
|
|
239
|
+
// 1. Real topic route first (works without a mention, per product spec).
|
|
240
|
+
if (msg.threadId !== undefined) {
|
|
241
|
+
const record = this.routes.get(msg.threadId)
|
|
242
|
+
if (record !== undefined) {
|
|
243
|
+
// Only the topic creator may continue its Session (admission already
|
|
244
|
+
// passed, but another authorized user could be in the same group).
|
|
245
|
+
if (!this.access.ownsRoute(msg, record)) return
|
|
246
|
+
await this.routeOperations.run(record.key, () => {
|
|
247
|
+
if (!this.started) return
|
|
248
|
+
return this.continueSession(msg, this.routes.get(record.key) ?? record, content)
|
|
249
|
+
})
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 2. Degraded chat-scoped route (topic-less fallback sessions).
|
|
255
|
+
const chatKey = chatRouteKey(msg.chatType, msg.chatId, msg.senderId)
|
|
256
|
+
const chatRecord = this.routes.get(chatKey)
|
|
257
|
+
if (chatRecord !== undefined) {
|
|
258
|
+
if (!this.access.ownsRoute(msg, chatRecord)) return
|
|
259
|
+
await this.routeOperations.run(chatRecord.key, () => {
|
|
260
|
+
if (!this.started) return
|
|
261
|
+
return this.continueSession(msg, this.routes.get(chatRecord.key) ?? chatRecord, content)
|
|
262
|
+
})
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 3. In-flight establishment for this (chat, user) pair: wait for it and
|
|
267
|
+
// re-route with serial semantics (see the `establishing` field docs).
|
|
268
|
+
const pending = this.establishing.get(chatKey)
|
|
269
|
+
if (pending !== undefined) {
|
|
270
|
+
const record = await pending.catch(() => undefined)
|
|
271
|
+
if (record !== undefined
|
|
272
|
+
&& (record.kind === 'chat' || (msg.threadId !== undefined && msg.threadId === record.key))) {
|
|
273
|
+
await this.routeOperations.run(record.key, () => {
|
|
274
|
+
if (!this.started) return
|
|
275
|
+
return this.continueSession(msg, this.routes.get(record.key) ?? record, content)
|
|
276
|
+
})
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
// Thread-scoped record but this message is not in that topic (or the
|
|
280
|
+
// establishment failed): fall through — a fresh session is what the
|
|
281
|
+
// serial order would have produced too.
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 4. New session: groups require a mention (unless disabled).
|
|
285
|
+
if (msg.chatType === 'group' && this.config.requireMention && !msg.mentionedBot) return
|
|
286
|
+
|
|
287
|
+
await this.startSession(msg, content)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Archive the attachment payload of one message, if any. Returns `undefined`
|
|
292
|
+
* when the message carries no archivable payload; otherwise the archive
|
|
293
|
+
* result whose `text` is the remaining prompt (possibly empty).
|
|
294
|
+
*
|
|
295
|
+
* Runs inside the same per-route FIFO as turns so a caption sent right after
|
|
296
|
+
* a file is queued behind the file's download instead of racing it.
|
|
297
|
+
*/
|
|
298
|
+
private async archiveAttachments(msg: NormalizedMessage): Promise<AttachmentArchiveResult | undefined> {
|
|
299
|
+
if (!hasAttachmentPayload(msg)) return undefined
|
|
300
|
+
|
|
301
|
+
const threadRecord = msg.threadId === undefined ? undefined : this.routes.get(msg.threadId)
|
|
302
|
+
// A foreign topic must never receive this sender's attachments: the
|
|
303
|
+
// archive path resolves the workspace from the route record.
|
|
304
|
+
if (threadRecord !== undefined && !this.access.ownsRoute(msg, threadRecord)) return undefined
|
|
305
|
+
const chatKey = chatRouteKey(msg.chatType, msg.chatId, msg.senderId)
|
|
306
|
+
const record = threadRecord ?? this.routes.get(chatKey)
|
|
307
|
+
// A brand-new group conversation still needs a mention; a topic the bot
|
|
308
|
+
// already owns does not.
|
|
309
|
+
if (record === undefined && msg.chatType === 'group'
|
|
310
|
+
&& this.config.requireMention && !msg.mentionedBot) {
|
|
311
|
+
return { saved: 0, failed: 0, failures: [], attachmentOnly: true, text: '' }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return await this.routeOperations.run(record?.key ?? chatKey, async () => {
|
|
315
|
+
if (!this.started) return { saved: 0, failed: 0, failures: [], attachmentOnly: true, text: '' }
|
|
316
|
+
// An existing topic has a workspace already; a conversation without one
|
|
317
|
+
// stages files below baseDir until its first Session creates the topic
|
|
318
|
+
// workspace and adopts them.
|
|
319
|
+
const directory = record === undefined
|
|
320
|
+
? stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId)
|
|
321
|
+
: attachmentsDir(record.workspacePath)
|
|
322
|
+
return await this.attachments.archive(msg, directory)
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Consume one plain reply as an `ask_user_question` free-text answer.
|
|
327
|
+
*
|
|
328
|
+
* Only sessions with a pending question are intercepted, so ordinary
|
|
329
|
+
* conversation and commands are untouched.
|
|
330
|
+
*/
|
|
331
|
+
private async tryAnswerPendingQuestion(msg: NormalizedMessage, content: string): Promise<boolean> {
|
|
332
|
+
const answerer = this.questionAnswerer
|
|
333
|
+
if (answerer === undefined || answerer.pendingCount() === 0) return false
|
|
334
|
+
const route = await this.commandRoute(msg)
|
|
335
|
+
if (route === undefined || !this.access.ownsRoute(msg, route)) return false
|
|
336
|
+
if (!answerer.hasPendingFor(route.sessionId)) return false
|
|
337
|
+
if (!answerer.submitText(route.sessionId, content)) return false
|
|
338
|
+
await this.delivery.commandCard(msg, {
|
|
339
|
+
kind: 'success', command: 'ask_user_question', sessionId: route.sessionId,
|
|
340
|
+
text: '已记录你的文字回答。',
|
|
341
|
+
}, { createThread: false })
|
|
342
|
+
return true
|
|
343
|
+
}
|
|
344
|
+
/** Resolve the route visible to one command without creating a Session. */
|
|
345
|
+
/** Public read-only route resolution (used by the command plane too). */
|
|
346
|
+
async resolveRoute(msg: NormalizedMessage): Promise<RouteRecord | undefined> {
|
|
347
|
+
return await this.commandRoute(msg)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private async commandRoute(msg: NormalizedMessage): Promise<RouteRecord | undefined> {
|
|
351
|
+
if (msg.threadId !== undefined) {
|
|
352
|
+
const threaded = this.routes.get(msg.threadId)
|
|
353
|
+
if (threaded !== undefined) return threaded
|
|
354
|
+
}
|
|
355
|
+
const chatKey = chatRouteKey(msg.chatType, msg.chatId, msg.senderId)
|
|
356
|
+
const degraded = this.routes.get(chatKey)
|
|
357
|
+
if (degraded !== undefined) return degraded
|
|
358
|
+
const pending = this.establishing.get(chatKey)
|
|
359
|
+
if (pending === undefined) return undefined
|
|
360
|
+
const record = await pending.catch(() => undefined)
|
|
361
|
+
if (record?.kind === 'chat') return record
|
|
362
|
+
if (record !== undefined && msg.threadId === record.key) return record
|
|
363
|
+
return undefined
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Serialize session establishment per (chat, user) pair. The promise is
|
|
367
|
+
* registered under the chat-scoped key SYNCHRONOUSLY (the key is known
|
|
368
|
+
* before any network op), so concurrent first messages all observe the same
|
|
369
|
+
* in-flight establishment instead of racing each other. The entry lives
|
|
370
|
+
* exactly as long as the establishment; waiters holding the promise still
|
|
371
|
+
* see the settled record after the entry is gone.
|
|
372
|
+
*/
|
|
373
|
+
private startSession(msg: NormalizedMessage, content: string): Promise<RouteRecord> {
|
|
374
|
+
const chatKey = chatRouteKey(msg.chatType, msg.chatId, msg.senderId)
|
|
375
|
+
const existing = this.establishing.get(chatKey)
|
|
376
|
+
if (existing !== undefined) return existing
|
|
377
|
+
const promise = this.establishSession(msg, content, chatKey)
|
|
378
|
+
this.establishing.set(chatKey, promise)
|
|
379
|
+
void promise.then(
|
|
380
|
+
() => { this.establishing.delete(chatKey) },
|
|
381
|
+
() => { this.establishing.delete(chatKey) },
|
|
382
|
+
)
|
|
383
|
+
return promise
|
|
384
|
+
}
|
|
385
|
+
private async establishSession(msg: NormalizedMessage, content: string, chatKey: string): Promise<RouteRecord> {
|
|
386
|
+
// 0. One-time onboarding for a private chat (persisted, never repeats).
|
|
387
|
+
if (this.config.welcomeOnboarding && msg.chatType === 'p2p') {
|
|
388
|
+
await this.sendOnboardingCard(msg)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 1. "Typing" reaction immediately (id captured for the swap).
|
|
392
|
+
const typingPromise = this.delivery.requireChannel().addReaction(msg.messageId, 'Typing').catch(() => undefined)
|
|
393
|
+
|
|
394
|
+
const sessionId = randomUUID() as SessionId
|
|
395
|
+
const initialStats = { sessionId, inputTokens: 0, outputTokens: 0 }
|
|
396
|
+
|
|
397
|
+
// 2. First card frame — also creates the topic (reply_in_thread). The
|
|
398
|
+
// topic identity is only known after Feishu answers, and it decides the
|
|
399
|
+
// workspace: every new topic gets its own directory.
|
|
400
|
+
const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(sessionId))
|
|
401
|
+
const typingId = await typingPromise
|
|
402
|
+
const key = sent.degraded || sent.threadId === undefined ? chatKey : sent.threadId
|
|
403
|
+
const kind = sent.degraded || sent.threadId === undefined ? 'chat' : 'thread'
|
|
404
|
+
|
|
405
|
+
// 3. Workspace for this topic, then adopt attachments staged before it.
|
|
406
|
+
let workspace: Workspace
|
|
407
|
+
try {
|
|
408
|
+
workspace = await ensureWorkspace(
|
|
409
|
+
this.ctx, this.config.baseDir, msg.chatType, msg.chatId, msg.senderId,
|
|
410
|
+
topicTokenFor(key, kind, sessionId),
|
|
411
|
+
)
|
|
412
|
+
await adoptStagedAttachments(
|
|
413
|
+
stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
|
|
414
|
+
workspace.path,
|
|
415
|
+
)
|
|
416
|
+
} catch (error) {
|
|
417
|
+
void this.repair.clear(sent.messageId)
|
|
418
|
+
await this.delivery.requireChannel().updateCard(sent.messageId, buildCard({
|
|
419
|
+
phase: 'done',
|
|
420
|
+
reasoning: '',
|
|
421
|
+
body: `会话创建失败:${String(error)}`,
|
|
422
|
+
stats: { ...initialStats },
|
|
423
|
+
})).catch(() => {})
|
|
424
|
+
throw error
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 3.5 Repair snapshot FIRST (before the route record and the agent):
|
|
428
|
+
// the crash window between the two writes then degrades to an orphan
|
|
429
|
+
// snapshot — which startup repair handles safely — instead of a zombie
|
|
430
|
+
// streaming card with no snapshot at all.
|
|
431
|
+
await this.repair.write({
|
|
432
|
+
key: sent.messageId,
|
|
433
|
+
sessionId,
|
|
434
|
+
chatId: msg.chatId,
|
|
435
|
+
triggerMessageId: msg.messageId,
|
|
436
|
+
reactionId: typingId,
|
|
437
|
+
cards: [{ messageId: sent.messageId, mode: 'card' }],
|
|
438
|
+
startedAt: Date.now(),
|
|
439
|
+
attempts: 0,
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
// 4. Create the agent/session bound to the workspace directory.
|
|
443
|
+
let handle: AgentHandle
|
|
444
|
+
try {
|
|
445
|
+
handle = await this.sessions.createSession(sessionId, workspace.path)
|
|
446
|
+
} catch (error) {
|
|
447
|
+
// The card lands in a final failure state — no repair needed later.
|
|
448
|
+
void this.repair.clear(sent.messageId)
|
|
449
|
+
await this.delivery.requireChannel().updateCard(sent.messageId, buildCard({
|
|
450
|
+
phase: 'done',
|
|
451
|
+
reasoning: '',
|
|
452
|
+
body: `会话创建失败:${String(error)}`,
|
|
453
|
+
stats: { ...initialStats },
|
|
454
|
+
})).catch(() => {})
|
|
455
|
+
throw error
|
|
456
|
+
}
|
|
457
|
+
void Promise.resolve(workspace.attachSession(sessionId)).catch(error => {
|
|
458
|
+
this.ctx.logger?.warn?.(
|
|
459
|
+
'dsh-channels: attaching initial session %s to workspace failed: %s',
|
|
460
|
+
sessionId, String(error),
|
|
461
|
+
)
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
// 5. Persist the route (thread-scoped, or degraded chat-scoped).
|
|
465
|
+
const createdAt = handle.agent.session.header.createdAt
|
|
466
|
+
const record: RouteRecord = {
|
|
467
|
+
key,
|
|
468
|
+
kind,
|
|
469
|
+
sessionId,
|
|
470
|
+
chatId: msg.chatId,
|
|
471
|
+
chatType: msg.chatType,
|
|
472
|
+
senderOpenId: msg.senderId,
|
|
473
|
+
workspacePath: workspace.path,
|
|
474
|
+
cardMessageId: sent.messageId,
|
|
475
|
+
createdAt,
|
|
476
|
+
lastActivityAt: createdAt,
|
|
477
|
+
sessionHistory: [{ sessionId, createdAt }],
|
|
478
|
+
}
|
|
479
|
+
await this.routes.put(key, record)
|
|
480
|
+
|
|
481
|
+
// 6. Stream into the card and hand the message to the driver.
|
|
482
|
+
this.streamer?.attach(handle.agent.session, sent.messageId, msg.messageId, typingId)
|
|
483
|
+
handle.agent.followup(feishuUserMessage(content))
|
|
484
|
+
return record
|
|
485
|
+
}
|
|
486
|
+
private async continueSession(msg: NormalizedMessage, record: RouteRecord, content: string): Promise<void> {
|
|
487
|
+
// Defence in depth: every current caller checks this first, but a future
|
|
488
|
+
// call path must not be able to continue another user's Session.
|
|
489
|
+
if (!this.access.ownsRoute(msg, record)) return
|
|
490
|
+
// Files staged before this conversation's topic existed follow its next
|
|
491
|
+
// turn (for example a file sent in the chat before the first prompt).
|
|
492
|
+
await adoptStagedAttachments(
|
|
493
|
+
stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
|
|
494
|
+
record.workspacePath,
|
|
495
|
+
).catch((error: unknown) => {
|
|
496
|
+
this.ctx.logger?.warn?.('dsh-channels: adopting staged attachments failed: %s', String(error))
|
|
497
|
+
})
|
|
498
|
+
const typingPromise = this.delivery.requireChannel().addReaction(msg.messageId, 'Typing').catch(() => undefined)
|
|
499
|
+
const live = await this.sessions.ensureLive(record)
|
|
500
|
+
|
|
501
|
+
const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(record.sessionId))
|
|
502
|
+
const typingId = await typingPromise
|
|
503
|
+
|
|
504
|
+
// Repair snapshot first — same crash-window reasoning as `startSession`.
|
|
505
|
+
await this.repair.write({
|
|
506
|
+
key: sent.messageId,
|
|
507
|
+
sessionId: record.sessionId,
|
|
508
|
+
chatId: msg.chatId,
|
|
509
|
+
triggerMessageId: msg.messageId,
|
|
510
|
+
reactionId: typingId,
|
|
511
|
+
cards: [{ messageId: sent.messageId, mode: 'card' }],
|
|
512
|
+
startedAt: Date.now(),
|
|
513
|
+
attempts: 0,
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
await this.routes.put(record.key, {
|
|
517
|
+
...record,
|
|
518
|
+
cardMessageId: sent.messageId,
|
|
519
|
+
lastActivityAt: Date.now(),
|
|
520
|
+
})
|
|
521
|
+
this.streamer?.attach(live.agent.session, sent.messageId, msg.messageId, typingId)
|
|
522
|
+
live.agent.followup(feishuUserMessage(content))
|
|
523
|
+
}
|
|
524
|
+
}
|