@dcrays/dcgchat-test 0.4.29 → 0.5.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,26 +1,24 @@
1
- import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'
2
- import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk'
1
+ import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core'
3
2
  import { dcgchatPlugin } from './src/channel.js'
4
3
  import { setDcgchatRuntime, setWorkspaceDir } from './src/utils/global.js'
5
4
  import { monitoringToolMessage } from './src/tool.js'
6
5
  import { setOpenClawConfig } from './src/utils/global.js'
7
6
  import { createDcgchatMessageTool } from './src/tools/messageTool.js'
8
7
 
9
- const plugin = {
8
+ export default defineChannelPluginEntry({
10
9
  id: "dcgchat-test",
11
10
  name: '书灵墨宝',
12
11
  description: '连接 OpenClaw 与 书灵墨宝 产品(WebSocket)',
13
- configSchema: emptyPluginConfigSchema(),
14
- register(api: OpenClawPluginApi) {
15
- setDcgchatRuntime(api.runtime)
12
+ plugin: dcgchatPlugin,
13
+ setRuntime: (runtime) => {
14
+ setDcgchatRuntime(runtime)
15
+ },
16
+ registerFull: (api) => {
16
17
  monitoringToolMessage(api)
17
18
  setOpenClawConfig(api.config)
18
- api.registerChannel({ plugin: dcgchatPlugin })
19
19
  setWorkspaceDir(api.config?.agents?.defaults?.workspace)
20
20
  api.registerTool((ctx) => {
21
21
  return createDcgchatMessageTool(ctx)
22
22
  })
23
23
  }
24
- }
25
-
26
- export default plugin
24
+ })
@@ -3,9 +3,25 @@
3
3
  "channels": [
4
4
  "dcgchat-test"
5
5
  ],
6
+ "description": "Gateway `event=cron` + `action=finished` 时:优先在 payload 中按 schemas/gateway-cron-finished.payload.json 提供 `attachments`;若缺省,插件在工作区规则下从 summary 回退提取路径并经 sendMedia 发送。",
6
7
  "configSchema": {
7
8
  "type": "object",
8
9
  "additionalProperties": false,
9
- "properties": {}
10
+ "properties": {
11
+ "allowedPaths": {
12
+ "type": "array",
13
+ "items": {
14
+ "type": "string"
15
+ },
16
+ "description": "允许发送文件的额外路径列表"
17
+ },
18
+ "allowedAttachmentExtensions": {
19
+ "type": "array",
20
+ "items": {
21
+ "type": "string"
22
+ },
23
+ "description": "在插件默认附件扩展名之外额外允许的后缀,如 [\".go\", \".rs\"]。默认已含常见办公/媒体及 .py/.ipynb 等;可省略本项。"
24
+ }
25
+ }
10
26
  }
11
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcrays/dcgchat-test",
3
- "version": "0.4.29",
3
+ "version": "0.5.0-alpha.1",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
6
6
  "main": "index.ts",
@@ -8,6 +8,7 @@
8
8
  "files": [
9
9
  "index.ts",
10
10
  "src",
11
+ "schemas",
11
12
  "openclaw.plugin.json"
12
13
  ],
13
14
  "keywords": [
@@ -16,6 +17,14 @@
16
17
  "websocket",
17
18
  "ai"
18
19
  ],
20
+ "peerDependencies": {
21
+ "openclaw": ">=2026.4.11"
22
+ },
23
+ "peerDependenciesMeta": {
24
+ "openclaw": {
25
+ "optional": true
26
+ }
27
+ },
19
28
  "dependencies": {
20
29
  "ali-oss": "file:src/libs/ali-oss-6.23.0.tgz",
21
30
  "axios": "file:src/libs/axios-1.13.6.tgz",
@@ -39,7 +48,11 @@
39
48
  "install": {
40
49
  "npmSpec": "@dcrays/dcgchat-test",
41
50
  "localPath": "extensions/dcgchat-test",
42
- "defaultChoice": "npm"
51
+ "defaultChoice": "npm",
52
+ "minHostVersion": ">=2026.4.11"
53
+ },
54
+ "compat": {
55
+ "pluginApi": ">=2026.4.11"
43
56
  }
44
57
  }
45
58
  }
package/src/bot.ts CHANGED
@@ -1,34 +1,20 @@
1
- import { randomUUID } from 'node:crypto'
2
1
  import path from 'node:path'
3
2
  import type { ReplyPayload } from 'openclaw/plugin-sdk'
4
- import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk'
3
+ import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk/channel-reply-pipeline'
5
4
  import type { InboundMessage } from './types.js'
6
- import {
7
- clearSentMediaKeys,
8
- getDcgchatRuntime,
9
- getOpenClawConfig,
10
- getSessionKey,
11
- getWorkspaceDir,
12
- setMsgStatus
13
- } from './utils/global.js'
5
+ import { getSessionKey, getWorkspaceDir, setMsgStatus } from './utils/global.js'
6
+ import { clearSentMediaKeys, getDcgchatRuntime, getOpenClawConfig } from './utils/global.js'
14
7
  import { normalizeOutboundMediaPaths, resolveAccount, sendDcgchatMedia } from './channel.js'
15
8
  import { generateSignUrl } from './request/api.js'
16
9
  import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
17
10
  import { dcgLogger } from './utils/log.js'
11
+ import { contextOverflowUserHint, isContextOverflowError } from './utils/agentErrors.js'
18
12
  import { channelInfo, systemCommand, stopCommand, ENV, ignoreToolCommand } from './utils/constant.js'
19
13
  import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
20
14
  import { waitUntilSubagentsIdle } from './tool.js'
21
- import {
22
- beginSupersedingUserTurn,
23
- clearActiveRunIdForSession,
24
- clearSessionStreamSuppression,
25
- interruptLocalDispatchAndGateway,
26
- isSessionStreamSuppressed,
27
- preemptInboundQueueForStop,
28
- releaseDispatchAbortIfCurrent,
29
- runInboundTurnSequenced,
30
- setActiveRunIdForSession
31
- } from './sessionTermination.js'
15
+ import { beginSupersedingUserTurn, clearActiveRunIdForSession, clearSessionStreamSuppression } from './sessionTermination.js'
16
+ import { interruptLocalDispatchAndGateway, isSessionStreamSuppressed, preemptInboundQueueForStop } from './sessionTermination.js'
17
+ import { releaseDispatchAbortIfCurrent, runInboundTurnSequenced, setActiveRunIdForSession } from './sessionTermination.js'
32
18
 
