@dcrays/dcgchat-test 0.4.19 → 0.4.20

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.19",
3
+ "version": "0.4.20",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
6
6
  "main": "index.ts",
package/src/bot.ts CHANGED
@@ -14,7 +14,7 @@ import { resolveAccount, sendDcgchatMedia } from './channel.js'
14
14
  import { generateSignUrl } from './request/api.js'
15
15
  import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
16
16
  import { dcgLogger } from './utils/log.js'
17
- import { channelInfo, systemCommand, interruptCommand, ENV, ignoreToolCommand } from './utils/constant.js'
17
+ import { channelInfo, systemCommand, stopCommand, 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
20
  import { getDescendantSessionKeysForRequester, resetSubagentStateForRequesterSession, waitUntilSubagentsIdle } from './tool.js'
@@ -33,12 +33,21 @@ const mediaMaxBytes = 300 * 1024 * 1024
33
33
  /** 当前会话最近一次 agent run 的 runId(供观测;/stop 时对子会话与主会话使用不传 runId 的 chat.abort 以终止该键下全部活跃 run) */
34
34
  const activeRunIdBySessionKey = new Map<string, string>()
35
35
 
36
+ /** 本通道用 dispatchReplyFromConfig 在进程内跑 agent,网关 chat.abort 往往碰不到该 run;靠 AbortSignal 才能真正停工具与模型调用。 */
37
+ const dispatchAbortBySessionKey = new Map<string, AbortController>()
38
+
36
39
  /**
37
40
  * 用户在该 sessionKey 上触发打断后,旧 run 的流式/投递不再下发;与 sessionKey 一一对应,支持多会话。
38
41
  * 清除时机:① 下一条非打断用户消息开始处理时;② 旧 run 收尾到 mobook 段时若仍抑制则跳过并发后删除。
39
42
  */
40
43
  const sessionStreamSuppressed = new Set<string>()
41
44
 
45
+ /**
46
+ * 每次 /stop 递增。用于丢弃「已被后续打断」的那条入站处理在 dispatch 结束后的尾部逻辑
47
+ *(否则旧 handler 会删掉 sessionStreamSuppressed、再发 sendFinal(end)/recordInboundSession,导致旧回复复活、新消息异常)。
48
+ */
49
+ const replyAbortEpochBySessionKey = new Map<string, number>()
50
+
42
51
  /** 各 sessionKey 当前轮回复的流式分片序号(仅统计实际下发的文本 chunk);每轮非打断消息开始时置 0 */
43
52
  const streamChunkIdxBySessionKey = new Map<string, number>()
44
53
 
@@ -167,6 +176,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
167
176
 
168
177
  const effectiveAgentId = embeddedAgentId ?? route.agentId
169
178
  const dcgSessionKey = getSessionKey(msg.content, account.accountId)
179
+ const replyAbortEpochAtStart = replyAbortEpochBySessionKey.get(dcgSessionKey) ?? 0
170
180
 
171
181
  const mergedParams = {
172
182
  userId: msg._userId,
@@ -297,10 +307,14 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
297
307
  }
298
308
  })
299
309
 
310
+ let dispatchAbort: AbortController | undefined
300
311
  try {
301
- if (!interruptCommand.includes(text?.trim())) {
312
+ if (!stopCommand.includes(text?.trim())) {
302
313
  sessionStreamSuppressed.delete(dcgSessionKey)
303
314
  streamChunkIdxBySessionKey.set(dcgSessionKey, 0)
315
+ dispatchAbortBySessionKey.get(dcgSessionKey)?.abort()
316
+ dispatchAbort = new AbortController()
317
+ dispatchAbortBySessionKey.set(dcgSessionKey, dispatchAbort)
304
318
  }
305
319
 
306
320
  if (systemCommand.includes(text?.trim())) {
@@ -315,6 +329,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
315
329
  dispatcher,
316
330
  replyOptions: {
317
331
  ...replyOptions,
332
+ abortSignal: dispatchAbort!.signal,
318
333
  onModelSelected: prefixContext.onModelSelected,
319
334
  onAgentRunStart: (runId) => {
320
335
  activeRunIdBySessionKey.set(dcgSessionKey, runId)
@@ -322,12 +337,22 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
322
337
  }
323
338
  })
324
339
  })
