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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.2",
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.12"
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.12"
53
+ },
54
+ "compat": {
55
+ "pluginApi": ">=2026.4.12"
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]
@@ -308,6 +316,7 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
308
316
  humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
309
317
  onReplyStart: async () => {},
310
318
  deliver: async (payload: ReplyPayload, info) => {
319
+ if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) return
311
320
  if (isSessionStreamSuppressed(dcgSessionKey)) return
312
321
  const mediaList = resolveReplyMediaList(payload)
313
322
  for (const mediaUrl of mediaList) {
@@ -316,14 +325,27 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
316
325
  sentMediaKeys.add(key)
317
326
  await sendDcgchatMedia({ sessionKey: dcgSessionKey, mediaUrl, text: '' })
318
327
  }
319
- dcgLogger(`[deliver]: len=${payload?.text?.length} sessionId=${outboundCtx.sessionId} ${formatText(payload?.text ?? '')}`)
328
+ if (payload?.text?.trim()) {
329
+ emitAssistantTextChunkFromSnapshot(payload.text)
330
+ }
331
+ dcgLogger(
332
+ `[deliver]: kind=${info.kind} len=${payload?.text?.length} sessionId=${outboundCtx.sessionId} ${formatText(payload?.text ?? '')}`
333
+ )
320
334
  },
321
335
  onError: (err: unknown, info: { kind: string }) => {
336
+ if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) {
337
+ dcgLogger(`${info.kind} reply failed (stale handler, ignored): ${String(err)}`, 'error')
338
+ return
339
+ }
340
+ dispatchReplyErrorHandledByOnError = true
322
341
  setMsgStatus(dcgSessionKey, 'finished')
342
+ const suppressed = isSessionStreamSuppressed(dcgSessionKey)
343
+ if (!suppressed && isContextOverflowError(err)) {
344
+ sendText(contextOverflowUserHint(), outboundCtx)
345
+ }
323
346
  sendFinal(outboundCtx, 'error')
324
347
  clearActiveRunIdForSession(dcgSessionKey)
325
348
  streamChunkIdxBySessionKey.delete(dcgSessionKey)
326
- const suppressed = isSessionStreamSuppressed(dcgSessionKey)
327
349
  dcgLogger(`${info.kind} reply failed${suppressed ? ' (stream suppressed)' : ''}: ${String(err)}`, 'error')
328
350
  },
329
351
  onIdle: () => {
@@ -338,27 +360,28 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
338
360
  dispatchAbort = await beginSupersedingUserTurn(dcgSessionKey)
339
361
  }
340
362
 
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())) {
363
+ // if (systemCommand.includes(text?.trim())) {
364
+ // dcgLogger(`dispatching ${text?.trim()}`)
365
+ // await core.channel.reply.withReplyDispatcher({
366
+ // dispatcher,
367
+ // onSettled: () => markDispatchIdle(),
368
+ // run: () =>
369
+ // core.channel.reply.dispatchReplyFromConfig({
370
+ // ctx: ctxPayload,
371
+ // cfg: config,
372
+ // dispatcher,
373
+ // replyOptions: {
374
+ // ...replyOptions,
375
+ // abortSignal: dispatchAbort!.signal,
376
+ // onModelSelected: prefixContext.onModelSelected,
377
+ // onAgentRunStart: (runId) => {
378
+ // setActiveRunIdForSession(dcgSessionKey, runId)
379
+ // }
380
+ // }
381
+ // })
382
+ // })
383
+ // } else
384
+ if (stopCommand.includes(text?.trim())) {
362
385
  const ctxForAbort = priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim() ? priorOutboundCtx : outboundCtx
363
386
  await interruptLocalDispatchAndGateway(dcgSessionKey, ctxForAbort)
364
387
  streamChunkIdxBySessionKey.delete(dcgSessionKey)
@@ -401,27 +424,11 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
401
424
  setActiveRunIdForSession(dcgSessionKey, runId)
402
425
  },
403
426
  onPartialReply: async (payload: ReplyPayload) => {
427
+ if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) return
404
428
  if (isSessionStreamSuppressed(dcgSessionKey)) return
405
-
406
429
  // --- Streaming text chunks ---
407
430
  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
- }
431
+ emitAssistantTextChunkFromSnapshot(payload.text)
425
432
  } else {
426
433
  dcgLogger(`onPartialReply no text (media/tool metadata): ${JSON.stringify(payload)}`)
427
434
  }
