@dcrays/dcgchat-test 0.6.8 → 0.6.12

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.
Files changed (60) hide show
  1. package/README.md +167 -0
  2. package/index.ts +35 -0
  3. package/openclaw.plugin.json +12 -41
  4. package/package.json +35 -8
  5. package/src/agent.ts +568 -0
  6. package/src/bot.ts +646 -0
  7. package/src/channel/index.ts +329 -0
  8. package/src/channel/outboundMedia.ts +144 -0
  9. package/src/channel/outboundTarget.ts +62 -0
  10. package/src/channel/uploadMediaUrl.ts +76 -0
  11. package/src/cron/message.ts +114 -0
  12. package/src/cron/params.ts +164 -0
  13. package/src/cron/toolGuard.ts +466 -0
  14. package/src/cron/types.ts +15 -0
  15. package/src/gateway/index.ts +430 -0
  16. package/src/gateway/security.ts +95 -0
  17. package/src/gateway/socket.ts +256 -0
  18. package/src/libs/ali-oss-6.23.0.tgz +0 -0
  19. package/src/libs/axios-1.13.6.tgz +0 -0
  20. package/src/libs/md5-2.3.0.tgz +0 -0
  21. package/src/libs/mime-types-3.0.2.tgz +0 -0
  22. package/src/libs/unzipper-0.12.3.tgz +0 -0
  23. package/src/libs/ws-8.19.0.tgz +0 -0
  24. package/src/monitor.ts +269 -0
  25. package/src/request/api.ts +80 -0
  26. package/src/request/oss.ts +200 -0
  27. package/src/request/request.ts +191 -0
  28. package/src/request/userInfo.ts +44 -0
  29. package/src/session.ts +28 -0
  30. package/src/skill.ts +114 -0
  31. package/src/tool.ts +603 -0
  32. package/src/tools/messageTool.ts +334 -0
  33. package/src/tools/toolCallGuard.ts +327 -0
  34. package/src/transport.ts +217 -0
  35. package/src/types.ts +139 -0
  36. package/src/utils/agentErrors.ts +116 -0
  37. package/src/utils/constant.ts +60 -0
  38. package/src/utils/env-config.ts +19 -0
  39. package/src/utils/formatLlmInputEvent.ts +48 -0
  40. package/src/utils/gatewayMsgHandler.ts +147 -0
  41. package/src/utils/global.ts +309 -0
  42. package/src/utils/inboundTurnState.ts +66 -0
  43. package/src/utils/log.ts +77 -0
  44. package/src/utils/mediaAttached.ts +244 -0
  45. package/src/utils/mediaEmitter.ts +54 -0
  46. package/src/utils/outboundAssistantText.ts +117 -0
  47. package/src/utils/params.ts +104 -0
  48. package/src/utils/passTxt.ts +4 -0
  49. package/src/utils/resolveRegisterConfig.ts +33 -0
  50. package/src/utils/searchFile.ts +228 -0
  51. package/src/utils/sessionState.ts +137 -0
  52. package/src/utils/sessionTermination.ts +228 -0
  53. package/src/utils/streamMerge.ts +150 -0
  54. package/src/utils/subagentRunMap.ts +71 -0
  55. package/src/utils/undiciFetchInterceptor.ts +346 -0
  56. package/src/utils/workspaceFilePaths.ts +18 -0
  57. package/src/utils/wsMessageHandler.ts +124 -0
  58. package/src/utils/zipExtract.ts +97 -0
  59. package/src/utils/zipPath.ts +24 -0
  60. package/index.js +0 -302
