@dcrays/dcgchat-test 0.3.35 → 0.3.37

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.35",
3
+ "version": "0.3.37",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
6
6
  "main": "index.ts",
package/src/agent.ts ADDED
@@ -0,0 +1,204 @@
1
+ import axios from 'axios'
2
+ /** @ts-ignore */
3
+ import unzipper from 'unzipper'
4
+ import { pipeline } from 'stream/promises'
5
+ import fs from 'fs'
6
+ import path from 'path'
7
+ import { getWorkspaceDir } from './utils/global.js'
8
+ import { getWsConnection } from './utils/global.js'
9
+ import { dcgLogger } from './utils/log.js'
10
+ import { isWsOpen } from './transport.js'
11
+ import { sendMessageToGateway } from './gateway/socket.js'
12
+ import { decodeZipEntryPath } from './utils/zipPath.js'
13
+
14
+ type IAgentParams = {
15
+ url: string
16
+ agent_code: string
17
+ agent_name: string
18
+ agent_description: string
19
+ }
20
+
21
+ function sendEvent(msgContent: Record<string, any>) {
22
+ const ws = getWsConnection()
23
+ if (isWsOpen()) {
24
+ ws?.send(
25
+ JSON.stringify({
26
+ messageType: 'openclaw_bot_event',
27
+ source: 'client',
28
+ content: msgContent
29
+ })
30
+ )
31
+ dcgLogger(`agent安装: ${JSON.stringify(msgContent)}`)
32
+ }
33
+ }
34
+
35
+ interface ICreateAgentParams {
36
+ code: string
37
+ workspace: string
38
+ name?: string
39
+ description?: string
40
+ msgContent?: Record<string, any>
41
+ }
42
+
43
+ /** 若 workspace-${code}/agent 存在,则复制到 agents/${code}/agent */
44
+ function copyAgentsFiles(code: string) {
45
+ const workspacePath = getWorkspaceDir()
46
+ if (!workspacePath) return
47
+ const workspaceDir = path.join(workspacePath, '../', `workspace-${code}`)
48
+ const agentDir = path.join(workspacePath, '../', `agents/${code}`)
49
+ const sourceAgent = path.join(workspaceDir, 'agent')
50
+ try {
51
+ if (!fs.existsSync(sourceAgent)) return
52
+ if (!fs.statSync(sourceAgent).isDirectory()) return
53
+ fs.mkdirSync(agentDir, { recursive: true })
54
+ const dest = path.join(agentDir, 'agent')
55
+ if (fs.existsSync(dest)) {
56
+ fs.rmSync(dest, { recursive: true, force: true })
57
+ }
58
+ fs.cpSync(sourceAgent, dest, { recursive: true })
59
+ } catch (err: unknown) {
60
+ dcgLogger(`copyAgentsFiles failed: ${String(err)}`, 'error')
61
+ }
62
+ }
63
+
64
+ export async function onCreateAgent(params: Record<string, any>) {
65
+ const { code, name, description } = params
66
+ try {
67
+ await sendMessageToGateway(JSON.stringify({ method: 'agents.create', params: { name: code, workspace: code } }))
68
+ } catch (err: unknown) {
69
+ dcgLogger(`agents.create failed: ${String(err)}`, 'error')
70
+ }
71
+ // Update config.name to the user-supplied display name (may contain CJK, spaces, etc.)
72
+ try {
73
+ await sendMessageToGateway(JSON.stringify({ method: 'agents.update', params: { name: name, agentId: code } }))
74
+ } catch (err: unknown) {
75
+ dcgLogger(`agents.update failed: ${String(err)}`, 'error')
76
+ }
77
+ if (description?.trim()) {
78
+ try {
79
+ await sendMessageToGateway(
80
+ JSON.stringify({
81
+ method: 'agents.files.set',
82
+ params: { agentId: code, name: 'IDENTITY.md', content: description.trim() }
83
+ })
84
+ )
85
+ } catch {
86
+ // Non-fatal
87
+ }
88
+ }
89
+ if (name?.trim()) {
90
+ try {
91
+ await sendMessageToGateway(
92
+ JSON.stringify({
93
+ method: 'agents.files.set',
94
+ params: { agentId: code, name: 'USER.md', content: name.trim() }
95
+ })
96
+ )
97
+ } catch {
98
+ // Non-fatal
99
+ }
100
+ }
101
+ copyAgentsFiles(code)
102
+ sendEvent({ ...params, status: 'ok' })
103
+ }
104
+
105
+ export async function createAgent(msgContent: Record<string, any>) {
106
+ const { url, code } = msgContent
107
+ if (!url || !code) {
108
+ dcgLogger(`createAgent failed empty url&code: ${JSON.stringify(msgContent)}`, 'error')
109
+ sendEvent({ ...msgContent, status: 'fail' })
110
+ return
111
+ }
112
+ const workspacePath = getWorkspaceDir()
113
+ const workspaceDir = path.join(workspacePath, '../', `workspace-${code}`)
114
+
115
+ // 如果目标目录已存在,先删除
116
+ if (fs.existsSync(workspaceDir)) {
117
+ fs.rmSync(workspaceDir, { recursive: true, force: true })
118
+ }
119
+
120
+ try {
121
+ // 下载 zip 文件
122
+ const response = await axios({
123
+ method: 'get',
124
+ url,
125
+ responseType: 'stream'
126
+ })
127
+ // 创建目标目录
128
+ fs.mkdirSync(workspaceDir, { recursive: true })
129
+ // 解压文件到目标目录,跳过顶层文件夹
130
+ await new Promise((resolve, reject) => {
131
+ const tasks: Promise<void>[] = []
132
+ let rootDir: string | null = null
133
+ let hasError = false
134
+
135
+ response.data
136
+ .pipe(unzipper.Parse())
137
+ .on('entry', (entry: any) => {
138
+ if (hasError) {
139
+ entry.autodrain()
140
+ return
141
+ }
142
+ try {
143
+ const flags = entry.props?.flags ?? 0
144
+ const entryPath = decodeZipEntryPath(entry.props?.pathBuffer, flags, entry.path)
145
+ const pathParts = entryPath.split('/')
146
+
147
+ // 检测根目录
148
+ if (!rootDir && pathParts.length > 1) {
149
+ rootDir = pathParts[0]
150
+ }
151
+ let newPath = entryPath
152
+ // 移除顶层文件夹
153
+ if (rootDir && entryPath.startsWith(rootDir + '/')) {
154
+ newPath = entryPath.slice(rootDir.length + 1)
155
+ }
156
+
157
+ if (!newPath) {
158
+ entry.autodrain()
159
+ return
160
+ }
161
+
162
+ const targetPath = path.join(workspacePath, newPath)
163
+
164
+ if (entry.type === 'Directory') {
165
+ fs.mkdirSync(targetPath, { recursive: true })
166
+ entry.autodrain()
167
+ } else {
168
+ const parentDir = path.dirname(targetPath)
169
+ fs.mkdirSync(parentDir, { recursive: true })
170
+ const writeStream = fs.createWriteStream(targetPath)
171
+ const task = pipeline(entry, writeStream).catch((err) => {
172
+ hasError = true
173
+ throw new Error(`解压文件失败 ${entryPath}: ${err.message}`)
174
+ })
175
+ tasks.push(task)
176
+ }
177
+ } catch (err) {
178
+ hasError = true
179
+ entry.autodrain()
180
+ reject(new Error(`处理entry失败: ${err}`))
181
+ }
182
+ })
183
+ .on('close', async () => {
184
+ try {
185
+ await Promise.all(tasks)
186
+ resolve(null)
187
+ } catch (err) {
188
+ reject(err)
189
+ }
190
+ })
191
+ .on('error', (err: { message: any }) => {
192
+ hasError = true
193
+ reject(new Error(`解压流错误: ${err.message}`))
194
+ })
195
+ })
196
+ await onCreateAgent(msgContent)
197
+ } catch (error) {
198
+ // 如果安装失败,清理目录
199
+ if (fs.existsSync(workspaceDir)) {
200
+ fs.rmSync(workspaceDir, { recursive: true, force: true })
201
+ }
202
+ sendEvent({ ...msgContent, status: 'fail' })
203
+ }
204
+ }
package/src/bot.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import path from 'node:path'
2
2
  import fs from 'node:fs'
