@modesto_ma/dsh-channels 0.6.0 → 0.8.0-beta.1

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.
@@ -1,207 +0,0 @@
1
- /**
2
- * The analyze API's HTTP route on the DSH Web server.
3
- *
4
- * Registers the exact route `POST /api/dsh-channels.logalyze` (see
5
- * `./contract.ts`) on the host `webServer`; hosts without one (headless) have
6
- * no analyze API. This module owns the transport-level checks, in order:
7
- * path, the `analyzeEnabled` switch, method, bearer token, `Idempotency-Key`,
8
- * media type, body size, and JSON validation; everything after validation
9
- * belongs to the {@link AnalyzeHandler}. Bodies, tokens, and prompts are never
10
- * logged.
11
- *
12
- * @module dsh-channels/analyze/server
13
- */
14
-
15
- import { createHash, timingSafeEqual } from 'node:crypto'
16
- import type { IncomingMessage, ServerResponse } from 'node:http'
17
- import type { HostContext } from '../dsh-types.ts'
18
- import {
19
- ANALYZE_MAX_BODY_BYTES, ANALYZE_ROUTE, IDEMPOTENCY_KEY_PATTERN, parseAnalyzeRequest,
20
- } from './contract.ts'
21
- import type { AnalyzeRequest, AnalyzeResponse } from './contract.ts'
22
-
23
- /** The narrow `ctx.webServer` surface the analyze route registers into. */
24
- export interface HostWebServer {
25
- register(route: {
26
- kind: 'exact'
27
- path: string
28
- handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
29
- }): () => void
30
- }
31
-
32
- /** The business side of the route. */
33
- export interface AnalyzeHandler {
34
- /**
35
- * Whether the API is switched on (`analyzeEnabled`), read per request.
36
- * @returns false to answer every request with 404.
37
- */
38
- enabled(): boolean
39
- /**
40
- * Resolve the expected bearer token for this request.
41
- * @returns the token, or undefined when it is not configured.
42
- */
43
- token(): Promise<string | undefined>
44
- /**
45
- * Accept one validated request.
46
- * @param request - the validated body.
47
- * @param idempotencyKey - the request's `Idempotency-Key`.
48
- * @returns the HTTP answer.
49
- */
50
- analyze(request: AnalyzeRequest, idempotencyKey: string): Promise<AnalyzeResponse>
51
- }
52
-
53
- class HttpError extends Error {
54
- constructor(readonly status: number, message: string) {
55
- super(message)
56
- }
57
- }
58
-
59
- function send(res: ServerResponse, response: AnalyzeResponse): void {
60
- const body = JSON.stringify(response.body)
61
- res.writeHead(response.status, {
62
- 'content-type': 'application/json; charset=utf-8',
63
- 'cache-control': 'no-store',
64
- 'content-length': Buffer.byteLength(body),
65
- })
66
- res.end(body)
67
- }
68
-
69
- function refuse(res: ServerResponse, status: number, error: string, headers: Record<string, string> = {}): void {
70
- for (const [name, value] of Object.entries(headers)) res.setHeader(name, value)
71
- send(res, { status, body: { ok: false, error } })
72
- }
73
-
74
- /** Constant-time token comparison over fixed-length digests. */
75
- function tokenMatches(presented: string, expected: string): boolean {
76
- const left = createHash('sha256').update(presented).digest()
77
- const right = createHash('sha256').update(expected).digest()
78
- return timingSafeEqual(left, right)
79
- }
80
-
81
- /** The bearer credential of one request, or undefined. */
82
- function bearerOf(req: IncomingMessage): string | undefined {
83
- const header = req.headers.authorization
84
- if (header === undefined) return undefined
85
- const match = /^Bearer[ \t]+(\S+)[ \t]*$/iu.exec(header)
86
- return match?.[1]
87
- }
88
-
89
- /** Read the body up to {@link ANALYZE_MAX_BODY_BYTES}; larger bodies fail with 413. */
90
- async function readBody(req: IncomingMessage): Promise<string> {
91
- const declared = Number(req.headers['content-length'])
92
- if (Number.isFinite(declared) && declared > ANALYZE_MAX_BODY_BYTES) {
93
- throw new HttpError(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`)
94
- }
95
- const chunks: Buffer[] = []
96
- let size = 0
97
- for await (const chunk of req) {
98
- const buffer = chunk as Buffer
99
- size += buffer.length
100
- if (size > ANALYZE_MAX_BODY_BYTES) throw new HttpError(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`)
101
- chunks.push(buffer)
102
- }
103
- return Buffer.concat(chunks).toString('utf8')
104
- }
105
-
106
- /**
107
- * Handle one HTTP request against the analyze contract.
108
- * @param req - the incoming request.
109
- * @param res - its response.
110
- * @param handler - the business side.
111
- */
112
- export async function handleAnalyzeHttp(
113
- req: IncomingMessage,
114
- res: ServerResponse,
115
- handler: AnalyzeHandler,
116
- ): Promise<void> {
117
- const pathname = new URL(req.url ?? '/', 'http://analyze.invalid').pathname
118
- if (pathname !== ANALYZE_ROUTE) {
119
- refuse(res, 404, '未知路径')
120
- return
121
- }
122
- if (!handler.enabled()) {
123
- refuse(res, 404, '日志分析接口未开启,请在设置面板「高级 → 日志分析接口」打开开关')
124
- return
125
- }
126
- if ((req.method ?? 'GET').toUpperCase() !== 'POST') {
127
- refuse(res, 405, '只接受 POST', { allow: 'POST' })
128
- return
129
- }
130
- const expected = await handler.token()
131
- if (expected === undefined || expected === '') {
132
- refuse(res, 503, '分析接口未配置访问令牌,请联系部署者配置 analyzeToken 引用的凭据')
133
- return
134
- }
135
- const presented = bearerOf(req)
136
- if (presented === undefined || !tokenMatches(presented, expected)) {
137
- refuse(res, 401, '访问令牌无效', { 'www-authenticate': 'Bearer' })
138
- return
139
- }
140
- const idempotencyKey = req.headers['idempotency-key']
141
- if (typeof idempotencyKey !== 'string' || !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
142
- refuse(res, 400, '缺少或不合法的 Idempotency-Key 请求头(1-200 个可见 ASCII 字符)')
143
- return
144
- }
145
- const mediaType = (req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()
146
- if (mediaType !== 'application/json') {
147
- refuse(res, 415, 'Content-Type 必须是 application/json')
148
- return
149
- }
150
- let raw: unknown
151
- try {
152
- raw = JSON.parse(await readBody(req))
153
- } catch (error) {
154
- if (error instanceof HttpError) {
155
- refuse(res, error.status, error.message)
156
- return
157
- }
158
- refuse(res, 400, '请求体不是合法的 JSON')
159
- return
160
- }
161
- const parsed = parseAnalyzeRequest(raw)
162
- if (!parsed.ok) {
163
- refuse(res, 400, parsed.error)
164
- return
165
- }
166
- send(res, await handler.analyze(parsed.value, idempotencyKey))
167
- }
168
-
169
- /**
170
- * Register the analyze route on the host Web server once it is available.
171
- * Requests skip the DSH login gate on purpose: the bearer token is this API's
172
- * only credential.
173
- * @param ctx - the plugin context.
174
- * @param handler - the business side.
175
- * @param warn - diagnostics sink for unexpected handler failures.
176
- */
177
- export function mountAnalyzeRoute(
178
- ctx: HostContext,
179
- handler: AnalyzeHandler,
180
- warn: (message: string, ...args: unknown[]) => void,
181
- ): void {
182
- const attach = (webCtx: HostContext): void => {
183
- const server = webCtx.get?.('webServer') as HostWebServer | undefined
184
- if (server === undefined) return
185
- webCtx.effect(() => server.register({
186
- kind: 'exact',
187
- path: ANALYZE_ROUTE,
188
- handler: async (req, res) => {
189
- try {
190
- await handleAnalyzeHttp(req, res, handler)
191
- } catch (error) {
192
- warn('dsh-channels: analyze request failed: %s', String(error))
193
- if (!res.headersSent) refuse(res, 500, `分析请求处理失败:${String(error)}`)
194
- else res.destroy()
195
- }
196
- },
197
- }), 'dsh-channels: analyze route')
198
- }
199
- const inject = (ctx as {
200
- inject?: (dependencies: string[], callback: (child: HostContext) => void) => unknown
201
- }).inject
202
- if (typeof inject === 'function') {
203
- inject.call(ctx, ['webServer'], attach)
204
- return
205
- }
206
- attach(ctx)
207
- }
@@ -1,116 +0,0 @@
1
- /**
2
- * Root card of a log-platform analysis: who started which analysis when, the
3
- * log-platform detail pages behind it, and a status tag (分析中 → 已完成 /
4
- * 已停止 / 失败 / 已中断). The analysis itself streams in the topic below the
5
- * card through the ordinary streaming card.
6
- *
7
- * @module dsh-channels/cards/analyze
8
- */
9
-
10
- import { CARD_CONFIG, CARD_SCHEMA, markdown, note } from './primitives.ts'
11
-
12
- /** One log-platform detail page listed on the root card. */
13
- export interface AnalyzeLogDetail {
14
- readonly name: string
15
- readonly url: string
16
- }
17
-
18
- /** Subject of one analysis, as the root card names it. */
19
- export interface AnalyzeSubject {
20
- /** Requester user_id (rendered as an @-mention). */
21
- readonly requesterUserId: string
22
- /** Trigger time as the platform sent it (shown verbatim). */
23
- readonly triggeredAt: string
24
- readonly app: string
25
- readonly errorCode: string
26
- readonly logDetails: readonly AnalyzeLogDetail[]
27
- }
28
-
29
- /**
30
- * Root card status: `running` until the first turn settles (`done`,
31
- * `stopped`, `error`), `failed` when the analysis could not start, or
32
- * `interrupted` when DSH restarted before the first turn settled.
33
- */
34
- export type AnalyzeStatus =
35
- | { readonly kind: 'running' }
36
- | { readonly kind: 'done' }
37
- | { readonly kind: 'stopped' }
38
- | { readonly kind: 'error' }
39
- | { readonly kind: 'failed'; readonly reason: string }
40
- | { readonly kind: 'interrupted' }
41
-
42
- /** Rows per table page (Feishu caps `page_size` at 10). */
43
- const TABLE_PAGE_SIZE = 10
44
-
45
- /** Escape markdown emphasis characters in platform-supplied text. */
46
- function plain(text: string): string {
47
- return text.replace(/([*_~`[\]\\])/gu, '\\$1')
48
- }
49
-
50
- /** Percent-encode the characters that would end a markdown link target early. */
51
- function linkTarget(url: string): string {
52
- return url.replace(/[()\s]/gu, char => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`)
53
- }
54
-
55
- /** Header tag text, tag color, and header template of each status. */
56
- function statusLook(status: AnalyzeStatus): { tag: string; color: string; template: string } {
57
- switch (status.kind) {
58
- case 'running': return { tag: '分析中', color: 'blue', template: 'blue' }
59
- case 'done': return { tag: '已完成', color: 'green', template: 'green' }
60
- case 'stopped': return { tag: '已停止', color: 'neutral', template: 'grey' }
61
- case 'error':
62
- case 'failed': return { tag: '失败', color: 'red', template: 'red' }
63
- case 'interrupted': return { tag: '已中断', color: 'orange', template: 'orange' }
64
- }
65
- }
66
-
67
- /** Footer line of each status. */
68
- function statusNote(status: AnalyzeStatus): string {
69
- switch (status.kind) {
70
- case 'running': return '分析过程在本卡片下的话题内实时输出;群内已授权的成员可以在话题内直接回复追问。'
71
- case 'done': return '分析已完成,结果见话题;在话题内回复即可继续追问。'
72
- case 'stopped': return '分析已停止;在话题内回复即可继续。'
73
- case 'error': return '分析回合失败,原因见话题;在话题内回复即可重试或追问。'
74
- case 'failed': return `分析未能开始:${plain(status.reason)}`
75
- case 'interrupted': return 'DSH 重启导致分析中断;在话题内回复即可继续。'
76
- }
77
- }
78
-
79
- /**
80
- * The analysis root card.
81
- * @param subject - the analysis subject.
82
- * @param status - the status the card shows.
83
- * @returns card JSON.
84
- */
85
- export function buildAnalyzeRootCard(subject: AnalyzeSubject, status: AnalyzeStatus): object {
86
- const look = statusLook(status)
87
- return {
88
- schema: CARD_SCHEMA,
89
- config: CARD_CONFIG,
90
- header: {
91
- title: { tag: 'plain_text', content: '日志分析' },
92
- template: look.template,
93
- text_tag_list: [{ tag: 'text_tag', text: { tag: 'plain_text', content: look.tag }, color: look.color }],
94
- },
95
- body: {
96
- elements: [
97
- markdown(`<at id=${subject.requesterUserId}></at> 于 ${plain(subject.triggeredAt)} 发起了对 **${plain(subject.app)}** 应用 **${plain(subject.errorCode)}** 错误码不良日志的分析。`),
98
- {
99
- tag: 'table',
100
- page_size: TABLE_PAGE_SIZE,
101
- row_height: 'low',
102
- header_style: { background_style: 'grey', bold: true, lines: 1 },
103
- columns: [
104
- { name: 'name', display_name: '日志', data_type: 'text', width: 'auto' },
105
- { name: 'link', display_name: '详情页', data_type: 'markdown', width: 'auto' },
106
- ],
107
- rows: subject.logDetails.map(detail => ({
108
- name: detail.name,
109
- link: `[查看](${linkTarget(detail.url)})`,
110
- })),
111
- },
112
- note(statusNote(status)),
113
- ],
114
- },
115
- }
116
- }