325
- } else if (interruptCommand.includes(text?.trim())) {
340
+ } else if (stopCommand.includes(text?.trim())) {
326
341
  dcgLogger(`interrupt command: ${text}`)
342
+ const inFlightAbort = dispatchAbortBySessionKey.get(dcgSessionKey)
343
+ if (inFlightAbort) {
344
+ dcgLogger(`interrupt: AbortController.abort() in-process run sessionKey=${dcgSessionKey}`)
345
+ inFlightAbort.abort()
346
+ dispatchAbortBySessionKey.delete(dcgSessionKey)
347
+ }
327
348
  const ctxForAbort = priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim() ? priorOutboundCtx : outboundCtx
328
349
  sendFinal(ctxForAbort.messageId?.trim() ? ctxForAbort : { ...ctxForAbort, messageId: `${Date.now()}` }, 'abort')
329
350
  sessionStreamSuppressed.add(dcgSessionKey)
330
- // 网关侧彻底中止:子 agent 使用独立 sessionKey,仅 abort 主会话不会停子 run;自深到浅 abort 后代,再 abort 主会话;不传 runId 以终止该键下全部活跃 chat run
351
+ replyAbortEpochBySessionKey.set(
352
+ dcgSessionKey,
353
+ (replyAbortEpochBySessionKey.get(dcgSessionKey) ?? 0) + 1
354
+ )
355
+ // 网关侧:子 agent 独立 sessionKey,自深到浅 abort 后再 abort 主会话;主会话有 runId 时带上以便网关侧若有登记可一并取消
331
356
  const descendantKeys = getDescendantSessionKeysForRequester(dcgSessionKey)
332
357
  const abortSubKeys = [...descendantKeys].reverse()
333
358
  if (abortSubKeys.length > 0) {
@@ -341,7 +366,13 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
341
366
  }
342
367
  }
343
368
  try {
344
- await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: dcgSessionKey } })
369
+ const mainRunId = activeRunIdBySessionKey.get(dcgSessionKey)
370
+ await sendGatewayRpc({
371
+ method: 'chat.abort',
372
+ params: mainRunId
373
+ ? { sessionKey: dcgSessionKey, runId: mainRunId }
374
+ : { sessionKey: dcgSessionKey }
375
+ })
345
376
  } catch (e) {
346
377
  dcgLogger(`chat.abort main ${dcgSessionKey}: ${String(e)}`, 'error')
347
378
  }
@@ -381,6 +412,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
381
412
  dispatcher,
382
413
  replyOptions: {
383
414
  ...replyOptions,
415
+ abortSignal: dispatchAbort!.signal,
384
416
  onModelSelected: prefixContext.onModelSelected,
385
417
  onAgentRunStart: (runId) => {
386
418
  activeRunIdBySessionKey.set(dcgSessionKey, runId)
@@ -425,9 +457,21 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
425
457
  }
426
458
  } catch (err: unknown) {
427
459
  dcgLogger(` dispatchReplyFromConfig error: ${String(err)}`, 'error')
460
+ } finally {
461
+ if (dispatchAbort && dispatchAbortBySessionKey.get(dcgSessionKey) === dispatchAbort) {
462
+ dispatchAbortBySessionKey.delete(dcgSessionKey)
463
+ }
464
+ }
465
+
466
+ const replyAbortEpochNow = replyAbortEpochBySessionKey.get(dcgSessionKey) ?? 0
467
+ if (replyAbortEpochNow !== replyAbortEpochAtStart) {
468
+ dcgLogger(
469
+ `skip post-reply tail: sessionKey=${dcgSessionKey} (abort epoch ${replyAbortEpochAtStart}→${replyAbortEpochNow}, stale handler after interrupt)`
470
+ )
471
+ return
428
472
  }
429
473
 