3
3
  import os from 'node:os'
4
- import type { ReplyPayload } from 'openclaw/plugin-sdk'
5
- import { createReplyPrefixContext } from 'openclaw/plugin-sdk'
4
+ import type { PluginRuntime, ReplyPayload } from 'openclaw/plugin-sdk'
5
+ import { createPluginRuntimeStore, createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk'
6
6
  import type { InboundMessage } from './types.js'
7
7
  import {
8
8
  clearSentMediaKeys,
@@ -18,8 +18,9 @@ import { extractMobookFiles } from './utils/searchFile.js'
18
18
  import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
19
19
  import { dcgLogger } from './utils/log.js'
20
20
  import { channelInfo, systemCommand, interruptCommand, ENV, ignoreToolCommand } from './utils/constant.js'
21
- import { sendMessageToGateway } from './gateway/socket.js'
22
- import { getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
21
+ import { sendGatewayRpc } from './gateway/socket.js'
22
+ import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
23
+ import { getChildSessionKeysTrackedForRequester, resetSubagentStateForRequesterSession, waitUntilSubagentsIdle } from './tool.js'
23
24
 
24
25
  type MediaInfo = {
25
26
  path: string
@@ -44,13 +45,6 @@ const sessionStreamSuppressed = new Set<string>()
44
45
  /** 各 sessionKey 当前轮回复的流式分片序号(仅统计实际下发的文本 chunk);每轮非打断消息开始时置 0 */
45
46
  const streamChunkIdxBySessionKey = new Map<string, number>()
46
47
 
47
- /** Active LLM generation abort controllers, keyed by conversationId */
48
- // const activeGenerations = new Map<string, AbortController>()
49
-
50
- /**
51
- * Extract agentId from conversation_id formatted as "agentId::suffix".
52
- * Returns null if the conversation_id does not contain the "::" separator.
53
- */
54
48
  export function extractAgentIdFromConversationId(conversationId: string): string | null {
55
49
  const idx = conversationId.indexOf('::')
56
50
  if (idx <= 0) return null
@@ -132,7 +126,14 @@ function resolveReplyMediaList(payload: ReplyPayload): string[] {
132
126
  if (payload.mediaUrls?.length) return payload.mediaUrls.filter(Boolean)
133
127
  return payload.mediaUrl ? [payload.mediaUrl] : []
134
128
  }
135
-
129
+ const typingCallbacks = createTypingCallbacks({
130
+ start: async () => {
131
+ console.log('typing start')
132
+ },
133
+ onStartError: (err) => {
134
+ console.log('typing start error', err)
135
+ }
136
+ })
136
137
  /**
137
138
  * 处理一条用户消息,调用 Agent 并返回回复
138
139
  */
@@ -151,7 +152,6 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
151
152
  const core = getDcgchatRuntime()
152
153
 
153
154
  const conversationId = msg.content.session_id?.trim()
154
- const agentId = msg.content.agent_id?.trim()
155
155
  const real_mobook = msg.content.real_mobook?.toString().trim()
156
156
 
157
157
  const route = core.channel.routing.resolveAgentRoute({
@@ -179,10 +179,6 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
179
179
  real_mobook
180
180
  }
181
181
  setParamsMessage(effectiveSessionKey, mergedParams)
182
- // 与 OpenClaw 会话投递里仍可能出现的 ctx.to=SenderId(userId)对齐,便于 getOutboundMsgParams 命中
183
- dcgLogger(
184
- `target normalize: rawTarget=${userId}, normalizedTarget=${effectiveSessionKey}, conversationId=${conversationId ?? ''}, messageId=${msg.content.message_id}`
185
- )
186
182
  setParamsMessage(userId, mergedParams)
187
183
  dcgLogger(`target alias bound: aliasTarget=${userId} -> sessionKey=${effectiveSessionKey}`)
188
184
  const outboundCtx = getEffectiveMsgParams(effectiveSessionKey)
@@ -190,29 +186,15 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
190
186
  effectiveAgentId && effectiveAgentId !== 'main' ? config.agents?.list?.find((a) => a.id === effectiveAgentId) : undefined
191
187
  const agentDisplayName = agentEntry?.name ?? (effectiveAgentId && effectiveAgentId !== 'main' ? effectiveAgentId : undefined)
192
188
 
193
- const safeSendFinal = (tag: string) => {
194
- if (finalSent) return
195
- finalSent = true
196
- sendFinal(outboundCtx, tag)
197
- setMsgStatus(effectiveSessionKey, 'finished')
198
- }
199
-
200
189
  const text = msg.content.text?.trim()
201
190
 
202
191
  if (!text) {
203
192
  sendTextMsg('你需要我帮你做什么呢?', outboundCtx)
204
- safeSendFinal('not text')
193
+ sendFinal(outboundCtx, 'not text')
205
194
  return
206
195
  }
207
196
 
208
197
  try {
209
- // Abort any existing generation for this conversation, then start a new one
210
- // const existingCtrl = activeGenerations.get(conversationId)
211
- // if (existingCtrl) existingCtrl.abort()
212
- // const genCtrl = new AbortController()
213
- // const genSignal = genCtrl.signal
214
- // activeGenerations.set(conversationId, genCtrl)
215
-
216
198
  // 处理用户上传的文件
217
199
  const files = msg.content.files ?? []
218
200
  let mediaPayload: Record<string, unknown> = {}
@@ -250,11 +232,10 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
250
232
  CommandAuthorized: true,
251
233
  OriginatingChannel: "dcgchat-test",
252
234
  OriginatingTo: effectiveSessionKey,
235
+ Target: effectiveSessionKey,
236
+ SourceTarget: effectiveSessionKey,
253
237
  ...mediaPayload
254
238
  })
255
- dcgLogger(
256
- `inbound context target: rawTarget=${userId}, normalizedTarget=${effectiveSessionKey}, ctx.To=${String(ctxPayload.To ?? '')}, ctx.SessionKey=${String(ctxPayload.SessionKey ?? '')}, ctx.OriginatingTo=${String(ctxPayload.OriginatingTo ?? '')}`
257
- )
258
239
 
259
240
  const sentMediaKeys = new Set<string>()
260
241
  const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
@@ -267,7 +248,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
267
248
  accountId: account.accountId
268
249
  })
269
250
 
270
- const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = core.channel.reply.createReplyDispatcherWithTyping({
251
+ const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
271
252
  responsePrefix: prefixContext.responsePrefix,
272
253
  responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
273
254
  humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
@@ -283,7 +264,8 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
283
264
  }
284
265
  },
285
266
  onError: (err: unknown, info: { kind: string }) => {
286
- safeSendFinal('error')
267
+ setMsgStatus(effectiveSessionKey, 'finished')
268
+ sendFinal(outboundCtx, 'error')
287
269
  dcgLogger(`${info.kind} reply failed: ${String(err)}`, 'error')
288
270
  activeRunIdBySessionKey.delete(effectiveSessionKey)
289
271
  streamChunkIdxBySessionKey.delete(effectiveSessionKey)
@@ -293,7 +275,9 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
293
275
  }
294
276
  dcgLogger(`${info.kind} reply failed: ${String(err)}`, 'error')
295
277
  },
296
- onIdle: () => {}
278
+ onIdle: () => {
279
+ typingCallbacks.onIdle?.()
280
+ }
297
281
  })
