@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.
- package/README.md +72 -15
- package/docs/attachment-pipeline-v2.md +2 -2
- package/docs/feishu-onboarding.md +4 -3
- package/lib/client.js +369 -94
- package/lib/client.js.map +1 -1
- package/lib/index.js +7847 -5293
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/controller.ts +32 -1
- package/src/analyze/contract.ts +121 -0
- package/src/analyze/launcher.ts +353 -0
- package/src/analyze/server.ts +201 -0
- package/src/cards/analyze.ts +116 -0
- package/src/cards/index.ts +1 -0
- package/src/cards/streaming.ts +58 -0
- package/src/client/api.ts +15 -0
- package/src/client/panel/access-card.tsx +96 -70
- package/src/client/panel/analyze-token-card.tsx +70 -0
- package/src/client/panel/index.tsx +72 -10
- package/src/client/panel/sessions-tab.tsx +7 -4
- package/src/client/panel/shared.tsx +12 -0
- package/src/client/panel/wiki-tab.tsx +62 -0
- package/src/config.ts +52 -14
- package/src/domain.ts +55 -0
- package/src/feishu/scopes.ts +19 -1
- package/src/feishu/tools.ts +3 -1
- package/src/feishu/wiki/a1.ts +97 -0
- package/src/feishu/wiki/api.ts +21 -6
- package/src/feishu/wiki/base-api.ts +267 -0
- package/src/feishu/wiki/base-tools.ts +423 -0
- package/src/feishu/wiki/shared.ts +284 -0
- package/src/feishu/wiki/sheet-api.ts +250 -0
- package/src/feishu/wiki/sheet-tools.ts +493 -0
- package/src/feishu/wiki/tools.ts +102 -189
- package/src/feishu-channel.ts +14 -1
- package/src/health.ts +12 -0
- package/src/host/agent.ts +5 -1
- package/src/index.ts +1 -1
- package/src/interactions/pending.ts +11 -2
- package/src/routing/inbound.ts +57 -14
- package/src/service.ts +161 -28
- package/src/settings/contract.ts +37 -6
- package/src/settings/routes.ts +66 -14
- package/src/settings/surface.ts +69 -20
- package/src/streamer.ts +156 -7
- 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.
|
|
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",
|
package/src/access/controller.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|