33
19
  type MediaInfo = {
34
20
  path: string
@@ -75,7 +61,6 @@ async function resolveMediaFromUrls(files: TFileInfo[], botToken: string): Promi
75
61
  const core = getDcgchatRuntime()
76
62
  const out: MediaInfo[] = []
77
63
  dcgLogger(`media: user upload files: ${JSON.stringify(files)}`)
78
-
79
64
  for (let i = 0; i < files.length; i++) {
80
65
  const file = files[i]
81
66
  try {
@@ -232,6 +217,8 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
232
217
  }
233
218
 
234
219
  try {
220
+ /** 为 true 表示 createReplyDispatcherWithTyping 的 onError 已执行(含 sendFinal),内部 catch 勿再收尾 */
221
+ let dispatchReplyErrorHandledByOnError = false
235
222
  if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code && !ignoreToolCommand.includes(text?.trim())) {
236
223
  const workspaceDir = getWorkspaceDir()
237
224
  const skill = msg.content.skills_scope[0]
@@ -287,6 +274,27 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
287
274
  /** 与 Feishu snapshot 模式一致:payload.text 为当前轮助手全文快照,据此算增量,避免工具前后快照变短或非单调时丢字 */
288
275
  let lastStreamSnapshot = ''
289
276
 
277
+ /** Core 在 block/final(及可选 tool)路径走 `deliver`,流式 token 才走 `onPartialReply`;二者需共用快照,避免双发或漏发 */
278
+ const emitAssistantTextChunkFromSnapshot = (raw: string | undefined) => {
279
+ if (!raw) return
280
+ const t = raw
281
+ let delta = ''
282
+ if (t.startsWith(lastStreamSnapshot)) {
283
+ delta = t.slice(lastStreamSnapshot.length)
284
+ lastStreamSnapshot = t
285
+ } else if (lastStreamSnapshot.startsWith(t)) {
286
+ // 快照缩短(模型修订等):不重复下发
287
+ } else {
288
+ delta = t
289
+ lastStreamSnapshot = t
290
+ }
291
+ if (delta.trim()) {
292
+ const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
293
+ streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
294
+ sendChunk(delta, outboundCtx, prev)
295
+ }
296
+ }
297
+
290
298
  if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code && !ignoreToolCommand.includes(text?.trim())) {
291
299
  const workspaceDir = getWorkspaceDir()
292
300
  const skill = msg.content.skills_scope[0]
@@ -316,14 +324,23 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
316
324
  sentMediaKeys.add(key)
317
325
  await sendDcgchatMedia({ sessionKey: dcgSessionKey, mediaUrl, text: '' })
318
326
  }
319
- dcgLogger(`[deliver]: len=${payload?.text?.length} sessionId=${outboundCtx.sessionId} ${formatText(payload?.text ?? '')}`)
327
+ if (payload?.text?.trim()) {
328
+ emitAssistantTextChunkFromSnapshot(payload.text)
329
+ }
330
+ dcgLogger(
331
+ `[deliver]: kind=${info.kind} len=${payload?.text?.length} sessionId=${outboundCtx.sessionId} ${formatText(payload?.text ?? '')}`
332
+ )
320
333
  },
321
334
  onError: (err: unknown, info: { kind: string }) => {
335
+ dispatchReplyErrorHandledByOnError = true
322
336
  setMsgStatus(dcgSessionKey, 'finished')
337
+ const suppressed = isSessionStreamSuppressed(dcgSessionKey)
338
+ if (!suppressed && isContextOverflowError(err)) {
339
+ sendText(contextOverflowUserHint(), outboundCtx)
340
+ }
323
341
  sendFinal(outboundCtx, 'error')
324
342
  clearActiveRunIdForSession(dcgSessionKey)
325
343
  streamChunkIdxBySessionKey.delete(dcgSessionKey)
326
- const suppressed = isSessionStreamSuppressed(dcgSessionKey)
327
344
  dcgLogger(`${info.kind} reply failed${suppressed ? ' (stream suppressed)' : ''}: ${String(err)}`, 'error')
328
345
  },
329
346
  onIdle: () => {
@@ -338,27 +355,28 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
338
355
  dispatchAbort = await beginSupersedingUserTurn(dcgSessionKey)
339
356
  }
340
357
 
341
- if (systemCommand.includes(text?.trim())) {
342
- dcgLogger(`dispatching ${text?.trim()}`)
343
- await core.channel.reply.withReplyDispatcher({
344
- dispatcher,
345
- onSettled: () => markDispatchIdle(),
346
- run: () =>
347
- core.channel.reply.dispatchReplyFromConfig({
348
- ctx: ctxPayload,
349
- cfg: config,
350
- dispatcher,
351
- replyOptions: {
352
- ...replyOptions,
353
- abortSignal: dispatchAbort!.signal,
354
- onModelSelected: prefixContext.onModelSelected,
355
- onAgentRunStart: (runId) => {
356
- setActiveRunIdForSession(dcgSessionKey, runId)
357
- }
358
- }
359
- })
360
- })
361
- } else if (stopCommand.includes(text?.trim())) {
358
+ // if (systemCommand.includes(text?.trim())) {
359
+ // dcgLogger(`dispatching ${text?.trim()}`)
360
+ // await core.channel.reply.withReplyDispatcher({
361
+ // dispatcher,
362
+ // onSettled: () => markDispatchIdle(),
363
+ // run: () =>
364
+ // core.channel.reply.dispatchReplyFromConfig({
365
+ // ctx: ctxPayload,
366
+ // cfg: config,
367
+ // dispatcher,
368
+ // replyOptions: {
369
+ // ...replyOptions,
370
+ // abortSignal: dispatchAbort!.signal,
371
+ // onModelSelected: prefixContext.onModelSelected,
372
+ // onAgentRunStart: (runId) => {
373
+ // setActiveRunIdForSession(dcgSessionKey, runId)
374
+ // }
375
+ // }
376
+ // })
377
+ // })
378
+ // } else
379
+ if (stopCommand.includes(text?.trim())) {
362
380
  const ctxForAbort = priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim() ? priorOutboundCtx : outboundCtx
363
381
  await interruptLocalDispatchAndGateway(dcgSessionKey, ctxForAbort)
364
382
  streamChunkIdxBySessionKey.delete(dcgSessionKey)
@@ -402,26 +420,9 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
402
420
  },
403
421
  onPartialReply: async (payload: ReplyPayload) => {
404
422
  if (isSessionStreamSuppressed(dcgSessionKey)) return
405
-
406
423
  // --- Streaming text chunks ---
407
424
  if (payload.text) {
408
- const t = payload.text
409
- let delta = ''
410
- if (t.startsWith(lastStreamSnapshot)) {
411
- delta = t.slice(lastStreamSnapshot.length)
412
- lastStreamSnapshot = t
413
- } else if (lastStreamSnapshot.startsWith(t)) {
414
- // 快照缩短(模型修订等):不重复下发
415
- } else {
416
- // 与上一轮快照不衔接(常见于工具后快照从新的助手片段重新开始):整段下发
417
- delta = t
418
- lastStreamSnapshot = t
419
- }
420
- if (delta.trim()) {
421
- const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
422
- streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
423
- sendChunk(delta, outboundCtx, prev)
424
- }
425
+ emitAssistantTextChunkFromSnapshot(payload.text)
425
426
  } else {
426
427
  dcgLogger(`onPartialReply no text (media/tool metadata): ${JSON.stringify(payload)}`)
427
428
  }
@@ -442,6 +443,13 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
442
443
  dcgLogger(`handleDcgchatMessage error: ${String(err)}`, 'error')
443
444
  if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
444
445
  setMsgStatus(dcgSessionKey, 'finished')
446
+ if (!dispatchReplyErrorHandledByOnError && isContextOverflowError(err) && !isSessionStreamSuppressed(dcgSessionKey)) {
447
+ sendText(contextOverflowUserHint(), outboundCtx)
448
+ sendFinal(outboundCtx, 'error')
449
+ clearActiveRunIdForSession(dcgSessionKey)
450
+ streamChunkIdxBySessionKey.delete(dcgSessionKey)
451
+ return
452
+ }
445
453
  }
446
454
  } finally {
447
455
  releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
@@ -495,6 +503,7 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
495
503
  if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
496
504
  setMsgStatus(dcgSessionKey, 'finished')
497
505
  }
498
- sendError(err instanceof Error ? err.message : String(err), outboundCtx)
506
+ const rawErr = err instanceof Error ? err.message : String(err)
507
+ sendError(isContextOverflowError(err) ? contextOverflowUserHint() : rawErr, outboundCtx)
499
508
  }