298
282
 
299
283
  try {
@@ -303,42 +287,78 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
303
287
  }
304
288
 
305
289
  if (systemCommand.includes(text?.trim())) {
306
- dcgLogger(`dispatching /new`)
307
- await core.channel.reply.dispatchReplyFromConfig({
308
- ctx: ctxPayload,
309
- cfg: config,
290
+ dcgLogger(`dispatching ${text?.trim()}`)
291
+ await core.channel.reply.withReplyDispatcher({
310
292
  dispatcher,
311
- replyOptions: {
312
- ...replyOptions,
313
- onModelSelected: prefixContext.onModelSelected,
314
- onAgentRunStart: (runId) => {
315
- activeRunIdBySessionKey.set(effectiveSessionKey, runId)
316
- }
317
- }
293
+ onSettled: () => markDispatchIdle(),
294
+ run: () =>
295
+ core.channel.reply.dispatchReplyFromConfig({
296
+ ctx: ctxPayload,
297
+ cfg: config,
298
+ dispatcher,
299
+ replyOptions: {
300
+ ...replyOptions,
301
+ onModelSelected: prefixContext.onModelSelected,
302
+ onAgentRunStart: (runId) => {
303
+ activeRunIdBySessionKey.set(effectiveSessionKey, runId)
304
+ }
305
+ }
306
+ })
318
307
  })
319
308
  } else if (interruptCommand.includes(text?.trim())) {
320
309
  dcgLogger(`interrupt command: ${text}`)
321
- safeSendFinal('abort')
310
+ sendFinal(outboundCtx, 'abort')
322
311
  sendText('会话已终止', outboundCtx)
323
312
  sessionStreamSuppressed.add(effectiveSessionKey)
324
- const runId = activeRunIdBySessionKey.get(effectiveSessionKey)
325
- sendMessageToGateway(
326
- JSON.stringify({
327
- method: 'chat.abort',
328
- params: {
329
- sessionKey: effectiveSessionKey,
330
- ...(runId ? { runId } : {})
331
- }
313
+
314
+ const abortOneSession = async (sessionKey: string) => {
315
+ try {
316
+ await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey } })
317
+ } catch (e) {
318
+ dcgLogger(`chat.abort ${sessionKey}: ${String(e)}`, 'error')
319
+ }
320
+ }
321
+
322
+ const keysToAbort = new Set<string>(getChildSessionKeysTrackedForRequester(effectiveSessionKey))
323
+ try {
324
+ const listed = await sendGatewayRpc<{ sessions?: Array<{ key?: string }> }>({
325
+ method: 'sessions.list',
326
+ params: { spawnedBy: effectiveSessionKey, limit: 256 }
332
327
  })
333
- )
334
- if (runId) activeRunIdBySessionKey.delete(effectiveSessionKey)
335
- safeSendFinal('stop')
328
+ for (const s of listed?.sessions ?? []) {
329
+ const k = typeof s?.key === 'string' ? s.key.trim() : ''
330
+ if (k) keysToAbort.add(k)
331
+ }
332
+ } catch (e) {
333
+ dcgLogger(`sessions.list spawnedBy: ${String(e)}`, 'error')
334
+ }
335
+ for (const sk of keysToAbort) {
336
+ await abortOneSession(sk)
337
+ }
338
+ await abortOneSession(effectiveSessionKey)
339
+
340
+ try {
341
+ await sendGatewayRpc({
342
+ method: 'sessions.reset',
343
+ params: { key: effectiveSessionKey, reason: 'reset' }
344
+ })
345
+ } catch (e) {
346
+ dcgLogger(`sessions.reset: ${String(e)}`, 'error')
347
+ }
348
+
349
+ activeRunIdBySessionKey.delete(effectiveSessionKey)
350
+ streamChunkIdxBySessionKey.delete(effectiveSessionKey)
351
+ resetSubagentStateForRequesterSession(effectiveSessionKey)
352
+ setMsgStatus(effectiveSessionKey, 'finished')
353
+ clearSentMediaKeys(msg.content.message_id)
354
+ clearParamsMessage(effectiveSessionKey)
355
+ clearParamsMessage(userId)
356
+
357
+ sendFinal(outboundCtx, 'stop')
336
358
  return
337
359
  } else {
338
- dcgLogger(`dispatching to agent (session=${route.sessionKey})`)
339
360
  const params = getEffectiveMsgParams(effectiveSessionKey)
340
361
  if (!ignoreToolCommand.includes(text?.trim())) {
341
- // message_received 没有 sessionKey 前置到bot中执行
342
362
  wsSendRaw(params, {
343
363
  is_finish: -1,
344
364
  tool_call_id: Date.now().toString(),
@@ -353,70 +373,58 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
353
373
  response: ''
354
374
  })
355
375
  }
356
- await core.channel.reply.dispatchReplyFromConfig({
357
- ctx: ctxPayload,
358
- cfg: config,
376
+ await core.channel.reply.withReplyDispatcher({
359
377
  dispatcher,
360
- replyOptions: {
361
- ...replyOptions,
362
- // abortSignal: genSignal,
363
- onModelSelected: prefixContext.onModelSelected,
364
- onAgentRunStart: (runId) => {
365
- activeRunIdBySessionKey.set(effectiveSessionKey, runId)
366
- },
367
- onPartialReply: async (payload: ReplyPayload) => {
368
- if (sessionStreamSuppressed.has(effectiveSessionKey)) return
369
-
370
- // Accumulate full text
371
- if (payload.text) {
372
- completeText = payload.text
373
- }
374
- // --- Streaming text chunks ---
375
- if (payload.text) {
376
- const delta = payload.text.startsWith(completeText.slice(0, streamedTextLen))
377
- ? payload.text.slice(streamedTextLen)
378
- : payload.text
379
- if (delta.trim()) {
380
- const prev = streamChunkIdxBySessionKey.get(effectiveSessionKey) ?? 0
381
- streamChunkIdxBySessionKey.set(effectiveSessionKey, prev + 1)
382
- sendChunk(delta, outboundCtx, prev)
383
- dcgLogger(`[stream]: chunkIdx=${prev} len=${delta.length} user=${msg._userId} ${delta.slice(0, 100)}`)
378
+ onSettled: () => markDispatchIdle(),
379
+ run: () =>
380
+ core.channel.reply.dispatchReplyFromConfig({
381
+ ctx: ctxPayload,
382
+ cfg: config,
383
+ dispatcher,
384
+ replyOptions: {
385
+ ...replyOptions,
386
+ onModelSelected: prefixContext.onModelSelected,
387
+ onAgentRunStart: (runId) => {
388
+ activeRunIdBySessionKey.set(effectiveSessionKey, runId)
389
+ },
390
+ onPartialReply: async (payload: ReplyPayload) => {
391
+ if (sessionStreamSuppressed.has(effectiveSessionKey)) return
392
+
393
+ if (payload.text) {
394
+ completeText = payload.text
395
+ }
396
+ // --- Streaming text chunks ---
397
+ if (payload.text) {
398
+ const delta = payload.text.startsWith(completeText.slice(0, streamedTextLen))
399
+ ? payload.text.slice(streamedTextLen)
400
+ : payload.text
401
+ if (delta.trim()) {
402
+ const prev = streamChunkIdxBySessionKey.get(effectiveSessionKey) ?? 0
403
+ streamChunkIdxBySessionKey.set(effectiveSessionKey, prev + 1)
404
+ sendChunk(delta, outboundCtx, prev)
405
+ dcgLogger(`[stream]: chunkIdx=${prev} len=${delta.length} user=${msg._userId} ${delta.slice(0, 100)}`)
406
+ }
407
+ streamedTextLen = payload.text.length
408
+ } else {
409
+ dcgLogger(`onPartialReply no text: ${JSON.stringify(payload)}`, 'error')
410
+ }
411
+ // --- Media from payload ---
412
+ const mediaList = resolveReplyMediaList(payload)
413
+ for (const mediaUrl of mediaList) {
414
+ const key = getMediaKey(mediaUrl)
415
+ if (sentMediaKeys.has(key)) continue
416
+ sentMediaKeys.add(key)
417
+ await sendDcgchatMedia({ sessionKey: effectiveSessionKey, mediaUrl, text: '' })
418
+ }
384
419
  }
385
- streamedTextLen = payload.text.length
386
- }
387
- // --- Media from payload ---
388
- const mediaList = resolveReplyMediaList(payload)
389
- for (const mediaUrl of mediaList) {
390
- const key = getMediaKey(mediaUrl)
391
- if (sentMediaKeys.has(key)) continue
392
- sentMediaKeys.add(key)
393
- await sendDcgchatMedia({ sessionKey: effectiveSessionKey, mediaUrl, text: '' })
394
420
  }
395
- }
396
- }
421
+ })
397
422
  })
398
423
  }
399
424
  } catch (err: unknown) {
400
- // if (genSignal.aborted) {
401
- // wasAborted = true
402
- // dcgLogger(` generation aborted for conversationId=${conversationId}`)
403
- // } else if (err instanceof Error && err.name === 'AbortError') {
404
- // wasAborted = true
405
- // dcgLogger(` generation aborted for conversationId=${conversationId}`)
406
- // } else {
407
- // dcgLogger(` dispatchReplyFromConfig error: ${String(err)}`, 'error')
408
- // }
409
- } finally {
410
- // if (activeGenerations.get(conversationId) === genCtrl) {
411
- // activeGenerations.delete(conversationId)
412
- // }
413
- }
414
- try {
415
- markRunComplete()
416
- markDispatchIdle()
417
- } catch (err) {
418
- dcgLogger(` markRunComplete||markRunComplete error: ${String(err)}`, 'error')
425
+ dcgLogger(` dispatchReplyFromConfig error: ${String(err)}`, 'error')
419
426
  }
427
+
420
428
  if (![...systemCommand, ...interruptCommand].includes(text?.trim())) {
421
429
  if (sessionStreamSuppressed.has(effectiveSessionKey)) {
422
430
  sessionStreamSuppressed.delete(effectiveSessionKey)
@@ -443,11 +451,10 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
443
451
  }
444
452
  }
445
453
  }
446
- safeSendFinal('end')
447
454
  clearSentMediaKeys(msg.content.message_id)
448
-
449
- // Record session metadata
450
455
  const storePath = core.channel.session.resolveStorePath(config.session?.store)
456
+ await waitUntilSubagentsIdle(effectiveSessionKey, { timeoutMs: 600_000 })
457
+ sendFinal(outboundCtx, 'end')
451
458
  dcgLogger(
452
459
  `record session route: rawTarget=${userId}, normalizedTarget=${effectiveSessionKey}, updateLastRoute.to=${effectiveSessionKey}, accountId=${route.accountId}`
453
460
  )
@@ -456,7 +463,6 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
456
463
  storePath,
457
464
  sessionKey: effectiveSessionKey,
458
465
  ctx: ctxPayload,
459
- // 与 Telegram/Discord 等一致:写入 deliveryContext.to,否则投递可能回退为 From(userId),channel sendMedia 里 ctx.to 会变成数字 userId
460
466
  updateLastRoute: {
461
467
  sessionKey: effectiveSessionKey,
462
468
  channel: "dcgchat-test",
@@ -473,7 +479,5 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
473
479
  } catch (err) {
474
480
  dcgLogger(` handle message failed: ${String(err)}`, 'error')
475
481
  sendError(err instanceof Error ? err.message : String(err), outboundCtx)
476
- } finally {
477
- safeSendFinal('finally')
478
482
  }
479
483
  }
package/src/channel.ts CHANGED
@@ -1,8 +1,15 @@
1
- import type { ChannelPlugin, OpenClawConfig } from 'openclaw/plugin-sdk'
2
- import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk'
1
+ import type { ChannelPlugin, OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
2
+ import { createPluginRuntimeStore, 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, getCronMessageId, getInfoBySessionKey, getOpenClawConfig, hasSentMediaKey } from './utils/global.js'
5
+ import {
6
+ addSentMediaKey,
7
+ getCronMessageId,
8
+ getDcgchatRuntime,
9
+ getInfoBySessionKey,
10
+ getOpenClawConfig,
11
+ hasSentMediaKey
12
+ } from './utils/global.js'
6
13
  import { isWsOpen, mergeDefaultParams, mergeSessionParams, sendFinal, wsSendRaw } from './transport.js'
7
14
  import { dcgLogger, setLogger } from './utils/log.js'
8
15
  import { getOutboundMsgParams, getParamsMessage } from './utils/params.js'
@@ -116,7 +123,7 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
116
123
  uiHints: {
117
124
  userId: {
118
125
  label: 'WS 连接 _userId',
119
- help: '仅用于拼接网关 WebSocket URL 的查询参数,不是 Agent 发消息时的 target。发消息请使用 effectiveSessionKey(与入站上下文 SessionKey 相同,格式如 agent:main:mobook:direct:…)。'
126
+ help: '仅用于拼接网关 WebSocket URL 的查询参数,不是 Agent 发消息时的 target。发消息请使用 effectiveSessionKey(与入站上下文 SessionKey 相同,格式如 agent:main:mobook:direct:<agent_id>:<session_id>)。'
120
127
  }
121
128
  }
122
129
  },
@@ -155,8 +162,9 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
155
162
  },
156
163
  agentPrompt: {
157
164
  messageToolHints: () => [
158
- '书灵墨宝:message 工具的 target 必须填 effectiveSessionKey(与当前会话 SessionKey / OriginatingTo 相同),形如 agent:main:mobook:direct:<agent_id>:<session_id>;不要填 channels.dcgchat.userId 或纯数字 userId。',
159
- 'OpenClaw 自带的 target 字段说明里仍可能出现 “user id”,在本频道请忽略该字样,一律按 effectiveSessionKey 理解。'
165
+ '生成文件后,**尽可能不要**把文件路径、地址直接告诉用户。',
166
+ '生成文件后,把文件名告诉用户。',
167
+ '生成文件后,必须调用 message 工具发送文件,不可以直接在文本回复里包含文件路径、文件名、地址。'
160
168
  ]
161
169
  },
162
170
  outbound: {
@@ -167,6 +175,7 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
167
175
  }
168
176
  return { ok: true, to: to }
169
177
  },