@@ -0,0 +1,334 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import type { AnyAgentTool } from 'openclaw/plugin-sdk'
4
+ import { jsonResult } from 'openclaw/plugin-sdk/channel-actions'
5
+ import { sendDcgchatMedia } from '..//channel/index.js'
6
+ import { isSessionActiveForTool } from '../tool.js'
7
+ import { dcgLogger } from '../utils/log.js'
8
+ import { getOutboundMsgParams } from '../utils/params.js'
9
+ import { emitOutboundAssistantText } from '../utils/outboundAssistantText.js'
10
+ import { mediaPathDedupeKey } from '../utils/global.js'
11
+ import { expandTildePath } from '../utils/workspaceFilePaths.js'
12
+ import { hasCronSessionKeyMap, normalizeSessionTarget } from '../channel/outboundTarget.js'
13
+
14
+ /** 与 `registerTool` 工厂入参一致(主包未导出 `OpenClawPluginToolContext` 时仅用所需字段)。 */
15
+ export type DcgchatMessageToolContext = {
16
+ sessionKey?: string
17
+ workspaceDir?: string
18
+ allowedPaths?: string[]
19
+ /** 通道配置 `allowedAttachmentExtensions`:在插件内置扩展名之外额外允许发送的附件后缀(如 `.py`、`.ipynb`),项可写 `.py` 或 `py`。 */
20
+ allowedAttachmentExtensions?: string[]
21
+ deliveryContext?: { to?: string }
22
+ }
23
+
24
+ /** 统一为 POSIX 风格斜杠,便于跨平台判断(不改变语义,仅用于匹配)。 */
25
+ function toPosixPath(p: string): string {
26
+ return path.normalize(p.trim()).replace(/\\/g, '/')
27
+ }
28
+
29
+ /** `filepath` 解析后在 `rootDir` 内或等于 `rootDir`(防 `..` 逃逸)。 */
30
+ function isPathInsideDir(filepath: string, rootDir: string): boolean {
31
+ const root = path.resolve(rootDir)
32
+ const resolved = path.resolve(filepath)
33
+ const rel = path.relative(root, resolved)
34
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return false
35
+ return true
36
+ }
37
+
38
+ /**
39
+ * 允许发送的路径:
40
+ * - 当前 Agent 工作区根及其子路径(`workspaceDir`,如 ~/.openclaw/workspace-xxx/output/...);
41
+ * - 兼容旧挂载:Unix `/workspace`、`/mobook`;Windows 盘符下 `workspace`、`mobook`。
42
+ */
43
+ function isSafePath(filepath: string, workspaceDir?: string, allowedPaths?: string[]): boolean {
44
+ // Check workspaceDir
45
+ const ws = workspaceDir?.trim()
46
+ if (ws && isPathInsideDir(filepath, ws)) return true
47
+
48
+ // Check allowedPaths from config
49
+ if (allowedPaths?.length) {
50
+ for (const allowed of allowedPaths) {
51
+ if (isPathInsideDir(filepath, allowed)) return true
52
+ }
53
+ }
54
+
55
+ // Check legacy mounts
56
+ const p = toPosixPath(filepath)
57
+ if (p.startsWith('/workspace/') || p === '/workspace') return true
58
+ if (p.startsWith('/mobook/') || p === '/mobook') return true
59
+ return p.includes('mobook') || p.includes('workspace')
60
+ }
61
+
62
+ /** 同一路径在 Windows 上可能大小写不同;媒体 key 与 channel/global 出站去重对齐 */
63
+ function pathKey(filepath: string): string {
64
+ return mediaPathDedupeKey(filepath.trim())
65
+ }
66
+
67
+ const fileType1 = ['.webp', '.gif', '.bmp', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.pdf', '.txt', '.rtf', '.odt', '.json']
68
+ const fileType2 = [
69
+ '.xml',
70
+ '.csv',
71
+ '.yaml',
72
+ '.yml',
73
+ '.html',
74
+ '.htm',
75
+ '.md',
76
+ '.markdown',
77
+ '.css',
78
+ '.js',
79
+ '.ts',
80
+ '.py',
81
+ '.pyi',
82
+ '.ipynb',
83
+ '.png',
84
+ '.jpg',
85
+ '.jpeg'
86
+ ]
87
+ const fileType3 = ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.exe', '.dmg', '.pkg', '.apk', '.ipa', '.log', '.dat', '.bin']
88
+ const fileType4 = ['.svg', '.ico', '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.webm']
89
+ const DEFAULT_SAFE_EXTENSIONS = new Set([...fileType1, ...fileType2, ...fileType3, ...fileType4])
90
+
91
+ function normalizeAttachmentExt(raw: string): string | null {
92
+ const t = raw.trim().toLowerCase()
93
+ if (!t) return null
94
+ return t.startsWith('.') ? t : `.${t}`
95
+ }
96
+
97
+ function buildSafeExtensions(extra?: string[]): Set<string> {
98
+ const set = new Set(DEFAULT_SAFE_EXTENSIONS)
99
+ for (const e of extra ?? []) {
100
+ const n = normalizeAttachmentExt(e)
101
+ if (n && n !== '.') set.add(n)
102
+ }
103
+ return set
104
+ }
105
+
106
+ type DcgchatMessageToolArgs = {
107
+ target?: string
108
+ content?: string
109
+ media?: { file?: string }[]
110
+ }
111
+
112
+ const messageToolParameters = {
113
+ type: 'object',
114
+ additionalProperties: false,
115
+ properties: {
116
+ target: {
117
+ type: 'string',
118
+ description: '目标会话键(sessionKey),必须与当前会话 SessionKey 一致,禁止填写 userId。'
119
+ },
120
+ content: {
121
+ type: 'string',
122
+ description: '发送文本内容'
123
+ },
124
+ media: {
125
+ type: 'array',
126
+ description: '发送附件',
127
+ items: {
128
+ type: 'object',
129
+ additionalProperties: false,
130
+ properties: {
131
+ file: {
132
+ type: 'string',
133
+ description:
134
+ '文件绝对路径:须在「当前 Agent 工作区」目录下(如 /root/.openclaw/workspace-xxx/output/28337/slices_result.json),或为兼容环境的 /workspace/、/mobook/(Windows 盘符下 workspace、mobook)'
135
+ }
136
+ },
137
+ required: ['file']
138
+ }
139
+ }
140
+ },
141
+ // 须至少提供正文或附件之一;用 anyOf(非 oneOf),否则同时带 content+media 时两个分支都满足会违反「恰好其一」而校验失败
142
+ anyOf: [{ required: ['content'] }, { required: ['media'] }]
143
+ }
144
+
145
+ /** 从正文提取可发送的文件路径(固定挂载 + 当前工作区前缀)。 */
146
+ function extractPaths(text: string | undefined, workspaceDir?: string): string[] {
147
+ if (!text) return []
148
+ const unix = text.match(/\/workspace\/[^\s]+|\/mobook\/[^\s]+/g) ?? []
149
+ const win = text.match(/[A-Za-z]:[/\\](?:workspace|mobook)[/\\][^\s]+/g) ?? []
150
+ const underWs: string[] = []
151
+ const ws = workspaceDir?.trim()
152
+ if (ws) {
153
+ const variants = new Set<string>()
154
+ variants.add(ws)
155
+ variants.add(toPosixPath(ws))
156
+ if (path.sep === '\\') variants.add(ws.replace(/\//g, '\\'))
157
+ for (const prefix of variants) {
158
+ if (!prefix) continue
159
+ let from = 0
160
+ while (from < text.length) {
161
+ const i = text.indexOf(prefix, from)
162
+ if (i === -1) break
163
+ let end = i + prefix.length
164
+ while (end < text.length && !/\s/.test(text[end])) end++
165
+ underWs.push(text.slice(i, end))
166
+ from = i + 1
167
+ }
168
+ }
169
+ }
170
+ return [...new Set([...unix, ...win, ...underWs])]
171
+ }
172
+
173
+ function isSafeFile(filepath: string, extensions: Set<string>) {
174
+ if (!fs.existsSync(filepath)) return false
175
+ const stat = fs.statSync(filepath)
176
+ if (!stat.isFile()) return false
177
+ if (stat.size === 0) return false
178
+ const ext = path.extname(filepath).toLowerCase()
179
+ return extensions.has(ext)
180
+ }
181
+
182
+ function getSessionKey(pluginCtx: DcgchatMessageToolContext): string {
183
+ const { sessionKey = '', deliveryContext } = pluginCtx
184
+ const isCron = sessionKey?.indexOf(':cron:') >= 0
185
+ if (isCron) {
186
+ return normalizeSessionTarget(deliveryContext?.to ?? '')
187
+ }
188
+ return sessionKey
189
+ }
190
+
191
+ /**
192
+ * 书灵墨宝出站消息工具:须符合 OpenClaw `AgentTool`(execute 返回 `AgentToolResult`)。
193
+ * 工具名使用 `dcgchat_message`,避免与核心内置 `message` 冲突。
194
+ * 通过注册时的 `OpenClawPluginToolContext.sessionKey` 出站,不再使用非标准的 `execute(args, ctx)`。
195
+ */
196
+ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext): AnyAgentTool {
197
+ const safeExtensions = buildSafeExtensions(pluginCtx.allowedAttachmentExtensions)
198
+ return {
199
+ name: 'dcgchat_message',
200
+ label: 'dcgchat_message',
201
+ description: `
202
+ 向用户发送消息。
203
+ 若传 target,target 必须是 sessionKey,不能是 userId。
204
+ 如果发送附件:必须使用 media 字段(不依赖 inline buttons,勿要求用户开启 channels.*.capabilities.inlineButtons)。
205
+ 文件路径须在当前 Agent 工作区目录下(随部署变化,如 ~/.openclaw/workspace-xxx/...),或为兼容环境的 /workspace/、/mobook/(Windows 盘符下 workspace、mobook)。
206
+ 禁止在正文中直接输出可访问路径(应通过 media 发送)
207
+ `,
208
+ parameters: messageToolParameters,
209
+ execute: async (_toolCallId, args, signal) => {
210
+ const toolArgs = args as DcgchatMessageToolArgs
211
+ if (signal?.aborted) {
212
+ const err = new Error('Message send aborted')
213
+ err.name = 'AbortError'
214
+ throw err
215
+ }
216
+
217
+ const sessionKey = getSessionKey(pluginCtx)
218
+ if (!sessionKey) {
219
+ dcgLogger(`dcgchat_message: missing sessionKey, cannot send message`, 'error')
220
+ // terminate:避免宿主在会话已结束后仍多轮调用模型重试 message,产生无效 token
221
+ return { ...jsonResult({ error: '缺少 sessionKey,无法向当前会话发送消息' }), terminate: true }
222
+ }
223
+ const isCronSessionKey = hasCronSessionKeyMap(sessionKey)
224
+ // 与 channel.outbound.sendText 一致:无活跃入站轮次则不下发。否则 /stop 或 sendFinal 之后,
225
+ // 网关里晚结束的 run 仍会调起 message 工具,getOutboundMsgParams 已指向**新**用户 messageId,造成旧回复错挂到后一条上。
226
+ if (!isCronSessionKey && !isSessionActiveForTool(sessionKey)) {
227
+ dcgLogger(`dcgchat_message: skipped (session not active, likely stale run after /stop or turn end): ${sessionKey}`)
228
+ return {
229
+ ...jsonResult({
230
+ success: false,
231
+ error: '当前会话无活跃入站轮次,已忽略发送;会话已结束,请勿再调用本工具。'
232
+ }),
233
+ terminate: true
234
+ }
235
+ }
236
+
237
+ try {
238
+ const sentFiles = new Set<string>()
239
+ const sentKeys = new Set<string>()
240
+ const workspaceDir = pluginCtx.workspaceDir
241
+ const allowedPaths = pluginCtx.allowedPaths
242
+ const mediaText = (toolArgs.content ?? '').trim()
243
+ let contentSentWithMedia = false
244
+
245
+ if (toolArgs.media?.length) {
246
+ const mediaUrls = []
247
+ for (const media of toolArgs.media) {
248
+ if (!media.file) continue
249
+ const filepath = expandTildePath(media.file)
250
+ if (!isSafePath(filepath, workspaceDir, allowedPaths)) continue
251
+ if (!isSafeFile(filepath, safeExtensions)) continue
252
+ const key = pathKey(filepath)
253
+ if (sentKeys.has(key)) continue
254
+
255
+ mediaUrls.push(filepath)
256
+ sentFiles.add(filepath)
257
+ sentKeys.add(key)
258
+ }
259
+ if (mediaUrls.length > 0) {
260
+ const text = isCronSessionKey && !contentSentWithMedia ? mediaText : undefined
261
+ if (text) contentSentWithMedia = true
262
+
263
+ await sendDcgchatMedia({ sessionKey, mediaUrls, text })
264
+ }
265
+ }
266
+
267
+ const fallbackPaths = extractPaths(toolArgs.content, workspaceDir)
268
+ const mediaUrls = []
269
+ for (const filepath of fallbackPaths) {
270
+ const normalizedFilepath = expandTildePath(filepath)
271
+ if (!isSafePath(normalizedFilepath, workspaceDir, allowedPaths)) {
272
+ dcgLogger(`非法路径:${normalizedFilepath}`, 'error')
273
+ continue
274
+ }
275
+ if (!isSafeFile(normalizedFilepath, safeExtensions)) {
276
+ dcgLogger(`非法文件类型:${normalizedFilepath}`, 'error')
277
+ continue
278
+ }
279
+ const key = pathKey(normalizedFilepath)
280
+ if (sentKeys.has(key)) continue
281
+
282
+ mediaUrls.push(normalizedFilepath)
283
+ sentFiles.add(normalizedFilepath)
284
+ sentKeys.add(key)
285
+ }
286
+ if (mediaUrls.length > 0) {
287
+ const text = isCronSessionKey && !contentSentWithMedia ? mediaText : undefined
288
+ if (text) contentSentWithMedia = true
289
+ await sendDcgchatMedia({ sessionKey, mediaUrls, text })
290
+ }
291
+
292
+ if (toolArgs.media?.length && sentFiles.size === 0) {
293
+ dcgLogger(`dcgchat_message: no media sent`, 'error')
294
+ return jsonResult({
295
+ success: false,
296
+ error:
297
+ '未能发送任何附件:路径须位于当前 Agent 工作区,或为 /workspace/、/mobook/ 下的真实文件(非空、扩展名在白名单内)。',
298
+ sentMediaCount: 0
299
+ })
300
+ }
301
+
302
+ let content = toolArgs.content ?? ''
303
+ for (const filepath of sentFiles) {
304
+ const posix = toPosixPath(filepath)
305
+ const variants = posix === filepath ? [filepath] : [filepath, posix]
306
+ const seen = new Set<string>()
307
+ for (const v of variants) {
308
+ if (!v || seen.has(v)) continue
309
+ seen.add(v)
310
+ content = content.split(v).join('')
311
+ }
312
+ }
313
+ content = content.trim()
314
+ if (contentSentWithMedia) content = ''
315
+
316
+ // 与 bot onPartialReply/deliver 共用 outboundAssistantText 快照增量,避免双发;NO_REPLY 场景由工具兜底正文
317
+ if (content.length > 0 && content !== 'NO_REPLY') {
318
+ const msgCtx = getOutboundMsgParams(sessionKey)
319
+ emitOutboundAssistantText(sessionKey, content, msgCtx)
320
+ }
321
+
322
+ return jsonResult({
323
+ success: true,
324
+ sentMediaCount: sentFiles.size
325
+ })
326
+ } catch (err) {
327
+ dcgLogger(`dcgchat_message: failed: ${String(err)}`, 'error')
328
+ return jsonResult({
329
+ error: err instanceof Error ? err.message : String(err)
330
+ })
331
+ }
332
+ }
333
+ }
334
+ }
@@ -0,0 +1,327 @@
1
+ import { mediaPathDedupeKey } from '../utils/global.js'
2
+ import { dcgLogger } from '../utils/log.js'
3
+ import { expandTildePath } from '../utils/workspaceFilePaths.js'
4
+
5
+ const LOG_TAG = 'tool-call-guard'
6
+
7
+ /** 与 OpenClaw `PluginHookBeforeToolCallResult` 对齐 */
8
+ export type DcgchatBeforeToolCallHookResult = {
9
+ params?: Record<string, unknown>
10
+ block?: boolean
11
+ blockReason?: string
12
+ }
13
+
14
+ const MAX_TRACKED_RUNS = 200
15
+
16
+ /** runId → 已下发的 message 正文 */
17
+ const sentMessageTextByRunId = new Map<string, Set<string>>()
18
+ /** runId → 已下发的 message 附件路径(规范化) */
19
+ const sentMessageFileByRunId = new Map<string, Set<string>>()
20
+ /** runId → 上一笔已成功完成的 FS 操作键(tool + 文件;仅拦截与上一笔相同的连续重复) */
21
+ const lastCompletedFsOpByRunId = new Map<string, string>()
22
+
23
+ const FS_LOOP_TOOLS = new Set(['read', 'write', 'edit', 'apply_patch'])
24
+ const TERMINATE_BLOCK_REASON = 'Tool round ended for this session (no active inbound turn). Do not retry; reply with NO_REPLY only.'
25
+
26
+ function pruneRunMap<T>(map: Map<string, T>, runId: string, factory: () => T): T {
27
+ let entry = map.get(runId)
28
+ if (!entry) {
29
+ if (map.size >= MAX_TRACKED_RUNS) {
30
+ const oldest = map.keys().next().value
31
+ if (oldest) map.delete(oldest)
32
+ }
33
+ entry = factory()
34
+ map.set(runId, entry)
35
+ }
36
+ return entry
37
+ }
38
+
39
+ function mergeBeforeToolCallResults(
40
+ ...results: Array<DcgchatBeforeToolCallHookResult | undefined>
41
+ ): DcgchatBeforeToolCallHookResult | undefined {
42
+ let paramsPatch: Record<string, unknown> | undefined
43
+ for (const r of results) {
44
+ if (!r) continue
45
+ if (r.block) return r
46
+ if (r.params) paramsPatch = r.params
47
+ }
48
+ return paramsPatch ? { params: paramsPatch } : undefined
49
+ }
50
+
51
+ function isBuiltinMessageTool(toolName: string): boolean {
52
+ return toolName === 'message'
53
+ }
54
+
55
+ function isDcgchatMessageTool(toolName: string): boolean {
56
+ return toolName === 'dcgchat_message'
57
+ }
58
+
59
+ function isOutboundMessagingTool(toolName: string): boolean {
60
+ return isBuiltinMessageTool(toolName) || isDcgchatMessageTool(toolName)
61
+ }
62
+
63
+ function isMessageSendAction(params: Record<string, unknown>): boolean {
64
+ const action = typeof params.action === 'string' ? params.action.trim().toLowerCase() : 'send'
65
+ return action === 'send'
66
+ }
67
+
68
+ function extractMessageSendText(params: Record<string, unknown>): string | null {
69
+ if (!isMessageSendAction(params)) return null
70
+ const message = typeof params.message === 'string' ? params.message.trim() : ''
71
+ return message || null
72
+ }
73
+
74
+ function extractDcgchatMessageSendText(params: Record<string, unknown>): string | null {
75
+ const content = typeof params.content === 'string' ? params.content.trim() : ''
76
+ if (!content || content === 'NO_REPLY') return null
77
+ return content
78
+ }
79
+
80
+ function extractDcgchatMessageSendFileKeys(params: Record<string, unknown>): string[] {
81
+ const media = params.media
82
+ if (!Array.isArray(media)) return []
83
+ const keys: string[] = []
84
+ for (const item of media) {
85
+ if (!item || typeof item !== 'object') continue
86
+ const file = (item as { file?: string }).file
87
+ if (typeof file === 'string' && file.trim()) {
88
+ const k = mediaPathDedupeKey(file.trim())
89
+ if (k) keys.push(k)
90
+ }
91
+ }
92
+ return [...new Set(keys)]
93
+ }
94
+
95
+ function extractOutboundMessagingText(toolName: string, params: Record<string, unknown>): string | null {
96
+ if (isBuiltinMessageTool(toolName)) return extractMessageSendText(params)
97
+ if (isDcgchatMessageTool(toolName)) return extractDcgchatMessageSendText(params)
98
+ return null
99
+ }
100
+
101
+ function extractOutboundMessagingFileKeys(toolName: string, params: Record<string, unknown>): string[] {
102
+ if (isBuiltinMessageTool(toolName)) return extractMessageSendFileKeys(params)
103
+ if (isDcgchatMessageTool(toolName)) return extractDcgchatMessageSendFileKeys(params)
104
+ return []
105
+ }
106
+
107
+ function isOutboundMessagingSend(toolName: string, params: Record<string, unknown>): boolean {
108
+ return extractOutboundMessagingText(toolName, params) !== null || extractOutboundMessagingFileKeys(toolName, params).length > 0
109
+ }
110
+
111
+ /** 内置 message 的 filePath / path / media[].file */
112
+ function extractMessageSendFileKeys(params: Record<string, unknown>): string[] {
113
+ if (!isMessageSendAction(params)) return []
114
+ const keys: string[] = []
115
+ for (const field of ['filePath', 'path'] as const) {
116
+ const v = params[field]
117
+ if (typeof v === 'string' && v.trim()) {
118
+ const k = mediaPathDedupeKey(v.trim())
119
+ if (k) keys.push(k)
120
+ }
121
+ }
122
+ const media = params.media
123
+ if (Array.isArray(media)) {
124
+ for (const item of media) {
125
+ if (!item || typeof item !== 'object') continue
126
+ const file = (item as { file?: string }).file
127
+ if (typeof file === 'string' && file.trim()) {
128
+ const k = mediaPathDedupeKey(file.trim())
129
+ if (k) keys.push(k)
130
+ }
131
+ }
132
+ }
133
+ return [...new Set(keys)]
134
+ }
135
+
136
+ /**
137
+ * 会话非 running 时仍须执行 `before_tool_call` + guard(由 tool.ts 外层门控调用)。
138
+ * 仅出站 `message(send)` 需要拦截;其它 message 动作无需放行。
139
+ */
140
+ export function shouldRunBeforeToolCallGuardWhenSessionInactive(event: {
141
+ toolName?: string
142
+ params?: Record<string, unknown>
143
+ }): boolean {
144
+ const toolName = event.toolName?.trim()
145
+ if (!toolName || !isOutboundMessagingTool(toolName)) return false
146
+ return isOutboundMessagingSend(toolName, event.params ?? {})
147
+ }
148
+
149
+ function stableJsonKey(value: unknown): string {
150
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
151
+ if (Array.isArray(value)) return `[${value.map(stableJsonKey).join(',')}]`
152
+ const obj = value as Record<string, unknown>
153
+ const keys = Object.keys(obj).sort()
154
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJsonKey(obj[k])}`).join(',')}}`
155
+ }
156
+
157
+ const FS_PATH_FIELDS = ['path', 'filePath', 'file_path', 'file'] as const
158
+
159
+ function normalizeFsPathParam(params: Record<string, unknown>): string | null {
160
+ for (const field of FS_PATH_FIELDS) {
161
+ const v = params[field]
162
+ if (typeof v === 'string' && v.trim()) {
163
+ const key = mediaPathDedupeKey(expandTildePath(v.trim()))
164
+ if (key) return key
165
+ }
166
+ }
167
+ return null
168
+ }
169
+
170
+ function fsReadRangeKey(params: Record<string, unknown>): string {
171
+ const offset = params.offset ?? params.startLine ?? ''
172
+ const limit = params.limit ?? params.endLine ?? ''
173
+ return `${String(offset)}:${String(limit)}`
174
+ }
175
+
176
+ /**
177
+ * 防循环操作键:同一工具 + 同一文件(read 另含 offset/limit)。
178
+ * 中间插入其它工具调用后会更新 last,允许对同文件再次执行同类操作。
179
+ */
180
+ function fsLoopOpKey(toolName: string, params: Record<string, unknown>): string | null {
181
+ if (!FS_LOOP_TOOLS.has(toolName)) return null
182
+ const pathKey = normalizeFsPathParam(params)
183
+ if (toolName === 'read') {
184
+ if (!pathKey) return `read:${stableJsonKey(params)}`
185
+ return `read:${pathKey}:${fsReadRangeKey(params)}`
186
+ }
187
+ if (pathKey) return `${toolName}:${pathKey}`
188
+ return `${toolName}:${stableJsonKey(params)}`
189
+ }
190
+
191
+ function setLastCompletedFsOp(runId: string, opKey: string): void {
192
+ if (lastCompletedFsOpByRunId.size >= MAX_TRACKED_RUNS && !lastCompletedFsOpByRunId.has(runId)) {
193
+ const oldest = lastCompletedFsOpByRunId.keys().next().value
194
+ if (oldest) lastCompletedFsOpByRunId.delete(oldest)
195
+ }
196
+ lastCompletedFsOpByRunId.set(runId, opKey)
197
+ }
198
+
199
+ function clearLastCompletedFsOp(runId: string): void {
200
+ lastCompletedFsOpByRunId.delete(runId)
201
+ }
202
+
203
+ function blockDuplicate(kind: string, runId: string, toolCallId: string | undefined, detail: string): DcgchatBeforeToolCallHookResult {
204
+ dcgLogger(`[${LOG_TAG}] block duplicate ${kind} runId=${runId} toolCallId=${toolCallId ?? ''} ${detail}`, 'log')
205
+ return {
206
+ block: true,
207
+ blockReason:
208
+ `Duplicate ${kind} in the current agent run (${detail}). ` +
209
+ 'Skip this repeated tool call and continue with other steps; do not retry the same call.'
210
+ }
211
+ }
212
+
213
+ function blockTerminateLike(runId: string, toolCallId: string | undefined, toolName: string): DcgchatBeforeToolCallHookResult {
214
+ dcgLogger(
215
+ `[${LOG_TAG}] block ${toolName} (session inactive, terminate-equivalent) runId=${runId} toolCallId=${toolCallId ?? ''}`,
216
+ 'log'
217
+ )
218
+ return { block: true, blockReason: TERMINATE_BLOCK_REASON }
219
+ }
220
+
221
+ function isCronSessionKey(sessionKey: string): boolean {
222
+ return sessionKey.includes(':cron:')
223
+ }
224
+
225
+ export type ToolCallGuardBeforeContext = {
226
+ sessionKey: string
227
+ /** 与 dcgchat_message / channel.sendText 一致:无活跃入站轮次则不应再出站 */
228
+ sessionActive: boolean
229
+ }
230
+
231
+ /**
232
+ * before_tool_call:内置 message(正文/附件)去重;FS 工具仅拦截「与上一笔成功调用相同文件+相同操作」的连续重复;
233
+ * 无活跃会话时对出站 message 做 terminate 等效 block(对齐 dcgchat_message)。
234
+ */
235
+ export function toolCallGuardBefore(
236
+ event: {
237
+ toolName?: string
238
+ params?: Record<string, unknown>
239
+ runId?: string
240
+ toolCallId?: string
241
+ },
242
+ ctx: ToolCallGuardBeforeContext
243
+ ): DcgchatBeforeToolCallHookResult | undefined {
244
+ const toolName = event.toolName?.trim()
245
+ if (!toolName) return undefined
246
+
247
+ const runId = event.runId?.trim()
248
+ if (!runId) return undefined
249
+
250
+ const params = event.params ?? {}
251
+
252
+ if (isOutboundMessagingTool(toolName)) {
253
+ if (isOutboundMessagingSend(toolName, params) && !ctx.sessionActive && !isCronSessionKey(ctx.sessionKey)) {
254
+ return blockTerminateLike(runId, event.toolCallId, toolName)
255
+ }
256
+
257
+ const text = extractOutboundMessagingText(toolName, params)
258
+ if (text) {
259
+ const sent = sentMessageTextByRunId.get(runId)
260
+ if (sent?.has(text)) {
261
+ return blockDuplicate(`${toolName} text`, runId, event.toolCallId, `len=${text.length}`)
262
+ }
263
+ }
264
+
265
+ const fileKeys = extractOutboundMessagingFileKeys(toolName, params)
266
+ if (fileKeys.length) {
267
+ const sentFiles = sentMessageFileByRunId.get(runId)
268
+ for (const fk of fileKeys) {
269
+ if (sentFiles?.has(fk)) {
270
+ return blockDuplicate(`${toolName} file`, runId, event.toolCallId, `path=${fk}`)
271
+ }
272
+ }
273
+ }
274
+ return undefined
275
+ }
276
+
277
+ const opKey = fsLoopOpKey(toolName, params)
278
+ if (!opKey) return undefined
279
+
280
+ if (lastCompletedFsOpByRunId.get(runId) === opKey) {
281
+ return blockDuplicate(toolName, runId, event.toolCallId, 'consecutive same file operation')
282
+ }
283
+ return undefined
284
+ }
285
+
286
+ /** after_tool_call:登记已成功完成的出站 message / 文件类工具,供后续 before 拦截 */
287
+ export function toolCallGuardAfter(event: {
288
+ toolName?: string
289
+ params?: Record<string, unknown>
290
+ runId?: string
291
+ error?: string
292
+ }): void {
293
+ if (event.error) return
294
+
295
+ const toolName = event.toolName?.trim()
296
+ if (!toolName) return
297
+
298
+ const runId = event.runId?.trim()
299
+ if (!runId) return
300
+
301
+ const params = event.params ?? {}
302
+
303
+ if (isOutboundMessagingTool(toolName)) {
304
+ const text = extractOutboundMessagingText(toolName, params)
305
+ if (text) {
306
+ pruneRunMap(sentMessageTextByRunId, runId, () => new Set()).add(text)
307
+ }
308
+ for (const fk of extractOutboundMessagingFileKeys(toolName, params)) {
309
+ pruneRunMap(sentMessageFileByRunId, runId, () => new Set()).add(fk)
310
+ }
311
+ clearLastCompletedFsOp(runId)
312
+ return
313
+ }
314
+
315
+ const opKey = fsLoopOpKey(toolName, params)
316
+ if (opKey) {
317
+ setLastCompletedFsOp(runId, opKey)
318
+ return
319
+ }
320
+ clearLastCompletedFsOp(runId)
321
+ }
322
+
323
+ export function mergeDcgchatBeforeToolCallHookResults(
324
+ ...results: Array<DcgchatBeforeToolCallHookResult | undefined>
325
+ ): DcgchatBeforeToolCallHookResult | undefined {
326
+ return mergeBeforeToolCallResults(...results)
327
+ }