@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,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
|
+
}
|