178
+ chunker: (text, limit) => getDcgchatRuntime().channel.text.chunkMarkdownText(text, limit),
170
179
  textChunkLimit: 4000,
171
180
  sendText: async (ctx) => {
172
181
  dcgLogger(`channel sendText to ${ctx.to} `)
@@ -3,8 +3,8 @@ 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 { finishedDcgchatCron, sendDcgchatCron } from '../cron.js'
7
6
  import { getWorkspaceDir } from '../utils/global.js'
7
+ import { handleGatewayEventMessage } from '../utils/gatewayMsgHanlder.js'
8
8
 
9
9
  export interface GatewayEvent {
10
10
  type: string
@@ -358,31 +358,7 @@ export class GatewayConnection {
358
358
  }
359
359
 
360
360
  if (msg.type === 'event') {
361
- try {
362
- // 定时任务相关事件
363
- if (msg.event === 'cron') {
364
- dcgLogger(`[Gateway] 收到事件: ${JSON.stringify(msg)}`)
365
- if (msg.payload?.action === 'added') {
366
- sendDcgchatCron(msg.payload?.jobId)
367
- }
368
- if (msg.payload?.action === 'updated') {
369
- sendDcgchatCron(msg.payload?.jobId as string)
370
- }
371
- if (msg.payload?.action === 'removed') {
372
- sendDcgchatCron(msg.payload?.jobId as string)
373
- }
374
- if (msg.payload?.action === 'finished') {
375
- finishedDcgchatCron(msg.payload?.jobId as string)
376
- }
377
- }
378
- } catch (error) {
379
- dcgLogger(`[Gateway] 处理事件失败: ${error}`, 'error')
380
- }
381
- const event: GatewayEvent = {
382
- type: msg.event as string,
383
- payload: msg.payload as Record<string, unknown> | undefined,
384
- seq: msg.seq as number | undefined
385
- }
361
+ const event = handleGatewayEventMessage(msg)
386
362
  this.eventHandlers.forEach((h) => h(event))
387
363
  }
388
364
  }
package/src/monitor.ts CHANGED
@@ -1,15 +1,10 @@
1
1
  import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk'
2
2
  import WebSocket from 'ws'
3
- import { handleDcgchatMessage } from './bot.js'
4
3
  import { resolveAccount } from './channel.js'
5
- import { setWsConnection, getOpenClawConfig, setMsgStatus, getSessionKey } from './utils/global.js'
6
- import type { InboundMessage } from './types.js'
7
- import { installSkill, uninstallSkill } from './skill.js'
4
+ import { setWsConnection, getOpenClawConfig } from './utils/global.js'
8
5
  import { dcgLogger } from './utils/log.js'
9
- import { onDisabledCronJob, onEnabledCronJob, onRemoveCronJob, onRunCronJob } from './cron.js'
10
- import { ignoreToolCommand } from './utils/constant.js'
11
6
  import { isWsOpen } from './transport.js'
12
- import { onRemoveSession } from './session.js'
7
+ import { handleParsedWsMessage } from './utils/wsMessageHandler.js'
13
8
 
14
9
  export type MonitorDcgchatOpts = {
15
10
  config?: ClawdbotConfig
@@ -129,50 +124,7 @@ export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<
129
124
  return
130
125
  }
131
126
 
132
- if (parsed.messageType == 'openclaw_bot_chat') {
133
- const msg = parsed as unknown as InboundMessage
134
- // 与 monitor 原逻辑一致:工具类指令不进入 running,避免误触工具链监控
135
- const effectiveSessionKey = getSessionKey(msg.content, account.accountId)
136
- if (!ignoreToolCommand.includes(msg.content.text?.trim() ?? '')) {
137
- setMsgStatus(effectiveSessionKey, 'running')
138
- } else {
139
- setMsgStatus(effectiveSessionKey, 'finished')
140
- }
141
- await handleDcgchatMessage(msg, account.accountId)
142
- } else if (parsed.messageType == 'openclaw_bot_event') {
143
- const { event_type, operation_type } = parsed.content ? parsed.content : ({} as Record<string, any>)
144
- if (event_type === 'skill') {
145
- const { skill_url, skill_code, skill_id, bot_token, websocket_trace_id } = parsed.content
146
- const content = { event_type, operation_type, skill_url, skill_code, skill_id, bot_token, websocket_trace_id }
147
- if (operation_type === 'install' || operation_type === 'enable' || operation_type === 'update') {
148
- installSkill({ path: skill_url, code: skill_code }, content)
149
- } else if (operation_type === 'remove' || operation_type === 'disable') {
150
- uninstallSkill({ code: skill_code }, content)
151
- } else {
152
- dcgLogger(`openclaw_bot_event unknown event_type: ${event_type}, ${data.toString()}`)
153
- }
154
- } else if (event_type === 'cron') {
155
- const { job_id, message_id } = parsed.content
156
- if (operation_type === 'remove') {
157
- await onRemoveCronJob(job_id)
158
- } else if (operation_type === 'enable') {
159
- await onEnabledCronJob(job_id)
160
- } else if (operation_type === 'disable') {
161
- await onDisabledCronJob(job_id)
162
- } else if (operation_type === 'run') {
163
- await onRunCronJob(job_id, message_id)
164
- }
165
- } else if (event_type === 'session') {
166
- const { agent_id, session_id, agent_clone_code } = parsed.content
167
- if (operation_type === 'remove') {
168
- await onRemoveSession({ agent_id, session_id, agent_clone_code, account_id: accountId })
169
- }
170
- } else {
171
- dcgLogger(`openclaw_bot_event unknown operation_type: ${operation_type}, ${data.toString()}`)
172
- }
173
- } else {
174
- dcgLogger(`ignoring unknown messageType: ${parsed.messageType}`, 'error')
175
- }
127
+ await handleParsedWsMessage(parsed, payloadStr, account.accountId)
176
128
  })
