@dcrays/dcgchat-test 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +167 -0
  2. package/index.ts +31 -0
  3. package/openclaw.plugin.json +12 -41
  4. package/package.json +38 -11
  5. package/src/agent.ts +592 -0
  6. package/src/bot.ts +676 -0
  7. package/src/channel/index.ts +329 -0
  8. package/src/channel/outboundMedia.ts +144 -0
  9. package/src/channel/outboundTarget.ts +24 -0
  10. package/src/channel/uploadMediaUrl.ts +76 -0
  11. package/src/cron/message.ts +109 -0
  12. package/src/cron/params.ts +166 -0
  13. package/src/cron/toolGuard.ts +285 -0
  14. package/src/cron/types.ts +15 -0
  15. package/src/gateway/index.ts +430 -0
  16. package/src/gateway/security.ts +95 -0
  17. package/src/gateway/socket.ts +256 -0
  18. package/src/libs/ali-oss-6.23.0.tgz +0 -0
  19. package/src/libs/axios-1.13.6.tgz +0 -0
  20. package/src/libs/md5-2.3.0.tgz +0 -0
  21. package/src/libs/mime-types-3.0.2.tgz +0 -0
  22. package/src/libs/unzipper-0.12.3.tgz +0 -0
  23. package/src/libs/ws-8.19.0.tgz +0 -0
  24. package/src/monitor.ts +269 -0
  25. package/src/request/api.ts +80 -0
  26. package/src/request/oss.ts +200 -0
  27. package/src/request/request.ts +191 -0
  28. package/src/request/userInfo.ts +44 -0
  29. package/src/session.ts +28 -0
  30. package/src/skill.ts +114 -0
  31. package/src/tool.ts +625 -0
  32. package/src/tools/messageTool.ts +334 -0
  33. package/src/tools/toolCallGuard.ts +323 -0
  34. package/src/transport.ts +217 -0
  35. package/src/types.ts +139 -0
  36. package/src/utils/agentErrors.ts +23 -0
  37. package/src/utils/constant.ts +60 -0
  38. package/src/utils/env-config.ts +19 -0
  39. package/src/utils/formatLlmInputEvent.ts +48 -0
  40. package/src/utils/gatewayMsgHandler.ts +89 -0
  41. package/src/utils/global.ts +306 -0
  42. package/src/utils/inboundTurnState.ts +66 -0
  43. package/src/utils/log.ts +77 -0
  44. package/src/utils/mediaAttached.ts +244 -0
  45. package/src/utils/mediaEmitter.ts +54 -0
  46. package/src/utils/outboundAssistantText.ts +110 -0
  47. package/src/utils/params.ts +104 -0
  48. package/src/utils/passTxt.ts +4 -0
  49. package/src/utils/resolveRegisterConfig.ts +33 -0
  50. package/src/utils/searchFile.ts +228 -0
  51. package/src/utils/sessionState.ts +137 -0
  52. package/src/utils/sessionTermination.ts +228 -0
  53. package/src/utils/streamMerge.ts +150 -0
  54. package/src/utils/subagentRunMap.ts +71 -0
  55. package/src/utils/workspaceFilePaths.ts +18 -0
  56. package/src/utils/wsMessageHandler.ts +124 -0
  57. package/src/utils/zipExtract.ts +97 -0
  58. package/src/utils/zipPath.ts +24 -0
  59. package/index.js +0 -304
