@dcrays/dcgchat-test 0.4.16 → 0.4.18

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.16",
3
+ "version": "0.4.18",
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
  /**
@@ -326,38 +326,32 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
326
326
  dcgLogger(`interrupt command: ${text}`)
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
- sendText('会话已终止', outboundCtx)
330
329
  sessionStreamSuppressed.add(dcgSessionKey)
331
- const abortOneSession = async (sessionKey: string) => {
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) {
332
337
  try {
333
- await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey } })
338
+ await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: subKey } })
334
339
  } catch (e) {
335
- dcgLogger(`chat.abort ${sessionKey}: ${String(e)}`, 'error')
340
+ dcgLogger(`chat.abort subagent ${subKey}: ${String(e)}`, 'error')
336
341
  }
337
342
  }
338
- const keysToAbort = new Set<string>(getChildSessionKeysTrackedForRequester(dcgSessionKey))
339
343
  try {
340
- const listed = await sendGatewayRpc<{ sessions?: Array<{ key?: string }> }>({
341
- method: 'sessions.list',
342
- params: { spawnedBy: dcgSessionKey, limit: 256 }
343
- })
344
- for (const s of listed?.sessions ?? []) {
345
- const k = typeof s?.key === 'string' ? s.key.trim() : ''
346
- if (k) keysToAbort.add(k)
347
- }
344
+ await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: dcgSessionKey } })
348
345
  } catch (e) {
349
- dcgLogger(`sessions.list spawnedBy: ${String(e)}`, 'error')
350
- }
351
- for (const sk of keysToAbort) {
352
- await abortOneSession(sk)
346
+ dcgLogger(`chat.abort main ${dcgSessionKey}: ${String(e)}`, 'error')
353
347
  }
354
- await abortOneSession(dcgSessionKey)
348
+ activeRunIdBySessionKey.delete(dcgSessionKey)
355
349
  streamChunkIdxBySessionKey.delete(dcgSessionKey)
350
+ clearSentMediaKeys(msg.content.message_id)
356
351
  resetSubagentStateForRequesterSession(dcgSessionKey)
357
352
  setMsgStatus(dcgSessionKey, 'finished')
358
- clearSentMediaKeys(msg.content.message_id)
359
353
  clearParamsMessage(dcgSessionKey)
360
- clearParamsMessage(userId)
354
+ sendText('会话已终止', outboundCtx)
361
355
  sendFinal(outboundCtx, 'stop')
362
356
  return
363
357
  } else {
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,8 +102,10 @@ 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
110
  dcgLogger(`outbound media skipped -> ws not open: ${opts.mediaUrl ?? ''}`)
83
111
  return
@@ -98,9 +126,13 @@ export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<v
98
126
  try {
99
127
  const botToken = msgCtx.botToken ?? getOpenClawConfig()?.channels?.["dcgchat-test"]?.botToken ?? ''
100
128
  const url = opts.mediaUrl ? await ossUpload(opts.mediaUrl, botToken, 1) : ''
129
+ if (!msgCtx.sessionId) {
130
+ const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
131
+ msgCtx.sessionId = sessionId
132
+ msgCtx.agentId = agentId
133
+ }
101
134
  wsSendRaw(msgCtx, {
102
135
  response: opts.text ?? '',
103
- message_tags: { source: 'file' },
104
136
  files: [{ url, name: fileName }]
105
137
  })
106
138
  dcgLogger(`dcgchat: sendMedia session=${sessionKey}, file=${fileName}`)
@@ -283,8 +315,9 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
283
315
  }
284
316
  },
285
317
  sendMedia: async (ctx) => {
286
- const isCron = ctx.to.indexOf('dcg-cron:') >= 0
287
- const to = normalizeSessionTarget(ctx.to)
318
+ const normalizedFromTo = normalizeSessionTarget(ctx.to)
319
+ const isCron = ctx.to.indexOf('dcg-cron:') >= 0 || extractCronJobIdFromIsolatedSessionKey(normalizedFromTo) !== null
320
+ const to = resolveIsolatedCronSessionToJobSessionKey(normalizedFromTo)
288
321
  const outboundCtx = getOutboundMsgParams(to)
289
322
  const msgCtx = getParamsMessage(to) ?? outboundCtx
290
323
  const cronMsgId = getCronMessageId(to)
@@ -301,7 +334,11 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
301
334
  }
302
335
 
303
336
  dcgLogger(`channel sendMedia to ${ctx.to}`)
304
- await sendDcgchatMedia({ sessionKey: to || '', mediaUrl: ctx.mediaUrl || '' })
337
+ await sendDcgchatMedia({
338
+ sessionKey: to || '',
339
+ mediaUrl: ctx.mediaUrl || '',
340
+ ...(isCron ? { messageId } : {})
341
+ })
305
342
  return {
306
343
  channel: "dcgchat-test",
307
344
  messageId,
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
  */
@@ -1,14 +1,17 @@
1
1
  import type { GatewayEvent } from '../gateway/index.js'
2
2
  import { finishedDcgchatCron, sendDcgchatCron } from '../cron.js'
3
3
  import { dcgLogger } from './log.js'
4
- import { getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
5
- import { sendChunk } from '../transport.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'
6
8
 
7
9
  /**
8
10
  * 处理网关 event 帧的副作用(agent 流式输出、cron 同步),并构造供上层分发的 GatewayEvent。
9
11
  */
10
12
  export function handleGatewayEventMessage(msg: { event?: string; payload?: Record<string, unknown>; seq?: number }): GatewayEvent {
11
13
  try {
14
+ // 子agent消息输出
12
15
  if (msg.event === 'agent') {
13
16
  const pl = msg.payload as { runId: string; data?: { delta?: unknown } }
14
17
  const sessionKey = getSessionKeyBySubAgentRunId(pl.runId)
@@ -19,6 +22,7 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
19
22
  }
20
23
  }
21
24
  }
25
+ // 定时任务
22
26
  if (msg.event === 'cron') {
23
27
  const p = msg.payload
24
28
  dcgLogger(`[Gateway] 收到定时任务事件: ${JSON.stringify(p)}`)
@@ -31,7 +35,7 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
31
35
  if (p?.action === 'removed') {
32
36
  sendDcgchatCron(p?.jobId as string)
33
37
  }
34
- if (p?.action === 'finished') {
38
+ if (p?.action === 'finished' && p?.status === 'ok') {
35
39
  finishedDcgchatCron(p?.jobId as string, p?.summary as string)
36
40
  }
37
41
  }
@@ -111,14 +111,15 @@ export function clearSentMediaKeys(messageId?: string) {
111
111
  /** 每个 sessionKey 下多个定时任务 messageId,入队在尾、出队在头(FIFO) */
112
112
  const cronMessageIdMap = new Map<string, string[]>()
113
113
 
114
- export function setCronMessageId(sk: string, messageId: string) {
115
- if (!sk?.trim() || !messageId?.trim()) return
114
+ export function setCronMessageId(sk: string, messageId: string | number | null | undefined) {
115
+ const mid = messageId != null && messageId !== '' ? String(messageId).trim() : ''
116
+ if (!sk?.trim() || !mid) return
116
117
  let q = cronMessageIdMap.get(sk)
117
118
  if (!q) {
118
119
  q = []
119
120
  cronMessageIdMap.set(sk, q)
120
121
  }
121
- q.push(messageId)
122
+ q.push(mid)
122
123
  }
123
124
 
124
125
  /** 窥视队首 messageId(不移除),供发送中与 finished 配对使用 */