@dcrays/dcgchat-test 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcrays/dcgchat-test",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
6
6
  "main": "index.ts",
package/src/bot.ts CHANGED
@@ -3,13 +3,20 @@ import path from 'node:path'
3
3
  import type { ReplyPayload } from 'openclaw/plugin-sdk'
4
4
  import { createReplyPrefixContext } from 'openclaw/plugin-sdk'
5
5
  import type { InboundMessage } from './types.js'
6
- import { clearSentMediaKeys, getDcgchatRuntime, getOpenClawConfig, getWorkspaceDir, setMsgStatus } from './utils/global.js'
6
+ import {
7
+ clearSentMediaKeys,
8
+ getDcgchatRuntime,
9
+ getOpenClawConfig,
10
+ getSessionKey,
11
+ getWorkspaceDir,
12
+ setMsgStatus
13
+ } from './utils/global.js'
7
14
  import { resolveAccount, sendDcgchatMedia } from './channel.js'
8
15
  import { generateSignUrl } from './request/api.js'
9
16
  import { extractMobookFiles } from './utils/searchFile.js'
10
- import { sendChunk, sendFinal, sendText as sendTextMsg, sendError } from './transport.js'
17
+ import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
11
18
  import { dcgLogger } from './utils/log.js'
12
- import { channelInfo, systemCommand, interruptCommand, ENV } from './utils/constant.js'
19
+ import { channelInfo, systemCommand, interruptCommand, ENV, ignoreToolCommand } from './utils/constant.js'
13
20
  import { sendMessageToGateway } from './gateway/socket.js'
14
21
  import { getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
15
22
 
@@ -24,6 +31,18 @@ type TFileInfo = { name: string; url: string }
24
31
 
25
32
  const mediaMaxBytes = 300 * 1024 * 1024
26
33
 
34
+ /** 当前会话最近一次 agent run 的 runId(供 chat.abort 精确打断;无则仅传 sessionKey 仍会中止该会话全部活动运行) */
35
+ const activeRunIdBySessionKey = new Map<string, string>()
36
+
37
+ /**
38
+ * 用户在该 sessionKey 上触发打断后,旧 run 的流式/投递不再下发;与 sessionKey 一一对应,支持多会话。
39
+ * 清除时机:① 下一条非打断用户消息开始处理时;② 旧 run 收尾到 mobook 段时若仍抑制则跳过并发后删除。
40
+ */
41
+ const sessionStreamSuppressed = new Set<string>()
42
+
43
+ /** 各 sessionKey 当前轮回复的流式分片序号(仅统计实际下发的文本 chunk);每轮非打断消息开始时置 0 */
44
+ const streamChunkIdxBySessionKey = new Map<string, number>()
45
+
27
46
  /** Active LLM generation abort controllers, keyed by conversationId */
28
47
  // const activeGenerations = new Map<string, AbortController>()
29
48
 
@@ -144,8 +163,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
144
163
  const embeddedAgentId = extractAgentIdFromConversationId(conversationId)
145
164
 
146
165
  const effectiveAgentId = embeddedAgentId ?? route.agentId
147
- const effectiveSessionKey =
148
- real_mobook === '1' ? route.sessionKey : `agent:main:mobook:direct:${agentId}:${conversationId}`.toLowerCase()
166
+ const effectiveSessionKey = getSessionKey(msg.content, account.accountId)
149
167
 
150
168
  setParamsMessage(effectiveSessionKey, {
151
169
  userId: msg._userId,
@@ -168,6 +186,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
168
186
  if (finalSent) return
169
187
  finalSent = true
170
188
  sendFinal(outboundCtx)
189
+ setMsgStatus(effectiveSessionKey, 'finished')
171
190
  }
172
191
 
173
192
  const text = msg.content.text?.trim()
@@ -209,7 +228,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
209
228
  RawBody: text,
210
229
  CommandBody: text,
211
230
  From: userId,
212
- To: conversationId,
231
+ To: effectiveSessionKey,
213
232
  SessionKey: effectiveSessionKey,
214
233
  AccountId: route.accountId,
215
234
  ChatType: 'direct',
@@ -222,7 +241,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
222
241
  WasMentioned: true,
223
242
  CommandAuthorized: true,
224
243
  OriginatingChannel: "dcgchat-test",
225
- OriginatingTo: `user:${userId}`,
244
+ OriginatingTo: effectiveSessionKey,
226
245
  ...mediaPayload
227
246
  })
228
247
 
@@ -243,6 +262,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
243
262
  humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
244
263
  onReplyStart: async () => {},
245
264
  deliver: async (payload: ReplyPayload, info) => {
265
+ if (sessionStreamSuppressed.has(effectiveSessionKey)) return
246
266
  const mediaList = resolveReplyMediaList(payload)
247
267
  for (const mediaUrl of mediaList) {
248
268
  const key = getMediaKey(mediaUrl)
@@ -252,15 +272,29 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
252
272
  }
253
273
  },
254
274
  onError: (err: unknown, info: { kind: string }) => {
275
+ activeRunIdBySessionKey.delete(effectiveSessionKey)
276
+ streamChunkIdxBySessionKey.delete(effectiveSessionKey)
277
+ if (sessionStreamSuppressed.has(effectiveSessionKey)) {
278
+ dcgLogger(`${info.kind} reply failed (stream suppressed): ${String(err)}`, 'error')
279
+ return
280
+ }
255
281
  safeSendFinal()
256
282
  dcgLogger(`${info.kind} reply failed: ${String(err)}`, 'error')
257
283
  },
258
284
  onIdle: () => {
285
+ activeRunIdBySessionKey.delete(effectiveSessionKey)
286
+ streamChunkIdxBySessionKey.delete(effectiveSessionKey)
287
+ if (sessionStreamSuppressed.has(effectiveSessionKey)) return
259
288
  safeSendFinal()
260
289
  }
