@dcrays/dcgchat-test 0.4.17 → 0.4.19

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.4.17",
3
+ "version": "0.4.19",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
6
6
  "main": "index.ts",
package/src/bot.ts CHANGED
@@ -17,7 +17,7 @@ import { dcgLogger } from './utils/log.js'
17
17
  import { channelInfo, systemCommand, interruptCommand, ENV, ignoreToolCommand } from './utils/constant.js'
18
18
  import { sendGatewayRpc } from './gateway/socket.js'
19
19
  import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
20
- import { getChildSessionKeysTrackedForRequester, resetSubagentStateForRequesterSession, waitUntilSubagentsIdle } from './tool.js'
20
+ import { getDescendantSessionKeysForRequester, resetSubagentStateForRequesterSession, waitUntilSubagentsIdle } from './tool.js'
21
21
 
22
22
  type MediaInfo = {
23
23
  path: string
@@ -30,7 +30,7 @@ type TFileInfo = { name: string; url: string }
30
30
 
31
31
  const mediaMaxBytes = 300 * 1024 * 1024
32
32
 
33
- /** 当前会话最近一次 agent run 的 runId(供 chat.abort 精确打断;无则仅传 sessionKey 仍会中止该会话全部活动运行) */
33
+ /** 当前会话最近一次 agent run 的 runId(供观测;/stop 时对子会话与主会话使用不传 runId 的 chat.abort 以终止该键下全部活跃 run) */
34
34
  const activeRunIdBySessionKey = new Map<string, string>()
35
35
 
36
36
  /**
@@ -174,7 +174,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
174
174
  sessionId: conversationId,
175
175
  messageId: msg.content.message_id,
176
176
  domainId: msg.content.domain_id,
177
- appId: msg.content.app_id,
177
+ appId: config.channels?.["dcgchat-test"]?.appId || 100,
178
178
  botId: msg.content.bot_id ?? '',
179
179
  agentId: msg.content.agent_id ?? '',
180
180
  sessionKey: dcgSessionKey,
@@ -327,12 +327,25 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
327
327
  const ctxForAbort = priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim() ? priorOutboundCtx : outboundCtx
328
328
  sendFinal(ctxForAbort.messageId?.trim() ? ctxForAbort : { ...ctxForAbort, messageId: `${Date.now()}` }, 'abort')
329
329
  sessionStreamSuppressed.add(dcgSessionKey)
330
+ // 网关侧彻底中止:子 agent 使用独立 sessionKey,仅 abort 主会话不会停子 run;自深到浅 abort 后代,再 abort 主会话;不传 runId 以终止该键下全部活跃 chat run
331
+ const descendantKeys = getDescendantSessionKeysForRequester(dcgSessionKey)
332
+ const abortSubKeys = [...descendantKeys].reverse()
333
+ if (abortSubKeys.length > 0) {
334
+ dcgLogger(`interrupt: chat.abort ${abortSubKeys.length} subagent session(s) (nested incl.)`)
335
+ }
336
+ for (const subKey of abortSubKeys) {
337
+ try {
338
+ await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: subKey } })
339
+ } catch (e) {
340
+ dcgLogger(`chat.abort subagent ${subKey}: ${String(e)}`, 'error')
341
+ }
342
+ }
330
343
  try {
331
- const runId = activeRunIdBySessionKey.get(dcgSessionKey)
332
- await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: dcgSessionKey, runId } })
344
+ await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: dcgSessionKey } })
333
345
  } catch (e) {
334
- dcgLogger(`chat.abort ${dcgSessionKey}: ${String(e)}`, 'error')
346
+ dcgLogger(`chat.abort main ${dcgSessionKey}: ${String(e)}`, 'error')
335
347
  }
348
+ activeRunIdBySessionKey.delete(dcgSessionKey)
336
349
  streamChunkIdxBySessionKey.delete(dcgSessionKey)
337
350
  clearSentMediaKeys(msg.content.message_id)
338
351
  resetSubagentStateForRequesterSession(dcgSessionKey)
package/src/channel.ts CHANGED
@@ -14,6 +14,7 @@ import { isWsOpen, mergeDefaultParams, mergeSessionParams, sendFinal, wsSendRaw
14
14
  import { dcgLogger, setLogger } from './utils/log.js'
15
15
  import { getOutboundMsgParams, getParamsMessage } from './utils/params.js'
16
16
  import { startDcgchatGatewaySocket } from './gateway/socket.js'
17
+ import { getCronJobsPath, readCronJob } from './cron.js'
17
18
 
18
19
  function dcgchatChannelCfg(): DcgchatConfig {
19
20
  return (getOpenClawConfig()?.channels?.["dcgchat-test"] as DcgchatConfig | undefined) ?? {}
@@ -61,6 +62,8 @@ export type DcgchatMediaSendOptions = {
61
62
  sessionKey: string
62
63
  mediaUrl?: string
63
64
  text?: string
65
+ /** 定时任务等场景须与 `getCronMessageId` 一致,避免沿用 map 里上一条用户消息的 messageId */
66
+ messageId?: string
64
67
  }