500
509
  }
package/src/channel.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs'
2
- import type { ChannelPlugin, OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
3
- import { createPluginRuntimeStore, DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk'
2
+ import type { ChannelPlugin, OpenClawConfig } from 'openclaw/plugin-sdk'
3
+ import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/account-id'
4
4
  import type { ResolvedDcgchatAccount, DcgchatConfig } from './types.js'
5
5
  import { ossUpload } from './request/oss.js'
6
6
  import {
@@ -204,14 +204,17 @@ export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<v
204
204
  dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
205
205
  return
206
206
  }
207
- // 判断文件存在
207
+ let mediaStat: fs.Stats
208
208
  try {
209
- if (!fs.existsSync(mediaUrl)) {
210
- dcgLogger(`dcgchat: sendMedia skipped (file not found): ${mediaUrl} sessionKey=${sessionKey}`, 'error')
211
- return
212
- }
209
+ mediaStat = fs.statSync(mediaUrl)
213
210
  } catch (err) {
214
211
  dcgLogger(`dcgchat: sendMedia skipped (cannot stat path): ${mediaUrl} ${String(err)} sessionKey=${sessionKey}`, 'error')
212
+ return
213
+ }
214
+ if (!mediaStat.isFile()) {
215
+ const kind = mediaStat.isDirectory() ? 'directory' : 'non-regular'
216
+ dcgLogger(`dcgchat: sendMedia skipped (not a regular file, ${kind}): ${mediaUrl} sessionKey=${sessionKey}`, 'error')
217
+ return
215
218
  }
216
219
 
217
220
  if (mediaUrl && msgCtx.sessionId) {
@@ -363,6 +366,7 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
363
366
  '书灵墨宝 / 内置 `message`:回复当前会话时 **不要传 `target`**,由 OpenClaw 使用工具上下文里的 `currentChannelId`(来自入站 `To`,即当前 SessionKey)。',
364
367
  '必须指定会话时:`target` 须为上下文中出现的 **整段 SessionKey,逐字一致**(如 `agent:…:mobook:direct:…` 或以 `agent:` 开头的路由键);**禁止**使用 `userId`、`From`、纯数字会话号等代替。',
365
368
  '生成文件后,**尽可能不要**把文件路径、地址直接告诉用户;把文件名告诉用户;须通过工具发文件,勿在正文里直接输出可访问路径。',
369
+ '用户待发送文件若**不在**兼容挂载 **`/workspace/`**、**`/mobook/`**(及 Windows 下 `workspace`、`mobook` 盘符路径),且也不在 **当前 Agent 工作区** 或通道 **`allowedPaths`** 所列目录内:**建议**用户「先复制进工作区再发」作为首选。',
366
370
  '使用 `dcgchat_message` 时同样遵守上述 SessionKey 规则(该工具通常由插件注入当前会话,一般无需自造 target)。'
367
371
  ]
368
372
  },
package/src/cron.ts CHANGED
@@ -135,7 +135,12 @@ export const onRunCronJob = async (jobId: string, messageId: string) => {
135
135
  )
136
136
  sendMessageToGateway(JSON.stringify({ method: 'cron.run', params: { id: jobId, mode: 'force' } }))
137
137
  }