package/src/bot.ts ADDED
@@ -0,0 +1,676 @@
1
+ import path from 'node:path'
2
+ import type { OpenClawConfig, ReplyPayload } from 'openclaw/plugin-sdk'
3
+ import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk/channel-reply-pipeline'
4
+ import type { IMsgParams, InboundMessage } from './types.js'
5
+ import {
6
+ formatText,
7
+ getSessionKey,
8
+ getWorkspaceDir,
9
+ isReviserExpert,
10
+ setMsgStatus,
11
+ clearSentMediaKeys,
12
+ removeAllCronMessageIdsForSession,
13
+ takeApartSessionKey,
14
+ getDcgchatRuntime,
15
+ getOpenClawConfig
16
+ } from './utils/global.js'
17
+ import { resolveAccount } from './channel/index.js'
18
+ import {
19
+ ensureExpertAgentWorkspaceBinding,
20
+ isAgentInstalled,
21
+ isAgentInstalling,
22
+ retryDownloadAgent,
23
+ waitForAgentInstall
24
+ } from './agent.js'
25
+ import { sendFinal, sendText as sendTextMsg, sendError, sendText } from './transport.js'
26
+ import { dcgLogger } from './utils/log.js'
27
+ import { contextOverflowUserHint, isContextOverflowError } from './utils/agentErrors.js'
28
+ import { isInsufficientBalanceAssistantText } from './utils/passTxt.js'
29
+ import {
30
+ systemCommand,
31
+ stopCommand,
32
+ ignoreToolCommand,
33
+ CHANNEL_ID,
34
+ AFTER_STOP_SETTLE_MS,
35
+ SUBAGENT_IDLE_TIMEOUT_MS
36
+ } from './utils/constant.js'
37
+ import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
38
+ import {
39
+ resetSubagentStateForRequesterSession,
40
+ waitUntilSubagentsIdle,
41
+ stringifyToolHookPayload,
42
+ sendToolCallMessage
43
+ } from './tool.js'
44
+ import { createStreamMergeController } from './utils/streamMerge.js'
45
+ import { createMediaEmitter } from './utils/mediaEmitter.js'
46
+ import {
47
+ beginDispatchAbortForInboundTurn,
48
+ clearActiveRunIdForSession,
49
+ clearSessionStreamSuppression,
50
+ isSessionStreamSuppressed,
51
+ releaseDispatchAbortIfCurrent,
52
+ requestStopInterruption,
53
+ runInboundTurnSequenced,
54
+ setActiveRunIdForSession,
55
+ waitForStopInterruption
56
+ } from './utils/sessionTermination.js'
57
+ import {
58
+ buildAgentInboundBodySuffix,
59
+ buildDcgchatInboundMediaPayload,
60
+ normalizeInboundFileList,
61
+ resolveMediaFromUrls,
62
+ type DcgchatInboundMediaInfo
63
+ } from './utils/mediaAttached.js'
64
+ import {
65
+ bumpInboundGeneration,
66
+ clearStopSeqForSession,
67
+ deleteStreamChunkIdxForSession,
68
+ getInboundGeneration,
69
+ getStopSeq,
70
+ resetStreamChunkIdxForSession
71
+ } from './utils/inboundTurnState.js'
72
+ import {
73
+ clearOutboundTextState,
74
+ emitOutboundAssistantText,
75
+ outboundHasEmittedAssistantText,
76
+ outboundHasStreamedAssistantText,
77
+ resetOutboundTextState,
78
+ resetOutboundTextSnapshot
79
+ } from './utils/outboundAssistantText.js'
80
+
81
+ /**
82
+ * 本轮入站**已正常收尾**时清理该 sessionKey 的内存态(params、去重、分片、msgStatus、子 agent 本地跟踪等)。
83
+ * 不可在「stale」或已有新入站占用的场景调用,否则会删掉新一轮写入的 `paramsMessageMap`。
84
+ * 保留入站 generation(单调计数,见 inboundTurnState);下一轮 turn 开始时会 bump,旧 handler 用 gen 不等判定 stale。
85
+ */
86
+ function clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey: string, messageId: string | undefined) {
87
+ clearParamsMessage(dcgSessionKey)
88
+ deleteStreamChunkIdxForSession(dcgSessionKey)
89
+ clearSessionStreamSuppression(dcgSessionKey)
90
+ clearActiveRunIdForSession(dcgSessionKey)
91
+ setMsgStatus(dcgSessionKey, '')
92
+ // 出站媒体去重 bucket:写入时为 `resolveOutboundMediaDedupeKey(messageId, sessionId, sessionKey)` 的结果,
93
+ // 可能是 messageId / sessionId / sessionKey 之一。turn 收尾时按这三者都清一次,覆盖 messageId 为空的兜底场景。
94
+ if (messageId) clearSentMediaKeys(messageId)
95
+ const { sessionId: tookApartSessionId } = takeApartSessionKey(dcgSessionKey)
96
+ if (tookApartSessionId) clearSentMediaKeys(tookApartSessionId)
97
+ clearSentMediaKeys(dcgSessionKey)
98
+ // cronMessageIdMap:sendDcgchatMedia 兜底路径会写一条永不消费的 entry(见 global.removeAllCronMessageIdsForSession 注释),
99
+ // turn 收尾时整条队列即可视为失效。
100
+ removeAllCronMessageIdsForSession(dcgSessionKey)
101
+ resetSubagentStateForRequesterSession(dcgSessionKey)
102
+ clearOutboundTextState(dcgSessionKey)
103
+ }
104
+
105
+ const DEFAULT_AGENT_ID = 'main'
106
+ const VALID_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i
107
+ const INVALID_AGENT_ID_CHARS_RE = /[^a-z0-9_-]+/g
108
+ const LEADING_AGENT_ID_DASH_RE = /^-+/
109
+ const TRAILING_AGENT_ID_DASH_RE = /-+$/
110
+
111
+ type HumanDelayConfig = NonNullable<NonNullable<NonNullable<OpenClawConfig['agents']>['defaults']>['humanDelay']>
112
+
113
+ function normalizeDcgAgentId(value: string | undefined): string {
114
+ const trimmed = (value ?? '').trim()
115
+ if (!trimmed) return DEFAULT_AGENT_ID
116
+ const normalized = trimmed.toLowerCase()
117
+ if (VALID_AGENT_ID_RE.test(trimmed)) return normalized
118
+ return (
119
+ normalized
120
+ .replace(INVALID_AGENT_ID_CHARS_RE, '-')
121
+ .replace(LEADING_AGENT_ID_DASH_RE, '')
122
+ .replace(TRAILING_AGENT_ID_DASH_RE, '')
123
+ .slice(0, 64) || DEFAULT_AGENT_ID
124
+ )
125
+ }
126
+
127
+ function resolveDcgHumanDelayConfig(cfg: OpenClawConfig, agentId: string | undefined): HumanDelayConfig | undefined {
128
+ const defaults = cfg.agents?.defaults?.humanDelay
129
+ const normalizedAgentId = normalizeDcgAgentId(agentId)
130
+ const overrides = cfg.agents?.list?.find((entry) => normalizeDcgAgentId(entry.id) === normalizedAgentId)?.humanDelay
131
+ if (!defaults && !overrides) return undefined
132
+ return {
133
+ mode: overrides?.mode ?? defaults?.mode,
134
+ minMs: overrides?.minMs ?? defaults?.minMs,
135
+ maxMs: overrides?.maxMs ?? defaults?.maxMs
136
+ }
137
+ }
138
+
139
+ const typingCallbacks = createTypingCallbacks({
140
+ start: async () => {},
141
+ onStartError: (err) => {
142
+ dcgLogger(`typing start error: ${String(err)}`, 'error')
143
+ }
144
+ })
145
+
146
+ function sendAbortFinalForInterruptedTurn(ctx: IMsgParams, stopMessageId: string | undefined, sessionKey: string): void {
147
+ const interruptedMessageId = ctx.messageId?.trim()
148
+ if (!interruptedMessageId || interruptedMessageId === stopMessageId?.trim()) return
149
+ sendFinal(ctx, 'abort')
150
+ dcgLogger(`stop abort final for interrupted turn: sessionKey=${sessionKey} messageId=${interruptedMessageId}`)
151
+ }
152
+
153
+ /** `/stop` 是 control message:立即 ack 当前消息,再用 stop barrier 终止旧 run 并阻塞后续普通消息。 */
154
+ async function handleStopControlMessage(msg: InboundMessage, accountId: string, interruptedOutboundCtx: IMsgParams | undefined): Promise<void> {
155
+ const config = getOpenClawConfig()
156
+ if (!config) {
157
+ return
158
+ }
159
+ const account = resolveAccount(config, accountId)
160
+ const dcgSessionKey = getSessionKey(msg.content, account.accountId)
161
+ const conversationId = msg.content.session_id?.trim()
162
+ const mergedParams = {
163
+ userId: msg._userId,
164
+ botToken: msg.content.bot_token,
165
+ sessionId: conversationId,
166
+ messageId: msg.content.message_id,
167
+ domainId: msg.content.domain_id,
168
+ appId: config.channels?.[CHANNEL_ID]?.appId || 100,
169
+ botId: msg.content.bot_id ?? '',
170
+ agentId: msg.content.agent_id ?? '',
171
+ sessionKey: dcgSessionKey,
172
+ real_mobook: msg.content.real_mobook?.toString().trim()
173
+ }
174
+ setParamsMessage(dcgSessionKey, mergedParams)
175
+ const outboundCtx = getEffectiveMsgParams(dcgSessionKey)
176
+ sendFinal(outboundCtx, 'stop')
177
+ const { started, barrier } = requestStopInterruption(dcgSessionKey)
178
+ if (started && interruptedOutboundCtx) {
179
+ sendAbortFinalForInterruptedTurn(interruptedOutboundCtx, msg.content.message_id, dcgSessionKey)
180
+ }
181
+ dcgLogger(`stop control ack: sessionKey=${dcgSessionKey} messageId=${msg.content.message_id} started=${started}`)
182
+ await barrier
183
+ }
184
+
185
+ /**
186
+ * 处理一条用户消息,调用 Agent 并返回回复(同会话串行见 sessionTermination.runInboundTurnSequenced)。
187
+ */
188
+ export async function handleDcgchatMessage(msg: InboundMessage, accountId: string): Promise<void> {
189
+ const config = getOpenClawConfig()
190
+ if (!config) {
191
+ dcgLogger('no OpenClaw config available', 'error')
192
+ return
193
+ }
194
+ const account = resolveAccount(config, accountId)
195
+ const queueSessionKey = getSessionKey(msg.content, account.accountId)
196
+ const queueText = (msg.content.text ?? '').trim()
197
+ const isStopInbound = stopCommand.includes(queueText)
198
+
199
+ const interruptedOutboundCtx = isStopInbound ? getEffectiveMsgParams(queueSessionKey) : undefined
200
+ if (isStopInbound) {
201
+ await handleStopControlMessage(msg, accountId, interruptedOutboundCtx)
202
+ return
203
+ }
204
+
205
+ await waitForStopInterruption(queueSessionKey)
206
+ await runInboundTurnSequenced(queueSessionKey, () => handleDcgchatMessageInboundTurn(msg, accountId))
207
+ }
208
+
209
+ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: string): Promise<void> {
210
+ let config = getOpenClawConfig()
211
+ if (!config) {
212
+ return
213
+ }
214
+ const account = resolveAccount(config, accountId)
215
+
216
+ const core = getDcgchatRuntime()
217
+
218
+ const conversationId = msg.content.session_id?.trim()
219
+ const real_mobook = msg.content.real_mobook?.toString().trim()
220
+
221
+ const agentCloneCode = msg.content?.agent_clone_code?.trim() || ''
222
+
223
+ if (agentCloneCode) {
224
+ await ensureExpertAgentWorkspaceBinding(agentCloneCode)
225
+ const latest = getOpenClawConfig()
226
+ if (latest) config = latest
227
+ }
228
+
229
+ let route = core.channel.routing.resolveAgentRoute({
230
+ cfg: config,
231
+ channel: CHANNEL_ID,
232
+ accountId: account.accountId,
233
+ peer: { kind: 'direct', id: conversationId }
234
+ })
235
+
236
+ let effectiveAgentId = route.agentId
237
+ const dcgSessionKey = getSessionKey(msg.content, account.accountId)
238
+
239
+ // 检测是否是 /stop 后的新消息(通过 stopSeq 判断)
240
+ const previousStopSeq = getStopSeq(dcgSessionKey)
241
+
242
+ // 每轮入站先卸掉可能残留的流抑制(如 /stop 与 dispatch 收尾竞态),避免 deliver 全程静默 return
243
+ clearSessionStreamSuppression(dcgSessionKey)
244
+ const inboundGenAtStart = bumpInboundGeneration(dcgSessionKey)
245
+
246
+ // 检测是否需要开始新任务(新消息而非延续之前的任务)
247
+ // 条件:是 /stop 后的第一条非 stop 消息。
248
+ const isAfterStop = previousStopSeq > 0 && !stopCommand.includes((msg.content.text ?? '').trim())
249
+ const isNewTask = isAfterStop
250
+
251
+ const mergedParams = {
252
+ userId: msg._userId,
253
+ botToken: msg.content.bot_token,
254
+ sessionId: conversationId,
255
+ messageId: msg.content.message_id,
256
+ domainId: msg.content.domain_id,
257
+ appId: config.channels?.[CHANNEL_ID]?.appId || 100,
258
+ botId: msg.content.bot_id ?? '',
259
+ agentId: msg.content.agent_id ?? '',
260
+ sessionKey: dcgSessionKey,
261
+ real_mobook
262
+ }
263
+ setParamsMessage(dcgSessionKey, mergedParams)
264
+ const outboundCtx = getEffectiveMsgParams(dcgSessionKey)
265
+ let agentDisplayName = null
266
+
267
+ dcgLogger(`dcgSessionKey: ${dcgSessionKey} , effectiveAgentId: ${effectiveAgentId} }`)
268
+
269
+ let text = msg.content.text?.trim()
270
+
271
+ if (!text) {
272
+ sendTextMsg('你需要我帮你做什么呢?', outboundCtx)
273
+ sendFinal(outboundCtx, 'not text')
274
+ clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
275
+ return
276
+ }
277
+
278
+ if (stopCommand.includes(text)) {
279
+ sendFinal(outboundCtx, 'stop')
280
+ clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
281
+ return
282
+ }
283
+
284
+ // 校验 agent 是否安装。gateway 重连等场景下可能漏掉安装事件,导致
285
+ // agent 文件不存在。若正在安装则等待结果;若未安装或安装失败,利用
286
+ // 缓存的 URL 尝试一次重新下载(只试一次),仍失败则提示网络异常。
287
+ if (agentCloneCode && dcgSessionKey.includes(agentCloneCode) && !isAgentInstalled(agentCloneCode)) {
288
+ let agentReady = false
289
+
290
+ if (isAgentInstalling(agentCloneCode)) {
291
+ const installOk = await waitForAgentInstall(agentCloneCode)
292
+ if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) {
293
+ dcgLogger(
294
+ `skip stale install result: sessionKey=${dcgSessionKey} inboundGen=${inboundGenAtStart} current=${getInboundGeneration(dcgSessionKey)}`
295
+ )
296
+ return
297
+ }
298
+ const isInstalled = isAgentInstalled(agentCloneCode)
299
+ dcgLogger(`installOk: ${installOk}, isAgentInstalled: ${isInstalled}`)
300
+ if (!installOk || !isInstalled) {
301
+ agentReady = await retryDownloadAgent(agentCloneCode)
302
+ } else {
303
+ agentReady = true
304
+ }
305
+ } else {
306
+ dcgLogger(`retry install because the agent not installed, ${agentCloneCode}`)
307
+ agentReady = await retryDownloadAgent(agentCloneCode)
308
+ }
309
+
310
+ dcgLogger(`agentReady: ${agentReady}`)
311
+ if (!agentReady || !isAgentInstalled(agentCloneCode)) {
312
+ dcgLogger(`receive agentCloneCode, but agent not ready: ${agentCloneCode}`)
313
+ sendTextMsg('抱歉,网络异常,请稍后重试', outboundCtx, { message_tags: { source: 'agent_not_found' }, is_finish: -1 })
314
+ sendFinal(outboundCtx, 'agent_not_installed')
315
+ clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
316
+ return
317
+ }
318
+
319
+ await ensureExpertAgentWorkspaceBinding(agentCloneCode)
320
+ const latestAfterInstall = getOpenClawConfig()
321
+ if (latestAfterInstall) config = latestAfterInstall
322
+ route = core.channel.routing.resolveAgentRoute({
323
+ cfg: config,
324
+ channel: CHANNEL_ID,
325
+ accountId: account.accountId,
326
+ peer: { kind: 'direct', id: conversationId }
327
+ })
328
+ effectiveAgentId = route.agentId
329
+ agentDisplayName = config.agents?.list?.find((a) => a.id === effectiveAgentId)?.name || effectiveAgentId || null
330
+ }
331
+
332
+ try {
333
+ let dispatchReplyErrorHandledByOnError = false
334
+ if ((msg.content.skills_scope?.length ?? 0) > 0 && !ignoreToolCommand.includes(text?.trim()) && !agentCloneCode) {
335
+ const workspaceDir = getWorkspaceDir()
336
+ const skill = msg.content.skills_scope?.[0]
337
+ if (skill?.skill_code) {
338
+ const skillDir = path.join(workspaceDir, 'skills', skill.skill_code)
339
+ const skillText = `用户选择使用此技能:"${skill.skill_code}",技能路径是:"${skillDir}",在目录下查找并`
340
+ text = `${skillText} ${text}`
341
+ }
342
+ }
343
+ // 处理用户上传的文件(兼容多种下行字段名与单文件对象)
344
+ const files = normalizeInboundFileList(msg.content as Record<string, unknown>)
345
+ let mediaListResolved: DcgchatInboundMediaInfo[] = []
346
+ let mediaPayload: Record<string, unknown> = {}
347
+ if (files.length > 0) {
348
+ // 从 OSS 下载 附件内容
349
+ mediaListResolved = await resolveMediaFromUrls(files, msg.content.bot_token)
350
+ mediaPayload = buildDcgchatInboundMediaPayload(mediaListResolved)
351
+ }
352
+
353
+ /** 与飞书一致:RawBody/CommandBody 不含系统附件说明,便于指令探测;完整提示在 BodyForAgent */
354
+ const rawAndCommandBody = text
355
+
356
+ // 如果是新任务(/stop 后或任务完成后),添加系统提示
357
+ let agentInboundText: string
358
+ if (isNewTask && !agentCloneCode) {
359
+ const systemPrompt =
360
+ '[系统提示] 这是一个全新的对话任务,请仅基于当前用户消息回复,不要延续之前的对话内容、未完成的任务或工具调用。当前是一个全新的开始。'
361
+ agentInboundText = buildAgentInboundBodySuffix(`${systemPrompt}\n\n用户消息:${rawAndCommandBody}`, mediaListResolved)
362
+ } else {
363
+ agentInboundText = buildAgentInboundBodySuffix(rawAndCommandBody, mediaListResolved)
364
+ }
365
+
366
+ const ctxPayload = core.channel.inbound.buildContext({
367
+ channel: CHANNEL_ID,
368
+ accountId: route.accountId,
369
+ provider: CHANNEL_ID,
370
+ surface: CHANNEL_ID,
371
+ messageId: msg.content.message_id,
372
+ timestamp: Date.now(),
373
+ from: dcgSessionKey,
374
+ sender: {
375
+ id: dcgSessionKey,
376
+ name: agentDisplayName ?? undefined
377
+ },
378
+ conversation: {
379
+ kind: 'direct',
380
+ id: conversationId ?? dcgSessionKey
381
+ },
382
+ route: {
383
+ agentId: effectiveAgentId ?? '',
384
+ accountId: route.accountId,
385
+ routeSessionKey: dcgSessionKey,
386
+ dispatchSessionKey: dcgSessionKey
387
+ },
388
+ reply: {
389
+ to: dcgSessionKey,
390
+ originatingTo: dcgSessionKey
391
+ },
392
+ message: {
393
+ body: agentInboundText,
394
+ bodyForAgent: agentInboundText,
395
+ rawBody: rawAndCommandBody,
396
+ commandBody: rawAndCommandBody
397
+ },
398
+ access: {
399
+ mentions: {
400
+ canDetectMention: true,
401
+ wasMentioned: true
402
+ }
403
+ },
404
+ channelContext: {
405
+ sender: {
406
+ id: dcgSessionKey
407
+ },
408
+ chat: {
409
+ id: conversationId ?? dcgSessionKey,
410
+ sessionId: conversationId,
411
+ domainId: msg.content.domain_id,
412
+ appId: msg.content.app_id,
413
+ botId: msg.content.bot_id,
414
+ agentId: msg.content.agent_id
415
+ }
416
+ },
417
+ media: mediaListResolved.map((m) => ({
418
+ path: m.path,
419
+ contentType: m.contentType || undefined,
420
+ messageId: msg.content.message_id
421
+ })),
422
+ extra: {
423
+ Target: dcgSessionKey,
424
+ SourceTarget: dcgSessionKey,
425
+ SuppressMessageReceivedHooks: true,
426
+ ...mediaPayload
427
+ }
428
+ })
429
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, { agentId: effectiveAgentId })
430
+
431
+ const mediaEmitter = createMediaEmitter({
432
+ sessionKey: dcgSessionKey,
433
+ onEmit: () => {
434
+ hasEmittedAssistantMedia = true
435
+ }
436
+ })
437
+ let hasEmittedAssistantMedia = false
438
+ // bot 流式/deliver 与 dcgchat_message 共用 outboundAssistantText 快照,避免双发或漏发
439
+ resetOutboundTextState(dcgSessionKey, inboundGenAtStart)
440
+ let partialTextSnapshot = ''
441
+ const streamMerge = createStreamMergeController({
442
+ sessionKey: dcgSessionKey,
443
+ inboundGenAtStart,
444
+ outboundCtx
445
+ })
446
+
447
+ const prefixContext = createReplyPrefixContext({
448
+ cfg: config,
449
+ agentId: effectiveAgentId ?? '',
450
+ channel: CHANNEL_ID,
451
+ accountId: account.accountId
452
+ })
453
+
454
+ const createDcgDeliveryAdapter = () => ({
455
+ deliver: async (payload: ReplyPayload, info: { kind: string }) => {
456
+ if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) return
457
+ if (isSessionStreamSuppressed(dcgSessionKey)) return
458
+ if (isInsufficientBalanceAssistantText(payload?.text)) {
459
+ dcgLogger(`[deliver]: pass text — skip raw insufficient-balance assistant line (plugin llm_output sends user copy)`)
460
+ return
461
+ }
462
+ await mediaEmitter.emitFromPayload(payload)
463
+ // 未真正发出过流式文本时,deliver/final 快照仍应作为正文兜底发送。
464
+ if (payload?.text?.trim() && !outboundHasStreamedAssistantText(dcgSessionKey)) {
465
+ emitOutboundAssistantText(dcgSessionKey, payload.text, outboundCtx, { emitDelta: (delta) => streamMerge.queueDelta(delta) })
466
+ }
467
+ const p = payload as Record<string, unknown>
468
+ const mediaCount = Array.isArray(p.mediaUrls) ? p.mediaUrls.length : p.mediaUrl ? 1 : 0
469
+ dcgLogger(
470
+ `[deliver]: kind=${info.kind}, text.length=${payload?.text?.length}, media.length=${mediaCount}, sessionId=${outboundCtx.sessionId}, ${formatText(payload?.text ?? '')}`
471
+ )
472
+ },
473
+ onError: (err: unknown, info: { kind: string }) => {
474
+ if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) {
475
+ dcgLogger(`${info.kind} reply failed (stale handler, ignored): ${String(err)}`)
476
+ return
477
+ }
478
+ dispatchReplyErrorHandledByOnError = true
479
+ const suppressed = isSessionStreamSuppressed(dcgSessionKey)
480
+ streamMerge.flush(true)
481
+ if (!suppressed && isContextOverflowError(err)) {
482
+ sendText(contextOverflowUserHint(), outboundCtx)
483
+ }
484
+ sendFinal(outboundCtx, 'error')
485
+ dcgLogger(`${info.kind} reply failed${suppressed ? ' (stream suppressed)' : ''}: ${String(err)}`, 'error')
486
+ clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
487
+ }
488
+ })
489
+
490
+ const createDcgReplyDispatcherOptions = () => ({
491
+ responsePrefix: prefixContext.responsePrefix,
492
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
493
+ humanDelay: resolveDcgHumanDelayConfig(config, effectiveAgentId),
494
+ onReplyStart: async () => {},
495
+ onIdle: () => {
496
+ typingCallbacks.onIdle?.()
497
+ }
498
+ })
499
+
500
+ // /stop 后立即发下一条时,宿主 reply run 可能尚未从 registry 释放 → 无 llm_input、秒 end。短延迟缓解竞态。
501
+ if (isAfterStop) {
502
+ if (AFTER_STOP_SETTLE_MS > 0) {
503
+ dcgLogger(`after-stop settle: waiting ${AFTER_STOP_SETTLE_MS}ms before dispatch sessionKey=${dcgSessionKey}`)
504
+ await new Promise<void>((r) => setTimeout(r, AFTER_STOP_SETTLE_MS))
505
+ }
506
+ if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) return
507
+ clearStopSeqForSession(dcgSessionKey)
508
+ clearSessionStreamSuppression(dcgSessionKey)
509
+ }
510
+
511
+ let dispatchAbort: AbortController | undefined
512
+ try {
513
+ resetStreamChunkIdxForSession(dcgSessionKey, 0)
514
+ dispatchAbort = beginDispatchAbortForInboundTurn(dcgSessionKey)
515
+ if (!ignoreToolCommand.includes(text?.trim())) {
516
+ const { text: thinkingContent, callId } = stringifyToolHookPayload('message_received', {}, {}, 'lifecycle')
517
+ sendToolCallMessage(dcgSessionKey, thinkingContent, callId, 0)
518
+ }
519
+ const dispatchReplyAttempt = async () => {
520
+ const dispatcherOptions = createDcgReplyDispatcherOptions()
521
+ const delivery = createDcgDeliveryAdapter()
522
+ await core.channel.inbound.dispatchReply({
523
+ cfg: config,
524
+ channel: CHANNEL_ID,
525
+ accountId: route.accountId,
526
+ agentId: effectiveAgentId ?? '',
527
+ routeSessionKey: dcgSessionKey,
528
+ storePath,
529
+ ctxPayload,
530
+ recordInboundSession: core.channel.session.recordInboundSession,
531
+ dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
532
+ delivery,
533
+ dispatcherOptions,
534
+ record: {
535
+ updateLastRoute: {
536
+ sessionKey: dcgSessionKey,
537
+ channel: CHANNEL_ID,
538
+ to: dcgSessionKey,
539
+ accountId: route.accountId
540
+ },
541
+ onRecordError: (err) => {
542
+ dcgLogger(` session record error: ${String(err)}`, 'error')
543
+ }
544
+ },
545
+ messageId: msg.content.message_id,
546
+ replyOptions: {
547
+ abortSignal: dispatchAbort!.signal,
548
+ onModelSelected: prefixContext.onModelSelected,
549
+ onAgentRunStart: (runId) => {
550
+ setActiveRunIdForSession(dcgSessionKey, runId)
551
+ },
552
+ onPartialReply: async (payload: ReplyPayload) => {
553
+ if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) return
554
+ if (isSessionStreamSuppressed(dcgSessionKey)) return
555
+ if (isInsufficientBalanceAssistantText(payload.text)) {
556
+ return
557
+ }
558
+ const p = payload as Record<string, unknown>
559
+ // --- Streaming text chunks / deltas ---
560
+ if (p.replace === true) {
561
+ resetOutboundTextSnapshot(dcgSessionKey)
562
+ streamMerge.flush(true)
563
+ if (typeof p.text === 'string' && p.text.trim()) {
564
+ partialTextSnapshot = p.text
565
+ emitOutboundAssistantText(dcgSessionKey, p.text, outboundCtx, {
566
+ markStreamed: true,
567
+ emitDelta: (delta) => streamMerge.queueDelta(delta)
568
+ })
569
+ }
570
+ } else if (typeof p.delta === 'string' && p.delta) {
571
+ partialTextSnapshot += p.delta
572
+ emitOutboundAssistantText(dcgSessionKey, partialTextSnapshot, outboundCtx, {
573
+ markStreamed: true,
574
+ emitDelta: (delta) => streamMerge.queueDelta(delta)
575
+ })
576
+ } else if (payload.text) {
577
+ partialTextSnapshot = payload.text
578
+ emitOutboundAssistantText(dcgSessionKey, payload.text, outboundCtx, {
579
+ markStreamed: true,
580
+ emitDelta: (delta) => streamMerge.queueDelta(delta)
581
+ })
582
+ } else {
583
+ dcgLogger(`[onPartialReply]: no text (media/tool metadata) keys=${Object.keys(p).join(',')}`)
584
+ }
585
+ // --- Media from payload ---
586
+ await mediaEmitter.emitFromPayload(payload)
587
+ }
588
+ }
589
+ })
590
+ }
591
+ await dispatchReplyAttempt()
592
+ streamMerge.flush(true)
593
+ } catch (err: unknown) {
594
+ dcgLogger(`handleDcgchatMessage error: ${String(err)}`, 'error')
595
+ if (getInboundGeneration(dcgSessionKey) === inboundGenAtStart && !dispatchReplyErrorHandledByOnError) {
596
+ streamMerge.flush(true)
597
+ if (!isSessionStreamSuppressed(dcgSessionKey)) {
598
+ sendText(isContextOverflowError(err) ? contextOverflowUserHint() : '抱歉,这次对话执行时发生异常,请稍后重试。', outboundCtx)
599
+ }
600
+ sendFinal(outboundCtx, 'error')
601
+ releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
602
+ streamMerge.dispose()
603
+ clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
604
+ return
605
+ }
606
+ if (getInboundGeneration(dcgSessionKey) === inboundGenAtStart && dispatchReplyErrorHandledByOnError) {
607
+ releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
608
+ streamMerge.dispose()
609
+ return
610
+ }
611
+ } finally {
612
+ if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) {
613
+ releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
614
+ streamMerge.dispose()
615
+ }
616
+ }
617
+
618
+ const inboundGenNow = getInboundGeneration(dcgSessionKey)
619
+ if (inboundGenNow !== inboundGenAtStart) {
620
+ streamMerge.dispose()
621
+ dcgLogger(`skip post-reply tail: sessionKey=${dcgSessionKey} (stale handler: inbound gen ${inboundGenAtStart}→${inboundGenNow})`)
622
+ return
623
+ }
624
+
625
+ if (![...systemCommand, ...stopCommand].includes(text?.trim())) {
626
+ if (isSessionStreamSuppressed(dcgSessionKey)) {
627
+ clearSessionStreamSuppression(dcgSessionKey)
628
+ }
629
+ }
630
+ clearSentMediaKeys(msg.content.message_id)
631
+ try {
632
+ try {
633
+ await waitUntilSubagentsIdle(dcgSessionKey, { timeoutMs: SUBAGENT_IDLE_TIMEOUT_MS, signal: dispatchAbort?.signal })
634
+ } catch (err) {
635
+ if (dispatchAbort?.signal.aborted || getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) {
636
+ throw err
637
+ }
638
+ const message = err instanceof Error ? err.message : String(err)
639
+ if (/^waitUntilSubagentsIdle timeout /u.test(message)) {
640
+ dcgLogger(`subagent idle wait timeout, force cleanup: sessionKey=${dcgSessionKey}, err=${message}`, 'error')
641
+ resetSubagentStateForRequesterSession(dcgSessionKey)
642
+ } else {
643
+ throw err
644
+ }
645
+ }
646
+ // 等待子 agent 期间可能已有新入站消息(generation 已变),不能再 end/finished,否则会误结束新轮或放行错挂的正文。
647
+ if (getInboundGeneration(dcgSessionKey) !== inboundGenAtStart) {
648
+ streamMerge.dispose()
649
+ dcgLogger(
650
+ `skip post-wait tail: sessionKey=${dcgSessionKey} (stale handler after subagent wait: inbound gen ${inboundGenAtStart}→${getInboundGeneration(dcgSessionKey)})`
651
+ )
652
+ return
653
+ }
654
+ } finally {
655
+ releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
656
+ }
657
+ streamMerge.flush(true)
658
+ if (!outboundHasEmittedAssistantText(dcgSessionKey) && !hasEmittedAssistantMedia) {
659
+ dcgLogger(`empty assistant reply: sessionKey=${dcgSessionKey}, messageId=${msg.content.message_id}`, 'error')
660
+ }
661
+ if (!isReviserExpert(dcgSessionKey)) {
662
+ sendFinal(outboundCtx, 'end')
663
+ } else {
664
+ dcgLogger(`审校专家跳过终止 sessionKey=${dcgSessionKey}, messageId=${msg.content.message_id}`)
665
+ }
666
+ streamMerge.dispose()
667
+ clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
668
+ } catch (err) {
669
+ dcgLogger(` handle message failed: ${String(err)}`, 'error')
670
+ if (getInboundGeneration(dcgSessionKey) === inboundGenAtStart) {
671
+ const rawErr = err instanceof Error ? err.message : String(err)
672
+ sendError(isContextOverflowError(err) ? contextOverflowUserHint() : rawErr, outboundCtx)
673
+ clearDcgSessionKeyCachesForCompletedTurn(dcgSessionKey, msg.content.message_id)
674
+ }
675
+ }
676
+ }