@modesto_ma/dsh-channels 0.5.0-beta.2 → 0.5.0-beta.4

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 (46) hide show
  1. package/README.md +72 -15
  2. package/docs/attachment-pipeline-v2.md +2 -2
  3. package/docs/feishu-onboarding.md +4 -3
  4. package/lib/client.js +369 -94
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +7847 -5293
  7. package/lib/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/access/controller.ts +32 -1
  10. package/src/analyze/contract.ts +121 -0
  11. package/src/analyze/launcher.ts +353 -0
  12. package/src/analyze/server.ts +201 -0
  13. package/src/cards/analyze.ts +116 -0
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/streaming.ts +58 -0
  16. package/src/client/api.ts +15 -0
  17. package/src/client/panel/access-card.tsx +96 -70
  18. package/src/client/panel/analyze-token-card.tsx +70 -0
  19. package/src/client/panel/index.tsx +72 -10
  20. package/src/client/panel/sessions-tab.tsx +7 -4
  21. package/src/client/panel/shared.tsx +12 -0
  22. package/src/client/panel/wiki-tab.tsx +62 -0
  23. package/src/config.ts +52 -14
  24. package/src/domain.ts +55 -0
  25. package/src/feishu/scopes.ts +19 -1
  26. package/src/feishu/tools.ts +3 -1
  27. package/src/feishu/wiki/a1.ts +97 -0
  28. package/src/feishu/wiki/api.ts +21 -6
  29. package/src/feishu/wiki/base-api.ts +267 -0
  30. package/src/feishu/wiki/base-tools.ts +423 -0
  31. package/src/feishu/wiki/shared.ts +284 -0
  32. package/src/feishu/wiki/sheet-api.ts +250 -0
  33. package/src/feishu/wiki/sheet-tools.ts +493 -0
  34. package/src/feishu/wiki/tools.ts +102 -189
  35. package/src/feishu-channel.ts +14 -1
  36. package/src/health.ts +12 -0
  37. package/src/host/agent.ts +5 -1
  38. package/src/index.ts +1 -1
  39. package/src/interactions/pending.ts +11 -2
  40. package/src/routing/inbound.ts +57 -14
  41. package/src/service.ts +161 -28
  42. package/src/settings/contract.ts +37 -6
  43. package/src/settings/routes.ts +66 -14
  44. package/src/settings/surface.ts +69 -20
  45. package/src/streamer.ts +156 -7
  46. package/src/workspaces.ts +31 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modesto_ma/dsh-channels",
3
- "version": "0.5.0-beta.2",
3
+ "version": "0.5.0-beta.4",
4
4
  "description": "DeepSeek Harness (DSH) 飞书渠道插件:一个 dsh 实例对应一个飞书机器人,私聊/群聊/话题群 @ 触发会话,话题绑定 session,卡片 2.0 流式回复;Web 设置里提供「飞书渠道」状态与常用配置面板",