430
- if (![...systemCommand, ...interruptCommand].includes(text?.trim())) {
474
+ if (![...systemCommand, ...stopCommand].includes(text?.trim())) {
431
475
  if (sessionStreamSuppressed.has(dcgSessionKey)) {
432
476
  sessionStreamSuppressed.delete(dcgSessionKey)
433
477
  }
package/src/channel.ts CHANGED
@@ -129,9 +129,8 @@ export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<v
129
129
  dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
130
130
  return
131
131
  }
132
-
133
132
  const fileName = mediaUrl?.split(/[\\/]/).pop() || ''
134
-
133
+ const notMessageId = `${msgCtx?.messageId}`?.length !== 13 || !msgCtx?.messageId
135
134
  try {
136
135
  const botToken = msgCtx.botToken ?? getOpenClawConfig()?.channels?.["dcgchat-test"]?.botToken ?? ''
137
136
  const url = opts.mediaUrl ? await ossUpload(opts.mediaUrl, botToken, 1) : ''
@@ -140,14 +139,16 @@ export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<v
140
139
  }
141
140
  wsSendRaw(msgCtx, {
142
141
  response: opts.text ?? '',
143
- is_finish: `${msgCtx?.messageId}`?.length === 13 ? -1 : 0,
142
+ is_finish: notMessageId ? -1 : 0,
143
+ message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
144
144
  files: [{ url, name: fileName }]
145
145
  })
146
146
  dcgLogger(`dcgchat: sendMedia session=${sessionKey}, file=${fileName}`)
147
147
  } catch (error) {
148
148
  wsSendRaw(msgCtx, {
149
149
  response: opts.text ?? '',
150
- is_finish: `${msgCtx?.messageId}`?.length === 13 ? -1 : 0,
150
+ message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
151
+ is_finish: notMessageId ? -1 : 0,
151
152
  files: [{ url: opts.mediaUrl ?? '', name: fileName }]
152
153
  })
153
154
  dcgLogger(`dcgchat: error sendMedia session=${sessionKey}: ${String(error)}`, 'error')
@@ -295,24 +296,10 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
295
296
  const outboundCtx = getOutboundMsgParams(to)
296
297
  const content: Record<string, unknown> = { response: ctx.text }
297
298
  if (!isCron) {
298
- // messageId = getCronMessageId(to) || `${Date.now()}`
299
- // const { sessionId, agentId } = getInfoBySessionKey(to)
300
- // content.is_finish = -1
301
- // content.message_tags = { source: 'cron' }
302
- // const merged = mergeDefaultParams({
303
- // agentId: agentId,
304
- // sessionId: `${sessionId}`,
305
- // messageId: messageId,
306
- // real_mobook: !sessionId ? 1 : ''
307
- // })
308
- // wsSendRaw(merged, content)
309
- // } else {
310
299
  if (outboundCtx?.sessionId) {
311
300
  messageId = outboundCtx?.messageId || `${Date.now()}`
312
301
  const newCtx = { ...outboundCtx, messageId }
313
302
  wsSendRaw(newCtx, content)
314
- } else {
315
- dcgLogger(`channel sendText to ${ctx.to} -> sessionId not found`, 'error')
316
303
  }
317
304
  }
318
305
  }
@@ -331,8 +318,8 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
331
318
  const cronMsgId = getCronMessageId(to)
332
319
  const fallbackMessageId = `${Date.now()}`
333
320
  const messageId = cronMsgId || (isCron ? fallbackMessageId : msgCtx?.messageId || fallbackMessageId)