65
68
 
66
69
  function normalizeSessionTarget(rawTo: string): string {
@@ -69,6 +72,29 @@ function normalizeSessionTarget(rawTo: string): string {
69
72
  return getParamsMessage(cleaned)?.sessionKey?.trim() || cleaned
70
73
  }
71
74
 
75
+ /**
76
+ * OpenClaw 定时任务 `sessionTarget: isolated` 时,出站 `to` 常为 `agent:<code>:cron:<jobId>[:run:…]`,
77
+ * 与 `paramsMessageMap` / `getCronMessageId` 使用的 jobs.json `sessionKey`(mobook 用户会话)不一致,导致发文件时 sessionId、messageId 错位或缺省。
78
+ */
79
+ function extractCronJobIdFromIsolatedSessionKey(sessionKey: string): string | null {
80
+ const parts = sessionKey.split(':').filter((p) => p.length > 0)
81
+ const i = parts.findIndex((p) => p.toLowerCase() === 'cron')
82
+ if (i < 0 || i + 1 >= parts.length) return null
83
+ return parts[i + 1] ?? null
84
+ }
85
+
86
+ function resolveIsolatedCronSessionToJobSessionKey(sessionKey: string): string {
87
+ const jobId = extractCronJobIdFromIsolatedSessionKey(sessionKey)
88
+ if (!jobId) return sessionKey
89
+ const job = readCronJob(getCronJobsPath(), jobId)
90
+ const sk = job && typeof job.sessionKey === 'string' ? job.sessionKey.trim() : ''
91
+ if (!sk) {
92
+ dcgLogger(`dcgchat: cron job ${jobId} has no sessionKey in jobs.json, keep outbound key=${sessionKey}`, 'error')
93
+ return sessionKey
94
+ }
95
+ return sk
96
+ }
97
+
72
98
  /** 出站返回的 chatId:含 `dcg-cron:` 时保留原始 `to`,便于下游识别定时投递 */
73
99
  function outboundChatId(rawTo: string | undefined, normalizedTo: string): string {
74
100
  const raw = rawTo?.trim() ?? ''
@@ -76,21 +102,32 @@ function outboundChatId(rawTo: string | undefined, normalizedTo: string): string
76
102
  }
77
103
 
78
104
  export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<void> {
79
- const sessionKey = normalizeSessionTarget(opts.sessionKey ?? '')
80
- const msgCtx = getOutboundMsgParams(sessionKey)
105
+ let sessionKey = normalizeSessionTarget(opts.sessionKey ?? '')
106
+ sessionKey = resolveIsolatedCronSessionToJobSessionKey(sessionKey)
107
+ const baseCtx = getOutboundMsgParams(sessionKey)
108
+ const msgCtx = opts.messageId?.trim() ? { ...baseCtx, messageId: opts.messageId.trim() } : baseCtx
81
109
  if (!isWsOpen()) {
82
- dcgLogger(`outbound media skipped -> ws not open: ${opts.mediaUrl ?? ''}`)
110
+ dcgLogger(`outbound media skipped -> ws not isWsOpen failed open: ${opts.mediaUrl ?? ''}`)
83
111
  return
84
112
  }
85
113
 
86
114
  const mediaUrl = opts.mediaUrl
87
- const dedupeId = msgCtx.messageId
88
- if (mediaUrl && dedupeId && hasSentMediaKey(dedupeId, mediaUrl)) {
89
- dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl}`)
90
- return
115
+
116
+ if (mediaUrl && msgCtx.sessionId) {
117
+ if (hasSentMediaKey(msgCtx.sessionId, mediaUrl)) {
118
+ dcgLogger(`dcgchat: sendMedia skipped (hasSentMediaKey): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
119
+ return
120
+ }
121
+ addSentMediaKey(msgCtx.sessionId, mediaUrl)
122
+ }
123
+
124
+ const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
125
+ if (!msgCtx.sessionId) {
126
+ msgCtx.sessionId = sessionId
91
127
  }
