@dcrays/dcgchat 0.3.35 → 0.4.4

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.
@@ -0,0 +1,215 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import type { AnyAgentTool } from 'openclaw/plugin-sdk'
5
+ import { jsonResult } from 'openclaw/plugin-sdk'
6
+ import { sendDcgchatMedia } from '../channel.js'
7
+ import { getOutboundMsgParams } from '../utils/params.js'
8
+ import { sendText } from '../transport.js'
9
+
10
+ /** 与 `registerTool` 工厂入参一致(主包未导出 `OpenClawPluginToolContext` 时仅用所需字段)。 */
11
+ export type DcgchatMessageToolContext = {
12
+ sessionKey?: string
13
+ workspaceDir?: string
14
+ }
15
+
16
+ /** 统一为 POSIX 风格斜杠,便于跨平台判断(不改变语义,仅用于匹配)。 */
17
+ function toPosixPath(p: string): string {
18
+ return path.normalize(p.trim()).replace(/\\/g, '/')
19
+ }
20
+
21
+ /** `filepath` 解析后在 `rootDir` 内或等于 `rootDir`(防 `..` 逃逸)。 */
22
+ function isPathInsideDir(filepath: string, rootDir: string): boolean {
23
+ const root = path.resolve(rootDir)
24
+ const resolved = path.resolve(filepath)
25
+ const rel = path.relative(root, resolved)
26
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return false
27
+ return true
28
+ }
29
+
30
+ /**
31
+ * 允许发送的路径:
32
+ * - 当前 Agent 工作区根及其子路径(`workspaceDir`,如 ~/.openclaw/workspace-xxx/output/...);
33
+ * - 兼容旧挂载:Unix `/workspace`、`/mobook`;Windows 盘符下 `workspace`、`mobook`。
34
+ */
35
+ function isSafePath(filepath: string, workspaceDir?: string): boolean {
36
+ const ws = workspaceDir?.trim()
37
+ if (ws && isPathInsideDir(filepath, ws)) return true
38
+ const p = toPosixPath(filepath)
39
+ if (p.startsWith('/workspace/') || p === '/workspace') return true
40
+ if (p === '/mobook') return true
41
+ return /^[A-Za-z]:\/(workspace|mobook)(\/|$)/.test(p)
42
+ }
43
+
44
+ /** 同一路径在 Windows 上可能大小写不同,用于 Set 去重。 */
45
+ function pathKey(filepath: string): string {
46
+ const n = path.normalize(filepath.trim())
47
+ return os.platform() === 'win32' ? n.toLowerCase() : n
48
+ }
49
+
50
+ const fileType1 = ['.webp', '.gif', '.bmp', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.pdf', '.txt', '.rtf', '.odt', '.json']
51
+ const fileType2 = ['.xml', '.csv', '.yaml', '.yml', '.html', '.htm', '.md', '.markdown', '.css', '.js', '.ts', '.png', '.jpg', '.jpeg']
52
+ const fileType3 = ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.exe', '.dmg', '.pkg', '.apk', '.ipa', '.log', '.dat', '.bin']
53
+ const fileType4 = ['.svg', '.ico', '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.webm']
54
+ const SAFE_EXTENSIONS = new Set([...fileType1, ...fileType2, ...fileType3, ...fileType4])
55
+
56
+ const messageToolParameters = {
57
+ type: 'object',
58
+ additionalProperties: false,
59
+ properties: {
60
+ target: {
61
+ type: 'string',
62
+ description: '目标会话键(sessionKey),必须与当前会话 SessionKey 一致,禁止填写 userId。'
63
+ },
64
+ content: {
65
+ type: 'string',
66
+ description: '发送文本内容'
67
+ },
68
+ media: {
69
+ type: 'array',
70
+ description: '发送附件',
71
+ items: {
72
+ type: 'object',
73
+ additionalProperties: false,
74
+ properties: {
75
+ file: {
76
+ type: 'string',
77
+ description:
78
+ '文件绝对路径:须在「当前 Agent 工作区」目录下(如 /root/.openclaw/workspace-xxx/output/28337/slices_result.json),或为兼容环境的 /workspace/、/mobook/(Windows 盘符下 workspace、mobook)'
79
+ }
80
+ },
81
+ required: ['file']
82
+ }
83
+ }
84
+ },
85
+ oneOf: [{ required: ['content'] }, { required: ['media'] }]
86
+ }
87
+
88
+ /** 从正文提取可发送的文件路径(固定挂载 + 当前工作区前缀)。 */
89
+ function extractPaths(text: string | undefined, workspaceDir?: string): string[] {
90
+ if (!text) return []
91
+ const unix = text.match(/\/workspace\/[^\s]+|\/mobook\/[^\s]+/g) ?? []
92
+ const win = text.match(/[A-Za-z]:[/\\](?:workspace|mobook)[/\\][^\s]+/g) ?? []
93
+ const underWs: string[] = []
94
+ const ws = workspaceDir?.trim()
95
+ if (ws) {
96
+ const variants = new Set<string>()
97
+ variants.add(ws)
98
+ variants.add(toPosixPath(ws))
99
+ if (path.sep === '\\') variants.add(ws.replace(/\//g, '\\'))
100
+ for (const prefix of variants) {
101
+ if (!prefix) continue
102
+ let from = 0
103
+ while (from < text.length) {
104
+ const i = text.indexOf(prefix, from)
105
+ if (i === -1) break
106
+ let end = i + prefix.length
107
+ while (end < text.length && !/\s/.test(text[end])) end++
108
+ underWs.push(text.slice(i, end))
109
+ from = i + 1
110
+ }
111
+ }
112
+ }
113
+ return [...new Set([...unix, ...win, ...underWs])]
114
+ }
115
+
116
+ function isSafeFile(filepath: string) {
117
+ if (!fs.existsSync(filepath)) return false
118
+ const stat = fs.statSync(filepath)
119
+ if (!stat.isFile()) return false
120
+ if (stat.size === 0) return false
121
+ const ext = path.extname(filepath).toLowerCase()
122
+ return SAFE_EXTENSIONS.has(ext)
123
+ }
124
+
125
+ /**
126
+ * 书灵墨宝出站消息工具:须符合 OpenClaw `AgentTool`(execute 返回 `AgentToolResult`)。
127
+ * 工具名使用 `dcgchat_message`,避免与核心内置 `message` 冲突。
128
+ * 通过注册时的 `OpenClawPluginToolContext.sessionKey` 出站,不再使用非标准的 `execute(args, ctx)`。
129
+ */
130
+ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext): AnyAgentTool {
131
+ return {
132
+ name: 'dcgchat_message',
133
+ label: 'dcgchat_message',
134
+ description: `
135
+ 向用户发送消息。
136
+ 若传 target,target 必须是 sessionKey,不能是 userId。
137
+ 如果发送附件:必须使用 media 字段
138
+ 文件路径须在当前 Agent 工作区目录下(随部署变化,如 ~/.openclaw/workspace-xxx/...),或为兼容环境的 /workspace/、/mobook/(Windows 盘符下 workspace、mobook)。
139
+ 禁止在正文中直接输出可访问路径(应通过 media 发送)
140
+ `,
141
+ parameters: messageToolParameters,
142
+ execute: async (_toolCallId, args, signal) => {
143
+ if (signal?.aborted) {
144
+ const err = new Error('Message send aborted')
145
+ err.name = 'AbortError'
146
+ throw err
147
+ }
148
+
149
+ const sessionKey = pluginCtx.sessionKey?.trim()
150
+ if (!sessionKey) {
151
+ return jsonResult({ error: '缺少 sessionKey,无法向当前会话发送消息' })
152
+ }
153
+
154
+ try {
155
+ const sentFiles = new Set<string>()
156
+ const sentKeys = new Set<string>()
157
+ const workspaceDir = pluginCtx.workspaceDir
158
+
159
+ if (args.media?.length) {
160
+ for (const media of args.media) {
161
+ const filepath = media.file
162
+ if (!filepath) continue
163
+ if (!isSafePath(filepath, workspaceDir)) continue
164
+ if (!isSafeFile(filepath)) continue
165
+ const key = pathKey(filepath)
166
+ if (sentKeys.has(key)) continue
167
+
168
+ await sendDcgchatMedia({ sessionKey, mediaUrl: filepath })
169
+ sentFiles.add(filepath)
170
+ sentKeys.add(key)
171
+ }
172
+ }
173
+
174
+ const fallbackPaths = extractPaths(args.content, workspaceDir)
175
+ for (const filepath of fallbackPaths) {
176
+ if (!isSafePath(filepath, workspaceDir)) continue
177
+ if (!isSafeFile(filepath)) continue
178
+ const key = pathKey(filepath)
179
+ if (sentKeys.has(key)) continue
180
+
181
+ await sendDcgchatMedia({ sessionKey, mediaUrl: filepath })
182
+ sentFiles.add(filepath)
183
+ sentKeys.add(key)
184
+ }
185
+
186
+ let content = args.content ?? ''
187
+ for (const filepath of sentFiles) {
188
+ const posix = toPosixPath(filepath)
189
+ const variants = posix === filepath ? [filepath] : [filepath, posix]
190
+ const seen = new Set<string>()
191
+ for (const v of variants) {
192
+ if (!v || seen.has(v)) continue
193
+ seen.add(v)
194
+ content = content.split(v).join('')
195
+ }
196
+ }
197
+ content = content.trim()
198
+
199
+ if (content.length > 0) {
200
+ const msgCtx = getOutboundMsgParams(sessionKey)
201
+ sendText(content, msgCtx)
202
+ }
203
+
204
+ return jsonResult({
205
+ success: true,
206
+ sentMediaCount: sentFiles.size
207
+ })
208
+ } catch (err) {
209
+ return jsonResult({
210
+ error: err instanceof Error ? err.message : String(err)
211
+ })
212
+ }
213
+ }
214
+ }
215
+ }
package/src/transport.ts CHANGED
@@ -152,11 +152,14 @@ export function wsSend(ctx: IMsgParams, content: Record<string, unknown>): boole
152
152
  */
153
153
  export function wsSendRaw(ctx: IMsgParams, content: Record<string, unknown>, isLog = true): boolean {
154
154
  const ws = getWsConnection()
155
- if (isWsOpen()) {
156
- ws?.send(JSON.stringify(buildOpenclawBotChat(ctx, content, { mergeChannelDefaults: true })))
157
- if (isLog) {
158
- dcgLogger('已发送:' + JSON.stringify(buildOpenclawBotChat(ctx, content, { mergeChannelDefaults: true })))
159
- }
155
+ if (ws?.readyState !== WebSocket.OPEN) {
156
+ dcgLogger(`server socket not ready ${ws?.readyState}`, 'error')
157
+ return false
158
+ }
159
+ const envelope = buildOpenclawBotChat(ctx, content, { mergeChannelDefaults: true })
160
+ ws.send(JSON.stringify(envelope))
161
+ if (isLog) {
162
+ dcgLogger('已发送:' + JSON.stringify(envelope))
160
163
  }
161
164
  return true
162
165
  }
@@ -166,7 +169,7 @@ export function sendChunk(text: string, ctx: IMsgParams, chunkIdx: number): bool
166
169
  }
167
170
 
168
171
  export function sendFinal(ctx: IMsgParams, tag: string): boolean {
169
- dcgLogger(` message handling complete state: final tag:${tag}`)
172
+ dcgLogger(` message handling complete state: to=${ctx.sessionId} final tag:${tag}`)
170
173
  return wsSend(ctx, { response: '', state: 'final' })
171
174
  }
172
175
 
@@ -178,7 +181,7 @@ export function sendError(errorMsg: string, ctx: IMsgParams): boolean {
178
181
  return wsSend(ctx, { response: `[错误] ${errorMsg}`, state: 'final' })
179
182
  }
180
183
 
181
- export function sendEventMessage(url: string, params: Record<string, string> = {}) {
184
+ export function sendEventMessage(params: Record<string, string> = {}) {
182
185
  const ctx = getParamsDefaults()
183
186
  const ws = getWsConnection()
184
187
  if (isWsOpen()) {
@@ -190,7 +193,6 @@ export function sendEventMessage(url: string, params: Record<string, string> = {
190
193
  bot_token: ctx.botToken,
191
194
  domain_id: ctx.domainId,
192
195
  app_id: ctx.appId,
193
- oss_url: url,
194
196
  bot_id: ctx.botId,
195
197
  ...params
196
198
  }
package/src/types.ts CHANGED
@@ -11,6 +11,11 @@ export type DcgchatConfig = {
11
11
  userId?: string
12
12
  domainId?: string
13
13
  appId?: string
14
+ /**
15
+ * 内置 `message` 工具走 OpenClaw 目标解析:`true`(默认)时仅将符合 sessionKey 形态的字符串视为合法 target,
16
+ * 纯数字(WS userId 等)会解析失败;设为 `false` 恢复旧版宽松行为(不推荐)。
17
+ */
18
+ strictMessageToolTarget?: boolean
14
19
  }
15
20
 
16
21
  export type ResolvedDcgchatAccount = {
@@ -38,7 +43,9 @@ export type InboundMessage = {
38
43
  source: string // 'server',
39
44
  // content: string;
40
45
  content: {
46
+ skills_scope: Record<string, any>[]
41
47
  bot_token: string
48
+ agent_clone_code?: string
42
49
  domain_id?: string
43
50
  app_id?: string
44
51
  bot_id?: string
@@ -2,6 +2,6 @@ export const ENV: 'production' | 'test' | 'develop' = 'production'
2
2
 
3
3
 
4
4
  export const systemCommand = ['/new', '/status']
5
- export const interruptCommand = ['chat.stop']
5
+ export const interruptCommand = ['/stop']
6
6
 
7
- export const ignoreToolCommand = ['/search', '/abort', '/stop', '/queue interrupt', ...systemCommand, ...interruptCommand]
7
+ export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...interruptCommand]
@@ -0,0 +1,47 @@
1
+ import type { GatewayEvent } from '../gateway/index.js'
2
+ import { finishedDcgchatCron, sendDcgchatCron } from '../cron.js'
3
+ import { dcgLogger } from './log.js'
4
+ import { getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
5
+ import { sendChunk } from '../transport.js'
6
+
7
+ /**
8
+ * 处理网关 event 帧的副作用(agent 流式输出、cron 同步),并构造供上层分发的 GatewayEvent。
9
+ */
10
+ export function handleGatewayEventMessage(msg: { event?: string; payload?: Record<string, unknown>; seq?: number }): GatewayEvent {
11
+ try {
12
+ if (msg.event === 'agent') {
13
+ const pl = msg.payload as { runId: string; data?: { delta?: unknown } }
14
+ const sessionKey = getSessionKeyBySubAgentRunId(pl.runId)
15
+ const outboundCtx = getEffectiveMsgParams(sessionKey)
16
+ if (pl.data?.delta) {
17
+ if (outboundCtx.sessionId) {
18
+ dcgLogger(`[Gateway] 收到agent事件: ${JSON.stringify(msg).slice(0, 100)}`)
19
+ sendChunk(pl.data.delta as string, outboundCtx, 0)
20
+ }
21
+ }
22
+ }
23
+ if (msg.event === 'cron') {
24
+ const p = msg.payload
25
+ dcgLogger(`[Gateway] 收到定时任务事件: ${JSON.stringify(p)}`)
26
+ if (p?.action === 'added') {
27
+ sendDcgchatCron(p?.jobId as string)
28
+ }
29
+ if (p?.action === 'updated') {
30
+ sendDcgchatCron(p?.jobId as string)
31
+ }
32
+ if (p?.action === 'removed') {
33
+ sendDcgchatCron(p?.jobId as string)
34
+ }
35
+ if (p?.action === 'finished') {
36
+ finishedDcgchatCron(p?.jobId as string)
37
+ }
38
+ }
39
+ } catch (error) {
40
+ dcgLogger(`[Gateway] 处理事件失败: ${error}`, 'error')
41
+ }
42
+ return {
43
+ type: msg.event as string,
44
+ payload: msg.payload,
45
+ seq: msg.seq as number | undefined
46
+ }
47
+ }
@@ -1,6 +1,12 @@
1
- /** socket connection */
2
1
  import type WebSocket from 'ws'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { createPluginRuntimeStore, type OpenClawConfig, type PluginRuntime } from 'openclaw/plugin-sdk'
6
+ import { channelInfo, ENV } from './constant.js'
7
+ import { dcgLogger } from './log.js'
3
8
 
9
+ /** socket connection */
4
10
  let ws: WebSocket | null = null
5
11
 
6
12
  export function setWsConnection(next: WebSocket | null) {
@@ -11,7 +17,6 @@ export function getWsConnection(): WebSocket | null {
11
17
  return ws
12
18
  }
13
19
 
14
- // OpenClawConfig
15
20
  let config: OpenClawConfig | null = null
16
21
 
17
22
  export function setOpenClawConfig(next: OpenClawConfig | null) {
@@ -22,15 +27,7 @@ export function getOpenClawConfig(): OpenClawConfig | null {
22
27
  return config
23
28
  }
24
29
 
25
- import type { OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
26
- import { dcgLogger } from './log.js'
27
- import { channelInfo, ENV } from './constant.js'
28
-
29
- const path = require('path')
30
- const fs = require('fs')
31
- const os = require('os')
32
-
33
- function getWorkspacePath() {
30
+ function getWorkspacePath(): string | null {
34
31
  const workspacePath = path.join(
35
32
  os.homedir(),
36
33
  config?.channels?.["dcgchat"]?.appId == 110 ? '.mobook' : '.openclaw',
@@ -42,14 +39,14 @@ function getWorkspacePath() {
42
39
  return null
43
40
  }
44
41
 
45
- let runtime: PluginRuntime | null = null
46
- let workspaceDir: string = getWorkspacePath()
42
+ let workspaceDir: string = getWorkspacePath() ?? ''
47
43
 
48
44
  export function setWorkspaceDir(dir?: string) {
49
45
  if (dir) {
50
46
  workspaceDir = dir
51
47
  }
52
48
  }
49
+
53
50
  export function getWorkspaceDir(): string {
54
51
  if (!workspaceDir) {
55
52
  dcgLogger?.('Workspace directory not initialized', 'error')
@@ -57,16 +54,10 @@ export function getWorkspaceDir(): string {
57
54
  return workspaceDir
58
55
  }
59
56
 
60
- export function setDcgchatRuntime(next: PluginRuntime) {
61
- runtime = next
62
- }
63
-
64
- export function getDcgchatRuntime(): PluginRuntime {
65
- if (!runtime) {
66
- dcgLogger?.('runtime not initialized', 'error')
67
- }
68
- return runtime as PluginRuntime
69
- }
57
+ const { setRuntime: setDcgchatRuntime, getRuntime: getDcgchatRuntime } = createPluginRuntimeStore<PluginRuntime>(
58
+ `${"dcgchat"} runtime not initialized`
59
+ )
60
+ export { setDcgchatRuntime, getDcgchatRuntime }
70
61
 
71
62
  export type MsgSessionStatus = 'running' | 'finished' | ''
72
63
 
@@ -117,19 +108,6 @@ export function clearSentMediaKeys(messageId?: string) {
117
108
  }
118
109
  }
119
110
 
120
- export const getSessionKey = (content: any, accountId?: string) => {
121
- const { real_mobook, agent_id, conversation_id, session_id } = content
122
- const core = getDcgchatRuntime()
123
-
124
- const route = core.channel.routing.resolveAgentRoute({
125
- cfg: getOpenClawConfig() as OpenClawConfig,
126
- channel: "dcgchat",
127
- accountId: accountId || 'default',
128
- peer: { kind: 'direct', id: session_id }
129
- })
130
- return real_mobook == '1' ? route.sessionKey : `agent:main:mobook:direct:${agent_id}:${session_id}`.toLowerCase()
131
- }
132
-
133
111
  const cronMessageIdMap = new Map<string, string>()
134
112
 
135
113
  export function setCronMessageId(sk: string, messageId: string) {
@@ -144,6 +122,21 @@ export function removeCronMessageId(sk: string) {
144
122
  cronMessageIdMap.delete(sk)
145
123
  }
146
124
 
125
+ export const getSessionKey = (content: any, accountId: string) => {
126
+ const { real_mobook, agent_id, agent_clone_code, session_id } = content
127
+ const core = getDcgchatRuntime()
128
+
129
+ const agentCode = agent_clone_code || 'main'
130
+
131
+ const route = core.channel.routing.resolveAgentRoute({
132
+ cfg: getOpenClawConfig() as OpenClawConfig,
133
+ channel: "dcgchat",
134
+ accountId: accountId || 'default',
135
+ peer: { kind: 'direct', id: session_id }
136
+ })
137
+ return real_mobook == '1' ? route.sessionKey : `agent:${agentCode}:mobook:direct:${agent_id}:${session_id}`.toLowerCase()
138
+ }
139
+
147
140
  export function getInfoBySessionKey(sk: string): { sessionId: string; agentId: string } {
148
141
  const sessionInfo = sk.split(':')
149
142
  return { sessionId: sessionInfo.at(-1) ?? '', agentId: sessionInfo.at(-2) ?? '' }
@@ -47,9 +47,8 @@ export function getEffectiveMsgParams(sessionKey?: string): IMsgParams {
47
47
  }
48
48
 
49
49
  /**
50
- * Agent `message` 工具的 `target` 应为 `effectiveSessionKey`(如 `agent:main:mobook:direct:...`)。
51
- * `setParamsMessage` 使用的 key 与此一致。若按 preferredKey 查不到 map,
52
- * 则回落到当前会话 `currentSessionKey`,避免拿到空 `messageId` / `sessionId` 导致无文件卡片、WS 上下文错误。
50
+ * Agent `dcgchat_message` / 出站 `target` 应为 `dcgSessionKey`(如 `agent:main:mobook:direct:...`)。
51
+ * `setParamsMessage` key 与此一致;查不到 map 时回落到配置缺省(无会话级 messageId/sessionId)。
53
52
  */
54
53
  export function getOutboundMsgParams(preferredKey: string): IMsgParams {
55
54
  const k = preferredKey?.trim()
@@ -69,3 +68,21 @@ export function setParamsMessage(sessionKey: string, params: Partial<IMsgParams>
69
68
  export function getParamsMessage(sessionKey: string): IMsgParams | undefined {
70
69
  return paramsMessageMap.get(sessionKey)
71
70
  }
71
+
72
+ export function clearParamsMessage(sessionKey: string): void {
73
+ const k = sessionKey?.trim()
74
+ if (!k) return
75
+ paramsMessageMap.delete(k)
76
+ }
77
+
78
+ // sessionKey 对应的 子agent的runId
79
+ const subagentRunIdMap = new Map<string, string>()
80
+ export function getSessionKeyBySubAgentRunId(runId: string): string | undefined {
81
+ return subagentRunIdMap.get(runId)
82
+ }
83
+ export function setSessionKeyBySubAgentRunId(runId: string, sessionKey: string) {
84
+ subagentRunIdMap.set(runId, sessionKey)
85
+ }
86
+ export function deleteSessionKeyBySubAgentRunId(runId: string) {
87
+ subagentRunIdMap.delete(runId)
88
+ }
@@ -0,0 +1,64 @@
1
+ import { handleDcgchatMessage } from '../bot.js'
2
+ import { setMsgStatus, getSessionKey } from './global.js'
3
+ import type { InboundMessage } from '../types.js'
4
+ import { installSkill, uninstallSkill } from '../skill.js'
5
+ import { dcgLogger } from './log.js'
6
+ import { onDisabledCronJob, onEnabledCronJob, onRemoveCronJob, onRunCronJob } from '../cron.js'
7
+ import { ignoreToolCommand } from './constant.js'
8
+ import { createAgent } from '../agent.js'
9
+
10
+ export type ParsedWsPayload = {
11
+ messageType?: string
12
+ content: any
13
+ }
14
+
15
+ /**
16
+ * 处理 WebSocket 已解析 JSON 且 content 已二次 parse 后的业务消息(openclaw_bot_chat / openclaw_bot_event)。
17
+ */
18
+ export async function handleParsedWsMessage(parsed: ParsedWsPayload, rawPayload: string, accountId: string): Promise<void> {
19
+ if (parsed.messageType == 'openclaw_bot_chat') {
20
+ const msg = parsed as unknown as InboundMessage
21
+ // 与 monitor 原逻辑一致:工具类指令不进入 running,避免误触工具链监控
22
+ const effectiveSessionKey = getSessionKey(msg.content, accountId)
23
+ if (!ignoreToolCommand.includes(msg.content.text?.trim() ?? '')) {
24
+ setMsgStatus(effectiveSessionKey, 'running')
25
+ } else {
26
+ setMsgStatus(effectiveSessionKey, 'finished')
27
+ }
28
+ await handleDcgchatMessage(msg, accountId)
29
+ } else if (parsed.messageType == 'openclaw_bot_event') {
30
+ const { event_type, operation_type } = parsed.content ? parsed.content : ({} as Record<string, any>)
31
+ if (event_type === 'skill') {
32
+ const { skill_url, skill_code, skill_id, bot_token, websocket_trace_id } = parsed.content
33
+ const content = { event_type, operation_type, skill_url, skill_code, skill_id, bot_token, websocket_trace_id }
34
+ if (operation_type === 'install' || operation_type === 'enable' || operation_type === 'update') {
35
+ installSkill({ path: skill_url, code: skill_code }, content)
36
+ } else if (operation_type === 'remove' || operation_type === 'disable') {
37
+ uninstallSkill({ code: skill_code }, content)
38
+ } else {
39
+ dcgLogger(`openclaw_bot_event unknown event_type: ${event_type}, ${rawPayload}`)
40
+ }
41
+ } else if (event_type === 'agent') {
42
+ if (operation_type === 'create') {
43
+ await createAgent(parsed.content)
44
+ } else {
45
+ dcgLogger(`openclaw_bot_event unknown event_type: ${event_type}, ${rawPayload}`)
46
+ }
47
+ } else if (event_type === 'cron') {
48
+ const { job_id, message_id } = parsed.content
49
+ if (operation_type === 'remove') {
50
+ await onRemoveCronJob(job_id)
51
+ } else if (operation_type === 'enable') {
52
+ await onEnabledCronJob(job_id)
53
+ } else if (operation_type === 'disable') {
54
+ await onDisabledCronJob(job_id)
55
+ } else if (operation_type === 'run') {
56
+ await onRunCronJob(job_id, message_id)
57
+ }
58
+ } else {
59
+ dcgLogger(`openclaw_bot_event unknown operation_type: ${operation_type}, ${rawPayload}`)
60
+ }
61
+ } else {
62
+ dcgLogger(`ignoring unknown messageType: ${parsed.messageType}`, 'error')
63
+ }
64
+ }
@@ -0,0 +1,97 @@
1
+ import path from 'path'
2
+ import fs from 'fs'
3
+ /** @ts-ignore */
4
+ import unzipper from 'unzipper'
5
+ import { pipeline } from 'stream/promises'
6
+ import { decodeZipEntryPath } from './zipPath.js'
7
+
8
+ /**
9
+ * 若且唯若所有条目都在同一顶层目录下(如 GitHub 下载的 repo-name/...),返回该目录名;否则返回 null。
10
+ * 不能再用「第一个多段路径的第一段」推断,否则 ZIP 条目顺序变化时会误判(例如先出现 .github/ 或 src/)。
11
+ */
12
+ export function computeSharedZipRootPrefix(decodedPaths: string[]): string | null {
13
+ const normalized = decodedPaths
14
+ .map((p) => p.replace(/\\/g, '/').replace(/\/+$/, ''))
15
+ .filter((p) => p.length > 0)
16
+
17
+ if (normalized.length === 0) return null
18
+
19
+ const firstSegs = new Set<string>()
20
+ for (const p of normalized) {
21
+ const seg = p.split('/').filter(Boolean)[0]
22
+ if (seg) firstSegs.add(seg)
23
+ }
24
+ if (firstSegs.size !== 1) return null
25
+
26
+ const root = [...firstSegs][0]!
27
+ const prefix = `${root}/`
28
+ for (const p of normalized) {
29
+ if (p !== root && !p.startsWith(prefix)) return null
30
+ }
31
+ return root
32
+ }
33
+
34
+ function assertSafeZipTarget(destDir: string, relativePath: string): string {
35
+ const resolvedPath = path.resolve(destDir, relativePath)
36
+ const resolvedDest = path.resolve(destDir)
37
+ const rel = path.relative(resolvedDest, resolvedPath)
38
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
39
+ throw new Error(`zip 路径越界: ${relativePath}`)
40
+ }
41
+ return resolvedPath
42
+ }
43
+
44
+ type ZipEntry = {
45
+ path: string
46
+ pathBuffer: Buffer
47
+ flags: number
48
+ type: string
49
+ stream: (password?: string) => NodeJS.ReadableStream
50
+ }
51
+
52
+ /**
53
+ * 将已下载的 zip 解压到 destDir;顶层单根目录(若存在)会被剥掉,与原先流式 Parse 行为一致,但根目录由全量路径计算,与条目顺序无关。
54
+ */
55
+ export async function extractZipBufferToDirectory(buf: Buffer, destDir: string): Promise<void> {
56
+ const directory = await unzipper.Open.buffer(buf)
57
+ const files = (await directory.files) as ZipEntry[]
58
+
59
+ const decodedPaths = files.map((entry) =>
60
+ decodeZipEntryPath(entry.pathBuffer, entry.flags ?? 0, entry.path)
61
+ )
62
+ const rootDir = computeSharedZipRootPrefix(decodedPaths)
63
+
64
+ // 与 unzipper 默认 extract 一致:串行读各 entry,避免同一 buffer 上多路解压竞争
65
+ for (let i = 0; i < files.length; i++) {
66
+ const entry = files[i]!
67
+ const entryPath = decodedPaths[i]!
68
+ let newPath = entryPath.replace(/\\/g, '/')
69
+ if (rootDir) {
70
+ if (newPath === rootDir || newPath === `${rootDir}/`) {
71
+ continue
72
+ }
73
+ if (newPath.startsWith(`${rootDir}/`)) {
74
+ newPath = newPath.slice(rootDir.length + 1)
75
+ }
76
+ }
77
+ newPath = newPath.replace(/\/+$/, '')
78
+ if (!newPath) continue
79
+
80
+ const targetPath = assertSafeZipTarget(destDir, newPath)
81
+
82
+ if (entry.type === 'Directory') {
83
+ fs.mkdirSync(targetPath, { recursive: true })
84
+ continue
85
+ }
86
+
87
+ const parentDir = path.dirname(targetPath)
88
+ fs.mkdirSync(parentDir, { recursive: true })
89
+ const writeStream = fs.createWriteStream(targetPath)
90
+ try {
91
+ await pipeline(entry.stream(), writeStream)
92
+ } catch (err) {
93
+ const message = err instanceof Error ? err.message : String(err)
94
+ throw new Error(`解压文件失败 ${entryPath}: ${message}`)
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * ZIP 文件名编码:规范要求 UTF-8 时设置 0x800;很多工具未设标志但仍写 UTF-8 字节。
3
+ * 无标志时若一律按 GBK 解码,会得到「鍥句功…」类乱码。先严格 UTF-8,失败再 GBK(兼容 Windows 中文 ZIP)。
4
+ */
5
+ export function decodeZipEntryPath(
6
+ pathBuffer: Buffer | Uint8Array | undefined,
7
+ flags: number,
8
+ fallbackPath: string
9
+ ): string {
10
+ if ((flags & 0x800) !== 0) {
11
+ if (pathBuffer) {
12
+ return new TextDecoder('utf-8').decode(pathBuffer)
13
+ }
14
+ return fallbackPath
15
+ }
16
+ if (pathBuffer && pathBuffer.length > 0) {
17
+ try {
18
+ return new TextDecoder('utf-8', { fatal: true }).decode(pathBuffer)
19
+ } catch {
20
+ return new TextDecoder('gbk').decode(pathBuffer)
21
+ }
22
+ }
23
+ return fallbackPath
24
+ }