@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.
Files changed (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Thin wrapper over the official SDK's `createLarkChannel` high-level module.
3
+ *
4
+ * The wrapper exists to pin the exact surface the plugin uses behind one
5
+ * interface so the unit tests can stub the official SDK wholesale (no
6
+ * network, no creds).
7
+ *
8
+ * @module dsh-channels/feishu-channel
9
+ */
10
+
11
+ import { createLarkChannel, LoggerLevel } from '@larksuiteoapi/node-sdk'
12
+ import type {
13
+ BotAddedEvent,
14
+ ChatInfo,
15
+ LarkChannel,
16
+ NormalizedMessage,
17
+ SendResult,
18
+ WSConnectionStatus,
19
+ } from '@larksuiteoapi/node-sdk'
20
+
21
+ export type { BotAddedEvent, ChatInfo, NormalizedMessage, SendResult } from '@larksuiteoapi/node-sdk'
22
+ export type FeishuConnectionStatus = WSConnectionStatus
23
+
24
+ /** Outbound payloads used by this plugin, including Buffer-backed file upload. */
25
+ export type FeishuSendInput =
26
+ | { card: object }
27
+ | { text: string }
28
+ | { file: { source: string | Buffer; fileName: string } }
29
+
30
+ /**
31
+ * One normalized card action (button click) event, as delivered by the
32
+ * official SDK's `card.action.trigger` handler (card JSON 2.0 callback
33
+ * behaviors). `messageId` is the card's own message id; `action.value` is the
34
+ * callback value object the button carried (SDK contract: object values).
35
+ */
36
+ export interface CardActionEvent {
37
+ /** Message id of the card the action belongs to. */
38
+ messageId: string
39
+ chatId: string
40
+ operator: { openId: string; userId?: string; name?: string }
41
+ action: { value: unknown; tag: string; name?: string; option?: string }
42
+ raw?: unknown
43
+ }
44
+
45
+ /** The minimal channel surface the plugin consumes. */
46
+ export interface FeishuChannel {
47
+ /** Bound handler for normalized inbound messages. */
48
+ onMessage(handler: (message: NormalizedMessage) => void | Promise<void>): () => void
49
+ /** Bound handler for card button-callback events. */
50
+ onCardAction(handler: (event: CardActionEvent) => void | Promise<void>): () => void
51
+ /**
52
+ * Bound handler for bot-added-to-chat events (`im.chat.member.bot.added_v1`).
53
+ * Requires the app to subscribe that event; without it the plugin falls
54
+ * back to the first denied @-mention for group information cards.
55
+ */
56
+ onBotAdded(handler: (event: BotAddedEvent) => void | Promise<void>): () => void
57
+ /** Chat name/owner/member count (needs chat-info read permission). */
58
+ getChatInfo(chatId: string): Promise<ChatInfo>
59
+ /** Bot open_id, available after `connect()` resolves. */
60
+ botOpenId(): string | undefined
61
+ /** Current official-SDK WebSocket lifecycle snapshot, when initialized. */
62
+ getConnectionStatus(): FeishuConnectionStatus | undefined
63
+ /** Start the WebSocket long connection (resolves after the handshake). */
64
+ connect(): Promise<void>
65
+ /** Stop the connection. */
66
+ disconnect(): Promise<void>
67
+ /** Send one message (text or card) with optional reply targeting. */
68
+ send(
69
+ chatId: string,
70
+ input: FeishuSendInput,
71
+ options?: { replyTo?: string; replyInThread?: boolean },
72
+ ): Promise<SendResult>
73
+ /** Full-card replacement of a previously sent card message. */
74
+ updateCard(messageId: string, card: object): Promise<void>
75
+ /** Add an emoji reaction; resolves with the reaction id needed for removal. */
76
+ addReaction(messageId: string, emojiType: string): Promise<string>
77
+ /** Remove a reaction previously added by the bot. */
78
+ removeReaction(messageId: string, reactionId: string): Promise<void>
79
+ /** Fetch the thread id of a sent message (undefined when not a topic message). */
80
+ threadIdOfMessage(messageId: string): Promise<string | undefined>
81
+ /**
82
+ * Stream one inbound message resource. Files and images share the same
83
+ * download endpoint; only the `type` query differs. The stream is bounded
84
+ * chunk-by-chunk so arbitrarily large files never have to fit in memory.
85
+ */
86
+ downloadResource(
87
+ messageId: string,
88
+ fileKey: string,
89
+ type: 'image' | 'file',
90
+ ): Promise<ResourceChunkStream>
91
+ }
92
+
93
+ /** Ordered byte chunks of one inbound resource. */
94
+ export type ResourceChunkStream = AsyncIterable<Uint8Array>
95
+
96
+ /**
97
+ * Chunk size for ranged file downloads. Feishu's message-resource endpoint
98
+ * rejects a single response larger than 100 MB (error 234037), but honors
99
+ * bounded Range requests, so large files are fetched in independent chunks.
100
+ */
101
+ export const RANGE_DOWNLOAD_CHUNK_BYTES = 32 * 1024 * 1024
102
+
103
+ /** Total object size from a `Content-Range: bytes start-end/total` header. */
104
+ export function contentRangeTotal(headers: unknown): number | undefined {
105
+ const value = headerValue(headers, 'content-range')
106
+ if (value === undefined) return undefined
107
+ const match = /\/(\d+)\s*$/u.exec(value)
108
+ if (match === null) return undefined
109
+ const total = Number(match[1])
110
+ return Number.isSafeInteger(total) && total >= 0 ? total : undefined
111
+ }
112
+
113
+ /** Case-insensitive header lookup over a plain object or an AxiosHeaders-like getter. */
114
+ function headerValue(headers: unknown, name: string): string | undefined {
115
+ if (headers === null || typeof headers !== 'object') return undefined
116
+ const record = headers as Record<string, unknown>
117
+ const lower = name.toLowerCase()
118
+ for (const key of Object.keys(record)) {
119
+ if (key.toLowerCase() !== lower) continue
120
+ const value = record[key]
121
+ if (typeof value === 'string') return value
122
+ }
123
+ const get = (headers as { get?: (key: string) => unknown }).get
124
+ if (typeof get === 'function') {
125
+ const value = get.call(headers, name)
126
+ return typeof value === 'string' ? value : undefined
127
+ }
128
+ return undefined
129
+ }
130
+
131
+ /** One bounded range request returning the response headers and byte stream. */
132
+ export type ResourceRangeFetcher = (range: string) => Promise<{
133
+ headers?: unknown
134
+ stream: AsyncIterable<Uint8Array>
135
+ }>
136
+
137
+ /**
138
+ * Download one object through sequential bounded Range requests. The caller
139
+ * supplies the first response's total size and a fetcher for the remaining
140
+ * ranges; bytes are yielded as they arrive so the archive writes them
141
+ * straight to disk.
142
+ */
143
+ export async function* downloadResourceInRanges(
144
+ fetchRange: ResourceRangeFetcher,
145
+ totalBytes: number,
146
+ chunkBytes = RANGE_DOWNLOAD_CHUNK_BYTES,
147
+ ): AsyncGenerator<Uint8Array> {
148
+ let offset = 0
149
+ while (offset < totalBytes) {
150
+ const end = Math.min(offset + chunkBytes, totalBytes) - 1
151
+ const response = await fetchRange(`bytes=${offset}-${end}`)
152
+ let received = 0
153
+ for await (const chunk of response.stream) {
154
+ received += chunk.length
155
+ yield chunk
156
+ }
157
+ if (received === 0) {
158
+ throw new Error(`dsh-channels: ranged download stalled at byte ${offset}/${totalBytes}`)
159
+ }
160
+ offset += received
161
+ }
162
+ }
163
+
164
+ export interface ChannelConnectOptions {
165
+ appId: string
166
+ appSecret: string
167
+ loggerLevel: 'debug' | 'info' | 'warn' | 'error'
168
+ requireMention: boolean
169
+ dmMode: 'open' | 'disabled'
170
+ }
171
+
172
+ class LarkChannelAdapter implements FeishuChannel {
173
+ private readonly channel: LarkChannel
174
+ private readonly requireMention: boolean
175
+ private readonly dmMode: 'open' | 'disabled'
176
+
177
+ constructor(options: ChannelConnectOptions) {
178
+ this.channel = createLarkChannel({
179
+ appId: options.appId,
180
+ appSecret: options.appSecret,
181
+ loggerLevel: LOGGER_LEVELS[options.loggerLevel],
182
+ // Keep the raw event body: rich-text messages attach files in a
183
+ // top-level `files` array the SDK normalizer drops, so the attachment
184
+ // intake recovers them from `msg.raw` instead.
185
+ includeRawEvent: true,
186
+ policy: {
187
+ // The plugin routes mentions itself (thread routing must work without
188
+ // mention), so the SDK policy layer stays permissive.
189
+ requireMention: false,
190
+ dmMode: options.dmMode === 'open' ? 'open' : 'disabled',
191
+ respondToMentionAll: false,
192
+ },
193
+ })
194
+ this.requireMention = options.requireMention
195
+ this.dmMode = options.dmMode
196
+ }
197
+
198
+ onMessage(handler: (message: NormalizedMessage) => void | Promise<void>): () => void {
199
+ return this.channel.on('message', handler)
200
+ }
201
+
202
+ onCardAction(handler: (event: CardActionEvent) => void | Promise<void>): () => void {
203
+ return this.channel.on('cardAction', handler)
204
+ }
205
+
206
+ onBotAdded(handler: (event: BotAddedEvent) => void | Promise<void>): () => void {
207
+ // The SDK event handler is synchronous; the plugin's handler is
208
+ // fire-and-forget (the service wraps it with its own catch/log).
209
+ return this.channel.on('botAdded', event => {
210
+ void handler(event)
211
+ })
212
+ }
213
+
214
+ async getChatInfo(chatId: string): Promise<ChatInfo> {
215
+ return this.channel.getChatInfo(chatId)
216
+ }
217
+
218
+ botOpenId(): string | undefined {
219
+ return this.channel.botIdentity?.openId
220
+ }
221
+
222
+ getConnectionStatus(): FeishuConnectionStatus | undefined {
223
+ return this.channel.getConnectionStatus()
224
+ }
225
+
226
+ async connect(): Promise<void> {
227
+ await this.channel.connect()
228
+ }
229
+
230
+ async disconnect(): Promise<void> {
231
+ await this.channel.disconnect()
232
+ }
233
+
234
+ async send(
235
+ chatId: string,
236
+ input: FeishuSendInput,
237
+ options?: { replyTo?: string; replyInThread?: boolean },
238
+ ): Promise<SendResult> {
239
+ return this.channel.send(chatId, input, options)
240
+ }
241
+
242
+ async updateCard(messageId: string, card: object): Promise<void> {
243
+ await this.channel.updateCard(messageId, card)
244
+ }
245
+
246
+ async addReaction(messageId: string, emojiType: string): Promise<string> {
247
+ return this.channel.addReaction(messageId, emojiType)
248
+ }
249
+
250
+ async removeReaction(messageId: string, reactionId: string): Promise<void> {
251
+ await this.channel.removeReaction(messageId, reactionId)
252
+ }
253
+
254
+ async threadIdOfMessage(messageId: string): Promise<string | undefined> {
255
+ const res = await this.channel.rawClient.im.v1.message.get({ path: { message_id: messageId } })
256
+ return res?.data?.items?.[0]?.thread_id
257
+ }
258
+
259
+ async downloadResource(
260
+ messageId: string,
261
+ fileKey: string,
262
+ type: 'image' | 'file',
263
+ ): Promise<ResourceChunkStream> {
264
+ if (type === 'image') {
265
+ // Images are platform-capped far below the download limit; one request.
266
+ const response = await this.rawResource(messageId, fileKey, type)
267
+ return chunkStream(response.stream)
268
+ }
269
+ // Probe one byte to learn the total size without triggering the whole-file
270
+ // size check. Feishu honors a bounded Range request even when the object
271
+ // is larger than the API's single-response limit.
272
+ const probe = await this.rawResource(messageId, fileKey, type, 'bytes=0-0')
273
+ const total = contentRangeTotal(probe.headers)
274
+ if (total === undefined) {
275
+ // The server ignored Range and returned the whole object; use it as is.
276
+ return chunkStream(probe.stream)
277
+ }
278
+ ;(probe.stream as { destroy?: () => void }).destroy?.()
279
+ if (total === 0) {
280
+ return {
281
+ async *[Symbol.asyncIterator]() { /* empty resource */ },
282
+ }
283
+ }
284
+ return downloadResourceInRanges(async range => {
285
+ const response = await this.rawResource(messageId, fileKey, type, range)
286
+ return { headers: response.headers, stream: chunkStream(response.stream) }
287
+ }, total)
288
+ }
289
+
290
+ /** One message-resource GET, with an optional Range header. */
291
+ private async rawResource(
292
+ messageId: string,
293
+ fileKey: string,
294
+ type: 'image' | 'file',
295
+ range?: string,
296
+ ): Promise<{ headers: unknown; stream: AsyncIterable<Uint8Array | Buffer> }> {
297
+ const response = await this.channel.rawClient.im.v1.messageResource.get(
298
+ { params: { type }, path: { message_id: messageId, file_key: fileKey } },
299
+ range === undefined ? undefined : { headers: { Range: range } },
300
+ ) as unknown as { getReadableStream(): AsyncIterable<Uint8Array | Buffer>; headers?: unknown }
301
+ return { headers: response.headers, stream: response.getReadableStream() }
302
+ }
303
+ }
304
+
305
+ /** Wrap one Node stream as a plain byte-chunk iterable. */
306
+ function chunkStream(source: AsyncIterable<Uint8Array | Buffer>): ResourceChunkStream {
307
+ return {
308
+ async *[Symbol.asyncIterator]() {
309
+ for await (const chunk of source) {
310
+ yield Buffer.isBuffer(chunk) ? new Uint8Array(chunk) : chunk
311
+ }
312
+ },
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Detect the raster format from magic bytes. Feishu's resource download does
318
+ * not hand back a media type, and DSH validates the declared type against the
319
+ * decoded bytes, so the plugin sniffs instead of trusting the file name.
320
+ */
321
+ export function sniffImageMediaType(data: Uint8Array): 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' | undefined {
322
+ if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4E && data[3] === 0x47) return 'image/png'
323
+ if (data.length >= 3 && data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) return 'image/jpeg'
324
+ if (data.length >= 12 && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50) return 'image/webp'
325
+ if (data.length >= 6 && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) return 'image/gif'
326
+ return undefined
327
+ }
328
+
329
+ const LOGGER_LEVELS: Record<ChannelConnectOptions['loggerLevel'], LoggerLevel> = {
330
+ debug: LoggerLevel.debug,
331
+ info: LoggerLevel.info,
332
+ warn: LoggerLevel.warn,
333
+ error: LoggerLevel.error,
334
+ }
335
+
336
+ /** Factory seam: tests substitute a stub adapter. */
337
+ export type ChannelFactory = (options: ChannelConnectOptions) => FeishuChannel
338
+
339
+ export const defaultChannelFactory: ChannelFactory = options => new LarkChannelAdapter(options)
package/src/health.ts ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Privacy-preserving health snapshot and Feishu card rendering.
3
+ *
4
+ * The fold consumes only aggregate lifecycle and service-presence signals.
5
+ * It never accepts configuration values, credentials, fiber errors, or stack
6
+ * traces, which keeps accidental disclosure out of the rendering boundary.
7
+ *
8
+ * @module dsh-channels/health
9
+ */
10
+
11
+ import { CordisFiberState } from './dsh-types.ts'
12
+ import type { CordisFiber } from './dsh-types.ts'
13
+ import type { FeishuConnectionStatus } from './feishu-channel.ts'
14
+ import { standaloneCard } from './cards/primitives.ts'
15
+
16
+ export const HEALTH_DEPENDENCY_NAMES = [
17
+ 'agents',
18
+ 'commands',
19
+ 'workspace',
20
+ 'storage',
21
+ 'credentials',
22
+ 'llm',
23
+ 'token-meter',
24
+ ] as const
25
+
26
+ export type HealthDependencyName = typeof HEALTH_DEPENDENCY_NAMES[number]
27
+ export type HealthLevel = 'healthy' | 'degraded' | 'unhealthy'
28
+
29
+ export interface FiberHealthCounts {
30
+ active: number
31
+ pending: number
32
+ loading: number
33
+ failed: number
34
+ unloading: number
35
+ }
36
+
37
+ /** States that make the Fold report something other than healthy. */
38
+ export type FiberIssueState = 'pending' | 'loading' | 'unloading' | 'failed'
39
+
40
+ /** One non-active fiber with a best-effort plugin name (no errors/stacks). */
41
+ export interface FiberHealthIssue {
42
+ state: FiberIssueState
43
+ label: string
44
+ }
45
+
46
+ /** Upper bound on the diagnostic list; counts still include every fiber. */
47
+ const MAX_INACTIVE_FIBERS = 12
48
+
49
+ export interface HealthSnapshotInput {
50
+ fibers: Iterable<CordisFiber>
51
+ dependencies: Readonly<Record<HealthDependencyName, boolean>>
52
+ connection?: FeishuConnectionStatus
53
+ botOpenId?: string
54
+ started: boolean
55
+ routeCount: number
56
+ liveAgentCount: number
57
+ inflightTurnCount: number
58
+ }
59
+
60
+ export interface HealthSnapshot extends Omit<HealthSnapshotInput, 'fibers' | 'botOpenId'> {
61
+ level: HealthLevel
62
+ fibers: FiberHealthCounts
63
+ /** Masked display only; the complete open_id never crosses this boundary. */
64
+ botOpenIdTail?: string
65
+ /** Non-active plugin fibers (capped at MAX_INACTIVE_FIBERS), diagnostics only. */
66
+ inactiveFibers: FiberHealthIssue[]
67
+ /** Total non-active fibers, including those beyond the capped list. */
68
+ inactiveTotal: number
69
+ /** Human-readable explanation of `level`; empty when healthy. */
70
+ reasons: string[]
71
+ }
72
+
73
+ /** Best-effort label for a fiber: Cordis exposes the plugin display name on the fiber. */
74
+ function fiberLabel(fiber: CordisFiber): string {
75
+ const name = fiber.name?.trim()
76
+ return name !== undefined && name !== '' ? name : '(未命名插件)'
77
+ }
78
+
79
+ /** One pass over the fiber table: state counts plus the non-active diagnostics. */
80
+ function foldFiberStates(fibers: Iterable<CordisFiber>): {
81
+ counts: FiberHealthCounts
82
+ inactive: FiberHealthIssue[]
83
+ inactiveTotal: number
84
+ } {
85
+ const counts: FiberHealthCounts = { active: 0, pending: 0, loading: 0, failed: 0, unloading: 0 }
86
+ const inactive: FiberHealthIssue[] = []
87
+ let inactiveTotal = 0
88
+ for (const fiber of fibers) {
89
+ const issueState: FiberIssueState | undefined =
90
+ fiber.state === CordisFiberState.PENDING ? 'pending'
91
+ : fiber.state === CordisFiberState.LOADING ? 'loading'
92
+ : fiber.state === CordisFiberState.UNLOADING ? 'unloading'
93
+ : fiber.state === CordisFiberState.FAILED ? 'failed'
94
+ : undefined
95
+ if (fiber.state === CordisFiberState.ACTIVE) {
96
+ counts.active++
97
+ continue
98
+ }
99
+ if (issueState === undefined) continue // disposed fibers are not health signals
100
+ counts[issueState === 'pending' ? 'pending' : issueState === 'loading' ? 'loading' : issueState === 'unloading' ? 'unloading' : 'failed']++
101
+ inactiveTotal++
102
+ if (inactive.length < MAX_INACTIVE_FIBERS) inactive.push({ state: issueState, label: fiberLabel(fiber) })
103
+ }
104
+ return { counts, inactive, inactiveTotal }
105
+ }
106
+
107
+ /** Fold every non-disposed Cordis fiber into the five product-facing states. */
108
+ export function countFiberStates(fibers: Iterable<CordisFiber>): FiberHealthCounts {
109
+ return foldFiberStates(fibers).counts
110
+ }
111
+
112
+ /** Human-readable connection state for diagnostics (keeps raw states visible). */
113
+ function connectionReason(state: string | undefined): string {
114
+ switch (state) {
115
+ case 'connected': return '已连接'
116
+ case 'connecting': return '连接中'
117
+ case 'reconnecting': return '重连中'
118
+ case 'failed': return '连接失败'
119
+ case 'idle': return '空闲(未连接)'
120
+ default: return state ?? '未知'
121
+ }
122
+ }
123
+
124
+ function labelList(labels: readonly string[], total: number, max = 6): string {
125
+ if (labels.length === 0) return ''
126
+ const shown = labels.slice(0, max).join('、')
127
+ return total > Math.min(labels.length, max) ? `${shown} 等 ${total} 个` : shown
128
+ }
129
+
130
+ /** Reveal only the final four characters, and nothing for unusually short ids. */
131
+ export function maskOpenIdTail(openId?: string): string | undefined {
132
+ if (openId === undefined || openId.length <= 4) return undefined
133
+ return `…${openId.slice(-4)}`
134
+ }
135
+
136
+ /** One deterministic fold owns both the snapshot and its overall conclusion. */
137
+ export function foldHealthSnapshot(input: HealthSnapshotInput): HealthSnapshot {
138
+ const { counts: fibers, inactive, inactiveTotal } = foldFiberStates(input.fibers)
139
+ const missingDependencies = HEALTH_DEPENDENCY_NAMES.filter(name => !input.dependencies[name])
140
+ const dependencyMissing = missingDependencies.length > 0
141
+ const connectionState = input.connection?.state
142
+
143
+ let level: HealthLevel
144
+ if (!input.started || dependencyMissing || connectionState === 'failed' || fibers.failed > 0) {
145
+ level = 'unhealthy'
146
+ } else if (
147
+ connectionState !== 'connected'
148
+ || fibers.pending > 0
149
+ || fibers.loading > 0
150
+ || fibers.unloading > 0
151
+ ) {
152
+ level = 'degraded'
153
+ } else {
154
+ level = 'healthy'
155
+ }
156
+
157
+ const reasons: string[] = []
158
+ if (!input.started) reasons.push('飞书通道尚未启动')
159
+ if (dependencyMissing) reasons.push(`主机服务未就绪:${missingDependencies.join('、')}`)
160
+ if (input.connection === undefined) reasons.push('飞书长连接尚未建立')
161
+ else if (connectionState === 'failed') reasons.push('飞书长连接失败')
162
+ else if (connectionState !== 'connected') reasons.push(`飞书长连接状态:${connectionReason(connectionState)}`)
163
+
164
+ const failedLabels = inactive.filter(issue => issue.state === 'failed').map(issue => issue.label)
165
+ if (fibers.failed > 0) {
166
+ reasons.push(`插件加载失败 ${fibers.failed} 个${failedLabels.length === 0 ? '' : `:${labelList(failedLabels, fibers.failed)}`}`)
167
+ }
168
+ const stalled = fibers.pending + fibers.loading + fibers.unloading
169
+ if (stalled > 0) {
170
+ const stalledLabels = inactive
171
+ .filter(issue => issue.state === 'pending' || issue.state === 'loading' || issue.state === 'unloading')
172
+ .map(issue => issue.label)
173
+ reasons.push(
174
+ `宿主其它插件仍有 ${stalled} 个入口在等待依赖或加载/卸载(与飞书通道无关)`
175
+ + (stalledLabels.length === 0 ? '' : `:${labelList(stalledLabels, stalled)}`),
176
+ )
177
+ }
178
+
179
+ return {
180
+ level,
181
+ fibers,
182
+ dependencies: input.dependencies,
183
+ connection: input.connection,
184
+ botOpenIdTail: maskOpenIdTail(input.botOpenId),
185
+ started: input.started,
186
+ routeCount: input.routeCount,
187
+ liveAgentCount: input.liveAgentCount,
188
+ inflightTurnCount: input.inflightTurnCount,
189
+ inactiveFibers: inactive,
190
+ inactiveTotal,
191
+ reasons,
192
+ }
193
+ }
194
+
195
+ const LEVEL_PRESENTATION: Record<HealthLevel, { icon: string; title: string }> = {
196
+ healthy: { icon: '✅', title: '运行健康' },
197
+ degraded: { icon: '⚠️', title: '运行降级' },
198
+ unhealthy: { icon: '❌', title: '运行异常' },
199
+ }
200
+
201
+ function formatTimestamp(timestamp?: number): string {
202
+ if (timestamp === undefined || !Number.isFinite(timestamp)) return '—'
203
+ const date = new Date(timestamp)
204
+ return Number.isNaN(date.getTime()) ? '—' : date.toISOString()
205
+ }
206
+
207
+ /** Render a standalone non-streaming health card from the sanitized snapshot. */
208
+ export function buildHealthCard(snapshot: HealthSnapshot): object {
209
+ const presentation = LEVEL_PRESENTATION[snapshot.level]
210
+ const dependencyLine = HEALTH_DEPENDENCY_NAMES
211
+ .map(name => `${snapshot.dependencies[name] ? '✅' : '❌'} ${name}`)
212
+ .join(' · ')
213
+ const connection = snapshot.connection
214
+ const connectionState = connection?.state ?? 'unknown'
215
+
216
+ return standaloneCard(`**${presentation.icon} 健康检查 · ${presentation.title}**`, [
217
+ { tag: 'markdown', content: `整体结论:**${snapshot.level}**` },
218
+ { tag: 'hr' },
219
+ {
220
+ tag: 'markdown',
221
+ content: `**Harness fibers**\nactive ${snapshot.fibers.active} · pending ${snapshot.fibers.pending} · loading ${snapshot.fibers.loading} · failed ${snapshot.fibers.failed} · unloading ${snapshot.fibers.unloading}`,
222
+ },
223
+ { tag: 'markdown', content: `**关键依赖**\n${dependencyLine}` },
224
+ {
225
+ tag: 'markdown',
226
+ content: `**飞书 WebSocket**\n${connectionState} · 最近连接 ${formatTimestamp(connection?.lastConnectTime)} · 下次重连 ${formatTimestamp(connection?.nextConnectTime)} · 重连次数 ${connection?.reconnectAttempts ?? 0} · bot open_id ${snapshot.botOpenIdTail ?? '—'}`,
227
+ },
228
+ {
229
+ tag: 'markdown',
230
+ content: `**dsh-channels**\n${snapshot.started ? 'started' : 'stopped'} · 持久化路由 ${snapshot.routeCount} · live Agent ${snapshot.liveAgentCount} · in-flight turn ${snapshot.inflightTurnCount}`,
231
+ },
232
+ ])
233
+ }