261
290
  })
262
291
 
263
292
  try {
293
+ if (!interruptCommand.includes(text?.trim())) {
294
+ sessionStreamSuppressed.delete(effectiveSessionKey)
295
+ streamChunkIdxBySessionKey.set(effectiveSessionKey, 0)
296
+ }
297
+
264
298
  if (systemCommand.includes(text?.trim())) {
265
299
  dcgLogger(`dispatching /new`)
266
300
  await core.channel.reply.dispatchReplyFromConfig({
@@ -269,21 +303,48 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
269
303
  dispatcher,
270
304
  replyOptions: {
271
305
  ...replyOptions,
272
- onModelSelected: prefixContext.onModelSelected
306
+ onModelSelected: prefixContext.onModelSelected,
307
+ onAgentRunStart: (runId) => {
308
+ activeRunIdBySessionKey.set(effectiveSessionKey, runId)
309
+ }
273
310
  }
274
311
  })
275
312
  } else if (interruptCommand.includes(text?.trim())) {
276
313
  dcgLogger(`interrupt command: ${text}`)
314
+ sendText('会话已终止', outboundCtx)
315
+ safeSendFinal()
316
+ sessionStreamSuppressed.add(effectiveSessionKey)
317
+ const runId = activeRunIdBySessionKey.get(effectiveSessionKey)
277
318
  sendMessageToGateway(
278
319
  JSON.stringify({
279
320
  method: 'chat.abort',
280
- params: { sessionKey: effectiveSessionKey }
321
+ params: {
322
+ sessionKey: effectiveSessionKey,
323
+ ...(runId ? { runId } : {})
324
+ }
281
325
  })
282
326
  )
283
- safeSendFinal()
327
+ if (runId) activeRunIdBySessionKey.delete(effectiveSessionKey)
284
328
  return
285
329
  } else {
286
330
  dcgLogger(`dispatching to agent (session=${route.sessionKey})`)
331
+ const params = getEffectiveMsgParams(effectiveSessionKey)
332
+ if (!ignoreToolCommand.includes(text?.trim())) {
333
+ // message_received 没有 sessionKey 前置到bot中执行
334
+ wsSendRaw(params, {
335
+ is_finish: -1,
336
+ tool_call_id: Date.now().toString(),
337
+ is_cover: 0,
338
+ thinking_content: JSON.stringify({
339
+ type: 'message_received',
340
+ specialIdentification: 'dcgchat_tool_call_special_identification',
341
+ toolName: '',
342
+ callId: Date.now().toString(),
343
+ params: ''
344
+ }),
345
+ response: ''
346
+ })
347
+ }
287
348
  await core.channel.reply.dispatchReplyFromConfig({
288
349
  ctx: ctxPayload,
289
350
  cfg: config,
@@ -292,7 +353,12 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
292
353
  ...replyOptions,
293
354
  // abortSignal: genSignal,
294
355
  onModelSelected: prefixContext.onModelSelected,
356
+ onAgentRunStart: (runId) => {
357
+ activeRunIdBySessionKey.set(effectiveSessionKey, runId)
358
+ },
295
359
  onPartialReply: async (payload: ReplyPayload) => {
360
+ if (sessionStreamSuppressed.has(effectiveSessionKey)) return
361
+
296
362
  // Accumulate full text
297
363
  if (payload.text) {
298
364
  completeText = payload.text
@@ -303,8 +369,10 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
303
369
  ? payload.text.slice(streamedTextLen)
304
370
  : payload.text
305
371
  if (delta.trim()) {
306
- sendChunk(delta, outboundCtx)
307
- dcgLogger(`[stream]: chunk ${delta.length} chars to user ${msg._userId} ${delta.slice(0, 100)}`)
372
+ const prev = streamChunkIdxBySessionKey.get(effectiveSessionKey) ?? 0
373
+ streamChunkIdxBySessionKey.set(effectiveSessionKey, prev + 1)
374
+ sendChunk(delta, outboundCtx, prev)
375
+ dcgLogger(`[stream]: chunkIdx=${prev} len=${delta.length} user=${msg._userId} ${delta.slice(0, 100)}`)
308
376
  }
309
377
  streamedTextLen = payload.text.length
310
378
  }
@@ -342,28 +410,31 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
342
410
  dcgLogger(` markRunComplete||markRunComplete error: ${String(err)}`, 'error')
343
411
  }
344
412
  if (![...systemCommand, ...interruptCommand].includes(text?.trim())) {
345
- for (const file of extractMobookFiles(completeText)) {
346
- const candidates: string[] = [file]
347
- candidates.push(path.join(getWorkspaceDir(), file))
348
- candidates.push(path.join(getWorkspaceDir(), file.replace(/^\//, '')))
349
- if (process.platform === 'win32') {
350
- const underMobook = file.replace(/^\/mobook\//i, '').replace(/^mobook[\\/]/i, '')
351
- if (underMobook) {
352
- candidates.push(path.join('C:\\', 'mobook', underMobook))
413
+ if (sessionStreamSuppressed.has(effectiveSessionKey)) {
414
+ sessionStreamSuppressed.delete(effectiveSessionKey)
415
+ } else {
416
+ for (const file of extractMobookFiles(completeText)) {
417
+ const candidates: string[] = [file]
418
+ candidates.push(path.join(getWorkspaceDir(), file))
419
+ candidates.push(path.join(getWorkspaceDir(), file.replace(/^\//, '')))
420
+ if (process.platform === 'win32') {
421
+ const underMobook = file.replace(/^\/mobook\//i, '').replace(/^mobook[\\/]/i, '')
422
+ if (underMobook) {
423
+ candidates.push(path.join('C:\\', 'mobook', underMobook))
424
+ }
425
+ }
426
+ const resolved = candidates.find((p) => fs.existsSync(p))
427
+ if (!resolved) continue
428
+ try {
429
+ await sendDcgchatMedia({ sessionKey: effectiveSessionKey, mediaUrl: resolved, text: '' })
430
+ } catch (err) {
431
+ dcgLogger(` sendDcgchatMedia error: ${String(err)}`, 'error')
353
432
  }
354
- }
355
- const resolved = candidates.find((p) => fs.existsSync(p))
356
- if (!resolved) continue
357
- try {
358
- await sendDcgchatMedia({ sessionKey: effectiveSessionKey, mediaUrl: resolved, text: '' })
359
- } catch (err) {
360
- dcgLogger(` sendDcgchatMedia error: ${String(err)}`, 'error')
361
433
  }
362
434
  }
363
435
  }
364
436
  safeSendFinal()
365
437
  clearSentMediaKeys(msg.content.message_id)
366
- setMsgStatus('finished')
367
438
 
368
439
  // Record session metadata
369
440
  const storePath = core.channel.session.resolveStorePath(config.session?.store)
package/src/channel.ts CHANGED
@@ -2,10 +2,10 @@ import type { ChannelPlugin, OpenClawConfig } from 'openclaw/plugin-sdk'
2
2
  import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk'
3
3
  import type { ResolvedDcgchatAccount, DcgchatConfig } from './types.js'
4
4
  import { ossUpload } from './request/oss.js'
5
- import { addSentMediaKey, getOpenClawConfig, hasSentMediaKey } from './utils/global.js'
5
+ import { addSentMediaKey, getCronMessageId, getOpenClawConfig, hasSentMediaKey } from './utils/global.js'
6
6
  import { isWsOpen, mergeDefaultParams, mergeSessionParams, sendFinal, wsSendRaw } from './transport.js'
7
7
  import { dcgLogger, setLogger } from './utils/log.js'
8
- import { getParamsDefaults, getEffectiveMsgParams, getCurrentSessionKey } from './utils/params.js'
8
+ import { getEffectiveMsgParams, getCurrentSessionKey } from './utils/params.js'
9
9
  import { startDcgchatGatewaySocket } from './gateway/socket.js'
10
10
 
11
11
  export type DcgchatMediaSendOptions = {
@@ -143,13 +143,23 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
143
143
  textChunkLimit: 4000,
144
144
  sendText: async (ctx) => {
145
145
  if (isWsOpen()) {
146
- const merged = mergeDefaultParams({
147
- agentId: ctx.accountId ?? '',
148
- sessionId: ctx.to,
149
- messageId: `${Date.now()}`
150
- })
151
- wsSendRaw(merged, { response: ctx.text })
152
- sendFinal(merged)
146
+ // if (ctx.to.indexOf('cron:') >= 0) {
147
+ // const sessionInfo = ctx.to.split(':')[1]
148
+ // const sessionId = sessionInfo.split('-')[0]
149
+ // const agentId = sessionInfo.split('-')[1]
150
+ // const merged = mergeDefaultParams({
151
+ // agentId: agentId,
152
+ // sessionId: sessionId,
153
+ // messageId: `${Date.now()}`,
154
+ // is_finish: -1
155
+ // })
156
+ // wsSendRaw(merged, { response: ctx.text })
157
+ // } else {
158
+ // }
159
+ const outboundCtx = getEffectiveMsgParams(ctx.to)
160
+ const messageId = getCronMessageId(ctx.to)
161
+ const newCtx = messageId ? { ...outboundCtx, messageId } : outboundCtx
162
+ wsSendRaw(newCtx, { response: ctx.text, is_finish: -1 })
153
163
  dcgLogger(`channel sendText to ${ctx.to} ${ctx.text?.slice(0, 50)}`)
154
164
  }
155
165
  return {
package/src/cron.ts CHANGED
@@ -1,17 +1,11 @@
1
1
  import path from 'node:path'
2
2
  import fs from 'node:fs'
3
- import crypto from 'node:crypto'
4
- import { execFile } from 'node:child_process'
5
- import { promisify } from 'node:util'
6
-
7
- const execFileAsync = promisify(execFile)
8
3
  import type { IMsgParams } from './types.js'
9
- import { sendEventMessage } from './transport.js'
10
- import { getWorkspaceDir } from './utils/global.js'
4
+ import { sendEventMessage, sendFinal } from './transport.js'
5
+ import { getWorkspaceDir, removeCronMessageId, setCronMessageId, setMsgStatus } from './utils/global.js'
11
6
  import { ossUpload } from './request/oss.js'
12
7
  import { dcgLogger } from './utils/log.js'
13
8
  import { sendMessageToGateway } from './gateway/socket.js'
14
- import { channelInfo, ENV } from './utils/constant.js'
15
9
  import { getCurrentSessionKey, getEffectiveMsgParams } from './utils/params.js'
16
10
 
17
11
  export function getCronJobsPath(): string {
@@ -20,6 +14,33 @@ export function getCronJobsPath(): string {
20
14
  return path.join(cronDir, 'jobs.json')
21
15
  }
22
16
 
17
+ type CronJobsFile = {
18
+ version?: number
19
+ jobs?: Array<{ id?: string; sessionKey?: string }>
20
+ }
21
+
22
+ /**
23
+ * 在 `jobPath` 指向的 jobs.json(通常为 getCronJobsPath())中按 id 查找任务并返回其 sessionKey。
24
+ */
25
+ export function readCronJobSessionKey(jobPath: string, jobId: string): string | null {
26
+ const id = jobId?.trim()
27
+ if (!id) return null
28
+ if (!fs.existsSync(jobPath)) {
29
+ dcgLogger(`readCronJobSessionKey: file not found ${jobPath}`, 'error')
30
+ return null
31
+ }
32
+ try {
33
+ const raw = fs.readFileSync(jobPath, 'utf8')
34
+ const data = JSON.parse(raw) as CronJobsFile
35
+ const job = (data.jobs ?? []).find((j) => j.id === id)
36
+ const sk = job?.sessionKey?.trim()
37
+ return sk || null
38
+ } catch (e) {
39
+ dcgLogger(`readCronJobSessionKey: failed to read ${jobPath}: ${String(e)}`, 'error')
40
+ return null
41
+ }
42
+ }
43
+
23
44
  function msgParamsToCtx(p: IMsgParams): IMsgParams | null {
24
45
  if (!p?.botToken) return null
25
46
  return p
@@ -103,36 +124,35 @@ export const onEnabledCronJob = async (jobId: string) => {
103
124
  }
104
125
  sendMessageToGateway(JSON.stringify({ method: 'cron.update', params: { id: jobId, patch: { enabled: true } } }))
105
126
  }
106
- export const onRunCronJob = async (jobId: string) => {
127
+ export const onRunCronJob = async (jobId: string, messageId: string) => {
107
128
  const id = jobId?.trim()
108
129
  if (!id) {
109
130
  dcgLogger('onRemoveCronJob: empty jobId', 'error')
110
131
  return
111
132
  }
112
- sendMessageToGateway(JSON.stringify({ method: 'cron.update', jobId }))
133
+ const jobPath = getCronJobsPath()
134
+ const sessionKey = readCronJobSessionKey(jobPath, jobId) || ''
135
+ if (!sessionKey) {
136
+ dcgLogger(`finishedDcgchatCron: no sessionKey for job id=${id}`, 'error')
137
+ return
138
+ }
139
+ setCronMessageId(sessionKey, messageId)
140
+ sendMessageToGateway(JSON.stringify({ method: 'cron.run', params: { id: jobId } }))
113
141
  }
114
- export const updateCronJobSessionKey = async (jobId: string) => {
142
+ export const finishedDcgchatCron = async (jobId: string) => {
115
143
  const id = jobId?.trim()
116
144
  if (!id) {
117
- dcgLogger('onRemoveCronJob: empty jobId', 'error')
145
+ dcgLogger('finishedDcgchatCron: empty jobId', 'error')
118
146
  return
119
147
  }
120
- const params = getEffectiveMsgParams(getCurrentSessionKey() ?? '')
121
- sendMessageToGateway(
122
- JSON.stringify({
123
- method: 'cron.update',
124
- params: {
125
- id: jobId,
126
- patch: {
127
- sessionKey: params.sessionKey,
128
- delivery: {
129
- channel: "dcgchat-test",
130
- to: params.sessionId,
131
- accountId: 14,
132
- bestEffort: true
133
- }
134
- }
135
- }
136
- })
137
- )
148
+ const jobPath = getCronJobsPath()
149
+ const sessionKey = readCronJobSessionKey(jobPath, id)
150
+ if (!sessionKey) {
151
+ dcgLogger(`finishedDcgchatCron: no sessionKey for job id=${id}`, 'error')
152
+ return
153
+ }
154
+ const outboundCtx = getEffectiveMsgParams(sessionKey)
155
+ sendFinal(outboundCtx)
156
+ removeCronMessageId(sessionKey)
157
+ dcgLogger(`finishedDcgchatCron: job=${id} sessionKey=${sessionKey}`)
138
158
  }
@@ -0,0 +1,185 @@
1
+ import { channelInfo, ENV } from './utils/constant.js'
2
+ /**
3
+ * cron-delivery-guard — 定时任务投递守护插件
4
+ *
5
+ * 核心机制:通过 before_tool_call 钩子拦截 cron 工具调用,
6
+ * 当 delivery.mode 为 "announce" 且未指定 channel 时,
7
+ * 自动注入 bestEffort: true,使投递失败时静默降级,
8
+ * 不影响 cron 执行结果的保存。
9
+ *
10
+ * 背景:
11
+ * - 定时任务的 delivery 设为 announce 模式,如果没有指定 channel,
12
+ * 投递可能因找不到有效渠道而失败
13
+ * - bestEffort: true 让框架在投递失败时不报错,避免丢失执行结果
14
+ */
15
+
16
+ import { dcgLogger } from './utils/log.js'
17
+
18
+ const LOG_TAG = 'cron-delivery-guard'
19
+
20
+ // ---- 类型定义 ----
21
+
22
+ interface ToolCallEvent {
23
+ toolName: string
24
+ toolCallId: string
25
+ params: Record<string, unknown>
26
+ result?: { content: string }
27
+ }
28
+
29
+ interface HookContext {
30
+ agentId: string
31
+ sessionKey: string
32
+ }
33
+
34
+ interface BeforeToolCallResult {
35
+ block?: boolean
36
+ blockReason?: string
37
+ params?: Record<string, unknown>
38
+ }
39
+
40
+ // ---- delivery 类型 ----
41
+
42
+ interface CronDelivery {
43
+ mode?: string
44
+ channel?: string
45
+ to?: string
46
+ bestEffort?: boolean
47
+ [key: string]: unknown
48
+ }
49
+ /**
50
+ * 解析 OpenClaw mobook direct 会话 key。
51
+ * 形如 `agent:main:mobook:direct:14:5466`(大小写不敏感,与路由 toLowerCase 一致)
52
+ * - 倒数第二段:account / peer(delivery.accountId)
53
+ * - 最后一段:会话 id(delivery.to)
54
+ */
55
+ export function formatterSessionKey(sessionKey: string): { agentId: string; sessionId: string } {
56
+ const parts = sessionKey.split(':').filter((s) => s.length > 0)
57
+ const norm = parts.map((s) => s.toLowerCase())
58
+ if (parts.length >= 6 && norm[0] === 'agent' && norm[2] === 'mobook' && norm[3] === 'direct') {
59
+ return {
60
+ agentId: parts[4] ?? '',
61
+ sessionId: parts[5] ?? ''
62
+ }
63
+ }
64
+ if (parts.length >= 2) {
65
+ return {
66
+ agentId: parts[parts.length - 2] ?? '',
67
+ sessionId: parts[parts.length - 1] ?? ''
68
+ }
69
+ }
70
+ return { agentId: '', sessionId: '' }
71
+ }
72
+
73
+ // ---- 辅助函数 ----
74
+
75
+ /**
76
+ * 判断是否为 cron 工具调用
77
+ */
78
+ function isCronTool(toolName: string): boolean {
79
+ return toolName === 'cron'
80
+ }
81
+
82
+ /**
83
+ * 从 cron 参数中提取 delivery 配置
84
+ * cron 工具的参数结构可能是:
85
+ * - params.delivery (顶层)
86
+ * - params.job.delivery (嵌套在 job 中)
87
+ */
88
+ function extractDelivery(params: Record<string, unknown>): CronDelivery | null {
89
+ // 尝试顶层 delivery
90
+ if (params.delivery && typeof params.delivery === 'object') {
91
+ return params.delivery as CronDelivery
92
+ }
93
+
94
+ // 尝试 job.delivery
95
+ const job = params.job as Record<string, unknown> | undefined
96
+ if (job?.delivery && typeof job.delivery === 'object') {
97
+ return job.delivery as CronDelivery
98
+ }
99
+
100
+ // 尝试 payload 中的 deliver 相关字段 (兼容 qqbot-cron 风格)
101
+ const payload = job?.payload as Record<string, unknown> | undefined
102
+ if (payload?.deliver === true && payload.channel === undefined) {
103
+ // payload 风格: { deliver: true, channel?: string }
104
+ // 这种情况不是 delivery 对象,跳过
105
+ return null
106
+ }
107
+
108
+ return null
109
+ }
110
+
111
+ /**
112
+ * 判断 delivery 是否需要注入 bestEffort
113
+ * 条件: mode 为 "announce" 且没有 channel
114
+ */
115
+ function needsBestEffort(delivery: CronDelivery): boolean {
116
+ return delivery.mode === 'announce' && !delivery.channel && !delivery.bestEffort
117
+ }
118
+
119
+ /**
120
+ * 深拷贝 params 并注入 bestEffort: true
121
+ */
122
+ function injectBestEffort(params: Record<string, unknown>, sk: string): Record<string, unknown> {
123
+ const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
124
+ const { agentId, sessionId } = formatterSessionKey(sk)
125
+ // 顶层 delivery
126
+ if (newParams.delivery && typeof newParams.delivery === 'object') {
127
+ ;(newParams.delivery as CronDelivery).bestEffort = true
128
+ ;(newParams.delivery as CronDelivery).to = sessionId
129
+ ;(newParams.delivery as CronDelivery).accountId = agentId
130
+ ;(newParams.delivery as CronDelivery).channel = "dcgchat-test"
131
+ newParams.sessionKey = sk
132
+ return newParams
133
+ }
134
+
135
+ // job.delivery
136
+ const job = newParams.job as Record<string, unknown> | undefined
137
+ if (job?.delivery && typeof job.delivery === 'object') {
138
+ ;(job.delivery as CronDelivery).bestEffort = true
139
+ ;(newParams.delivery as CronDelivery).to = sessionId
140
+ ;(newParams.delivery as CronDelivery).accountId = agentId
141
+ ;(newParams.delivery as CronDelivery).channel = "dcgchat-test"
142
+ newParams.sessionKey = sk
143
+ return newParams
144
+ }
145
+
146
+ return newParams
147
+ }
148
+
149
+ export function cronToolCall(event: { toolName: any; params: any; toolCallId: any }, sk: string) {
150
+ const { toolName, params, toolCallId } = event
151
+
152
+ // 仅处理 cron 工具
153
+ if (isCronTool(toolName)) {
154
+ const delivery = extractDelivery(params)
155
+ if (!delivery) {
156
+ dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) has no delivery config, skip.`)
157
+ return undefined
158
+ }
159
+ if (!needsBestEffort(delivery)) {
160
+ dcgLogger(
161
+ `[${LOG_TAG}] cron call (${toolCallId}) delivery does not need bestEffort ` +
162
+ `(mode=${String(delivery.mode)}, channel=${String(delivery.channel)}, bestEffort=${String(delivery.bestEffort)}), skip.`
163
+ )
164
+ return undefined
165
+ }
166
+
167
+ // ★ 核心:注入 bestEffort: true
168
+ const newParams = injectBestEffort(params, sk)
169
+ dcgLogger(
170
+ `[${LOG_TAG}] cron call (${toolCallId}) injected bestEffort=true ` +
171
+ `(mode=announce, no channel). delivery=${JSON.stringify(newParams.delivery || (newParams.job as Record<string, unknown>)?.delivery)}`
172
+ )
173
+
174
+ return { params: newParams }
175
+ } else if (toolName === 'exec') {
176
+ if (params.command.indexOf('cron create') || params.command.indexOf('cron add')) {
177
+ const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
178
+ newParams.command =
179
+ params.command.replace('--json', '') + ` --session-key ${sk} --channel ${"dcgchat-test"} --to ${sk} --json`
180
+ return { params: newParams }
181
+ }
182
+ }
183
+
184
+ return undefined
185
+ }
@@ -3,7 +3,7 @@ import { WebSocket } from 'ws'
3
3
  import crypto from 'crypto'
4
4
  import { deriveDeviceIdFromPublicKey, publicKeyRawBase64UrlFromPem, buildDeviceAuthPayloadV3, signDevicePayload } from './security.js'
5
5
  import { dcgLogger } from '../utils/log.js'
6
- import { sendDcgchatCron, updateCronJobSessionKey } from '../cron.js'
6
+ import { finishedDcgchatCron, sendDcgchatCron } from '../cron.js'
7
7
 
8
8
  export interface GatewayEvent {
9
9
  type: string
@@ -196,7 +196,7 @@ export class GatewayConnection {
196
196
  dcgLogger('Gateway connection opened(等待 connect.challenge)')
197
197
  })
198
198
 
199
- this.ws.on('message', (data) => {
199
+ this.ws.on('message', (data, ...args) => {
200
200
  try {
201
201
  const msg = JSON.parse(data.toString())
202
202
  this.handleMessage(
@@ -357,20 +357,18 @@ export class GatewayConnection {
357
357
 
358
358
  if (msg.type === 'event') {
359
359
  try {
360
+ // 定时任务相关事件
360
361
  if (msg.event === 'cron') {
361
362
  dcgLogger(`[Gateway] 收到事件: ${JSON.stringify(msg)}`)
362
363
  if (msg.payload?.action === 'added') {
363
- updateCronJobSessionKey(msg.payload?.jobId as string)
364
- }
365
- if (msg.payload?.action === 'updated') {
366
364
  sendDcgchatCron()
367
365
  }
368
- if (msg.payload?.action === 'added') {
369
- updateCronJobSessionKey(msg.payload?.jobId as string)
370
- }
371
366
  if (msg.payload?.action === 'updated') {
372
367
  sendDcgchatCron()
373
368
  }
369
+ if (msg.payload?.action === 'finished') {
370
+ finishedDcgchatCron(msg.payload?.jobId as string)
371
+ }
374
372
  }
375
373
  } catch (error) {
376
374
  dcgLogger(`[Gateway] 处理事件失败: ${error}`, 'error')
@@ -230,10 +230,8 @@ export function startDcgchatGatewaySocket(): void {
230
230
  socketStopped = false
231
231
  clearReconnectTimer()
232
232
  if (startupConnectTimer != null) return
233
- startupConnectTimer = setTimeout(() => {
234
- startupConnectTimer = null
235
- void connectPersistentGateway()
236
- }, 10000)
233
+ startupConnectTimer = null
234
+ void connectPersistentGateway()
237
235
  }
238
236
 
239
237
  /**
package/src/monitor.ts CHANGED
@@ -2,12 +2,12 @@ import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk'
2
2
  import WebSocket from 'ws'
3
3
  import { handleDcgchatMessage } from './bot.js'
4
4
  import { resolveAccount } from './channel.js'
5
- import { setWsConnection, getOpenClawConfig, setMsgStatus } from './utils/global.js'
5
+ import { setWsConnection, getOpenClawConfig, setMsgStatus, getSessionKey } from './utils/global.js'
6
6
  import type { InboundMessage } from './types.js'
7
7
  import { installSkill, uninstallSkill } from './skill.js'
8
8
  import { dcgLogger } from './utils/log.js'
9
- import { ignoreToolCommand } from './utils/constant.js'
10
9
  import { onDisabledCronJob, onEnabledCronJob, onRemoveCronJob, onRunCronJob } from './cron.js'
10
+ import { ignoreToolCommand } from './utils/constant.js'
11
11
 
12
12
  export type MonitorDcgchatOpts = {
13
13
  config?: ClawdbotConfig
@@ -126,10 +126,13 @@ export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<
126
126
 
127
127
  if (parsed.messageType == 'openclaw_bot_chat') {
128
128
  const msg = parsed as unknown as InboundMessage
129
- if (!ignoreToolCommand.includes(msg.content.text?.trim())) {
130
- setMsgStatus('running')
129
+ // monitor 原逻辑一致:工具类指令不进入 running,避免误触工具链监控
130
+ const effectiveSessionKey = getSessionKey(msg.content, account.accountId)
131
+ if (!ignoreToolCommand.includes(msg.content.text?.trim() ?? '')) {
132
+ setMsgStatus(effectiveSessionKey, 'running')
133
+ } else {
134
+ setMsgStatus(effectiveSessionKey, 'finished')
131
135
  }
132
-
133
136
  await handleDcgchatMessage(msg, account.accountId)
134
137
  } else if (parsed.messageType == 'openclaw_bot_event') {
135
138
  const { event_type, operation_type } = parsed.content ? parsed.content : ({} as Record<string, any>)
@@ -152,15 +155,15 @@ export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<
152
155
  dcgLogger(`openclaw_bot_event unknown event_type: ${event_type}, ${data.toString()}`)
153
156
  }
154
157
  } else if (event_type === 'cron') {
155
- const { job_id } = parsed.content
158
+ const { job_id, message_id } = parsed.content
156
159
  if (operation_type === 'remove') {
157
160
  await onRemoveCronJob(job_id)
158
161
  } else if (operation_type === 'enable') {
159
162
  await onEnabledCronJob(job_id)
160
163
  } else if (operation_type === 'disable') {
161
164
  await onDisabledCronJob(job_id)
162
- } else if (operation_type === 'exec') {
163
- await onRunCronJob(job_id)
165
+ } else if (operation_type === 'run') {
166
+ await onRunCronJob(job_id, message_id)
164
167
  }
165
168
  } else {
166
169
  dcgLogger(`openclaw_bot_event unknown operation_type: ${operation_type}, ${data.toString()}`)
package/src/tool.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'
2
- import { getMsgStatus, getWsConnection } from './utils/global.js'
2
+ import { getMsgStatus } from './utils/global.js'
3
3
  import { dcgLogger } from './utils/log.js'
4
- import { isWsOpen, sendFinal, sendText } from './transport.js'
4
+ import { sendFinal, sendText, wsSendRaw } from './transport.js'
5
5
  import { getCurrentSessionKey, getEffectiveMsgParams } from './utils/params.js'
6
+ import { channelInfo, ENV } from './utils/constant.js'
7
+ import { cronToolCall } from './cronToolCall.js'
6
8
 
7
9
  let toolCallId = ''
8
10
  let toolName = ''
@@ -31,8 +33,10 @@ type PluginHookName =
31
33
  | 'subagent_ended'
32
34
  | 'gateway_start'
33
35
  | 'gateway_stop'
36
+
37
+ // message_received 没有 sessionKey 前置到bot中执行
34
38
  const eventList = [
35
- { event: 'message_received', message: '' },
39
+ // { event: 'message_received', message: '' },
36
40
  // {event: 'before_model_resolve', message: ''},
37
41
  // {event: 'before_prompt_build', message: '正在查阅背景资料,构建思考逻辑'},
38
42
  // {event: 'before_agent_start', message: '书灵墨宝已就位,准备开始执行任务'},
@@ -50,75 +54,78 @@ const eventList = [
50
54
  { event: 'after_tool_call', message: '' }
51
55
  ]
52
56
 
53
- function sendToolCallMessage(text: string, toolCallId: string, isCover: number) {
54
- const ws = getWsConnection()
55
- const sk = getCurrentSessionKey()
56
- if (!sk) return
57
+ function sendToolCallMessage(sk: string, text: string, toolCallId: string, isCover: number) {
57
58
  const params = getEffectiveMsgParams(sk)
58
- if (isWsOpen()) {
59
- ws?.send(
60
- JSON.stringify({
61
- messageType: 'openclaw_bot_chat',
62
- _userId: params?.userId,
63
- source: 'client',
64
- is_finish: -1,
65
- content: {
66
- is_finish: -1,
67
- bot_token: params?.botToken,
68
- domain_id: params?.domainId,
69
- app_id: params?.appId,
70
- bot_id: params?.botId,
71
- agent_id: params?.agentId,
72
- tool_call_id: toolCallId,
73
- is_cover: isCover,
74
- thinking_content: text,
75
- response: '',
76
- session_id: params?.sessionId,
77
- message_id: params?.messageId || Date.now().toString()
78
- }
79
- })
80
- )
81
- }
59
+ wsSendRaw(params, {
60
+ is_finish: -1,
61
+ tool_call_id: toolCallId,
62
+ is_cover: isCover,
63
+ thinking_content: text,
64
+ response: ''
65
+ })
66
+ }
67
+
68
+ /**
69
+ * 深拷贝 params 并注入 bestEffort: true
70
+ */
71
+ interface CronDelivery {
72
+ mode?: string
73
+ channel?: string
74
+ to?: string
75
+ bestEffort?: boolean
76
+ [key: string]: unknown
82
77
  }
83
78
 
84
79
  export function monitoringToolMessage(api: OpenClawPluginApi) {
85
80
  for (const item of eventList) {
86
81
  api.on(item.event as PluginHookName, (event: any, args: any) => {
87
- const status = getMsgStatus()
88
- if (status === 'running') {
89
- if (['after_tool_call', 'before_tool_call'].includes(item.event)) {
90
- const { result: _result, ...rest } = event
91
- dcgLogger(`工具调用结果: ~ event:${item.event} ~ params:${JSON.stringify(rest)}`)
92
- const text = JSON.stringify({
93
- type: item.event,
94
- specialIdentification: 'dcgchat_tool_call_special_identification',
95
- callId: event.toolCallId || event.runId || Date.now().toString(),
96
- ...rest,
97
- status: item.event === 'after_tool_call' ? 'finished' : 'running'
98
- })
99
- sendToolCallMessage(text, event.toolCallId || event.runId || Date.now().toString(), item.event === 'after_tool_call' ? 1 : 0)
100
- } else if (item.event) {
101
- dcgLogger(`工具调用结果: ~ event:${item.event}`)
102
- if (item.event === 'llm_output') {
103
- if (event.lastAssistant?.errorMessage === '429-账户额度耗尽') {
104
- const message = '您的积分已消耗完,您可以通过充值积分来继续使用'
105
- const sk = ((args?.sessionKey as string | undefined) ?? getCurrentSessionKey()) || ''
106
- if (!sk) return
107
- const msgCtx = getEffectiveMsgParams(sk)
108
- sendText(message, msgCtx, { message_tags: { insufficient_balance: 1 }, is_finish: -1 })
109
- sendFinal(msgCtx)
110
- return
82
+ const sk = args?.sessionKey as string
83
+ if (sk) {
84
+ const status = getMsgStatus(sk)
85
+ if (status === 'running') {
86
+ if (['after_tool_call', 'before_tool_call'].includes(item.event)) {
87
+ const { result: _result, ...rest } = event
88
+ dcgLogger(`工具调用结果: ~ event:${item.event} ~ params:${JSON.stringify(rest)}`)
89
+
90
+ if (item.event === 'before_tool_call') {
91
+ return cronToolCall(rest, sk)
92
+ }
93
+ const text = JSON.stringify({
94
+ type: item.event,
95
+ specialIdentification: 'dcgchat_tool_call_special_identification',
96
+ callId: event.toolCallId || event.runId || Date.now().toString(),
97
+ ...rest,
98
+ status: item.event === 'after_tool_call' ? 'finished' : 'running'
99
+ })
100
+ sendToolCallMessage(
101
+ sk,
102
+ text,
103
+ event.toolCallId || event.runId || Date.now().toString(),
104
+ item.event === 'after_tool_call' ? 1 : 0
105
+ )
106
+ } else if (item.event) {
107
+ const msgCtx = getEffectiveMsgParams(sk)
108
+ if (item.event === 'llm_output') {
109
+ if (event.lastAssistant?.errorMessage === '429-账户额度耗尽') {
110
+ const message = '您的积分已消耗完,您可以通过充值积分来继续使用'
111
+ sendText(message, msgCtx, { message_tags: { insufficient_balance: 1 }, is_finish: -1 })
112
+ sendFinal(msgCtx)
113
+ return
114
+ }
111
115
  }
116
+ const text = JSON.stringify({
117
+ type: item.event,
118
+ specialIdentification: 'dcgchat_tool_call_special_identification',
119
+ toolName: '',
120
+ callId: event.runId || Date.now().toString(),
121
+ params: item.message
122
+ })
123
+ sendToolCallMessage(sk, text, event.runId || Date.now().toString(), 0)
124
+ dcgLogger(`工具调用结果: ~ event:${item.event} ${status}`)
112
125
  }
113
- const text = JSON.stringify({
114
- type: item.event,
115
- specialIdentification: 'dcgchat_tool_call_special_identification',
116
- toolName: '',
117
- callId: event.runId || Date.now().toString(),
118
- params: item.message
119
- })
120
- sendToolCallMessage(text, event.runId || Date.now().toString(), 0)
121
126
  }
127
+ } else {
128
+ dcgLogger(`工具调用结果: ~ event:${item.event} ~ 没有sessionKey 为执行`)
122
129
  }
123
130
  })
124
131
  }
package/src/transport.ts CHANGED
@@ -158,8 +158,8 @@ export function wsSendRaw(ctx: IMsgParams, content: Record<string, unknown>): bo
158
158
  return true
159
159
  }
160
160
 
161
- export function sendChunk(text: string, ctx: IMsgParams): boolean {
162
- return wsSend(ctx, { response: text, state: 'chunk' })
161
+ export function sendChunk(text: string, ctx: IMsgParams, chunkIdx: number): boolean {
162
+ return wsSend(ctx, { response: text, state: 'chunk', chunk_idx: chunkIdx })
163
163
  }
164
164
 
165
165
  export function sendFinal(ctx: IMsgParams): boolean {
package/src/types.ts CHANGED
@@ -127,4 +127,5 @@ export interface IMsgParams {
127
127
  /** 与 OpenClaw 路由一致,用于 map 与异步链路(工具 / HTTP / cron)对齐当前会话 */
128
128
  sessionKey?: string
129
129
  real_mobook?: string | number
130
+ is_finish?: number
130
131
  }
@@ -2,6 +2,6 @@ export const ENV: 'production' | 'test' | 'develop' = 'test'
2
2
 
3
3
 
4
4
  export const systemCommand = ['/new', '/status']
5
- export const interruptCommand = ['/stop']
5
+ export const interruptCommand = ['chat.stop']
6
6
 
7
- export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...interruptCommand]
7
+ export const ignoreToolCommand = ['/search', '/abort', '/stop', '/queue interrupt', ...systemCommand, ...interruptCommand]
@@ -68,12 +68,22 @@ export function getDcgchatRuntime(): PluginRuntime {
68
68
  return runtime as PluginRuntime
69
69
  }
70
70
 
71
- let msgStatus: 'running' | 'finished' | '' = ''
72
- export function setMsgStatus(status: 'running' | 'finished' | '') {
73
- msgStatus = status
71
+ export type MsgSessionStatus = 'running' | 'finished' | ''
72
+
73
+ const msgStatusBySessionKey = new Map<string, MsgSessionStatus>()
74
+
75
+ export function setMsgStatus(sessionKey: string, status: MsgSessionStatus) {
76
+ if (!sessionKey?.trim()) return
77
+ if (status === '') {
78
+ msgStatusBySessionKey.delete(sessionKey)
79
+ } else {
80
+ msgStatusBySessionKey.set(sessionKey, status)
81
+ }
74
82
  }
75
- export function getMsgStatus() {
76
- return msgStatus
83
+
84
+ export function getMsgStatus(sessionKey: string): MsgSessionStatus {
85
+ if (!sessionKey?.trim()) return ''
86
+ return msgStatusBySessionKey.get(sessionKey) ?? ''
77
87
  }
78
88
 
79
89
  const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
@@ -106,3 +116,30 @@ export function clearSentMediaKeys(messageId?: string) {
106
116
  sentMediaKeysBySession.clear()
107
117
  }
108
118
  }
119
+
120
+ export const getSessionKey = (content: any, accountId: string) => {
121
+ const { real_mobook, agent_id, conversation_id, session_id } = content
122
+ const core = getDcgchatRuntime()
123
+
124
+ const route = core.channel.routing.resolveAgentRoute({
125
+ cfg: getOpenClawConfig() as OpenClawConfig,
126
+ channel: "dcgchat-test",
127
+ accountId: accountId || 'default',
128
+ peer: { kind: 'direct', id: session_id }
129
+ })
130
+ return real_mobook === '1' ? route.sessionKey : `agent:main:mobook:direct:${agent_id}:${session_id}`.toLowerCase()
131
+ }
132
+
133
+ const cronMessageIdMap = new Map<string, string>()
134
+
135
+ export function setCronMessageId(sk: string, messageId: string) {
136
+ cronMessageIdMap.set(sk, messageId)
137
+ }
138
+
139
+ export function getCronMessageId(sk: string): string {
140
+ return cronMessageIdMap.get(sk) ?? ''
141
+ }
142
+
143
+ export function removeCronMessageId(sk: string) {
144
+ cronMessageIdMap.delete(sk)
145
+ }
@@ -53,7 +53,9 @@ export function getEffectiveMsgParams(sessionKey?: string): IMsgParams {
53
53
  export function setParamsMessage(sessionKey: string, params: Partial<IMsgParams>) {
54
54
  if (!sessionKey) return
55
55
  currentSessionKey = sessionKey
56
- paramsMessageMap.set(sessionKey, resolveParamsMessage({ ...params, sessionKey }))
56
+ const previous = paramsMessageMap.get(sessionKey)
57
+ const base = previous ? resolveParamsMessage(previous) : getParamsDefaults()
58
+ paramsMessageMap.set(sessionKey, resolveParamsMessage({ ...base, ...params, sessionKey }))
57
59
  }
58
60
 
59
61
  export function getParamsMessage(sessionKey: string): IMsgParams | undefined {