5
5
  "repository": {
6
6
  "type": "git",
@@ -156,7 +156,38 @@ export class AccessController {
156
156
  * execution inside someone else's topic.
157
157
  */
158
158
  ownsRoute(msg: NormalizedMessage, record: RouteRecord): boolean {
159
- return record.senderUserId === msg.senderId && record.chatId === msg.chatId
159
+ return this.mayActOnRoute(msg.senderId, msg.chatId, record)
160
+ }
161
+
162
+ /**
163
+ * Whether `userId`, acting in `chatId`, may continue or decide for the
164
+ * route's Session: the route owner, or, on a group-shared route
165
+ * (`record.shared`), any user the admission matrix allows in that chat.
166
+ * @param userId - Feishu user_id of the actor.
167
+ * @param chatId - chat the action comes from (must be the route's chat).
168
+ * @param record - the route acted on.
169
+ * @returns true when the action is allowed.
170
+ */
171
+ mayActOnRoute(userId: string | undefined, chatId: string, record: RouteRecord): boolean {
172
+ if (userId === undefined || record.chatId !== chatId) return false
173
+ if (record.senderUserId === userId) return true
174
+ if (record.shared !== true) return false
175
+ return decideAccess(
176
+ { chatType: record.chatType, chatId, senderId: userId },
177
+ this.config(),
178
+ this.deps.store.snapshot(),
179
+ ).kind === 'allow'
180
+ }
181
+
182
+ /**
183
+ * Whether the analyze API may post into `chatId`: an authorized group, or
184
+ * any group in `open` access mode.
185
+ * @param chatId - target group chat id.
186
+ * @returns true when the chat is a permitted target.
187
+ */
188
+ mayAnalyzeIn(chatId: string): boolean {
189
+ if (this.config().accessMode === 'open') return true
190
+ return this.deps.store.snapshot().groups.has(chatId)
160
191
  }
161
192
 
162
193
  /** Drop in-memory notices (shutdown; durable state lives in the store). */
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Wire contract of the log-platform analyze API.
3
+ *
4
+ * `POST /dsh-channels/analyze` on the dedicated `analyzeListen` port, with
5
+ * `Authorization: Bearer <token>`, an `Idempotency-Key` header, and a JSON
6
+ * body naming the requester, the trigger time, the subject (application +
7
+ * error code), the log-platform detail pages, the target group, and the
8
+ * prompt. A valid request posts the analysis root card into the group and
9
+ * answers `202`; the analysis then streams in a topic below that card. Log
10
+ * packages are the prompt's business: the analysis skill fetches whatever the
11
+ * prompt names, and the detail pages are only listed on the card.
12
+ *
13
+ * @module dsh-channels/analyze/contract
14
+ */
15
+
16
+ import { createHash } from 'node:crypto'
17
+ import { z } from 'zod'
18
+ import { FEISHU_USER_ID_PATTERN } from '../domain.ts'
19
+
20
+ /** The only path the analyze listener serves. */
21
+ export const ANALYZE_ROUTE = '/dsh-channels/analyze'
22
+
23
+ /** Largest accepted request body (the prompt dominates it). */
24
+ export const ANALYZE_MAX_BODY_BYTES = 1024 * 1024
25
+
26
+ /** Largest accepted prompt, in characters. */
27
+ export const ANALYZE_MAX_PROMPT_CHARS = 100_000
28
+
29
+ /**
30
+ * `Idempotency-Key` format: 1–200 visible ASCII characters without spaces, so
31
+ * a platform can use a UUID or its own `type:id` token.
32
+ */
33
+ export const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7E]{1,200}$/u
34
+
35
+ /**
36
+ * Application / error-code segment: it names a workspace directory
37
+ * (`<baseDir>/analyze/<app>/<errorCode>`), so only path-safe characters pass.
38
+ */
39
+ const SUBJECT_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/u
40
+
41
+ /** Most detail pages one request may list on its root card. */
42
+ export const ANALYZE_MAX_LOG_DETAILS = 50
43
+
44
+ /** Feishu group chat id (`oc_…`). */
45
+ const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,128}$/u
46
+
47
+ /** JSON body of one analyze request. */
48
+ export const analyzeRequestSchema = z.object({
49
+ /** Feishu user_id of the person who started the analysis on the platform. */
50
+ requesterUserId: z.string().regex(FEISHU_USER_ID_PATTERN, 'must be a Feishu user_id').max(128),
51
+ /** When the analysis was started on the platform, shown verbatim on the root card. */
52
+ triggeredAt: z.string().min(1).max(64).refine(value => value.trim() !== '', 'must not be blank'),
53
+ /** Application the logs belong to (workspace directory segment). */
54
+ app: z.string().regex(SUBJECT_SEGMENT, 'must be 1-128 of A-Z a-z 0-9 . _ -'),
55
+ /** Error code of the logs (workspace directory segment). */
56
+ errorCode: z.string().regex(SUBJECT_SEGMENT, 'must be 1-128 of A-Z a-z 0-9 . _ -'),
57
+ /** Log-platform detail pages of the logs that triggered the analysis (listed on the root card). */
58
+ logDetails: z.array(z.object({
59
+ /** Row label. */
60
+ name: z.string().min(1).max(200).refine(value => value.trim() !== '', 'must not be blank'),
61
+ /** Detail page address. */
62
+ url: z.url({ protocol: /^https?$/u, error: 'must be an http(s) URL' }).max(2048),
63
+ }).strict()).min(1).max(ANALYZE_MAX_LOG_DETAILS),
64
+ /** Target group chat (`oc_…`); must be an authorized group. */
65
+ chatId: z.string().regex(CHAT_ID, 'must be a Feishu group chat_id (oc_...)'),
66
+ /** Prompt forwarded verbatim as the analysis Session's first user message. */
67
+ prompt: z.string().min(1).max(ANALYZE_MAX_PROMPT_CHARS)
68
+ .refine(value => value.trim() !== '', 'must not be blank'),
69
+ }).strict()
70
+
71
+ export type AnalyzeRequest = z.infer<typeof analyzeRequestSchema>
72
+
73
+ /** Body of an accepted (or replayed) request. */
74
+ export interface AnalyzeAccepted {
75
+ readonly ok: true
76
+ /** Target group chat id. */
77
+ readonly chatId: string
78
+ /** Root card message id; the analysis topic hangs below it. */
79
+ readonly messageId: string
80
+ }
81
+
82
+ /** Body of a refused request. */
83
+ export interface AnalyzeRefused {
84
+ readonly ok: false
85
+ /** Readable reason (Chinese, shown to platform operators). */
86
+ readonly error: string
87
+ }
88
+
89
+ /** One HTTP answer of the analyze handler. */
90
+ export interface AnalyzeResponse {
91
+ readonly status: number
92
+ readonly body: AnalyzeAccepted | AnalyzeRefused
93
+ }
94
+
95
+ /**
96
+ * Fingerprint of one validated request, used to tell a retry of the same
97
+ * request from a reused `Idempotency-Key`. Field order is fixed.
98
+ * @param request - the validated request.
99
+ * @returns SHA-256 hex digest.
100
+ */
101
+ export function analyzeRequestHash(request: AnalyzeRequest): string {
102
+ const canonical = JSON.stringify([
103
+ request.requesterUserId, request.triggeredAt, request.app, request.errorCode,
104
+ request.logDetails.map(detail => [detail.name, detail.url]), request.chatId, request.prompt,
105
+ ])
106
+ return createHash('sha256').update(canonical).digest('hex')
107
+ }
108
+
109
+ /**
110
+ * Validate one decoded JSON body.
111
+ * @param raw - the decoded body.
112
+ * @returns the request, or a readable list of field errors.
113
+ */
114
+ export function parseAnalyzeRequest(raw: unknown): { ok: true; value: AnalyzeRequest } | { ok: false; error: string } {
115
+ const parsed = analyzeRequestSchema.safeParse(raw)
116
+ if (parsed.success) return { ok: true, value: parsed.data }
117
+ const details = parsed.error.issues
118
+ .map(issue => `${issue.path.join('.') || '(body)'}: ${issue.message}`)
119
+ .join('; ')
120
+ return { ok: false, error: `请求参数不合法:${details}` }
121
+ }
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Log-platform analysis launcher: the business side of the analyze API.
3
+ *
4
+ * Synchronously (before the `202`): service state, target-group permission,
5
+ * `Idempotency-Key` replay, and the root card (分析中). In the background: the
6
+ * topic (opened by the first streaming card, replied in thread below the root
7
+ * card), the `<baseDir>/analyze/<app>/<errorCode>` workspace and its per-topic
8
+ * directory, the Session, the group-shared route, and the first turn: the
9
+ * platform prompt verbatim, streamed into that card like any topic turn. When
10
+ * the first turn settles, the root card's status follows it (已完成 / 已停止 /
11
+ * 失败), and the request record keeps the same status. At channel start,
12
+ * records still `running` belong to analyses a previous instance never
13
+ * finished; {@link AnalyzeLauncher.markInterrupted} marks their cards 已中断.
14
+ * Log packages are fetched by the analysis skill the prompt invokes.
15
+ *
16
+ * The topic is reserved in the inbound router from its creation until the
17
+ * route and the first prompt are published, so replies posted meanwhile queue
18
+ * behind the first turn. Follow-ups then use the ordinary topic routing: the
19
+ * route is `shared`, so every member admitted in the group may continue it. A
20
+ * failure before the first turn (including a disconnect meanwhile) marks the
21
+ * root card failed with the reason, closes the streaming card, and leaves no
22
+ * route.
23
+ *
24
+ * @module dsh-channels/analyze/launcher
25
+ */
26
+
27
+ import { randomUUID } from 'node:crypto'
28
+ import type { AgentHandle, HostContext, SessionId } from '../dsh-types.ts'
29
+ import type { FeishuConfig } from '../config.ts'
30
+ import { buildAnalyzeRootCard, buildCard, initialStreamingCard } from '../cards.ts'
31
+ import type { AnalyzeStatus, AnalyzeSubject } from '../cards.ts'
32
+ import type { RouteOperationQueue } from '../commands.ts'
33
+ import type { AnalyzeRequestRecord, FeishuDomainHandle, RouteRecord } from '../domain.ts'
34
+ import type { FeishuChannel } from '../feishu-channel.ts'
35
+ import { feishuUserMessage } from '../routing/inbound.ts'
36
+ import { enterTopic, placementServices, planTopic } from '../session/isolation.ts'
37
+ import type { TopicPlacement } from '../session/isolation.ts'
38
+ import type { SessionManager } from '../session/manager.ts'
39
+ import type { InflightRepair } from '../session/repair.ts'
40
+ import type { RouteStore } from '../session/routes.ts'
41
+ import type { TurnStreamer } from '../streamer.ts'
42
+ import { ensureAnalyzeWorkspace, topicTokenFor } from '../workspaces.ts'
43
+ import { analyzeRequestHash } from './contract.ts'
44
+ import type { AnalyzeAccepted, AnalyzeRequest, AnalyzeResponse } from './contract.ts'
45
+ import type { AnalyzeHandler } from './server.ts'
46
+
47
+ /** Collaborators of the launcher (late-bound where the service swaps them per connection). */
48
+ export interface AnalyzeLauncherDeps {
49
+ ctx: HostContext
50
+ config: FeishuConfig
51
+ started(): boolean
52
+ channel(): FeishuChannel | undefined
53
+ domain(): FeishuDomainHandle | undefined
54
+ routes: RouteStore
55
+ repair: InflightRepair
56
+ sessions: SessionManager
57
+ streamer(): TurnStreamer | undefined
58
+ /** Shared per-route FIFO (the first turn must precede any follow-up). */
59
+ queue: RouteOperationQueue
60
+ /**
61
+ * Reserve an opened topic until its route is published, so replies posted
62
+ * meanwhile wait instead of being dropped or starting a competing Session.
63
+ * The returned closer is called exactly once.
64
+ */
65
+ reserveTopic(threadId: string): () => void
66
+ /** Whether the analyze API may post into the group. */
67
+ mayAnalyzeIn(chatId: string): boolean
68
+ /** Resolve the configured bearer token; undefined when unresolvable. */
69
+ resolveToken(): Promise<string | undefined>
70
+ }
71
+
72
+ /** One accepted request still answering its `202`. */
73
+ interface PendingAccept {
74
+ readonly bodyHash: string
75
+ readonly response: Promise<AnalyzeResponse>
76
+ }
77
+
78
+ /** Readable reason of an error value. */
79
+ function messageOf(error: unknown): string {
80
+ return error instanceof Error ? error.message : String(error)
81
+ }
82
+
83
+ export class AnalyzeLauncher implements AnalyzeHandler {
84
+ /** `Idempotency-Key` → request still being accepted (concurrent retries share it). */
85
+ private readonly accepting = new Map<string, PendingAccept>()
86
+ /** Background analyses still starting (drained on shutdown). */
87
+ private readonly running = new Set<Promise<void>>()
88
+
89
+ constructor(private readonly deps: AnalyzeLauncherDeps) {}
90
+
91
+ async token(): Promise<string | undefined> {
92
+ return await this.deps.resolveToken()
93
+ }
94
+
95
+ async analyze(request: AnalyzeRequest, idempotencyKey: string): Promise<AnalyzeResponse> {
96
+ const bodyHash = analyzeRequestHash(request)
97
+ const pending = this.accepting.get(idempotencyKey)
98
+ if (pending !== undefined) {
99
+ if (pending.bodyHash !== bodyHash) return reusedKey()
100
+ return await pending.response
101
+ }
102
+ const response = this.accept(request, idempotencyKey, bodyHash)
103
+ this.accepting.set(idempotencyKey, { bodyHash, response })
104
+ try {
105
+ return await response
106
+ } finally {
107
+ this.accepting.delete(idempotencyKey)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Wait for every background analysis that is still starting. Called after
113
+ * `started` turns false, so each one ends on its failure card or, when its
114
+ * first prompt was already sent, as an ordinary topic.
115
+ */
116
+ async drain(): Promise<void> {
117
+ await Promise.allSettled([...this.running])
118
+ }
119
+
120
+ /**
121
+ * Mark every analysis still `running` as interrupted, on its card and in
122
+ * its record. Called at channel start before the listener opens, so each
123
+ * such record belongs to a previous instance whose first turn never
124
+ * settled. A card that cannot be updated (deleted, recalled) is logged and
125
+ * still recorded as interrupted, so it is not retried on every start.
126
+ */
127
+ async markInterrupted(): Promise<void> {
128
+ const channel = this.deps.channel()
129
+ const domain = this.deps.domain()
130
+ if (channel === undefined || domain === undefined) return
131
+ for (const [key, record] of [...domain.analyzeRequests.entries()]) {
132
+ if (record.status !== 'running') continue
133
+ await channel.updateCard(record.messageId, buildAnalyzeRootCard(record.subject, { kind: 'interrupted' }))
134
+ .catch((error: unknown) => {
135
+ this.deps.ctx.logger?.warn?.('dsh-channels: marking analysis card %s interrupted failed: %s', key, String(error))
136
+ })
137
+ await this.recordStatus(key, 'interrupted')
138
+ }
139
+ }
140
+
141
+ private async accept(request: AnalyzeRequest, key: string, bodyHash: string): Promise<AnalyzeResponse> {
142
+ const channel = this.deps.channel()
143
+ const domain = this.deps.domain()
144
+ if (!this.deps.started() || channel === undefined || domain === undefined) {
145
+ return refused(503, '飞书渠道尚未连接,请稍后重试')
146
+ }
147
+ const stored = domain.analyzeRequests.get(key)
148
+ if (stored !== undefined) {
149
+ if (stored.bodyHash !== bodyHash) return reusedKey()
150
+ return { status: 202, body: { ok: true, chatId: stored.chatId, messageId: stored.messageId } }
151
+ }
152
+ if (!this.deps.mayAnalyzeIn(request.chatId)) {
153
+ return refused(403, '目标群未授权:请管理员在该群发送 /auth group add,或在设置面板「访问与授权」中添加')
154
+ }
155
+ const subject = subjectOf(request)
156
+ let rootId: string
157
+ try {
158
+ rootId = (await channel.send(request.chatId, {
159
+ card: buildAnalyzeRootCard(subject, { kind: 'running' }),
160
+ })).messageId
161
+ } catch (error) {
162
+ return refused(502, `分析卡片发送失败(机器人需在目标群内):${messageOf(error)}`)
163
+ }
164
+ const body: AnalyzeAccepted = { ok: true, chatId: request.chatId, messageId: rootId }
165
+ try {
166
+ await domain.analyzeRequests.put(key, {
167
+ key, bodyHash, chatId: request.chatId, messageId: rootId, acceptedAt: Date.now(),
168
+ subject: { ...subject, logDetails: [...subject.logDetails] }, status: 'running',
169
+ })
170
+ } catch (error) {
171
+ // The card is visible already; a lost record only means a retry of this
172
+ // key would post a second analysis.
173
+ this.deps.ctx.logger?.warn?.('dsh-channels: recording analyze request %s failed: %s', key, String(error))
174
+ }
175
+ const run = this.launch(channel, request, subject, rootId, key)
176
+ this.running.add(run)
177
+ void run.finally(() => { this.running.delete(run) })
178
+ return { status: 202, body }
179
+ }
180
+
181
+ /** Start the analysis below an accepted root card; every failure ends on the cards. */
182
+ private async launch(
183
+ channel: FeishuChannel,
184
+ request: AnalyzeRequest,
185
+ subject: AnalyzeSubject,
186
+ rootId: string,
187
+ key: string,
188
+ ): Promise<void> {
189
+ const warn = (message: string, ...args: unknown[]): void => {
190
+ this.deps.ctx.logger?.warn?.(message, ...args)
191
+ }
192
+ const showStatus = async (status: AnalyzeStatus): Promise<void> => {
193
+ await channel.updateCard(rootId, buildAnalyzeRootCard(subject, status)).catch((error: unknown) => {
194
+ warn('dsh-channels: updating analysis card %s failed: %s', rootId, String(error))
195
+ })
196
+ await this.recordStatus(key, status.kind)
197
+ }
198
+ const sessionId = randomUUID() as SessionId
199
+ let handle: AgentHandle | undefined
200
+ let cardId: string | undefined
201
+ let threadId: string | undefined
202
+ let releaseTopic: (() => void) | undefined
203
+ let published = false
204
+ try {
205
+ const topic = await this.openTopic(channel, request.chatId, rootId, sessionId)
206
+ cardId = topic.cardId
207
+ threadId = topic.threadId
208
+ releaseTopic = this.deps.reserveTopic(threadId)
209
+ const services = placementServices(this.deps.ctx)
210
+ const workspace = await ensureAnalyzeWorkspace(
211
+ this.deps.ctx, this.deps.config.baseDir, request.app, request.errorCode,
212
+ )
213
+ const topicToken = topicTokenFor(threadId, 'thread', sessionId)
214
+ const plan = await planTopic(services, this.deps.config, workspace.path, topicToken, warn)
215
+ this.assertStarted()
216
+ handle = await this.deps.sessions.createSession(sessionId, plan.cwd)
217
+ const placement: TopicPlacement = await enterTopic(
218
+ services, handle.agent, plan, workspace.path, topicToken, warn,
219
+ )
220
+ if (plan.cwd === workspace.path) {
221
+ void Promise.resolve(workspace.attachSession(sessionId)).catch((error: unknown) => {
222
+ warn('dsh-channels: attaching analysis session %s to workspace failed: %s', sessionId, String(error))
223
+ })
224
+ }
225
+ await this.deps.sessions.mountGuidance(handle.agent, placement.sessionDir)
226
+ this.assertStarted()
227
+
228
+ const createdAt = handle.agent.session.header.createdAt
229
+ const record: RouteRecord = {
230
+ key: threadId,
231
+ kind: 'thread',
232
+ sessionId,
233
+ chatId: request.chatId,
234
+ chatType: 'group',
235
+ senderUserId: request.requesterUserId,
236
+ workspacePath: workspace.path,
237
+ sessionDir: placement.sessionDir,
238
+ ...(placement.worktreeBranch === undefined ? {} : { worktreeBranch: placement.worktreeBranch }),
239
+ cardMessageId: cardId,
240
+ createdAt,
241
+ lastActivityAt: createdAt,
242
+ sessionHistory: [{ sessionId, createdAt }],
243
+ shared: true,
244
+ }
245
+ await this.deps.repair.write({
246
+ key: cardId,
247
+ sessionId,
248
+ chatId: request.chatId,
249
+ triggerMessageId: rootId,
250
+ cards: [{ messageId: cardId, mode: 'card' }],
251
+ startedAt: Date.now(),
252
+ attempts: 0,
253
+ })
254
+ const agent = handle.agent
255
+ const routeKey = threadId
256
+ const streamCardId = cardId
257
+ // Route, stream binding, and prompt land in one queue slot, so a reply
258
+ // posted in the topic meanwhile is delivered after the first turn.
259
+ await this.deps.queue.run(routeKey, async () => {
260
+ this.assertStarted()
261
+ await this.deps.routes.put(routeKey, record)
262
+ published = true
263
+ this.deps.streamer()?.attach(agent.session, streamCardId, rootId, undefined, {
264
+ onSettled: outcome => { void showStatus({ kind: outcome }) },
265
+ })
266
+ agent.followup(feishuUserMessage(request.prompt))
267
+ })
268
+ } catch (error) {
269
+ const reason = messageOf(error)
270
+ warn('dsh-channels: analysis for %s/%s failed to start: %s', request.app, request.errorCode, reason)
271
+ if (published && threadId !== undefined) {
272
+ await this.deps.routes.remove(threadId).catch((removeError: unknown) => {
273
+ warn('dsh-channels: removing analysis route %s failed: %s', threadId, String(removeError))
274
+ })
275
+ }
276
+ await handle?.dispose().catch(() => {})
277
+ if (cardId !== undefined) {
278
+ void this.deps.repair.clear(cardId)
279
+ await channel.updateCard(cardId, buildCard({
280
+ phase: 'error',
281
+ reasoning: '',
282
+ body: `❌ 分析未能开始:${reason}`,
283
+ stats: { sessionId, inputTokens: 0, outputTokens: 0 },
284
+ })).catch(() => {})
285
+ }
286
+ await showStatus({ kind: 'failed', reason })
287
+ } finally {
288
+ releaseTopic?.()
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Open the analysis topic: the first streaming card, replied in thread below
294
+ * the root card. The topic id is read from that card (or the root card, in
295
+ * topic groups).
296
+ */
297
+ private async openTopic(
298
+ channel: FeishuChannel,
299
+ chatId: string,
300
+ rootId: string,
301
+ sessionId: SessionId,
302
+ ): Promise<{ cardId: string; threadId: string }> {
303
+ const sent = await channel.send(chatId, { card: initialStreamingCard(sessionId) }, {
304
+ replyTo: rootId, replyInThread: true,
305
+ })
306
+ const threadId = await channel.threadIdOfMessage(sent.messageId).catch(() => undefined)
307
+ ?? await channel.threadIdOfMessage(rootId).catch(() => undefined)
308
+ if (threadId === undefined) {
309
+ await channel.updateCard(sent.messageId, buildCard({
310
+ phase: 'error',
311
+ reasoning: '',
312
+ body: '❌ 无法在目标群创建话题',
313
+ stats: { sessionId, inputTokens: 0, outputTokens: 0 },
314
+ })).catch(() => {})
315
+ throw new Error('无法在目标群创建话题(读取话题 id 失败)')
316
+ }
317
+ return { cardId: sent.messageId, threadId }
318
+ }
319
+
320
+ /** Store a root card status in the request record (best-effort; the card already shows it). */
321
+ private async recordStatus(key: string, status: AnalyzeRequestRecord['status']): Promise<void> {
322
+ const domain = this.deps.domain()
323
+ const record = domain?.analyzeRequests.get(key)
324
+ if (domain === undefined || record === undefined) return
325
+ try {
326
+ await domain.analyzeRequests.put(key, { ...record, status })
327
+ } catch (error) {
328
+ this.deps.ctx.logger?.warn?.('dsh-channels: recording analysis %s status failed: %s', key, String(error))
329
+ }
330
+ }
331
+
332
+ private assertStarted(): void {
333
+ if (!this.deps.started()) throw new Error('飞书渠道已断开,分析已取消')
334
+ }
335
+ }
336
+
337
+ function subjectOf(request: AnalyzeRequest): AnalyzeSubject {
338
+ return {
339
+ requesterUserId: request.requesterUserId,
340
+ triggeredAt: request.triggeredAt,
341
+ app: request.app,
342
+ errorCode: request.errorCode,
343
+ logDetails: request.logDetails,
344
+ }
345
+ }
346
+
347
+ function refused(status: number, error: string): AnalyzeResponse {
348
+ return { status, body: { ok: false, error } }
349
+ }
350
+
351
+ function reusedKey(): AnalyzeResponse {
352
+ return refused(409, 'Idempotency-Key 已被内容不同的请求使用')
353
+ }