@modesto_ma/dsh-guanxiang 0.1.0 → 0.2.0-beta.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modesto_ma/dsh-guanxiang",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-beta.2",
4
4
  "description": "观象 GuanXiang:DeepSeek Harness (DSH) 日志分析插件。/guanxiang 识别日志所属应用,把分析派发给工作目录为该应用代码仓库的子 Agent;Web 左侧栏提供「观象」设置面板",
5
5
  "repository": {
6
6
  "type": "git",
@@ -82,6 +82,7 @@
82
82
  },
83
83
  "devDependencies": {
84
84
  "@deepseek-ai/cordis": "^4.0.5",
85
+ "@modesto_ma/dsh-channels": "^0.8.0-beta.1",
85
86
  "@semantic-release/commit-analyzer": "^13.0.1",
86
87
  "@semantic-release/github": "^12.0.10",
87
88
  "@semantic-release/npm": "^13.2.0",
@@ -0,0 +1,124 @@
1
+ /**
2
+ * 日志平台分析接口的请求与响应。
3
+ *
4
+ * `POST /open-api/logalyze`(DSH Web 服务器上),带
5
+ * `Authorization: Bearer <令牌>`、`Idempotency-Key` 请求头和 JSON 请求体:
6
+ * 发起人、触发时间、应用与错误码、日志详情页、目标群和提示词。合法请求把
7
+ * 分析根卡片发进群并回 `202`,分析随后在卡片下的话题里流式进行。日志包由
8
+ * 提示词调用的分析 SKILL 自行获取,详情页只列在卡片上。
9
+ *
10
+ * 接口原由 dsh-channels 0.7 提供;迁到观象后请求、响应和错误文本不变。
11
+ *
12
+ * @module dsh-guanxiang/analyze/contract
13
+ */
14
+
15
+ import { createHash } from 'node:crypto'
16
+ import { z } from 'zod'
17
+
18
+ export { ANALYZE_ROUTE } from '../settings/contract.ts'
19
+
20
+ /** Largest accepted request body (the prompt dominates it). */
21
+ export const ANALYZE_MAX_BODY_BYTES = 1024 * 1024
22
+
23
+ /** Largest accepted prompt, in characters. */
24
+ export const ANALYZE_MAX_PROMPT_CHARS = 100_000
25
+
26
+ /**
27
+ * `Idempotency-Key` format: 1–200 visible ASCII characters without spaces, so
28
+ * a platform can use a UUID or its own `type:id` token.
29
+ */
30
+ export const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7E]{1,200}$/u
31
+
32
+ /** Feishu `user_id`: not an `open_id` (`ou_`), `union_id` (`on_`), or chat id (`oc_`). */
33
+ const FEISHU_USER_ID = /^(?!ou_|on_|oc_)[A-Za-z0-9_-]+$/u
34
+
35
+ /**
36
+ * Application / error-code segment: it names a workspace directory
37
+ * (`<baseDir>/analyze/<app>/<errorCode>` below the Feishu channel's
38
+ * `baseDir`), so only path-safe characters pass.
39
+ */
40
+ const SUBJECT_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/u
41
+
42
+ /** Most detail pages one request may list on its root card. */
43
+ export const ANALYZE_MAX_LOG_DETAILS = 50
44
+
45
+ /** Feishu group chat id (`oc_…`). */
46
+ const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,128}$/u
47
+
48
+ /** JSON body of one analyze request. */
49
+ export const analyzeRequestSchema = z.object({
50
+ /** Feishu user_id of the person who started the analysis on the platform. */
51
+ requesterUserId: z.string().regex(FEISHU_USER_ID, 'must be a Feishu user_id').max(128),
52
+ /** When the analysis was started on the platform, shown verbatim on the root card. */
53
+ triggeredAt: z.string().min(1).max(64).refine(value => value.trim() !== '', 'must not be blank'),
54
+ /** Application the logs belong to (workspace directory segment). */
55
+ app: z.string().regex(SUBJECT_SEGMENT, 'must be 1-128 of A-Z a-z 0-9 . _ -'),
56
+ /** Error code of the logs (workspace directory segment). */
57
+ errorCode: z.string().regex(SUBJECT_SEGMENT, 'must be 1-128 of A-Z a-z 0-9 . _ -'),
58
+ /** Log-platform detail pages of the logs that triggered the analysis (listed on the root card). */
59
+ logDetails: z.array(z.object({
60
+ /** Row label. */
61
+ name: z.string().min(1).max(200).refine(value => value.trim() !== '', 'must not be blank'),
62
+ /** Detail page address. */
63
+ url: z.url({ protocol: /^https?$/u, error: 'must be an http(s) URL' }).max(2048),
64
+ }).strict()).min(1).max(ANALYZE_MAX_LOG_DETAILS),
65
+ /** Target group chat (`oc_…`); must be an authorized group. */
66
+ chatId: z.string().regex(CHAT_ID, 'must be a Feishu group chat_id (oc_...)'),
67
+ /** Prompt forwarded verbatim as the analysis Session's first user message. */
68
+ prompt: z.string().min(1).max(ANALYZE_MAX_PROMPT_CHARS)
69
+ .refine(value => value.trim() !== '', 'must not be blank'),
70
+ }).strict()
71
+
72
+ /** One validated analyze request. */
73
+ export type AnalyzeRequest = z.infer<typeof analyzeRequestSchema>
74
+
75
+ /** Body of an accepted (or replayed) request. */
76
+ export interface AnalyzeAccepted {
77
+ readonly ok: true
78
+ /** Target group chat id. */
79
+ readonly chatId: string
80
+ /** Root card message id; the analysis topic hangs below it. */
81
+ readonly messageId: string
82
+ }
83
+
84
+ /** Body of a refused request. */
85
+ export interface AnalyzeRefused {
86
+ readonly ok: false
87
+ /** Readable reason (Chinese, shown to platform operators). */
88
+ readonly error: string
89
+ }
90
+
91
+ /** One HTTP answer of the analyze handler. */
92
+ export interface AnalyzeResponse {
93
+ readonly status: number
94
+ readonly body: AnalyzeAccepted | AnalyzeRefused
95
+ }
96
+
97
+ /**
98
+ * Fingerprint of one validated request, used to tell a retry of the same
99
+ * request from a reused `Idempotency-Key`. Field order is fixed: dsh-channels
100
+ * 0.7 stored the same digest, so its accepted keys keep replaying.
101
+ * @param request - the validated request.
102
+ * @returns SHA-256 hex digest.
103
+ */
104
+ export function analyzeRequestHash(request: AnalyzeRequest): string {
105
+ const canonical = JSON.stringify([
106
+ request.requesterUserId, request.triggeredAt, request.app, request.errorCode,
107
+ request.logDetails.map(detail => [detail.name, detail.url]), request.chatId, request.prompt,
108
+ ])
109
+ return createHash('sha256').update(canonical).digest('hex')
110
+ }
111
+
112
+ /**
113
+ * Validate one decoded JSON body.
114
+ * @param raw - the decoded body.
115
+ * @returns the request, or a readable list of field errors.
116
+ */
117
+ export function parseAnalyzeRequest(raw: unknown): { ok: true; value: AnalyzeRequest } | { ok: false; error: string } {
118
+ const parsed = analyzeRequestSchema.safeParse(raw)
119
+ if (parsed.success) return { ok: true, value: parsed.data }
120
+ const details = parsed.error.issues
121
+ .map(issue => `${issue.path.join('.') || '(body)'}: ${issue.message}`)
122
+ .join('; ')
123
+ return { ok: false, error: `请求参数不合法:${details}` }
124
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * 日志平台分析接口在 DSH Web 服务器上的路由。
3
+ *
4
+ * 只在宿主同时提供 `webServer` 和版本兼容的 dsh-channels `feishuTopics`
5
+ * 服务时注册精确路由 `POST /open-api/logalyze`;dsh-channels 未加载、重载
6
+ * 中、版本不兼容或为 0.7 及更早版本(自己注册该路由)时观象不注册,两者
7
+ * 不会争用同一路径。
8
+ * 本模块按顺序做传输层检查:路径、开关、方法、Bearer 令牌、
9
+ * `Idempotency-Key`、媒体类型、请求体大小和 JSON 校验;通过后交给
10
+ * `feishuTopics`。请求体、令牌和提示词都不写日志。
11
+ *
12
+ * @module dsh-guanxiang/analyze/server
13
+ */
14
+
15
+ import { createHash, timingSafeEqual } from 'node:crypto'
16
+ import type { IncomingMessage, ServerResponse } from 'node:http'
17
+ import type { HostContext, HostWebServer } from '../host.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
+ import { FEISHU_TOPICS_SERVICE, launchAnalysis, lookupTopics } from './topics.ts'
23
+
24
+ /** The plugin side of the route: switch and token, read per request. */
25
+ export interface AnalyzeGate {
26
+ /** @returns whether the API is switched on; false answers every request with 404. */
27
+ enabled(): boolean
28
+ /** Credential reference holding the bearer token, named in the 503 answer while it is unset. */
29
+ tokenEnv(): string
30
+ /**
31
+ * Resolve the expected bearer token.
32
+ * @returns the token, or undefined when it is not configured or cannot be read.
33
+ */
34
+ token(): Promise<string | undefined>
35
+ }
36
+
37
+ /** Everything after transport checks: start the analysis of one validated request. */
38
+ export type AnalyzeLaunch = (request: AnalyzeRequest, idempotencyKey: string) => Promise<AnalyzeResponse>
39
+
40
+ class HttpError extends Error {
41
+ constructor(readonly status: number, message: string) {
42
+ super(message)
43
+ }
44
+ }
45
+
46
+ function send(res: ServerResponse, response: AnalyzeResponse): void {
47
+ const body = JSON.stringify(response.body)
48
+ res.writeHead(response.status, {
49
+ 'content-type': 'application/json; charset=utf-8',
50
+ 'cache-control': 'no-store',
51
+ 'content-length': Buffer.byteLength(body),
52
+ })
53
+ res.end(body)
54
+ }
55
+
56
+ function refuse(res: ServerResponse, status: number, error: string, headers: Record<string, string> = {}): void {
57
+ for (const [name, value] of Object.entries(headers)) res.setHeader(name, value)
58
+ send(res, { status, body: { ok: false, error } })
59
+ }
60
+
61
+ /** Constant-time token comparison over fixed-length digests. */
62
+ function tokenMatches(presented: string, expected: string): boolean {
63
+ const left = createHash('sha256').update(presented).digest()
64
+ const right = createHash('sha256').update(expected).digest()
65
+ return timingSafeEqual(left, right)
66
+ }
67
+
68
+ /** The bearer credential of one request, or undefined. */
69
+ function bearerOf(req: IncomingMessage): string | undefined {
70
+ const header = req.headers.authorization
71
+ if (header === undefined) return undefined
72
+ const match = /^Bearer[ \t]+(\S+)[ \t]*$/iu.exec(header)
73
+ return match?.[1]
74
+ }
75
+
76
+ /** Read the body up to {@link ANALYZE_MAX_BODY_BYTES}; larger bodies fail with 413. */
77
+ async function readBody(req: IncomingMessage): Promise<string> {
78
+ const declared = Number(req.headers['content-length'])
79
+ if (Number.isFinite(declared) && declared > ANALYZE_MAX_BODY_BYTES) {
80
+ throw new HttpError(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`)
81
+ }
82
+ const chunks: Buffer[] = []
83
+ let size = 0
84
+ for await (const chunk of req) {
85
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk as Buffer
86
+ size += buffer.length
87
+ if (size > ANALYZE_MAX_BODY_BYTES) throw new HttpError(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`)
88
+ chunks.push(buffer)
89
+ }
90
+ return Buffer.concat(chunks).toString('utf8')
91
+ }
92
+
93
+ /**
94
+ * Handle one HTTP request against the analyze API.
95
+ * @param req - the incoming request.
96
+ * @param res - its response.
97
+ * @param gate - switch and token.
98
+ * @param launch - starts the analysis of a validated request.
99
+ */
100
+ export async function handleAnalyzeHttp(
101
+ req: IncomingMessage,
102
+ res: ServerResponse,
103
+ gate: AnalyzeGate,
104
+ launch: AnalyzeLaunch,
105
+ ): Promise<void> {
106
+ const pathname = new URL(req.url ?? '/', 'http://analyze.invalid').pathname
107
+ if (pathname !== ANALYZE_ROUTE) {
108
+ refuse(res, 404, '未知路径')
109
+ return
110
+ }
111
+ if (!gate.enabled()) {
112
+ refuse(res, 404, '日志分析接口未开启,请在观象设置面板「分析接口」打开开关')
113
+ return
114
+ }
115
+ if ((req.method ?? 'GET').toUpperCase() !== 'POST') {
116
+ refuse(res, 405, '只接受 POST', { allow: 'POST' })
117
+ return
118
+ }
119
+ const expected = await gate.token()
120
+ if (expected === undefined || expected === '') {
121
+ refuse(res, 503, `分析接口未配置访问令牌,请在观象设置面板「分析接口」保存令牌(凭据名 ${gate.tokenEnv()})`)
122
+ return
123
+ }
124
+ const presented = bearerOf(req)
125
+ if (presented === undefined || !tokenMatches(presented, expected)) {
126
+ refuse(res, 401, '访问令牌无效', { 'www-authenticate': 'Bearer' })
127
+ return
128
+ }
129
+ const idempotencyKey = req.headers['idempotency-key']
130
+ if (typeof idempotencyKey !== 'string' || !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
131
+ refuse(res, 400, '缺少或不合法的 Idempotency-Key 请求头(1-200 个可见 ASCII 字符)')
132
+ return
133
+ }
134
+ const mediaType = (req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()
135
+ if (mediaType !== 'application/json') {
136
+ refuse(res, 415, 'Content-Type 必须是 application/json')
137
+ return
138
+ }
139
+ let raw: unknown
140
+ try {
141
+ raw = JSON.parse(await readBody(req))
142
+ } catch (error) {
143
+ if (error instanceof HttpError) {
144
+ refuse(res, error.status, error.message)
145
+ return
146
+ }
147
+ refuse(res, 400, '请求体不是合法的 JSON')
148
+ return
149
+ }
150
+ const parsed = parseAnalyzeRequest(raw)
151
+ if (!parsed.ok) {
152
+ refuse(res, 400, parsed.error)
153
+ return
154
+ }
155
+ send(res, await launch(parsed.value, idempotencyKey))
156
+ }
157
+
158
+ /**
159
+ * Register the route while the host provides both `webServer` and a
160
+ * compatible `feishuTopics`; it is removed when either goes away. Requests skip the DSH
161
+ * login gate on purpose: the bearer token is this API's only credential.
162
+ * @param ctx - the plugin context.
163
+ * @param gate - switch and token.
164
+ */
165
+ export function mountAnalyzeRoute(ctx: HostContext, gate: AnalyzeGate): void {
166
+ const attach = (scope: HostContext): void => {
167
+ const server = scope.get?.('webServer') as HostWebServer | undefined
168
+ if (server === undefined) return
169
+ const topics = lookupTopics(scope.get?.(FEISHU_TOPICS_SERVICE))
170
+ if (topics.availability !== 'ready') {
171
+ if (topics.availability === 'incompatible') ctx.logger?.warn?.('guanxiang: dsh-channels 的 feishuTopics 服务版本与观象不兼容,分析接口未注册;请把两者升级到相互兼容的版本')
172
+ return
173
+ }
174
+ const { service } = topics
175
+ const launch: AnalyzeLaunch = (request, key) => launchAnalysis(service, request, key)
176
+ scope.effect(() => server.register({
177
+ kind: 'exact',
178
+ path: ANALYZE_ROUTE,
179
+ handler: async (req, res) => {
180
+ try {
181
+ await handleAnalyzeHttp(req, res, gate, launch)
182
+ } catch (error) {
183
+ ctx.logger?.warn?.('guanxiang: 分析请求处理失败:%s', String(error))
184
+ if (!res.headersSent) refuse(res, 500, `分析请求处理失败:${String(error)}`)
185
+ else res.destroy()
186
+ }
187
+ },
188
+ }), 'guanxiang: analyze route')
189
+ }
190
+ if (typeof ctx.inject === 'function') {
191
+ ctx.inject(['webServer', FEISHU_TOPICS_SERVICE], attach)
192
+ return
193
+ }
194
+ attach(ctx)
195
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 分析请求到 dsh-channels `feishuTopics` 服务的转换:根卡片内容、工作区
3
+ * `analyze/<app>/<errorCode>`、幂等键与指纹,以及服务结果到 HTTP 响应的映射。
4
+ * 卡片、工作区和指纹与 dsh-channels 0.7 自带的分析接口一致,升级前接受的
5
+ * `Idempotency-Key` 升级后仍按原结果重放。
6
+ *
7
+ * 只从 `@modesto_ma/dsh-channels/topics` 引入类型;服务在运行时经
8
+ * `ctx.get('feishuTopics')` 取得,版本不符时不调用。
9
+ *
10
+ * @module dsh-guanxiang/analyze/topics
11
+ */
12
+
13
+ import type {
14
+ FeishuTopicService, FeishuTopicsVersion, TopicLaunchResult, TopicLaunchSpec,
15
+ } from '@modesto_ma/dsh-channels/topics'
16
+ import type { TopicsAvailability } from '../settings/contract.ts'
17
+ import { analyzeRequestHash } from './contract.ts'
18
+ import type { AnalyzeRequest, AnalyzeResponse } from './contract.ts'
19
+
20
+ /** Name dsh-channels provides the service under. */
21
+ export const FEISHU_TOPICS_SERVICE = 'feishuTopics'
22
+
23
+ /** The `feishuTopics` contract version this plugin speaks. */
24
+ export const FEISHU_TOPICS_VERSION: FeishuTopicsVersion = 1
25
+
26
+ /** Result of looking the service up. */
27
+ export type TopicsLookup =
28
+ | { readonly availability: 'ready'; readonly service: FeishuTopicService }
29
+ | { readonly availability: Exclude<TopicsAvailability, 'ready'> }
30
+
31
+ /**
32
+ * Classify the value `ctx.get('feishuTopics')` returned. The provider is
33
+ * another package that may be older or newer than this one, so its version
34
+ * is checked at run time.
35
+ * @param value - the looked-up service, or undefined.
36
+ * @returns the usable service, or why there is none.
37
+ */
38
+ export function lookupTopics(value: unknown): TopicsLookup {
39
+ if (value === undefined || value === null) return { availability: 'missing' }
40
+ const service = value as Partial<Record<keyof FeishuTopicService, unknown>>
41
+ if (service.version !== FEISHU_TOPICS_VERSION || typeof service.launch !== 'function') return { availability: 'incompatible' }
42
+ return { availability: 'ready', service: value as FeishuTopicService }
43
+ }
44
+
45
+ /**
46
+ * The topic one analyze request starts.
47
+ * @param request - the validated request.
48
+ * @param idempotencyKey - the request's `Idempotency-Key`, used unchanged as the launch key.
49
+ * @returns the launch spec.
50
+ */
51
+ export function analyzeTopicSpec(request: AnalyzeRequest, idempotencyKey: string): TopicLaunchSpec {
52
+ return {
53
+ key: idempotencyKey,
54
+ fingerprint: analyzeRequestHash(request),
55
+ chatId: request.chatId,
56
+ requesterUserId: request.requesterUserId,
57
+ card: {
58
+ title: '日志分析',
59
+ taskName: '分析',
60
+ lead: [
61
+ { text: `于 ${request.triggeredAt} 发起了对 ` },
62
+ { text: request.app, bold: true },
63
+ { text: ' 应用 ' },
64
+ { text: request.errorCode, bold: true },
65
+ { text: ' 错误码不良日志的分析。' },
66
+ ],
67
+ links: { nameHeader: '日志', linkHeader: '详情页', rows: request.logDetails },
68
+ },
69
+ workspace: {
70
+ segments: ['analyze', request.app, request.errorCode],
71
+ title: `日志分析 · ${request.app} · ${request.errorCode}`,
72
+ },
73
+ prompt: request.prompt,
74
+ }
75
+ }
76
+
77
+ /**
78
+ * HTTP answer of one launch result.
79
+ * @param result - what the service answered.
80
+ * @returns status and body.
81
+ */
82
+ export function analyzeResponseOf(result: TopicLaunchResult): AnalyzeResponse {
83
+ if (result.ok) return { status: 202, body: { ok: true, chatId: result.chatId, messageId: result.messageId } }
84
+ switch (result.code) {
85
+ case 'not-connected': return refused(503, result.message)
86
+ case 'forbidden-chat': return refused(403, result.message)
87
+ case 'key-conflict': return refused(409, 'Idempotency-Key 已被内容不同的请求使用')
88
+ case 'send-failed': return refused(502, result.message)
89
+ // The request passed this plugin's own validation, so a refused spec is a
90
+ // disagreement between the two plugins, not a client error.
91
+ case 'invalid': return refused(500, `分析请求处理失败:${result.message}`)
92
+ default: return refused(500, `分析请求处理失败:${String((result as { message?: unknown }).message)}`)
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Start the analysis topic of one validated request.
98
+ * @param topics - the `feishuTopics` service.
99
+ * @param request - the validated request.
100
+ * @param idempotencyKey - the request's `Idempotency-Key`.
101
+ * @returns the HTTP answer.
102
+ */
103
+ export async function launchAnalysis(topics: FeishuTopicService, request: AnalyzeRequest, idempotencyKey: string): Promise<AnalyzeResponse> {
104
+ return analyzeResponseOf(await topics.launch(analyzeTopicSpec(request, idempotencyKey)))
105
+ }
106
+
107
+ function refused(status: number, error: string): AnalyzeResponse {
108
+ return { status, body: { ok: false, error } }
109
+ }