@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.
- package/README.md +31 -47
- package/lib/client.js +44 -122
- package/lib/client.js.map +1 -1
- package/lib/index.js +436 -557
- package/lib/index.js.map +1 -1
- package/package.json +4 -1
- package/src/access/controller.ts +3 -3
- package/src/cards/index.ts +1 -1
- package/src/cards/topic.ts +104 -0
- package/src/client/origin.tsx +1 -1
- package/src/client/panel/advanced-tab.tsx +5 -29
- package/src/client/panel/index.tsx +2 -39
- package/src/client/panel/shared.tsx +0 -12
- package/src/config.ts +26 -16
- package/src/domain.ts +89 -32
- package/src/host/core.ts +32 -1
- package/src/host/services.ts +5 -0
- package/src/index.ts +11 -4
- package/src/routing/inbound.ts +1 -1
- package/src/service.ts +20 -25
- package/src/settings/contract.ts +6 -21
- package/src/settings/overlay.ts +3 -1
- package/src/settings/routes.ts +2 -3
- package/src/settings/surface.ts +1 -14
- package/src/streamer.ts +91 -5
- package/src/topics/contract.ts +149 -0
- package/src/topics/launcher.ts +428 -0
- package/src/workspaces.ts +25 -18
- package/src/analyze/contract.ts +0 -125
- package/src/analyze/launcher.ts +0 -376
- package/src/analyze/server.ts +0 -207
- package/src/cards/analyze.ts +0 -116
package/src/analyze/server.ts
DELETED
|
@@ -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
|
-
}
|
package/src/cards/analyze.ts
DELETED
|
@@ -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
|
-
}
|