@@ -442,6 +449,13 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
442
449
  dcgLogger(`handleDcgchatMessage error: ${String(err)}`, 'error')
443
450
  if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
444
451
  setMsgStatus(dcgSessionKey, 'finished')
452
+ if (!dispatchReplyErrorHandledByOnError && isContextOverflowError(err) && !isSessionStreamSuppressed(dcgSessionKey)) {
453
+ sendText(contextOverflowUserHint(), outboundCtx)
454
+ sendFinal(outboundCtx, 'error')
455
+ clearActiveRunIdForSession(dcgSessionKey)
456
+ streamChunkIdxBySessionKey.delete(dcgSessionKey)
457
+ return
458
+ }
445
459
  }
446
460
  } finally {
447
461
  releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
@@ -495,6 +509,7 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
495
509
  if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
496
510
  setMsgStatus(dcgSessionKey, 'finished')
497
511
  }
498
- sendError(err instanceof Error ? err.message : String(err), outboundCtx)
512
+ const rawErr = err instanceof Error ? err.message : String(err)
513
+ sendError(isContextOverflowError(err) ? contextOverflowUserHint() : rawErr, outboundCtx)
499
514
  }
500
515
  }
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
 
@@ -59,6 +59,13 @@ export function preemptInboundQueueForStop(sessionKey: string): void {
59
59
  dispatchAbortBySessionKey.delete(sessionKey)
60
60
  }
61
61
  inboundTurnTailBySessionKey.set(sessionKey, Promise.resolve())
62
+ // 立即标记流抑制,防止 /stop 到 interruptLocalDispatchAndGateway 之间网关事件仍能推送
63
+ markSessionStreamSuppressed(sessionKey)
64
+ // 队尾被重置后旧 handler 会变成「僵尸」与后续 /stop 并发;仅靠后续 interrupt 晚一拍时网关 run 仍占位。
65
+ // 尽早对网关发 interrupt 级 abort(与 /stop 内 interrupt 重复无害),缩短僵尸窗口。
66
+ void abortGatewayRunsForSession(sessionKey, 'interrupt').catch((e) =>
67
+ dcgLogger(`preempt: gateway abort: ${String(e)}`, 'error')
68
+ )
62
69
  dcgLogger(`inbound queue: reset tail for /stop sessionKey=${sessionKey}`)
63
70
  }
64
71
 
@@ -123,6 +130,13 @@ export async function beginSupersedingUserTurn(sessionKey: string): Promise<Abor
123
130
  sessionStreamSuppressed.delete(sessionKey)
124
131
  dispatchAbortBySessionKey.get(sessionKey)?.abort()
125
132
  await abortGatewayRunsForSession(sessionKey, 'supersede')
133
+ // `/stop` 的 interrupt 会先清空 activeRunId;此时 supersede 可能整段跳过主会话 abort,网关仍跑着僵尸 run,
134
+ // 新一轮会「秒结束」且无回复。再发一次无 runId 的 main abort 作为兜底(幂等)。
135
+ try {
136
+ await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey } })
137
+ } catch (e) {
138
+ dcgLogger(`supersede: best-effort main chat.abort ${sessionKey}: ${String(e)}`, 'error')
139
+ }
126
140
  const ac = new AbortController()
127
141
  dispatchAbortBySessionKey.set(sessionKey, ac)
128
142
  return ac