92
- if (mediaUrl && dedupeId) {
93
- addSentMediaKey(dedupeId, mediaUrl)
128
+ if (!mediaUrl || !msgCtx.sessionId) {
129
+ dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
130
+ return
94
131
  }
95
132
 
96
133
  const fileName = mediaUrl?.split(/[\\/]/).pop() || ''
@@ -98,16 +135,19 @@ export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<v
98
135
  try {
99
136
  const botToken = msgCtx.botToken ?? getOpenClawConfig()?.channels?.["dcgchat-test"]?.botToken ?? ''
100
137
  const url = opts.mediaUrl ? await ossUpload(opts.mediaUrl, botToken, 1) : ''
138
+ if (!msgCtx.agentId) {
139
+ msgCtx.agentId = agentId
140
+ }
101
141
  wsSendRaw(msgCtx, {
102
142
  response: opts.text ?? '',
103
- message_tags: { source: 'file' },
143
+ is_finish: `${msgCtx?.messageId}`?.length === 13 ? -1 : 0,
104
144
  files: [{ url, name: fileName }]
105
145
  })
106
146
  dcgLogger(`dcgchat: sendMedia session=${sessionKey}, file=${fileName}`)
107
147
  } catch (error) {
108
148
  wsSendRaw(msgCtx, {
109
149
  response: opts.text ?? '',
110
- message_tags: { source: 'file' },
150
+ is_finish: `${msgCtx?.messageId}`?.length === 13 ? -1 : 0,
111
151
  files: [{ url: opts.mediaUrl ?? '', name: fileName }]
112
152
  })
113
153
  dcgLogger(`dcgchat: error sendMedia session=${sessionKey}: ${String(error)}`, 'error')
@@ -283,8 +323,9 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
283
323
  }
284
324
  },
285
325
  sendMedia: async (ctx) => {
286
- const isCron = ctx.to.indexOf('dcg-cron:') >= 0
287
- const to = normalizeSessionTarget(ctx.to)
326
+ const normalizedFromTo = normalizeSessionTarget(ctx.to)
327
+ const isCron = ctx.to.indexOf('dcg-cron:') >= 0 || extractCronJobIdFromIsolatedSessionKey(normalizedFromTo) !== null
328
+ const to = resolveIsolatedCronSessionToJobSessionKey(normalizedFromTo)
288
329
  const outboundCtx = getOutboundMsgParams(to)
289
330
  const msgCtx = getParamsMessage(to) ?? outboundCtx
290
331
  const cronMsgId = getCronMessageId(to)
@@ -301,7 +342,11 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
301
342
  }
302
343
 
303
344
  dcgLogger(`channel sendMedia to ${ctx.to}`)
