@dcrays/dcgchat 0.4.27 → 0.5.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/src/tool.ts DELETED
@@ -1,422 +0,0 @@
1
- import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'
2
- import { getMsgStatus } from './utils/global.js'
3
- import { dcgLogger } from './utils/log.js'
4
- import { sendFinal, sendText, wsSendRaw } from './transport.js'
5
- import { getEffectiveMsgParams, deleteSessionKeyBySubAgentRunId, setSessionKeyBySubAgentRunId } from './utils/params.js'
6
- import { cronToolCall } from './cronToolCall.js'
7
-
8
- type PluginHookName =
9
- | 'before_model_resolve'
10
- | 'before_prompt_build'
11
- | 'before_agent_start'
12
- | 'llm_input'
13
- | 'llm_output'
14
- | 'agent_end'
15
- | 'before_compaction'
16
- | 'after_compaction'
17
- | 'before_reset'
18
- | 'message_received'
19
- | 'message_sending'
20
- | 'message_sent'
21
- | 'before_tool_call'
22
- | 'after_tool_call'
23
- | 'tool_result_persist'
24
- | 'before_message_write'
25
- | 'session_start'
26
- | 'session_end'
27
- | 'subagent_spawning'
28
- | 'subagent_delivery_target'
29
- | 'subagent_spawned'
30
- | 'subagent_ended'
31
- | 'gateway_start'
32
- | 'gateway_stop'
33
-
34
- // message_received 没有 sessionKey 前置到bot中执行
35
- const eventList = [
36
- // { event: 'message_received', message: '' },
37
- // {event: 'before_model_resolve', message: ''},
38
- // {event: 'before_prompt_build', message: '正在查阅背景资料,构建思考逻辑'},
39
- // {event: 'before_agent_start', message: '书灵墨宝已就位,准备开始执行任务'},
40
- { event: 'subagent_spawning', message: '' },
41
- { event: 'subagent_spawned', message: '' },
42
- { event: 'subagent_delivery_target', message: '' },
43
- // {event: 'llm_input', message: ''},
44
- { event: 'llm_output', message: '' },
45
- // {event: 'agent_end', message: '核心任务已处理完毕...'},
46
- { event: 'subagent_ended', message: '' },
47
- // {event: 'before_message_write', message: '正在将本次对话存入记忆库...'},
48
- // {event: 'message_sending', message: ''},
49
- // {event: 'message_send', message: ''},
50
- { event: 'before_tool_call', message: '' },
51
- { event: 'after_tool_call', message: '' }
52
- ]
53
-
54
- /** 子 agent 的 sessionKey 往往未写入 params map,回落到主会话 outbound 参数避免 messageId 缺失 */
55
- function resolveOutboundParamsForSession(sk: string) {
56
- const k = sk.trim()
57
- let params = getEffectiveMsgParams(k)
58
- if (params.messageId?.trim() || params.sessionId?.trim()) return params
59
- const parent = requesterByChildSessionKey.get(k)
60
- if (parent) {
61
- const parentParams = getEffectiveMsgParams(parent)
62
- if (parentParams.messageId?.trim() || parentParams.sessionId?.trim()) return parentParams
63
- }
64
- return params
65
- }
66
-
67
- /** 主会话已 running 时,子会话上的工具/事件也应下发(否则子 key 无 running 状态会整段丢消息) */
68
- export function isSessionActiveForTool(sk: string): boolean {
69
- const k = sk.trim()
70
- if (!k) return false
71
- if (getMsgStatus(k) === 'running') return true
72
- const parent = requesterByChildSessionKey.get(k)
73
- return parent ? getMsgStatus(parent) === 'running' : false
74
- }
75
-
76
- function sendToolCallMessage(sk: string, text: string, toolCallId: string, isCover: number) {
77
- const params = resolveOutboundParamsForSession(sk)
78
- const content = { is_finish: -1, tool_call_id: toolCallId, is_cover: isCover, thinking_content: text, response: '' }
79
- wsSendRaw(params, content, false)
80
- }
81
-
82
- /**
83
- * 深拷贝 params 并注入 bestEffort: true
84
- */
85
- interface CronDelivery {
86
- mode?: string
87
- channel?: string
88
- to?: string
89
- bestEffort?: boolean
90
- [key: string]: unknown
91
- }
92
-
93
- // --- Subagent 活跃跟踪(按主会话 requesterSessionKey)---
94
-
95
- /** 主会话 sessionKey -> 仍活跃的子 agent runId */
96
- const activeSubagentRunIdsByRequester = new Map<string, Set<string>>()
97
- /** 子会话 childSessionKey -> 主会话 requesterSessionKey */
98
- const requesterByChildSessionKey = new Map<string, string>()
99
- /** 子会话 childSessionKey -> spawn 时的 runId(ended 事件可能不带 runId) */
100
- const runIdByChildSessionKey = new Map<string, string>()
101
- /** 主会话 -> 等待「子 agent 全部结束」的回调 */
102
- const subagentIdleWaiters = new Map<string, Set<() => void>>()
103
-
104
- function getOrCreateRunIdSet(requesterSessionKey: string): Set<string> {
105
- let set = activeSubagentRunIdsByRequester.get(requesterSessionKey)
106
- if (!set) {
107
- set = new Set()
108
- activeSubagentRunIdsByRequester.set(requesterSessionKey, set)
109
- }
110
- return set
111
- }
112
-
113
- function flushSubagentIdleWaiters(requesterSessionKey: string): void {
114
- const set = activeSubagentRunIdsByRequester.get(requesterSessionKey)
115
- if (set && set.size > 0) return
116
- activeSubagentRunIdsByRequester.delete(requesterSessionKey)
117
- const waiters = subagentIdleWaiters.get(requesterSessionKey)
118
- if (!waiters?.size) return
119
- subagentIdleWaiters.delete(requesterSessionKey)
120
- for (const w of waiters) {
121
- try {
122
- w()
123
- } catch (e) {
124
- dcgLogger(`subagent idle waiter error: ${String(e)}`, 'error')
125
- }
126
- }
127
- }
128
-
129
- function registerSubagentSpawn(requesterSessionKey: string, runId: string, childSessionKey: string): void {
130
- const req = requesterSessionKey.trim()
131
- const rid = runId.trim()
132
- const child = childSessionKey.trim()
133
- if (!req || !rid || !child) {
134
- dcgLogger(`subagent track spawn skipped: missing key req=${req} runId=${rid} child=${child}`)
135
- return
136
- }
137
- getOrCreateRunIdSet(req).add(rid)
138
- requesterByChildSessionKey.set(child, req)
139
- runIdByChildSessionKey.set(child, rid)
140
- dcgLogger(`subagent track spawn: requester=${req} runId=${rid} child=${child} active=${getOrCreateRunIdSet(req).size}`)
141
- }
142
-
143
- function registerSubagentEnd(
144
- ctx: { requesterSessionKey?: string; sessionKey?: string },
145
- targetSessionKey: string,
146
- runId?: string
147
- ): void {
148
- const child = targetSessionKey.trim()
149
- if (!child) return
150
- const req = ctx.requesterSessionKey?.trim() || requesterByChildSessionKey.get(child) || ctx.sessionKey?.trim() || ''
151
- const resolvedRunId = (runId?.trim() || runIdByChildSessionKey.get(child) || '').trim()
152
- deleteSessionKeyBySubAgentRunId(resolvedRunId)
153
- if (!req) {
154
- dcgLogger(`subagent track end: no requester for child=${child} runId=${resolvedRunId}`)
155
- requesterByChildSessionKey.delete(child)
156
- runIdByChildSessionKey.delete(child)
157
- return
158
- }
159
- const set = activeSubagentRunIdsByRequester.get(req)
160
- if (set && resolvedRunId) {
161
- set.delete(resolvedRunId)
162
- }
163
- requesterByChildSessionKey.delete(child)
164
- runIdByChildSessionKey.delete(child)
165
- dcgLogger(`subagent track end: requester=${req} runId=${resolvedRunId || 'n/a'} remaining=${set?.size ?? 0}`)
166
- if (set && set.size === 0) {
167
- activeSubagentRunIdsByRequester.delete(req)
168
- }
169
- flushSubagentIdleWaiters(req)
170
- }
171
-
172
- /** 当前跟踪到的、挂在该主会话下的子会话 sessionKey(供 /stop 时逐个 chat.abort) */
173
- export function getChildSessionKeysTrackedForRequester(requesterSessionKey: string): string[] {
174
- const req = requesterSessionKey.trim()
175
- if (!req) return []
176
- const out: string[] = []
177
- for (const [child, parent] of requesterByChildSessionKey.entries()) {
178
- if (parent === req) out.push(child)
179
- }
180
- return out
181
- }
182
-
183
- /**
184
- * 自根 requester 起 BFS 收集所有已跟踪后代子会话(含嵌套)。网关 abort 时宜自深到浅,调用方对结果 `.reverse()` 后再逐个 chat.abort。
185
- */
186
- export function getDescendantSessionKeysForRequester(rootRequesterSessionKey: string): string[] {
187
- const root = rootRequesterSessionKey.trim()
188
- if (!root) return []
189
- const ordered: string[] = []
190
- const seen = new Set<string>()
191
- let frontier = getChildSessionKeysTrackedForRequester(root)
192
- while (frontier.length > 0) {
193
- const next: string[] = []
194
- for (const sk of frontier) {
195
- const k = sk.trim()
196
- if (!k || seen.has(k)) continue
197
- seen.add(k)
198
- ordered.push(k)
199
- next.push(...getChildSessionKeysTrackedForRequester(k))
200
- }
201
- frontier = next
202
- }
203
- return ordered
204
- }
205
-
206
- /**
207
- * 打断后清空本地子 agent 跟踪(runId、父子映射、子 runId→sessionKey),并唤醒 waitUntilSubagentsIdle,避免永久挂起。
208
- */
209
- export function resetSubagentStateForRequesterSession(requesterSessionKey: string): void {
210
- const req = requesterSessionKey.trim()
211
- if (!req) return
212
-
213
- const runIdSet = activeSubagentRunIdsByRequester.get(req)
214
- if (runIdSet) {
215
- for (const rid of runIdSet) {
216
- deleteSessionKeyBySubAgentRunId(rid)
217
- }
218
- }
219
-
220
- for (const [child, parent] of [...requesterByChildSessionKey.entries()]) {
221
- if (parent === req) {
222
- requesterByChildSessionKey.delete(child)
223
- runIdByChildSessionKey.delete(child)
224
- }
225
- }
226
-
227
- activeSubagentRunIdsByRequester.delete(req)
228
-
229
- const waiters = subagentIdleWaiters.get(req)
230
- if (!waiters?.size) return
231
- subagentIdleWaiters.delete(req)
232
- for (const w of waiters) {
233
- try {
234
- w()
235
- } catch (e) {
236
- dcgLogger(`subagent idle waiter error: ${String(e)}`, 'error')
237
- }
238
- }
239
- }
240
-
241
- /** 当前主会话下仍在跑的子 agent 数量(按 spawn 时 runId 去重) */
242
- export function getActiveSubagentCount(sessionKey: string): number {
243
- const sk = sessionKey?.trim()
244
- if (!sk) return 0
245
- return activeSubagentRunIdsByRequester.get(sk)?.size ?? 0
246
- }
247
-
248
- /**
249
- * 等到指定主会话下已跟踪的子 agent 全部结束(spawn 失败等路径也会触发 subagent_ended,一般会配对清理)。
250
- * 注意:须在收到 `subagent_spawned` 之后才会计入;仅 spawning 未 spawned 的不会阻塞。
251
- */
252
- export function waitUntilSubagentsIdle(sessionKey: string, opts?: { timeoutMs?: number; signal?: AbortSignal }): Promise<void> {
253
- const sk = sessionKey?.trim()
254
- if (!sk) return Promise.resolve()
255
-
256
- if (getActiveSubagentCount(sk) === 0) return Promise.resolve()
257
-
258
- return new Promise<void>((resolve, reject) => {
259
- let settled = false
260
- const finish = (fn: () => void) => {
261
- if (settled) return
262
- settled = true
263
- if (timeoutId) clearTimeout(timeoutId)
264
- opts?.signal?.removeEventListener('abort', onAbort)
265
- removeWaiter()
266
- fn()
267
- }
268
-
269
- const removeWaiter = () => {
270
- const bucket = subagentIdleWaiters.get(sk)
271
- if (!bucket) return
272
- bucket.delete(onIdle)
273
- if (bucket.size === 0) subagentIdleWaiters.delete(sk)
274
- }
275
-
276
- const onIdle = () => finish(() => resolve())
277
-
278
- const onAbort = () => {
279
- const reason = opts?.signal?.reason
280
- finish(() => reject(reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted'))))
281
- }
282
-
283
- let timeoutId: ReturnType<typeof setTimeout> | undefined
284
- if (opts?.timeoutMs != null && opts.timeoutMs > 0) {
285
- timeoutId = setTimeout(
286
- () => finish(() => reject(new Error(`waitUntilSubagentsIdle timeout ${opts.timeoutMs}ms`))),
287
- opts.timeoutMs
288
- )
289
- }
290
-
291
- if (opts?.signal) {
292
- if (opts.signal.aborted) {
293
- onAbort()
294
- return
295
- }
296
- opts.signal.addEventListener('abort', onAbort, { once: true })
297
- }
298
-
299
- let set = subagentIdleWaiters.get(sk)
300
- if (!set) {
301
- set = new Set()
302
- subagentIdleWaiters.set(sk, set)
303
- }
304
- set.add(onIdle)
305
-
306
- if (getActiveSubagentCount(sk) === 0) {
307
- onIdle()
308
- }
309
- })
310
- }
311
-
312
- function resolveHookSessionKey(
313
- eventName: string,
314
- args: { sessionKey?: string; requesterSessionKey?: string; runId?: string }
315
- ): string {
316
- if (
317
- eventName === 'subagent_spawned' ||
318
- eventName === 'subagent_ended' ||
319
- eventName === 'subagent_spawning' ||
320
- eventName === 'subagent_delivery_target'
321
- ) {
322
- if (args?.runId && args?.requesterSessionKey) setSessionKeyBySubAgentRunId(args?.runId, args?.requesterSessionKey)
323
- return (args?.requesterSessionKey || args?.sessionKey || '').trim()
324
- }
325
- return (args?.sessionKey || '').trim()
326
- }
327
-
328
- /** 定时触发时会话往往非 running,但仍需跑 before_tool_call 以注入 sessionKey / delivery(见 cronToolCall) */
329
- function shouldRunBeforeToolCallWithoutRunningSession(event: { toolName?: string; params?: { command?: string } }): boolean {
330
- if (event?.toolName === 'cron') return true
331
- const cmd = event?.params?.command
332
- if (event?.toolName === 'exec' && typeof cmd === 'string') {
333
- return cmd.includes('cron create') || cmd.includes('cron add')
334
- }
335
- return false
336
- }
337
-
338
- function trackSubagentLifecycle(eventName: string, event: any, args: any): void {
339
- if (eventName === 'subagent_spawned') {
340
- const runId = typeof event?.runId === 'string' ? event.runId : ''
341
- const childSessionKey = typeof event?.childSessionKey === 'string' ? event.childSessionKey : ''
342
- const requester =
343
- typeof args?.requesterSessionKey === 'string'
344
- ? args.requesterSessionKey
345
- : typeof args?.sessionKey === 'string'
346
- ? args.sessionKey
347
- : ''
348
- registerSubagentSpawn(requester, runId, childSessionKey)
349
- return
350
- }
351
- if (eventName === 'subagent_ended') {
352
- const targetSessionKey = typeof event?.targetSessionKey === 'string' ? event.targetSessionKey : ''
353
- const runId = typeof event?.runId === 'string' ? event.runId : undefined
354
- registerSubagentEnd(args ?? {}, targetSessionKey, runId)
355
- }
356
- }
357
-
358
- export function monitoringToolMessage(api: OpenClawPluginApi) {
359
- for (const item of eventList) {
360
- api.on(item.event as PluginHookName, (event: any, args: any) => {
361
- trackSubagentLifecycle(item.event, event, args)
362
- const sk = resolveHookSessionKey(item.event, args ?? {})
363
- if (sk) {
364
- const toolHooksOk =
365
- isSessionActiveForTool(sk) || (item.event === 'before_tool_call' && shouldRunBeforeToolCallWithoutRunningSession(event))
366
- if (toolHooksOk) {
367
- if (['after_tool_call', 'before_tool_call'].includes(item.event)) {
368
- const { result: _result, ...rest } = event
369
- dcgLogger(`工具调用结果: ~ event:${item.event} ~ params:${JSON.stringify(rest)}`)
370
-
371
- if (item.event === 'before_tool_call') {
372
- const hookResult = cronToolCall(rest, sk)
373
- const text = JSON.stringify({
374
- type: item.event,
375
- specialIdentification: 'dcgchat_tool_call_special_identification',
376
- callId: event.toolCallId || event.runId || Date.now().toString(),
377
- ...rest,
378
- status: 'running'
379
- })
380
- sendToolCallMessage(sk, text, event.toolCallId || event.runId || Date.now().toString(), 0)
381
- return hookResult
382
- }
383
- const text = JSON.stringify({
384
- type: item.event,
385
- specialIdentification: 'dcgchat_tool_call_special_identification',
386
- callId: event.toolCallId || event.runId || Date.now().toString(),
387
- ...rest,
388
- status: item.event === 'after_tool_call' ? 'finished' : 'running'
389
- })
390
- sendToolCallMessage(
391
- sk,
392
- text,
393
- event.toolCallId || event.runId || Date.now().toString(),
394
- item.event === 'after_tool_call' ? 1 : 0
395
- )
396
- } else if (item.event) {
397
- const msgCtx = resolveOutboundParamsForSession(sk)
398
- if (item.event === 'llm_output') {
399
- if (event.lastAssistant?.errorMessage === '1003-额度不足请充值') {
400
- const message = '您的积分已消耗完,您可以通过充值积分来继续使用'
401
- sendText(message, msgCtx, { message_tags: { insufficient_balance: 1 }, is_finish: -1 })
402
- sendFinal(msgCtx, '积分不足')
403
- return
404
- }
405
- }
406
- const text = JSON.stringify({
407
- type: item.event,
408
- specialIdentification: 'dcgchat_tool_call_special_identification',
409
- toolName: '',
410
- callId: event.runId || Date.now().toString(),
411
- params: item.message
412
- })
413
- sendToolCallMessage(sk, text, event.runId || Date.now().toString(), 0)
414
- dcgLogger(`工具调用结果: ~ event:${item.event}`)
415
- }
416
- }
417
- } else if (item.event !== 'before_tool_call') {
418
- dcgLogger(`工具调用结果: ~ event:${item.event} ~ 没有sessionKey 为执行`)
419
- }
420
- })
421
- }
422
- }
@@ -1,224 +0,0 @@
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.startsWith('/mobook/') || 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
- if (args.media?.length && sentFiles.size === 0) {
187
- return jsonResult({
188
- success: false,
189
- error:
190
- '未能发送任何附件:路径须位于当前 Agent 工作区,或为 /workspace/、/mobook/ 下的真实文件(非空、扩展名在白名单内)。',
191
- sentMediaCount: 0
192
- })
193
- }
194
-
195
- let content = args.content ?? ''
196
- for (const filepath of sentFiles) {
197
- const posix = toPosixPath(filepath)
198
- const variants = posix === filepath ? [filepath] : [filepath, posix]
199
- const seen = new Set<string>()
200
- for (const v of variants) {
201
- if (!v || seen.has(v)) continue
202
- seen.add(v)
203
- content = content.split(v).join('')
204
- }
205
- }
206
- content = content.trim()
207
-
208
- if (content.length > 0) {
209
- const msgCtx = getOutboundMsgParams(sessionKey)
210
- sendText(content, msgCtx)
211
- }
212
-
213
- return jsonResult({
214
- success: true,
215
- sentMediaCount: sentFiles.size
216
- })
217
- } catch (err) {
218
- return jsonResult({
219
- error: err instanceof Error ? err.message : String(err)
220
- })
221
- }
222
- }
223
- }
224
- }