177
129
 
178
130
  ws.on('close', (code, reason) => {
package/src/skill.ts CHANGED
@@ -9,6 +9,7 @@ import { getWsConnection } from './utils/global.js'
9
9
  import { dcgLogger } from './utils/log.js'
10
10
  import { isWsOpen } from './transport.js'
11
11
  import { sendMessageToGateway } from './gateway/socket.js'
12
+ import { decodeZipEntryPath } from './utils/zipPath.js'
12
13
 
13
14
  type ISkillParams = {
14
15
  path: string
@@ -69,13 +70,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
69
70
  }
70
71
  try {
71
72
  const flags = entry.props?.flags ?? 0
72
- const isUtf8 = (flags & 0x800) !== 0
73
- let entryPath: string
74
- if (!isUtf8 && entry.props?.pathBuffer) {
75
- entryPath = new TextDecoder('gbk').decode(entry.props.pathBuffer)
76
- } else {
77
- entryPath = entry.path
78
- }
73
+ const entryPath = decodeZipEntryPath(entry.props?.pathBuffer, flags, entry.path)
79
74
  const pathParts = entryPath.split('/')
80
75
 
81
76
  // 检测根目录
package/src/tool.ts CHANGED
@@ -2,7 +2,7 @@ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'
2
2
  import { getMsgStatus } from './utils/global.js'
3
3
  import { dcgLogger } from './utils/log.js'
4
4
  import { sendFinal, sendText, wsSendRaw } from './transport.js'
5
- import { getEffectiveMsgParams } from './utils/params.js'
5
+ import { getEffectiveMsgParams, deleteSessionKeyBySubAgentRunId, setSessionKeyBySubAgentRunId } from './utils/params.js'
6
6
  import { cronToolCall } from './cronToolCall.js'
7
7
 
8
8
  type PluginHookName =
@@ -68,10 +68,244 @@ interface CronDelivery {
68
68
  [key: string]: unknown
69
69
  }
70
70
 
71
+ // --- Subagent 活跃跟踪(按主会话 requesterSessionKey)---
72
+
73
+ /** 主会话 sessionKey -> 仍活跃的子 agent runId */
74
+ const activeSubagentRunIdsByRequester = new Map<string, Set<string>>()
75
+ /** 子会话 childSessionKey -> 主会话 requesterSessionKey */
76
+ const requesterByChildSessionKey = new Map<string, string>()
77
+ /** 子会话 childSessionKey -> spawn 时的 runId(ended 事件可能不带 runId) */
78
+ const runIdByChildSessionKey = new Map<string, string>()
79
+ /** 主会话 -> 等待「子 agent 全部结束」的回调 */
80
+ const subagentIdleWaiters = new Map<string, Set<() => void>>()
81
+
82
+ function getOrCreateRunIdSet(requesterSessionKey: string): Set<string> {
83
+ let set = activeSubagentRunIdsByRequester.get(requesterSessionKey)
84
+ if (!set) {
85
+ set = new Set()
86
+ activeSubagentRunIdsByRequester.set(requesterSessionKey, set)
87
+ }
88
+ return set
89
+ }
90
+
91
+ function flushSubagentIdleWaiters(requesterSessionKey: string): void {
92
+ const set = activeSubagentRunIdsByRequester.get(requesterSessionKey)
93
+ if (set && set.size > 0) return
94
+ activeSubagentRunIdsByRequester.delete(requesterSessionKey)
95
+ const waiters = subagentIdleWaiters.get(requesterSessionKey)
96
+ if (!waiters?.size) return
97
+ subagentIdleWaiters.delete(requesterSessionKey)
98
+ for (const w of waiters) {
99
+ try {
100
+ w()
101
+ } catch (e) {
102
+ dcgLogger(`subagent idle waiter error: ${String(e)}`, 'error')
103
+ }
104
+ }
105
+ }
106
+
107
+ function registerSubagentSpawn(requesterSessionKey: string, runId: string, childSessionKey: string): void {
108
+ const req = requesterSessionKey.trim()
109
+ const rid = runId.trim()
110
+ const child = childSessionKey.trim()
111
+ if (!req || !rid || !child) {
112
+ dcgLogger(`subagent track spawn skipped: missing key req=${req} runId=${rid} child=${child}`)
113
+ return
114
+ }
115
+ getOrCreateRunIdSet(req).add(rid)
116
+ requesterByChildSessionKey.set(child, req)
117
+ runIdByChildSessionKey.set(child, rid)
118
+ dcgLogger(`subagent track spawn: requester=${req} runId=${rid} child=${child} active=${getOrCreateRunIdSet(req).size}`)
119
+ }
120
+
121
+ function registerSubagentEnd(
122
+ ctx: { requesterSessionKey?: string; sessionKey?: string },
123
+ targetSessionKey: string,
124
+ runId?: string
125
+ ): void {
126
+ const child = targetSessionKey.trim()
127
+ if (!child) return
128
+ const req = ctx.requesterSessionKey?.trim() || requesterByChildSessionKey.get(child) || ctx.sessionKey?.trim() || ''
129
+ const resolvedRunId = (runId?.trim() || runIdByChildSessionKey.get(child) || '').trim()
130
+ deleteSessionKeyBySubAgentRunId(resolvedRunId)
131
+ if (!req) {
132
+ dcgLogger(`subagent track end: no requester for child=${child} runId=${resolvedRunId}`)
133
+ requesterByChildSessionKey.delete(child)
134
+ runIdByChildSessionKey.delete(child)
135
+ return
136
+ }
137
+ const set = activeSubagentRunIdsByRequester.get(req)
138
+ if (set && resolvedRunId) {
139
+ set.delete(resolvedRunId)
140
+ }
141
+ requesterByChildSessionKey.delete(child)
142
+ runIdByChildSessionKey.delete(child)
143
+ dcgLogger(`subagent track end: requester=${req} runId=${resolvedRunId || 'n/a'} remaining=${set?.size ?? 0}`)
144
+ if (set && set.size === 0) {
145
+ activeSubagentRunIdsByRequester.delete(req)
146
+ }
147
+ flushSubagentIdleWaiters(req)
148
+ }
149
+
150
+ /** 当前跟踪到的、挂在该主会话下的子会话 sessionKey(供 /stop 时逐个 chat.abort) */
151
+ export function getChildSessionKeysTrackedForRequester(requesterSessionKey: string): string[] {
152
+ const req = requesterSessionKey.trim()
153
+ if (!req) return []
154
+ const out: string[] = []
155
+ for (const [child, parent] of requesterByChildSessionKey.entries()) {
156
+ if (parent === req) out.push(child)
157
+ }
158
+ return out
159
+ }
160
+
161
+ /**
162
+ * 打断后清空本地子 agent 跟踪(runId、父子映射、子 runId→sessionKey),并唤醒 waitUntilSubagentsIdle,避免永久挂起。
163
+ */
164
+ export function resetSubagentStateForRequesterSession(requesterSessionKey: string): void {
165
+ const req = requesterSessionKey.trim()
166
+ if (!req) return
167
+
168
+ const runIdSet = activeSubagentRunIdsByRequester.get(req)
169
+ if (runIdSet) {
170
+ for (const rid of runIdSet) {
171
+ deleteSessionKeyBySubAgentRunId(rid)
172
+ }
173
+ }
174
+
175
+ for (const [child, parent] of [...requesterByChildSessionKey.entries()]) {
176
+ if (parent === req) {
177
+ requesterByChildSessionKey.delete(child)
178
+ runIdByChildSessionKey.delete(child)
179
+ }
180
+ }
181
+
182
+ activeSubagentRunIdsByRequester.delete(req)
183
+
184
+ const waiters = subagentIdleWaiters.get(req)
185
+ if (!waiters?.size) return
186
+ subagentIdleWaiters.delete(req)
187
+ for (const w of waiters) {
188
+ try {
189
+ w()
190
+ } catch (e) {
191
+ dcgLogger(`subagent idle waiter error: ${String(e)}`, 'error')
192
+ }
193
+ }
194
+ }
195
+
196
+ /** 当前主会话下仍在跑的子 agent 数量(按 spawn 时 runId 去重) */
197
+ export function getActiveSubagentCount(sessionKey: string): number {
198
+ const sk = sessionKey?.trim()
199
+ if (!sk) return 0
200
+ return activeSubagentRunIdsByRequester.get(sk)?.size ?? 0
201
+ }
202
+
203
+ /**
204
+ * 等到指定主会话下已跟踪的子 agent 全部结束(spawn 失败等路径也会触发 subagent_ended,一般会配对清理)。
205
+ * 注意:须在收到 `subagent_spawned` 之后才会计入;仅 spawning 未 spawned 的不会阻塞。
206
+ */
207
+ export function waitUntilSubagentsIdle(sessionKey: string, opts?: { timeoutMs?: number; signal?: AbortSignal }): Promise<void> {
208
+ console.log('🚀 ~ waitUntilSubagentsIdle ~ sessionKey:', sessionKey)
209
+ const sk = sessionKey?.trim()
210
+ if (!sk) return Promise.resolve()
211
+
212
+ if (getActiveSubagentCount(sk) === 0) return Promise.resolve()
213
+
214
+ return new Promise<void>((resolve, reject) => {
215
+ let settled = false
216
+ const finish = (fn: () => void) => {
217
+ if (settled) return
218
+ settled = true
219
+ if (timeoutId) clearTimeout(timeoutId)
220
+ opts?.signal?.removeEventListener('abort', onAbort)
221
+ removeWaiter()
222
+ fn()
223
+ }
224
+
225
+ const removeWaiter = () => {
226
+ const bucket = subagentIdleWaiters.get(sk)
227
+ if (!bucket) return
228
+ bucket.delete(onIdle)
229
+ if (bucket.size === 0) subagentIdleWaiters.delete(sk)
230
+ }
231
+
232
+ const onIdle = () => finish(() => resolve())
233
+
234
+ const onAbort = () => {
235
+ const reason = opts?.signal?.reason
236
+ finish(() => reject(reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted'))))
237
+ }
238
+
239
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
240
+ if (opts?.timeoutMs != null && opts.timeoutMs > 0) {
241
+ timeoutId = setTimeout(
242
+ () => finish(() => reject(new Error(`waitUntilSubagentsIdle timeout ${opts.timeoutMs}ms`))),
243
+ opts.timeoutMs
244
+ )
245
+ }
246
+
247
+ if (opts?.signal) {
248
+ if (opts.signal.aborted) {
249
+ onAbort()
250
+ return
251
+ }
252
+ opts.signal.addEventListener('abort', onAbort, { once: true })
253
+ }
254
+
255
+ let set = subagentIdleWaiters.get(sk)
256
+ if (!set) {
257
+ set = new Set()
258
+ subagentIdleWaiters.set(sk, set)
259
+ }
260
+ set.add(onIdle)
261
+
262
+ if (getActiveSubagentCount(sk) === 0) {
263
+ onIdle()
264
+ }
265
+ })
266
+ }
267
+
268
+ function resolveHookSessionKey(
269
+ eventName: string,
270
+ args: { sessionKey?: string; requesterSessionKey?: string; runId?: string }
271
+ ): string {
272
+ if (
273
+ eventName === 'subagent_spawned' ||
274
+ eventName === 'subagent_ended' ||
275
+ eventName === 'subagent_spawning' ||
276
+ eventName === 'subagent_delivery_target'
277
+ ) {
278
+ if (args?.runId && args?.requesterSessionKey) setSessionKeyBySubAgentRunId(args?.runId, args?.requesterSessionKey)
279
+ return (args?.requesterSessionKey || args?.sessionKey || '').trim()
280
+ }
281
+ return (args?.sessionKey || '').trim()
282
+ }
283
+
284
+ function trackSubagentLifecycle(eventName: string, event: any, args: any): void {
285
+ if (eventName === 'subagent_spawned') {
286
+ const runId = typeof event?.runId === 'string' ? event.runId : ''
287
+ const childSessionKey = typeof event?.childSessionKey === 'string' ? event.childSessionKey : ''
288
+ const requester =
289
+ typeof args?.requesterSessionKey === 'string'
290
+ ? args.requesterSessionKey
291
+ : typeof args?.sessionKey === 'string'
292
+ ? args.sessionKey
293
+ : ''
294
+ registerSubagentSpawn(requester, runId, childSessionKey)
295
+ return
296
+ }
297
+ if (eventName === 'subagent_ended') {
298
+ const targetSessionKey = typeof event?.targetSessionKey === 'string' ? event.targetSessionKey : ''
299
+ const runId = typeof event?.runId === 'string' ? event.runId : undefined
300
+ registerSubagentEnd(args ?? {}, targetSessionKey, runId)
301
+ }
302
+ }
303
+
71
304
  export function monitoringToolMessage(api: OpenClawPluginApi) {
72
305
  for (const item of eventList) {
73
306
  api.on(item.event as PluginHookName, (event: any, args: any) => {
74
- const sk = args?.sessionKey as string
307
+ trackSubagentLifecycle(item.event, event, args)
308
+ const sk = resolveHookSessionKey(item.event, args ?? {})
75
309
  if (sk) {
76
310
  const status = getMsgStatus(sk)
77
311
  if (status === 'running') {
package/src/types.ts CHANGED
@@ -39,6 +39,7 @@ export type InboundMessage = {
39
39
  // content: string;
40
40
  content: {
41
41
  bot_token: string
42
+ agent_clone_code?: string
42
43
  domain_id?: string
43
44
  app_id?: string
44
45
  bot_id?: string
@@ -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 = ['chat.stop']
5
+ export const interruptCommand = ['/stop']
6
6
 
7
- export const ignoreToolCommand = ['/search', '/abort', '/stop', '/queue interrupt', ...systemCommand, ...interruptCommand]
7
+ export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...interruptCommand]
@@ -0,0 +1,43 @@
1
+ import type { GatewayEvent } from '../gateway/index.js'
2
+ import { finishedDcgchatCron, sendDcgchatCron } from '../cron.js'
3
+ import { dcgLogger } from './log.js'
4
+ import { getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
5
+ import { sendChunk } from '../transport.js'
6
+
7
+ /**
8
+ * 处理网关 event 帧的副作用(agent 流式输出、cron 同步),并构造供上层分发的 GatewayEvent。
9
+ */
10
+ export function handleGatewayEventMessage(msg: { event?: string; payload?: Record<string, unknown>; seq?: number }): GatewayEvent {
11
+ try {
12
+ if (msg.event === 'agent') {
13
+ dcgLogger(`[Gateway] 收到agent事件: ${JSON.stringify(msg).slice(0, 100)}`)
14
+ const pl = msg.payload as { runId: string; data?: { delta?: unknown } }
15
+ const sessionKey = getSessionKeyBySubAgentRunId(pl.runId)
16
+ const outboundCtx = getEffectiveMsgParams(sessionKey)
17
+ if (outboundCtx.sessionId && pl.data?.delta) sendChunk(pl.data.delta as string, outboundCtx, 0)
18
+ }
19
+ if (msg.event === 'cron') {
20
+ const p = msg.payload
21
+ dcgLogger(`[Gateway] 收到定时任务事件: ${JSON.stringify(p)}`)
22
+ if (p?.action === 'added') {
23
+ sendDcgchatCron(p?.jobId as string)
24
+ }
25
+ if (p?.action === 'updated') {
26
+ sendDcgchatCron(p?.jobId as string)
27
+ }
28
+ if (p?.action === 'removed') {
29
+ sendDcgchatCron(p?.jobId as string)
30
+ }
31
+ if (p?.action === 'finished') {
32
+ finishedDcgchatCron(p?.jobId as string)
33
+ }
34
+ }
35
+ } catch (error) {
36
+ dcgLogger(`[Gateway] 处理事件失败: ${error}`, 'error')
37
+ }
38
+ return {
39
+ type: msg.event as string,
40
+ payload: msg.payload,
41
+ seq: msg.seq as number | undefined
42
+ }
43
+ }
@@ -22,7 +22,7 @@ export function getOpenClawConfig(): OpenClawConfig | null {
22
22
  return config
23
23
  }
24
24
 
25
- import type { OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
25
+ import { createPluginRuntimeStore, type OpenClawConfig, type PluginRuntime } from 'openclaw/plugin-sdk'
26
26
  import { dcgLogger } from './log.js'
27
27
  import { channelInfo, ENV } from './constant.js'
28
28
 
@@ -42,7 +42,6 @@ function getWorkspacePath() {
42
42
  return null
43
43
  }
44
44
 
45
- let runtime: PluginRuntime | null = null
46
45
  let workspaceDir: string = getWorkspacePath()
47
46
 
48
47
  export function setWorkspaceDir(dir?: string) {
@@ -56,17 +55,10 @@ export function getWorkspaceDir(): string {
56
55
  }
57
56
  return workspaceDir
58
57
  }
59
-
60
- export function setDcgchatRuntime(next: PluginRuntime) {
61
- runtime = next
62
- }
63
-
64
- export function getDcgchatRuntime(): PluginRuntime {
65
- if (!runtime) {
66
- dcgLogger?.('runtime not initialized', 'error')
67
- }
68
- return runtime as PluginRuntime
69
- }
58
+ const { setRuntime: setDcgchatRuntime, getRuntime: getDcgchatRuntime } = createPluginRuntimeStore<PluginRuntime>(
59
+ `${"dcgchat-test"} runtime not initialized`
60
+ )
61
+ export { setDcgchatRuntime, getDcgchatRuntime }
70
62
 
71
63
  export type MsgSessionStatus = 'running' | 'finished' | ''
72
64
 
@@ -117,19 +109,6 @@ export function clearSentMediaKeys(messageId?: string) {
117
109
  }
118
110
  }
119
111
 
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
112
  const cronMessageIdMap = new Map<string, string>()
134
113
 
135
114
  export function setCronMessageId(sk: string, messageId: string) {
@@ -144,6 +123,21 @@ export function removeCronMessageId(sk: string) {
144
123
  cronMessageIdMap.delete(sk)
145
124
  }
146
125
 
126
+ export const getSessionKey = (content: any, accountId: string) => {
127
+ const { real_mobook, agent_id, agent_clone_code, session_id } = content
128
+ const core = getDcgchatRuntime()
129
+
130
+ const anentCode = agent_clone_code || 'main'
131
+
132
+ const route = core.channel.routing.resolveAgentRoute({
133
+ cfg: getOpenClawConfig() as OpenClawConfig,
134
+ channel: "dcgchat-test",
135
+ accountId: accountId || 'default',
136
+ peer: { kind: 'direct', id: session_id }
137
+ })
138
+ return real_mobook == '1' ? route.sessionKey : `agent:${anentCode}:mobook:direct:${agent_id}:${session_id}`.toLowerCase()
139
+ }
140
+
147
141
  export function getInfoBySessionKey(sk: string): { sessionId: string; agentId: string } {
148
142
  const sessionInfo = sk.split(':')
149
143
  return { sessionId: sessionInfo.at(-1) ?? '', agentId: sessionInfo.at(-2) ?? '' }
@@ -69,3 +69,21 @@ export function setParamsMessage(sessionKey: string, params: Partial<IMsgParams>
69
69
  export function getParamsMessage(sessionKey: string): IMsgParams | undefined {
70
70
  return paramsMessageMap.get(sessionKey)
71
71
  }
72
+
73
+ export function clearParamsMessage(sessionKey: string): void {
74
+ const k = sessionKey?.trim()
75
+ if (!k) return
76
+ paramsMessageMap.delete(k)
77
+ }
78
+
79
+ // sessionKey 对应的 子agent的runId
80
+ const subagentRunIdMap = new Map<string, string>()
81
+ export function getSessionKeyBySubAgentRunId(runId: string): string | undefined {
82
+ return subagentRunIdMap.get(runId)
83
+ }
84
+ export function setSessionKeyBySubAgentRunId(runId: string, sessionKey: string) {
85
+ subagentRunIdMap.set(runId, sessionKey)
86
+ }
87
+ export function deleteSessionKeyBySubAgentRunId(runId: string) {
88
+ subagentRunIdMap.delete(runId)
89
+ }
@@ -0,0 +1,64 @@
1
+ import { handleDcgchatMessage } from '../bot.js'
2
+ import { setMsgStatus, getSessionKey } from './global.js'
3
+ import type { InboundMessage } from '../types.js'
4
+ import { installSkill, uninstallSkill } from '../skill.js'
5
+ import { dcgLogger } from './log.js'
6
+ import { onDisabledCronJob, onEnabledCronJob, onRemoveCronJob, onRunCronJob } from '../cron.js'
7
+ import { ignoreToolCommand } from './constant.js'
8
+ import { createAgent } from '../agent.js'
9
+
10
+ export type ParsedWsPayload = {
11
+ messageType?: string
12
+ content: any
13
+ }
14
+
15
+ /**
16
+ * 处理 WebSocket 已解析 JSON 且 content 已二次 parse 后的业务消息(openclaw_bot_chat / openclaw_bot_event)。
17
+ */
18
+ export async function handleParsedWsMessage(parsed: ParsedWsPayload, rawPayload: string, accountId: string): Promise<void> {
19
+ if (parsed.messageType == 'openclaw_bot_chat') {
20
+ const msg = parsed as unknown as InboundMessage
21
+ // 与 monitor 原逻辑一致:工具类指令不进入 running,避免误触工具链监控
22
+ const effectiveSessionKey = getSessionKey(msg.content, accountId)
23
+ if (!ignoreToolCommand.includes(msg.content.text?.trim() ?? '')) {
24
+ setMsgStatus(effectiveSessionKey, 'running')
25
+ } else {
26
+ setMsgStatus(effectiveSessionKey, 'finished')
27
+ }
28
+ await handleDcgchatMessage(msg, accountId)
29
+ } else if (parsed.messageType == 'openclaw_bot_event') {
30
+ const { event_type, operation_type } = parsed.content ? parsed.content : ({} as Record<string, any>)
31
+ if (event_type === 'skill') {
32
+ const { skill_url, skill_code, skill_id, bot_token, websocket_trace_id } = parsed.content
33
+ const content = { event_type, operation_type, skill_url, skill_code, skill_id, bot_token, websocket_trace_id }
34
+ if (operation_type === 'install' || operation_type === 'enable' || operation_type === 'update') {
35
+ installSkill({ path: skill_url, code: skill_code }, content)
36
+ } else if (operation_type === 'remove' || operation_type === 'disable') {
37
+ uninstallSkill({ code: skill_code }, content)
38
+ } else {
39
+ dcgLogger(`openclaw_bot_event unknown event_type: ${event_type}, ${rawPayload}`)
40
+ }
41
+ } else if (event_type === 'agent') {
42
+ if (operation_type === 'install') {
43
+ createAgent(parsed.content)
44
+ } else {
45
+ dcgLogger(`openclaw_bot_event unknown event_type: ${event_type}, ${rawPayload}`)
46
+ }
47
+ } else if (event_type === 'cron') {
48
+ const { job_id, message_id } = parsed.content
49
+ if (operation_type === 'remove') {
50
+ await onRemoveCronJob(job_id)
51
+ } else if (operation_type === 'enable') {
52
+ await onEnabledCronJob(job_id)
53
+ } else if (operation_type === 'disable') {
54
+ await onDisabledCronJob(job_id)
55
+ } else if (operation_type === 'run') {
56
+ await onRunCronJob(job_id, message_id)
57
+ }
58
+ } else {
59
+ dcgLogger(`openclaw_bot_event unknown operation_type: ${operation_type}, ${rawPayload}`)
60
+ }
61
+ } else {
62
+ dcgLogger(`ignoring unknown messageType: ${parsed.messageType}`, 'error')
63
+ }
64
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * ZIP 文件名编码:规范要求 UTF-8 时设置 0x800;很多工具未设标志但仍写 UTF-8 字节。
3
+ * 无标志时若一律按 GBK 解码,会得到「鍥句功…」类乱码。先严格 UTF-8,失败再 GBK(兼容 Windows 中文 ZIP)。
4
+ */
5
+ export function decodeZipEntryPath(
6
+ pathBuffer: Buffer | Uint8Array | undefined,
7
+ flags: number,
8
+ fallbackPath: string
9
+ ): string {
10
+ if ((flags & 0x800) !== 0) {
11
+ if (pathBuffer) {
12
+ return new TextDecoder('utf-8').decode(pathBuffer)
13
+ }
14
+ return fallbackPath
15
+ }
16
+ if (pathBuffer && pathBuffer.length > 0) {
17
+ try {
18
+ return new TextDecoder('utf-8', { fatal: true }).decode(pathBuffer)
19
+ } catch {
20
+ return new TextDecoder('gbk').decode(pathBuffer)
21
+ }
22
+ }
23
+ return fallbackPath
24
+ }