304
- await sendDcgchatMedia({ sessionKey: to || '', mediaUrl: ctx.mediaUrl || '' })
345
+ await sendDcgchatMedia({
346
+ sessionKey: to || '',
347
+ mediaUrl: ctx.mediaUrl || '',
348
+ ...(isCron ? { messageId } : {})
349
+ })
305
350
  return {
306
351
  channel: "dcgchat-test",
307
352
  messageId,
package/src/cron.ts CHANGED
@@ -135,7 +135,7 @@ 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) => {
138
+ export const finishedDcgchatCron = async (jobId: string, summary: string, hasFileOutput?: boolean) => {
139
139
  const id = jobId?.trim()
140
140
  if (!id) {
141
141
  dcgLogger('finishedDcgchatCron: empty jobId', 'error')
@@ -159,7 +159,11 @@ export const finishedDcgchatCron = async (jobId: string, summary: string) => {
159
159
  messageId: messageId || `${Date.now()}`,
160
160
  real_mobook: !sessionId ? 1 : ''
161
161
  })
162
- wsSendRaw(merged, { response: summary, message_tags: { source: 'cron' }, is_finish: -1 })
162
+ const message_tags = { source: 'cron' } as Record<string, string | boolean>
163
+ if (hasFileOutput) {
164
+ message_tags.hasFile = true
165
+ }
166
+ wsSendRaw(merged, { response: summary, message_tags, is_finish: -1 })
163
167
  setTimeout(() => {
164
168
  sendFinal(merged, 'cron send')
165
169
  }, 200)
@@ -135,10 +135,11 @@ function injectBestEffort(params: Record<string, unknown>, sk: string): Record<s
135
135
  // job.delivery
136
136
  const job = newParams.job as Record<string, unknown> | undefined
137
137
  if (job?.delivery && typeof job.delivery === 'object') {
138
- ;(job.delivery as CronDelivery).bestEffort = true
139
- ;(newParams.delivery as CronDelivery).to = `dcg-cron:${sk}`
140
- ;(newParams.delivery as CronDelivery).accountId = agentId
141
- ;(newParams.delivery as CronDelivery).channel = "dcgchat-test"
138
+ const jd = job.delivery as CronDelivery
139
+ jd.bestEffort = true
140
+ jd.to = `dcg-cron:${sk}`
141
+ jd.accountId = agentId
142
+ jd.channel = "dcgchat-test"
142
143
  newParams.sessionKey = sk
143
144
  return newParams
144
145
  }
@@ -154,14 +155,14 @@ export function cronToolCall(event: { toolName: any; params: any; toolCallId: an
154
155
  const delivery = extractDelivery(params)
155
156
  if (!delivery) {
156
157
  dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) has no delivery config, skip.`)
157
- return params
158
+ return undefined
158
159
  }
159
160
  if (!needsBestEffort(delivery)) {
160
161
  dcgLogger(
161
162
  `[${LOG_TAG}] cron call (${toolCallId}) delivery does not need bestEffort ` +
162
163
  `(mode=${String(delivery.mode)}, channel=${String(delivery.channel)}, bestEffort=${String(delivery.bestEffort)}), skip.`
163
164
  )
164
- return params
165
+ return undefined
165
166
  }
166
167
 
167
168
  // ★ 核心:注入 bestEffort: true
@@ -179,9 +180,9 @@ export function cronToolCall(event: { toolName: any; params: any; toolCallId: an
179
180
  params.command.replace('--json', '') + ` --session-key ${sk} --channel ${"dcgchat-test"} --to dcg-cron:${sk} --json`
180
181
  return { params: newParams }
181
182
  } else {
182
- return params
183
+ return undefined
183
184
  }
184
185
  }
185
186
 
186
- return params
187
+ return undefined
187
188
  }
package/src/tool.ts CHANGED
@@ -180,6 +180,29 @@ export function getChildSessionKeysTrackedForRequester(requesterSessionKey: stri
180
180
  return out
181
181
  }
182
182
 
183
+ /**
184
+ * 自根 requester 起 BFS 收集所有已跟踪后代子会话(含嵌套)。网关 abort 时宜自深到浅,调用方对结果 `.reverse()` 后再逐个 chat.abort。
185
+ */
186
+ export function getDescendantSessionKeysForRequester(rootRequesterSessionKey: string): string[] {
187
+ const root = rootRequesterSessionKey.trim()
188
+ if (!root) return []
189
+ const ordered: string[] = []
190
+ const seen = new Set<string>()
191
+ let frontier = getChildSessionKeysTrackedForRequester(root)
192
+ while (frontier.length > 0) {
193
+ const next: string[] = []
194
+ for (const sk of frontier) {
195
+ const k = sk.trim()
196
+ if (!k || seen.has(k)) continue
197
+ seen.add(k)
198
+ ordered.push(k)
199
+ next.push(...getChildSessionKeysTrackedForRequester(k))
200
+ }
201
+ frontier = next
202
+ }
203
+ return ordered
204
+ }
205
+
183
206
  /**
184
207
  * 打断后清空本地子 agent 跟踪(runId、父子映射、子 runId→sessionKey),并唤醒 waitUntilSubagentsIdle,避免永久挂起。
185
208
  */
@@ -302,6 +325,16 @@ function resolveHookSessionKey(
302
325
  return (args?.sessionKey || '').trim()
303
326
  }
304
327
 
328
+ /** 定时触发时会话往往非 running,但仍需跑 before_tool_call 以注入 sessionKey / delivery(见 cronToolCall) */
329
+ function shouldRunBeforeToolCallWithoutRunningSession(event: { toolName?: string; params?: { command?: string } }): boolean {
330
+ if (event?.toolName === 'cron') return true
331
+ const cmd = event?.params?.command
332
+ if (event?.toolName === 'exec' && typeof cmd === 'string') {
333
+ return cmd.includes('cron create') || cmd.includes('cron add')
334
+ }
335
+ return false
336
+ }
337
+
305
338
  function trackSubagentLifecycle(eventName: string, event: any, args: any): void {
306
339
  if (eventName === 'subagent_spawned') {
307
340
  const runId = typeof event?.runId === 'string' ? event.runId : ''
@@ -328,7 +361,10 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
328
361
  trackSubagentLifecycle(item.event, event, args)
329
362
  const sk = resolveHookSessionKey(item.event, args ?? {})
330
363
  if (sk) {
331
- if (isSessionActiveForTool(sk)) {
364
+ const toolHooksOk =
365
+ isSessionActiveForTool(sk) ||
366
+ (item.event === 'before_tool_call' && shouldRunBeforeToolCallWithoutRunningSession(event))
367
+ if (toolHooksOk) {
332
368
  if (['after_tool_call', 'before_tool_call'].includes(item.event)) {
333
369
  const { result: _result, ...rest } = event
334
370
  dcgLogger(`工具调用结果: ~ event:${item.event} ~ params:${JSON.stringify(rest)}`)
package/src/transport.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getWsConnection } from './utils/global.js'
1
+ import { clearSentMediaKeys, getWsConnection } from './utils/global.js'
2
2
  import { dcgLogger } from './utils/log.js'
3
3
  import type { IMsgParams } from './types.js'
4
4
  import { getEffectiveMsgParams, getParamsDefaults } from './utils/params.js'
@@ -170,6 +170,7 @@ export function sendChunk(text: string, ctx: IMsgParams, chunkIdx: number): bool
170
170
 
171
171
  export function sendFinal(ctx: IMsgParams, tag: string): boolean {
172
172
  dcgLogger(` message handling complete state: to=${ctx.sessionId} final tag:${tag}`)
173
+ clearSentMediaKeys(ctx.sessionId)
173
174
  return wsSend(ctx, { response: '', state: 'final' })
174
175
  }
175
176
 
@@ -35,8 +35,13 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
35
35
  if (p?.action === 'removed') {
36
36
  sendDcgchatCron(p?.jobId as string)
37
37
  }
38
- if (p?.action === 'finished') {
39
- finishedDcgchatCron(p?.jobId as string, p?.summary as string)
38
+ if (p?.action === 'finished' && p?.status === 'ok') {
39
+ const hasFileOutput = p.delivered === true && p.deliveryStatus === 'delivered'
40
+ let summary = p?.summary as string
41
+ if (summary.indexOf('HEARTBEAT_OK') >= 0 && summary !== 'HEARTBEAT_OK') {
42
+ summary = summary.replace('HEARTBEAT_OK', '')
43
+ }
44
+ finishedDcgchatCron(p?.jobId as string, summary, hasFileOutput)
40
45
  }
41
46
  }
42
47
  } catch (error) {
@@ -82,11 +82,11 @@ const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
82
82
  /** 已发送媒体去重:外层 messageId → 内层该会话下已发送的媒体 key(文件名) */
83
83
  const sentMediaKeysBySession = new Map<string, Set<string>>()
84
84
 
85
- function getSessionMediaSet(messageId: string): Set<string> {
86
- let set = sentMediaKeysBySession.get(messageId)
85
+ function getSessionMediaSet(sessionId: string): Set<string> {
86
+ let set = sentMediaKeysBySession.get(sessionId)
87
87
  if (!set) {
88
88
  set = new Set<string>()
89
- sentMediaKeysBySession.set(messageId, set)
89
+ sentMediaKeysBySession.set(sessionId, set)
90
90
  }
91
91
  return set
92
92
  }
@@ -95,14 +95,14 @@ export function addSentMediaKey(messageId: string, url: string) {
95
95
  getSessionMediaSet(messageId).add(getMediaKey(url))
96
96
  }
97
97
 
98
- export function hasSentMediaKey(messageId: string, url: string): boolean {
99
- return sentMediaKeysBySession.get(messageId)?.has(getMediaKey(url)) ?? false
98
+ export function hasSentMediaKey(sessionId: string, url: string): boolean {
99
+ return sentMediaKeysBySession.get(sessionId)?.has(getMediaKey(url)) ?? false
100
100
  }
101
101
 
102
102
  /** 不传 messageId 时清空全部会话;传入则只清空该会话 */
103
- export function clearSentMediaKeys(messageId?: string) {
104
- if (messageId) {
105
- sentMediaKeysBySession.delete(messageId)
103
+ export function clearSentMediaKeys(sessionId?: string) {
104
+ if (sessionId) {
105
+ sentMediaKeysBySession.delete(sessionId)
106
106
  } else {
107
107
  sentMediaKeysBySession.clear()
108
108
  }