138
- export const finishedDcgchatCron = async (jobId: string, summary: string, hasFileOutput?: boolean) => {
138
+ /**
139
+ * 发送定时完成摘要(纯文本)。附件须由网关在 `payload.attachments` 中给出,
140
+ * 并由 `gatewayMsgHanlder` 在调用本函数前经 `sendDcgchatMedia` 发送。
141
+ * @param hasSchemaAttachments `payload.attachments` 非空时为 true,用于 `message_tags.hasFile`
142
+ */
143
+ export const finishedDcgchatCron = async (jobId: string, summary: string, hasSchemaAttachments?: boolean) => {
139
144
  const id = jobId?.trim()
140
145
  if (!id) {
141
146
  dcgLogger('finishedDcgchatCron: empty jobId', 'error')
@@ -160,7 +165,7 @@ export const finishedDcgchatCron = async (jobId: string, summary: string, hasFil
160
165
  real_mobook: !sessionId ? 1 : ''
161
166
  })
162
167
  const message_tags = { source: 'cron' } as Record<string, string | boolean>
163
- if (hasFileOutput) {
168
+ if (hasSchemaAttachments) {
164
169
  message_tags.hasFile = true
165
170
  }
166
171
  wsSendRaw(merged, { response: summary, message_tags, is_finish: -1 })
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 与 `schemas/gateway-cron-finished.payload.json` 对齐:`attachments` 为首选。
3
+ * 若宿主未下发 `attachments`(当前 Gateway 常见情况),则仅在 `summary` 中按工作区规则提取路径(见 `resolveCronFinishedLocalPaths`),仍经 `sendDcgchatMedia` 发送。
4
+ */
5
+
6
+ import {
7
+ expandTildePath,
8
+ extractWorkspaceFilePathsFromText,
9
+ isAllowedSendPath,
10
+ trimArtifactPathCandidate
11
+ } from '../utils/workspaceFilePaths.js'
12
+
13
+ export type CronFinishedAttachmentItem = string | { path?: string; file?: string }
14
+
15
+ /** 网关 `event: "cron"` 且 `action === "finished"` 时插件消费的 payload 子集 */
16
+ export type CronGatewayFinishedPayload = {
17
+ jobId?: string
18
+ action?: string
19
+ status?: string
20
+ summary?: string
21
+ delivered?: boolean
22
+ deliveryStatus?: string
23
+ sessionId?: string
24
+ sessionKey?: string
25
+ attachments?: CronFinishedAttachmentItem[]
26
+ }
27
+
28
+ /** JSON Schema 中 `attachments` 数组片段(便于在代码里引用、与仓库内 JSON 文件保持语义一致) */
29
+ export const CRON_FINISHED_ATTACHMENTS_ITEMS_SCHEMA = {
30
+ type: 'array',
31
+ description: '本轮定时任务产出的本地文件路径,按顺序经通道发送为附件',
32
+ items: {
33
+ oneOf: [
34
+ { type: 'string', minLength: 1, description: '绝对路径' },
35
+ {
36
+ type: 'object',
37
+ additionalProperties: false,
38
+ properties: {
39
+ path: { type: 'string', minLength: 1 },
40
+ file: { type: 'string', minLength: 1 }
41
+ },
42
+ description: '优先 path,否则 file'
43
+ }
44
+ ]
45
+ }
46
+ } as const
47
+
48
+ function pushAttachmentItem(item: unknown, out: string[]): void {
49
+ if (typeof item === 'string') {
50
+ const s = item.trim()
51
+ if (s) out.push(s)
52
+ return
53
+ }
54
+ if (item && typeof item === 'object') {
55
+ const o = item as Record<string, unknown>
56
+ const p = typeof o.path === 'string' ? o.path.trim() : ''
57
+ const f = typeof o.file === 'string' ? o.file.trim() : ''
58
+ const s = p || f
59
+ if (s) out.push(s)
60
+ }
61
+ }
62
+
63
+ /**
64
+ * 从网关 payload 取出 `attachments` 中的路径字符串(去重保序)。
65
+ * 不解析 `summary`、不扫描 Markdown。
66
+ */
67
+ export function normalizeCronFinishedAttachmentPaths(payload: unknown): string[] {
68
+ if (payload == null || typeof payload !== 'object') return []
69
+ const p = payload as CronGatewayFinishedPayload
70
+ const raw = p.attachments
71
+ if (!Array.isArray(raw)) return []
72
+ const acc: string[] = []
73
+ for (const item of raw) {
74
+ pushAttachmentItem(item, acc)
75
+ }
76
+ const seen = new Set<string>()
77
+ const deduped: string[] = []
78
+ for (const s of acc) {
79
+ if (seen.has(s)) continue
80
+ seen.add(s)
81
+ deduped.push(s)
82
+ }
83
+ return deduped
84
+ }
85
+
86
+ function dedupePaths(paths: string[]): string[] {
87
+ const seen = new Set<string>()
88
+ const out: string[] = []
89
+ for (const s of paths) {
90
+ if (!s || seen.has(s)) continue
91
+ seen.add(s)
92
+ out.push(s)
93
+ }
94
+ return out
95
+ }
96
+
97
+ /**
98
+ * 解析本轮待经通道发送的本地路径:优先 `payload.attachments`;
99
+ * 若为空则用 `summary` 与 `extractWorkspaceFilePathsFromText` 一致的工作区/挂载规则提取(非任意全文扫描)。
100
+ */
101
+ export function resolveCronFinishedLocalPaths(
102
+ payload: unknown,
103
+ summary: string | undefined,
104
+ workspaceDir: string,
105
+ allowedPaths?: string[]
106
+ ): string[] {
107
+ const fromSchema = normalizeCronFinishedAttachmentPaths(payload)
108
+ .map((s) => expandTildePath(trimArtifactPathCandidate(s)))
109
+ .filter(Boolean)
110
+ const schemaOk = fromSchema.filter((p: string) => isAllowedSendPath(p, workspaceDir, allowedPaths))
111
+ if (schemaOk.length > 0) {
112
+ return dedupePaths(schemaOk)
113
+ }
114
+ const fromSummary = extractWorkspaceFilePathsFromText(summary, workspaceDir).filter((p: string) =>
115
+ isAllowedSendPath(p, workspaceDir, allowedPaths)
116
+ )
117
+ return dedupePaths(fromSummary)
118
+ }
@@ -358,8 +358,13 @@ export class GatewayConnection {
358
358
  }
359
359
 
360
360
  if (msg.type === 'event') {
361
- const event = handleGatewayEventMessage(msg)
362
- this.eventHandlers.forEach((h) => h(event))
361
+ void handleGatewayEventMessage(msg)
362
+ .then((event) => {
363
+ this.eventHandlers.forEach((h) => h(event))
364
+ })
365
+ .catch((err) => {
366
+ dcgLogger(`[Gateway] event 处理异步失败: ${String(err)}`, 'error')
367
+ })
363
368
  }
364
369
  }
365
370
 
package/src/monitor.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk'
1
+ import type { OpenClawConfig, RuntimeEnv } from 'openclaw/plugin-sdk'
2
2
  import WebSocket from 'ws'
3
3
  import { resolveAccount } from './channel.js'
4
4
  import { setWsConnection, getOpenClawConfig } from './utils/global.js'
@@ -7,15 +7,12 @@ import { isWsOpen } from './transport.js'
7
7
  import { handleParsedWsMessage } from './utils/wsMessageHandler.js'
8
8
 
9
9
  export type MonitorDcgchatOpts = {
10
- config?: ClawdbotConfig
10
+ config?: OpenClawConfig
11
11
  runtime?: RuntimeEnv
12
12
  abortSignal?: AbortSignal
13
13
  accountId?: string
14
14
  }
15
15
 
16
- const RECONNECT_DELAY_MS = 3000
17
- const HEARTBEAT_INTERVAL_MS = 30_000
18
-
19
16
  function buildConnectUrl(account: Record<string, string>): string {
20
17
  const { wsUrl, botToken, userId, domainId, appId } = account
21
18
  const url = new URL(wsUrl)
@@ -27,6 +24,9 @@ function buildConnectUrl(account: Record<string, string>): string {
27
24
  }
28
25
 
29
26
  export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<void> {
27
+ // WebSocket `open` 可能在 connect() 同 tick 内同步触发,间隔须在此行之前完成初始化
28
+ const RECONNECT_DELAY_MS = 3000
29
+ const HEARTBEAT_INTERVAL_MS = 30_000
30
30
  const { abortSignal, accountId } = opts
31
31
 
32
32
  const config = getOpenClawConfig()
@@ -24,9 +24,7 @@ const tokenCache = new Map<string, TokenCacheEntry>()
24
24
  export function setUserTokenCache(botToken: string, userToken: string): void {
25
25
  const expiresAt = Date.now() + TOKEN_CACHE_DURATION
26
26
  tokenCache.set(botToken, { token: userToken, expiresAt })
27
- dcgLogger(
28
- `[token-cache] cached userToken for botToken=${botToken.slice(0, 10)}..., expires at ${new Date(expiresAt).toISOString()}`
29
- )
27
+ dcgLogger(`[token-cache] cached userToken for botToken=${botToken.slice(0, 10)}..., expires at ${new Date(expiresAt).toISOString()}`)
30
28
  }
31
29
 
32
30
  /**
@@ -47,10 +45,6 @@ export function getUserTokenCache(botToken: string): string | null {
47
45
  tokenCache.delete(botToken)
48
46
  return null
49
47
  }
50
-
51
- dcgLogger(
52
- `[token-cache] cache hit for botToken=${botToken.slice(0, 10)}..., valid until ${new Date(entry.expiresAt).toISOString()}`
53
- )
54
48
  return entry.token
55
49
  }
56
50
 
package/src/skill.ts CHANGED
@@ -55,7 +55,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
55
55
  // 创建目标目录
56
56
  fs.mkdirSync(skillDir, { recursive: true })
57
57
  // 解压文件到目标目录,跳过顶层文件夹
58
- await new Promise((resolve, reject) => {
58
+ const result = await new Promise((resolve, reject) => {
59
59
  const tasks: Promise<void>[] = []
60
60
  let rootDir: string | null = null
61
61
  let hasError = false
@@ -64,7 +64,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
64
64
  .pipe(unzipper.Parse())
65
65
  .on('entry', (entry: any) => {
66
66
  if (hasError) {
67
- entry.autodrain()
67
+ entry.autodrain() // 消耗并丢弃当前 zip 条目的数据流
68
68
  return
69
69
  }
70
70
  try {
@@ -113,6 +113,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
113
113
  await Promise.all(tasks)
114
114
  resolve(null)
115
115
  } catch (err) {
116
+ hasError = true
116
117
  reject(err)
117
118
  }
118
119
  })
@@ -121,7 +122,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
121
122
  reject(new Error(`解压流错误: ${err.message}`))
122
123
  })
123
124
  })
124
- sendEvent({ ...msgContent, status: 'ok' })
125
+ sendEvent({ ...msgContent, status: result === null ? 'ok' : 'fail' })
125
126
  sendMessageToGateway(JSON.stringify({ method: 'skills.status', params: {} }))
126
127
  } catch (error) {
127
128
  // 如果安装失败,清理目录
package/src/tool.ts CHANGED
@@ -5,34 +5,11 @@ import { sendFinal, sendText, wsSendRaw } from './transport.js'
5
5
  import { getEffectiveMsgParams, deleteSessionKeyBySubAgentRunId, setSessionKeyBySubAgentRunId } from './utils/params.js'
6
6
  import { cronToolCall } from './cronToolCall.js'
7
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'
8
+ /** `OpenClawPluginApi['on']` 对齐,随宿主 SDK 扩展钩子名时自动一致 */
9
+ type PluginHookName = Parameters<OpenClawPluginApi['on']>[0]
33
10
 
34
11
  // message_received 没有 sessionKey 前置到bot中执行
35
- const eventList = [
12
+ const eventList: ReadonlyArray<{ event: PluginHookName; message: string }> = [
36
13
  // { event: 'message_received', message: '' },
37
14
  // {event: 'before_model_resolve', message: ''},
38
15
  // {event: 'before_prompt_build', message: '正在查阅背景资料,构建思考逻辑'},
@@ -357,7 +334,11 @@ function trackSubagentLifecycle(eventName: string, event: any, args: any): void
357
334
 
358
335
  export function monitoringToolMessage(api: OpenClawPluginApi) {
359
336
  for (const item of eventList) {
360
- api.on(item.event as PluginHookName, (event: any, args: any) => {
337
+ api.on(item.event, (event: any, args: any) => {
338
+ // ACP 等非 subagent 的 ended 事件不应驱动书灵子会话状态机(见 SDK PluginHookSubagentEndedEvent.targetKind)
339
+ if (item.event === 'subagent_ended' && event?.targetKind === 'acp') {
340
+ return
341
+ }
361
342
  trackSubagentLifecycle(item.event, event, args)
362
343
  const sk = resolveHookSessionKey(item.event, args ?? {})
363
344
  if (sk) {
@@ -397,9 +378,9 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
397
378
  const msgCtx = resolveOutboundParamsForSession(sk)
398
379
  if (item.event === 'llm_output') {
399
380
  if (event.lastAssistant?.errorMessage === '1003-额度不足请充值') {
400
- const message = '您的积分已消耗完,您可以通过充值积分来继续使用'
381
+ const message = '您的墨滴已消耗完,您可以通过充值墨滴来继续使用'
401
382
  sendText(message, msgCtx, { message_tags: { insufficient_balance: 1 }, is_finish: -1 })
402
- sendFinal(msgCtx, '积分不足')
383
+ sendFinal(msgCtx, '墨滴不足')
403
384
  return
404
385
  }
405
386
  }
@@ -2,7 +2,7 @@ import fs from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import type { AnyAgentTool } from 'openclaw/plugin-sdk'
5
- import { jsonResult } from 'openclaw/plugin-sdk'
5
+ import { jsonResult } from 'openclaw/plugin-sdk/channel-actions'
6
6
  import { sendDcgchatMedia } from '../channel.js'
7
7
  import { getOutboundMsgParams } from '../utils/params.js'
8
8
  import { sendText } from '../transport.js'
@@ -11,6 +11,9 @@ import { sendText } from '../transport.js'
11
11
  export type DcgchatMessageToolContext = {
12
12
  sessionKey?: string
13
13
  workspaceDir?: string
14
+ allowedPaths?: string[]
15
+ /** 通道配置 `allowedAttachmentExtensions`:在插件内置扩展名之外额外允许发送的附件后缀(如 `.py`、`.ipynb`),项可写 `.py` 或 `py`。 */
16
+ allowedAttachmentExtensions?: string[]
14
17
  }
15
18
 
16
19
  /** 统一为 POSIX 风格斜杠,便于跨平台判断(不改变语义,仅用于匹配)。 */
@@ -32,9 +35,19 @@ function isPathInsideDir(filepath: string, rootDir: string): boolean {
32
35
  * - 当前 Agent 工作区根及其子路径(`workspaceDir`,如 ~/.openclaw/workspace-xxx/output/...);
33
36
  * - 兼容旧挂载:Unix `/workspace`、`/mobook`;Windows 盘符下 `workspace`、`mobook`。
34
37
  */
35
- function isSafePath(filepath: string, workspaceDir?: string): boolean {
38
+ function isSafePath(filepath: string, workspaceDir?: string, allowedPaths?: string[]): boolean {
39
+ // Check workspaceDir
36
40
  const ws = workspaceDir?.trim()
37
41
  if (ws && isPathInsideDir(filepath, ws)) return true
42
+
43
+ // Check allowedPaths from config
44
+ if (allowedPaths?.length) {
45
+ for (const allowed of allowedPaths) {
46
+ if (isPathInsideDir(filepath, allowed)) return true
47
+ }
48
+ }
49
+
50
+ // Check legacy mounts
38
51
  const p = toPosixPath(filepath)
39
52
  if (p.startsWith('/workspace/') || p === '/workspace') return true
40
53
  if (p.startsWith('/mobook/') || p === '/mobook') return true
@@ -48,10 +61,43 @@ function pathKey(filepath: string): string {
48
61
  }
49
62
 
50
63
  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']
64
+ const fileType2 = [
65
+ '.xml',
66
+ '.csv',
67
+ '.yaml',
68
+ '.yml',
69
+ '.html',
70
+ '.htm',
71
+ '.md',
72
+ '.markdown',
73
+ '.css',
74
+ '.js',
75
+ '.ts',
76
+ '.py',
77
+ '.pyi',
78
+ '.ipynb',
79
+ '.png',
80
+ '.jpg',
81
+ '.jpeg'
82
+ ]
52
83
  const fileType3 = ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.exe', '.dmg', '.pkg', '.apk', '.ipa', '.log', '.dat', '.bin']
53
84
  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])
85
+ const DEFAULT_SAFE_EXTENSIONS = new Set([...fileType1, ...fileType2, ...fileType3, ...fileType4])
86
+
87
+ function normalizeAttachmentExt(raw: string): string | null {
88
+ const t = raw.trim().toLowerCase()
89
+ if (!t) return null
90
+ return t.startsWith('.') ? t : `.${t}`
91
+ }
92
+
93
+ function buildSafeExtensions(extra?: string[]): Set<string> {
94
+ const set = new Set(DEFAULT_SAFE_EXTENSIONS)
95
+ for (const e of extra ?? []) {
96
+ const n = normalizeAttachmentExt(e)
97
+ if (n && n !== '.') set.add(n)
98
+ }
99
+ return set
100
+ }
55
101
 
56
102
  const messageToolParameters = {
57
103
  type: 'object',
@@ -82,6 +128,7 @@ const messageToolParameters = {
82
128
  }
83
129
  }
84
130
  },
131
+ // 须至少提供正文或附件之一;用 anyOf(非 oneOf),否则同时带 content+media 时两个分支都满足会违反「恰好其一」而校验失败
85
132
  anyOf: [{ required: ['content'] }, { required: ['media'] }]
86
133
  }
87
134
 
@@ -113,13 +160,13 @@ function extractPaths(text: string | undefined, workspaceDir?: string): string[]
113
160
  return [...new Set([...unix, ...win, ...underWs])]
114
161
  }
115
162
 
116
- function isSafeFile(filepath: string) {
163
+ function isSafeFile(filepath: string, extensions: Set<string>) {
117
164
  if (!fs.existsSync(filepath)) return false
118
165
  const stat = fs.statSync(filepath)
119
166
  if (!stat.isFile()) return false
120
167
  if (stat.size === 0) return false
121
168
  const ext = path.extname(filepath).toLowerCase()
122
- return SAFE_EXTENSIONS.has(ext)
169
+ return extensions.has(ext)
123
170
  }
124
171
 
125
172
  /**
@@ -128,6 +175,7 @@ function isSafeFile(filepath: string) {
128
175
  * 通过注册时的 `OpenClawPluginToolContext.sessionKey` 出站,不再使用非标准的 `execute(args, ctx)`。
129
176
  */
130
177
  export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext): AnyAgentTool {
178
+ const safeExtensions = buildSafeExtensions(pluginCtx.allowedAttachmentExtensions)
131
179
  return {
132
180
  name: 'dcgchat_message',
133
181
  label: 'dcgchat_message',
@@ -155,13 +203,14 @@ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext):
155
203
  const sentFiles = new Set<string>()
156
204
  const sentKeys = new Set<string>()
157
205
  const workspaceDir = pluginCtx.workspaceDir
206
+ const allowedPaths = pluginCtx.allowedPaths
158
207
 
159
208
  if (args.media?.length) {
160
209
  for (const media of args.media) {
161
210
  const filepath = media.file
162
211
  if (!filepath) continue
163
- if (!isSafePath(filepath, workspaceDir)) continue
164
- if (!isSafeFile(filepath)) continue
212
+ if (!isSafePath(filepath, workspaceDir, allowedPaths)) continue
213
+ if (!isSafeFile(filepath, safeExtensions)) continue
165
214
  const key = pathKey(filepath)
166
215
  if (sentKeys.has(key)) continue
167
216
 
@@ -173,8 +222,8 @@ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext):
173
222
 
174
223
  const fallbackPaths = extractPaths(args.content, workspaceDir)
175
224
  for (const filepath of fallbackPaths) {
176
- if (!isSafePath(filepath, workspaceDir)) continue
177
- if (!isSafeFile(filepath)) continue
225
+ if (!isSafePath(filepath, workspaceDir, allowedPaths)) continue
226
+ if (!isSafeFile(filepath, safeExtensions)) continue
178
227
  const key = pathKey(filepath)
179
228
  if (sentKeys.has(key)) continue
180
229
 
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 识别 OpenClaw embedded agent 抛出的上下文/压缩相关错误(日志与异常文案可能略有差异)。
3
+ */
4
+ function errorText(err: unknown): string {
5
+ if (err instanceof Error) return err.message
6
+ if (typeof err === 'string') return err
7
+ return String(err)
8
+ }
9
+
10
+ export function isContextOverflowError(err: unknown): boolean {
11
+ const t = errorText(err)
12
+ return (
13
+ /context overflow/i.test(t) ||
14
+ /prompt too large/i.test(t) ||
15
+ /auto-compaction failed/i.test(t) ||
16
+ /\(precheck\)/i.test(t)
17
+ )
18
+ }
19
+
20
+ /** 用户可见说明(不含 transport 层前缀) */
21
+ export function contextOverflowUserHint(): string {
22
+ return '当前对话过长,已超过模型上下文限制;自动压缩未完全生效时会出现此情况。请尝试新开对话、缩短任务,或换用更大上下文的模型。'
23
+ }
@@ -1,15 +1,21 @@
1
1
  import type { GatewayEvent } from '../gateway/index.js'
2
- import { finishedDcgchatCron, sendDcgchatCron } from '../cron.js'
2
+ import { finishedDcgchatCron, getCronJobsPath, readCronJob, sendDcgchatCron } from '../cron.js'
3
+ import { sendDcgchatMedia } from '../channel.js'
4
+ import { resolveCronFinishedLocalPaths } from '../gateway/cronFinishedPayload.js'
5
+ import { channelInfo, ENV } from './constant.js'
3
6
  import { dcgLogger } from './log.js'
4
- import { clearParamsMessage, getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
5
- import { sendChunk, sendFinal, sendText } from '../transport.js'
6
- import { resetSubagentStateForRequesterSession } from '../tool.js'
7
- import { setMsgStatus } from './global.js'
7
+ import { getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
8
+ import { sendChunk } from '../transport.js'
9
+ import { getCronMessageId, getOpenClawConfig, getWorkspaceDir, setCronMessageId } from './global.js'
8
10
 
9
11
  /**
10
12
  * 处理网关 event 帧的副作用(agent 流式输出、cron 同步),并构造供上层分发的 GatewayEvent。
11
13
  */
12
- export function handleGatewayEventMessage(msg: { event?: string; payload?: Record<string, unknown>; seq?: number }): GatewayEvent {
14
+ export async function handleGatewayEventMessage(msg: {
15
+ event?: string
16
+ payload?: Record<string, unknown>
17
+ seq?: number
18
+ }): Promise<GatewayEvent> {
13
19
  try {
14
20
  // 子agent消息输出
15
21
  if (msg.event === 'agent') {
@@ -36,12 +42,35 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
36
42
  sendDcgchatCron(p?.jobId as string)
37
43
  }
38
44
  if (p?.action === 'finished' && p?.status === 'ok') {
39
- const hasFileOutput = p.delivered === true && p.deliveryStatus === 'delivered'
40
- let summary = p?.summary as string
45
+ let summary = typeof p?.summary === 'string' ? p.summary : ''
41
46
  if (summary.indexOf('HEARTBEAT_OK') >= 0 && summary !== 'HEARTBEAT_OK') {
42
47
  summary = summary.replace('HEARTBEAT_OK', '')
43
48
  }
44
- finishedDcgchatCron(p?.jobId as string, summary, hasFileOutput)
49
+
50
+ const jobIdStr = typeof p?.jobId === 'string' ? p.jobId.trim() : ''
51
+ const cfg = getOpenClawConfig()
52
+ const chCfg = cfg?.channels?.["dcgchat-test" as keyof NonNullable<typeof cfg.channels>] as { allowedPaths?: string[] } | undefined
53
+ const attachmentPaths = resolveCronFinishedLocalPaths(p, summary, getWorkspaceDir(), chCfg?.allowedPaths)
54
+ const jobPath = getCronJobsPath()
55
+ const job = jobIdStr ? readCronJob(jobPath, jobIdStr) : null
56
+ const sessionKey = job && typeof job.sessionKey === 'string' ? job.sessionKey.trim() : ''
57
+
58
+ if (sessionKey && attachmentPaths.length > 0) {
59
+ const messageId = getCronMessageId(sessionKey) || `${Date.now()}`
60
+ setCronMessageId(sessionKey, messageId)
61
+ for (const mediaPath of attachmentPaths) {
62
+ try {
63
+ await sendDcgchatMedia({ sessionKey, mediaUrl: mediaPath, messageId })
64
+ } catch (err) {
65
+ dcgLogger(`[Gateway] cron 附件经通道发送失败: ${mediaPath} ${String(err)}`, 'error')
66
+ }
67
+ }
68
+ } else if (attachmentPaths.length > 0 && !sessionKey) {
69
+ dcgLogger(`[Gateway] cron finished 有可发送附件路径但 jobs.json 无 sessionKey: jobId=${jobIdStr}`, 'error')
70
+ }
71
+
72
+ const hasAttachments = attachmentPaths.length > 0
73
+ finishedDcgchatCron(jobIdStr, summary, hasAttachments)
45
74
  }
46
75
  }
47
76
  } catch (error) {
@@ -2,7 +2,8 @@ import type WebSocket from 'ws'
2
2
  import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
- import { createPluginRuntimeStore, type OpenClawConfig, type PluginRuntime } from 'openclaw/plugin-sdk'
5
+ import type { OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
6
+ import { createPluginRuntimeStore } from 'openclaw/plugin-sdk/runtime-store'
6
7
  import { channelInfo, ENV } from './constant.js'
7
8
  import { dcgLogger } from './log.js'
8
9
 
@@ -0,0 +1,89 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+
4
+ /** 统一为 POSIX 风格斜杠,便于跨平台判断。 */
5
+ export function toPosixPath(p: string): string {
6
+ return path.normalize(p.trim()).replace(/\\/g, '/')
7
+ }
8
+
9
+ /** `filepath` 解析后在 `rootDir` 内或等于 `rootDir`(防 `..` 逃逸)。 */
10
+ export function isPathInsideDir(filepath: string, rootDir: string): boolean {
11
+ const root = path.resolve(rootDir)
12
+ const resolved = path.resolve(filepath)
13
+ const rel = path.relative(root, resolved)
14
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return false
15
+ return true
16
+ }
17
+
18
+ /**
19
+ * 与 dcgchat_message 一致:允许的路径为当前工作区根、`allowedPaths`、或兼容挂载 /workspace、/mobook。
20
+ */
21
+ export function isAllowedSendPath(filepath: string, workspaceDir?: string, allowedPaths?: string[]): boolean {
22
+ const ws = workspaceDir?.trim()
23
+ if (ws && isPathInsideDir(filepath, ws)) return true
24
+ if (allowedPaths?.length) {
25
+ for (const allowed of allowedPaths) {
26
+ if (isPathInsideDir(filepath, allowed)) return true
27
+ }
28
+ }
29
+ const p = toPosixPath(filepath)
30
+ if (p.startsWith('/workspace/') || p === '/workspace') return true
31
+ if (p.startsWith('/mobook/') || p === '/mobook') return true
32
+ return /^[A-Za-z]:\/(workspace|mobook)(\/|$)/.test(p)
33
+ }
34
+
35
+ /** 去掉 Markdown/标点误粘在路径末尾的字符(如 `**`、反引号、右括号)。 */
36
+ export function trimArtifactPathCandidate(raw: string): string {
37
+ return raw.replace(/[`'",,*_)\]]+$/u, '').trimEnd()
38
+ }
39
+
40
+ /**
41
+ * 将正文里的 `~/...` / `~\...` 展开为绝对路径(`path.resolve` 不会处理 `~`)。
42
+ */
43
+ export function expandTildePath(input: string): string {
44
+ const s = input.trim()
45
+ if (!s) return s
46
+ if (s === '~') return os.homedir()
47
+ if (s.startsWith('~/')) {
48
+ return path.resolve(os.homedir(), s.slice(2))
49
+ }
50
+ if (s.startsWith('~\\')) {
51
+ return path.resolve(os.homedir(), s.slice(2))
52
+ }
53
+ return s
54
+ }
55
+
56
+ /**
57
+ * 从正文中提取可作为附件发送的本地路径(与 messageTool 规则一致:工作区前缀、`/workspace`、`~` 等)。
58
+ */
59
+ export function extractWorkspaceFilePathsFromText(text: string | undefined, workspaceDir?: string): string[] {
60
+ if (!text) return []
61
+ const unix = text.match(/\/workspace\/[^\s]+|\/mobook\/[^\s]+/g) ?? []
62
+ const win = text.match(/[A-Za-z]:[/\\](?:workspace|mobook)[/\\][^\s]+/g) ?? []
63
+ const tildePaths = text.match(/~[/\\][^\s`'")\]]+/g) ?? []
64
+ const underWs: string[] = []
65
+ const ws = workspaceDir?.trim()
66
+ if (ws) {
67
+ const variants = new Set<string>()
68
+ variants.add(ws)
69
+ variants.add(toPosixPath(ws))
70
+ if (path.sep === '\\') variants.add(ws.replace(/\//g, '\\'))
71
+ for (const prefix of variants) {
72
+ if (!prefix) continue
73
+ let from = 0
74
+ while (from < text.length) {
75
+ const i = text.indexOf(prefix, from)
76
+ if (i === -1) break
77
+ let end = i + prefix.length
78
+ while (end < text.length && !/\s/.test(text[end])) end++
79
+ underWs.push(text.slice(i, end))
80
+ from = i + 1
81
+ }
82
+ }
83
+ }
84
+ const cleaned = [...unix, ...win, ...tildePaths, ...underWs]
85
+ .map((s) => trimArtifactPathCandidate(s))
86
+ .filter(Boolean)
87
+ const resolved = cleaned.map((s) => (s.startsWith('~') ? expandTildePath(s) : s))
88
+ return [...new Set(resolved)]
89
+ }