package/src/skill.ts CHANGED
@@ -19,14 +19,13 @@ type ISkillParams = {
19
19
  function sendEvent(msgContent: Record<string, any>) {
20
20
  const ws = getWsConnection()
21
21
  if (isWsOpen()) {
22
- ws?.send(
23
- JSON.stringify({
24
- messageType: 'openclaw_bot_event',
25
- source: 'client',
26
- content: msgContent
27
- })
28
- )
29
- dcgLogger(`技能安装: ${JSON.stringify(msgContent)}`)
22
+ const msg = JSON.stringify({
23
+ messageType: 'openclaw_bot_event',
24
+ source: 'client',
25
+ content: msgContent
26
+ });
27
+ ws?.send(msg);
28
+ dcgLogger(`[Send]技能安装: ${msg}`)
30
29
  }
31
30
  }
32
31
 
@@ -55,7 +54,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
55
54
  // 创建目标目录
56
55
  fs.mkdirSync(skillDir, { recursive: true })
57
56
  // 解压文件到目标目录,跳过顶层文件夹
58
- await new Promise((resolve, reject) => {
57
+ const result = await new Promise((resolve, reject) => {
59
58
  const tasks: Promise<void>[] = []
60
59
  let rootDir: string | null = null
61
60
  let hasError = false
@@ -64,7 +63,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
64
63
  .pipe(unzipper.Parse())
65
64
  .on('entry', (entry: any) => {
66
65
  if (hasError) {
67
- entry.autodrain()
66
+ entry.autodrain() // 消耗并丢弃当前 zip 条目的数据流
68
67
  return
69
68
  }
70
69
  try {
@@ -113,6 +112,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
113
112
  await Promise.all(tasks)
114
113
  resolve(null)
115
114
  } catch (err) {
115
+ hasError = true
116
116
  reject(err)
117
117
  }
118
118
  })
@@ -121,7 +121,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
121
121
  reject(new Error(`解压流错误: ${err.message}`))
122
122
  })
123
123
  })
124
- sendEvent({ ...msgContent, status: 'ok' })
124
+ sendEvent({ ...msgContent, status: result === null ? 'ok' : 'fail' })
125
125
  sendMessageToGateway(JSON.stringify({ method: 'skills.status', params: {} }))
126
126
  } catch (error) {
127
127
  // 如果安装失败,清理目录
@@ -136,16 +136,11 @@ export function uninstallSkill(params: Omit<ISkillParams, 'path'>, msgContent: R
136
136
  const { code } = params
137
137
 
138
138
  const workspacePath = getWorkspaceDir()
139
- if (!workspacePath) {
140
- sendEvent({ ...msgContent, status: 'ok' })
141
- }
142
-
143
- const skillDir = path.join(workspacePath, 'skills', code)
144
-
145
- if (fs.existsSync(skillDir)) {
146
- fs.rmSync(skillDir, { recursive: true, force: true })
147
- sendEvent({ ...msgContent, status: 'ok' })
148
- } else {
149
- sendEvent({ ...msgContent, status: 'ok' })
139
+ if (workspacePath) {
140
+ const skillDir = path.join(workspacePath, 'skills', code)
141
+ if (fs.existsSync(skillDir)) {
142
+ fs.rmSync(skillDir, { recursive: true, force: true })
143
+ }
150
144
  }
145
+ sendEvent({ ...msgContent, status: 'ok' })
151
146
  }
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
 
package/src/transport.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { clearSentMediaKeys, getWsConnection } from './utils/global.js'
2
+ import { isSessionStreamSuppressed } from './sessionTermination.js'
2
3
  import { dcgLogger } from './utils/log.js'
3
4
  import type { IMsgParams } from './types.js'
4
5
  import { getEffectiveMsgParams, getParamsDefaults } from './utils/params.js'
@@ -139,6 +140,7 @@ export function isWsOpen(): boolean {
139
140
  * `ctx` 须由调用方用 getEffectiveMsgParams(sessionKey) 等解析好;`content` 为完整业务 payload。
140
141
  */
141
142
  export function wsSend(ctx: IMsgParams, content: Record<string, unknown>): boolean {
143
+ if (ctx.sessionKey && isSessionStreamSuppressed(ctx.sessionKey)) return false
142
144
  const ws = getWsConnection()
143
145
  if (ws?.readyState !== WebSocket.OPEN) return false
144
146
  const envelope = buildOpenclawBotChat(ctx, content)
@@ -151,6 +153,7 @@ export function wsSend(ctx: IMsgParams, content: Record<string, unknown>): boole
151
153
  * `ctx` 须由调用方解析(如需合并覆盖可先 mergeSessionParams)。
152
154
  */
153
155
  export function wsSendRaw(ctx: IMsgParams, content: Record<string, unknown>, isLog = true): boolean {
156
+ if (ctx.sessionKey && isSessionStreamSuppressed(ctx.sessionKey)) return false
154
157
  const ws = getWsConnection()
155
158
  if (ws?.readyState !== WebSocket.OPEN) {
156
159
  dcgLogger(`server socket not ready ${ws?.readyState}`, 'error')
@@ -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
+ }