334
-
335
- if (!outboundCtx?.sessionId) {
321
+ const { sessionId } = getInfoBySessionKey(to)
322
+ if (!sessionId) {
336
323
  dcgLogger(`channel sendMedia to ${ctx.to} -> sessionId not found`, 'error')
337
324
  return {
338
325
  channel: "dcgchat-test",
@@ -117,34 +117,44 @@ function needsBestEffort(delivery: CronDelivery): boolean {
117
117
  }
118
118
 
119
119
  /**
120
- * 深拷贝 params 并注入 bestEffort: true
120
+ * 深拷贝 params,在 delivery 上写入 dcg 路由(to / accountId / sessionKey)。
121
+ * bestEffort + 默认 channel 仅在 needsBestEffort 为真时写入(announce 且无 channel)。
122
+ *
123
+ * 说明:原先仅在 needsBestEffort 为真时才改 delivery,若 jobs 里已有 channel 会整段跳过,
124
+ * 导致 `delivery.to` 永远不会被本钩子写入;运行时若缺 to 就会一直 not-delivered。
121
125
  */
122
- function injectBestEffort(params: Record<string, unknown>, sk: string): Record<string, unknown> {
126
+ function patchCronDeliveryInParams(
127
+ params: Record<string, unknown>,
128
+ sk: string,
129
+ deliverySnapshot: CronDelivery
130
+ ): Record<string, unknown> | null {
123
131
  const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
124
- const { agentId, sessionId } = formatterSessionKey(sk)
125
- // 顶层 delivery
132
+ const { agentId } = formatterSessionKey(sk)
133
+ const announceNoChannel = needsBestEffort(deliverySnapshot)
134
+
135
+ const apply = (d: CronDelivery) => {
136
+ d.to = `dcg-cron:${sk}`
137
+ if (agentId) d.accountId = agentId
138
+ if (announceNoChannel) {
139
+ d.bestEffort = true
140
+ d.channel = "dcgchat-test"
141
+ }
142
+ }
143
+
126
144
  if (newParams.delivery && typeof newParams.delivery === 'object') {
127
- ;(newParams.delivery as CronDelivery).bestEffort = true
128
- ;(newParams.delivery as CronDelivery).to = `dcg-cron:${sk}`
129
- ;(newParams.delivery as CronDelivery).accountId = agentId
130
- ;(newParams.delivery as CronDelivery).channel = "dcgchat-test"
145
+ apply(newParams.delivery as CronDelivery)
131
146
  newParams.sessionKey = sk
132
147
  return newParams
133
148
  }
134
149
 
135
- // job.delivery
136
150
  const job = newParams.job as Record<string, unknown> | undefined
137
151
  if (job?.delivery && typeof job.delivery === 'object') {
138
- const jd = job.delivery as CronDelivery
139
- jd.bestEffort = true
140
- jd.to = `dcg-cron:${sk}`
141
- jd.accountId = agentId
142
- jd.channel = "dcgchat-test"
152
+ apply(job.delivery as CronDelivery)
143
153
  newParams.sessionKey = sk
144
154
  return newParams
145
155
  }
146
156
 
147
- return newParams
157
+ return null
148
158
  }
149
159
 
150
160
  export function cronToolCall(event: { toolName: any; params: any; toolCallId: any }, sk: string) {
@@ -157,20 +167,24 @@ export function cronToolCall(event: { toolName: any; params: any; toolCallId: an
157
167
  dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) has no delivery config, skip.`)
158
168
  return undefined
159
169
  }
160
- if (!needsBestEffort(delivery)) {
161
- dcgLogger(
162
- `[${LOG_TAG}] cron call (${toolCallId}) delivery does not need bestEffort ` +
163
- `(mode=${String(delivery.mode)}, channel=${String(delivery.channel)}, bestEffort=${String(delivery.bestEffort)}), skip.`
164
- )
170
+ const newParams = patchCronDeliveryInParams(params, sk, delivery)
171
+ if (!newParams) {
172
+ dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) could not locate delivery on params clone, skip.`)
165
173
  return undefined
166
174
  }
167
175
 
168
- // 核心:注入 bestEffort: true
169
- const newParams = injectBestEffort(params, sk)
170
- dcgLogger(
171
- `[${LOG_TAG}] cron call (${toolCallId}) injected bestEffort=true ` +
172
- `(mode=announce, no channel). delivery=${JSON.stringify(newParams.delivery || (newParams.job as Record<string, unknown>)?.delivery)}`
173
- )
176
+ const patched = newParams.delivery ?? (newParams.job as Record<string, unknown>)?.delivery
177
+ if (needsBestEffort(delivery)) {
178
+ dcgLogger(
179
+ `[${LOG_TAG}] cron call (${toolCallId}) patched delivery (announce, no channel): bestEffort=true, ` +
180
+ `delivery=${JSON.stringify(patched)}`
181
+ )
182
+ } else {
183
+ dcgLogger(
184
+ `[${LOG_TAG}] cron call (${toolCallId}) patched delivery.to / accountId (sessionKey=${sk}), ` +
185
+ `delivery=${JSON.stringify(patched)}`
186
+ )
187
+ }
174
188
 
175
189
  return { params: newParams }
176
190
  } else if (toolName === 'exec') {
@@ -2,6 +2,6 @@ export const ENV: 'production' | 'test' | 'develop' = 'test'
2
2
 
3
3
 
4
4
  export const systemCommand = ['/new', '/status']
5
- export const interruptCommand = ['/stop']
5
+ export const stopCommand = ['/stop']
6
6
 
7
- export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...interruptCommand]
7
+ export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...stopCommand]