@dcrays/dcgchat 0.4.13 → 0.4.24
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 +1 -1
- package/src/bot.ts +99 -65
- package/src/channel.ts +106 -33
- package/src/cron.ts +19 -16
- package/src/cronToolCall.ts +44 -30
- package/src/sessionTermination.ts +154 -0
- package/src/tool.ts +71 -6
- package/src/transport.ts +2 -1
- package/src/utils/constant.ts +2 -2
- package/src/utils/gatewayMsgHanlder.ts +13 -4
- package/src/utils/global.ts +30 -13
package/package.json
CHANGED
package/src/bot.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
1
2
|
import path from 'node:path'
|
|
2
3
|
import type { ReplyPayload } from 'openclaw/plugin-sdk'
|
|
3
4
|
import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk'
|
|
@@ -14,10 +15,20 @@ import { resolveAccount, sendDcgchatMedia } from './channel.js'
|
|
|
14
15
|
import { generateSignUrl } from './request/api.js'
|
|
15
16
|
import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
|
|
16
17
|
import { dcgLogger } from './utils/log.js'
|
|
17
|
-
import { channelInfo, systemCommand,
|
|
18
|
-
import { sendGatewayRpc } from './gateway/socket.js'
|
|
18
|
+
import { channelInfo, systemCommand, stopCommand, ENV, ignoreToolCommand } from './utils/constant.js'
|
|
19
19
|
import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
|
|
20
|
-
import {
|
|
20
|
+
import { waitUntilSubagentsIdle } from './tool.js'
|
|
21
|
+
import {
|
|
22
|
+
beginSupersedingUserTurn,
|
|
23
|
+
clearActiveRunIdForSession,
|
|
24
|
+
clearSessionStreamSuppression,
|
|
25
|
+
interruptLocalDispatchAndGateway,
|
|
26
|
+
isSessionStreamSuppressed,
|
|
27
|
+
preemptInboundQueueForStop,
|
|
28
|
+
releaseDispatchAbortIfCurrent,
|
|
29
|
+
runInboundTurnSequenced,
|
|
30
|
+
setActiveRunIdForSession
|
|
31
|
+
} from './sessionTermination.js'
|
|
21
32
|
|
|
22
33
|
type MediaInfo = {
|
|
23
34
|
path: string
|
|
@@ -30,14 +41,18 @@ type TFileInfo = { name: string; url: string }
|
|
|
30
41
|
|
|
31
42
|
const mediaMaxBytes = 300 * 1024 * 1024
|
|
32
43
|
|
|
33
|
-
/** 当前会话最近一次 agent run 的 runId(供 chat.abort 精确打断;无则仅传 sessionKey 仍会中止该会话全部活动运行) */
|
|
34
|
-
const activeRunIdBySessionKey = new Map<string, string>()
|
|
35
|
-
|
|
36
44
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
45
|
+
* 每条入站消息(含 /stop)递增。用于丢弃「已被后续入站抢占」的处理在 dispatch 结束后的尾部逻辑。
|
|
46
|
+
* 仅 /stop 时的 epoch 无法覆盖「新用户消息抢占上一轮」的并发 WS 场景,会导致旧 handler 仍执行 sendFinal(end)、
|
|
47
|
+
* 而新消息被误判为 stale;且上一轮若未对网关 chat.abort,服务端 run 会继续,表现为「新消息秒结束、旧回复复活」。
|
|
39
48
|
*/
|
|
40
|
-
const
|
|
49
|
+
const inboundGenerationBySessionKey = new Map<string, number>()
|
|
50
|
+
|
|
51
|
+
function bumpInboundGeneration(sessionKey: string): number {
|
|
52
|
+
const n = (inboundGenerationBySessionKey.get(sessionKey) ?? 0) + 1
|
|
53
|
+
inboundGenerationBySessionKey.set(sessionKey, n)
|
|
54
|
+
return n
|
|
55
|
+
}
|
|
41
56
|
|
|
42
57
|
/** 各 sessionKey 当前轮回复的流式分片序号(仅统计实际下发的文本 chunk);每轮非打断消息开始时置 0 */
|
|
43
58
|
const streamChunkIdxBySessionKey = new Map<string, number>()
|
|
@@ -140,16 +155,29 @@ const typingCallbacks = createTypingCallbacks({
|
|
|
140
155
|
})
|
|
141
156
|
|
|
142
157
|
/**
|
|
143
|
-
* 处理一条用户消息,调用 Agent
|
|
158
|
+
* 处理一条用户消息,调用 Agent 并返回回复(同会话串行见 sessionTermination.runInboundTurnSequenced)。
|
|
144
159
|
*/
|
|
145
160
|
export async function handleDcgchatMessage(msg: InboundMessage, accountId: string): Promise<void> {
|
|
146
|
-
let completeText = ''
|
|
147
161
|
const config = getOpenClawConfig()
|
|
148
162
|
if (!config) {
|
|
149
163
|
dcgLogger('no config available', 'error')
|
|
150
164
|
return
|
|
151
165
|
}
|
|
152
166
|
const account = resolveAccount(config, accountId)
|
|
167
|
+
const queueSessionKey = getSessionKey(msg.content, account.accountId)
|
|
168
|
+
const queueText = (msg.content.text ?? '').trim()
|
|
169
|
+
if (stopCommand.includes(queueText)) {
|
|
170
|
+
preemptInboundQueueForStop(queueSessionKey)
|
|
171
|
+
}
|
|
172
|
+
await runInboundTurnSequenced(queueSessionKey, () => handleDcgchatMessageInboundTurn(msg, accountId))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: string): Promise<void> {
|
|
176
|
+
const config = getOpenClawConfig()
|
|
177
|
+
if (!config) {
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
const account = resolveAccount(config, accountId)
|
|
153
181
|
const userId = msg._userId.toString()
|
|
154
182
|
|
|
155
183
|
const core = getDcgchatRuntime()
|
|
@@ -168,6 +196,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
168
196
|
|
|
169
197
|
const effectiveAgentId = embeddedAgentId ?? route.agentId
|
|
170
198
|
const dcgSessionKey = getSessionKey(msg.content, account.accountId)
|
|
199
|
+
const inboundGenAtStart = bumpInboundGeneration(dcgSessionKey)
|
|
171
200
|
|
|
172
201
|
const mergedParams = {
|
|
173
202
|
userId: msg._userId,
|
|
@@ -175,7 +204,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
175
204
|
sessionId: conversationId,
|
|
176
205
|
messageId: msg.content.message_id,
|
|
177
206
|
domainId: msg.content.domain_id,
|
|
178
|
-
appId:
|
|
207
|
+
appId: config.channels?.["dcgchat"]?.appId || 100,
|
|
179
208
|
botId: msg.content.bot_id ?? '',
|
|
180
209
|
agentId: msg.content.agent_id ?? '',
|
|
181
210
|
sessionKey: dcgSessionKey,
|
|
@@ -195,6 +224,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
195
224
|
if (!text) {
|
|
196
225
|
sendTextMsg('你需要我帮你做什么呢?', outboundCtx)
|
|
197
226
|
sendFinal(outboundCtx, 'not text')
|
|
227
|
+
setMsgStatus(dcgSessionKey, 'finished')
|
|
198
228
|
return
|
|
199
229
|
}
|
|
200
230
|
|
|
@@ -251,7 +281,8 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
251
281
|
|
|
252
282
|
const sentMediaKeys = new Set<string>()
|
|
253
283
|
const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
|
|
254
|
-
|
|
284
|
+
/** 与 Feishu snapshot 模式一致:payload.text 为当前轮助手全文快照,据此算增量,避免工具前后快照变短或非单调时丢字 */
|
|
285
|
+
let lastStreamSnapshot = ''
|
|
255
286
|
|
|
256
287
|
if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code && !ignoreToolCommand.includes(text?.trim())) {
|
|
257
288
|
const workspaceDir = getWorkspaceDir()
|
|
@@ -274,7 +305,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
274
305
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
|
|
275
306
|
onReplyStart: async () => {},
|
|
276
307
|
deliver: async (payload: ReplyPayload, info) => {
|
|
277
|
-
if (
|
|
308
|
+
if (isSessionStreamSuppressed(dcgSessionKey)) return
|
|
278
309
|
const mediaList = resolveReplyMediaList(payload)
|
|
279
310
|
for (const mediaUrl of mediaList) {
|
|
280
311
|
const key = getMediaKey(mediaUrl)
|
|
@@ -287,9 +318,9 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
287
318
|
onError: (err: unknown, info: { kind: string }) => {
|
|
288
319
|
setMsgStatus(dcgSessionKey, 'finished')
|
|
289
320
|
sendFinal(outboundCtx, 'error')
|
|
290
|
-
|
|
321
|
+
clearActiveRunIdForSession(dcgSessionKey)
|
|
291
322
|
streamChunkIdxBySessionKey.delete(dcgSessionKey)
|
|
292
|
-
const suppressed =
|
|
323
|
+
const suppressed = isSessionStreamSuppressed(dcgSessionKey)
|
|
293
324
|
dcgLogger(`${info.kind} reply failed${suppressed ? ' (stream suppressed)' : ''}: ${String(err)}`, 'error')
|
|
294
325
|
},
|
|
295
326
|
onIdle: () => {
|
|
@@ -297,10 +328,11 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
297
328
|
}
|
|
298
329
|
})
|
|
299
330
|
|
|
331
|
+
let dispatchAbort: AbortController | undefined
|
|
300
332
|
try {
|
|
301
|
-
if (!
|
|
302
|
-
sessionStreamSuppressed.delete(dcgSessionKey)
|
|
333
|
+
if (!stopCommand.includes(text?.trim())) {
|
|
303
334
|
streamChunkIdxBySessionKey.set(dcgSessionKey, 0)
|
|
335
|
+
dispatchAbort = await beginSupersedingUserTurn(dcgSessionKey)
|
|
304
336
|
}
|
|
305
337
|
|
|
306
338
|
if (systemCommand.includes(text?.trim())) {
|
|
@@ -315,49 +347,22 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
315
347
|
dispatcher,
|
|
316
348
|
replyOptions: {
|
|
317
349
|
...replyOptions,
|
|
350
|
+
abortSignal: dispatchAbort!.signal,
|
|
318
351
|
onModelSelected: prefixContext.onModelSelected,
|
|
319
352
|
onAgentRunStart: (runId) => {
|
|
320
|
-
|
|
353
|
+
setActiveRunIdForSession(dcgSessionKey, runId)
|
|
321
354
|
}
|
|
322
355
|
}
|
|
323
356
|
})
|
|
324
357
|
})
|
|
325
|
-
} else if (
|
|
326
|
-
dcgLogger(`interrupt command: ${text}`)
|
|
358
|
+
} else if (stopCommand.includes(text?.trim())) {
|
|
327
359
|
const ctxForAbort = priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim() ? priorOutboundCtx : outboundCtx
|
|
328
|
-
|
|
329
|
-
sendText('会话已终止', outboundCtx)
|
|
330
|
-
sessionStreamSuppressed.add(dcgSessionKey)
|
|
331
|
-
const abortOneSession = async (sessionKey: string) => {
|
|
332
|
-
try {
|
|
333
|
-
await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey } })
|
|
334
|
-
} catch (e) {
|
|
335
|
-
dcgLogger(`chat.abort ${sessionKey}: ${String(e)}`, 'error')
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
const keysToAbort = new Set<string>(getChildSessionKeysTrackedForRequester(dcgSessionKey))
|
|
339
|
-
try {
|
|
340
|
-
const listed = await sendGatewayRpc<{ sessions?: Array<{ key?: string }> }>({
|
|
341
|
-
method: 'sessions.list',
|
|
342
|
-
params: { spawnedBy: dcgSessionKey, limit: 256 }
|
|
343
|
-
})
|
|
344
|
-
for (const s of listed?.sessions ?? []) {
|
|
345
|
-
const k = typeof s?.key === 'string' ? s.key.trim() : ''
|
|
346
|
-
if (k) keysToAbort.add(k)
|
|
347
|
-
}
|
|
348
|
-
} catch (e) {
|
|
349
|
-
dcgLogger(`sessions.list spawnedBy: ${String(e)}`, 'error')
|
|
350
|
-
}
|
|
351
|
-
for (const sk of keysToAbort) {
|
|
352
|
-
await abortOneSession(sk)
|
|
353
|
-
}
|
|
354
|
-
await abortOneSession(dcgSessionKey)
|
|
360
|
+
await interruptLocalDispatchAndGateway(dcgSessionKey, ctxForAbort)
|
|
355
361
|
streamChunkIdxBySessionKey.delete(dcgSessionKey)
|
|
356
|
-
resetSubagentStateForRequesterSession(dcgSessionKey)
|
|
357
|
-
setMsgStatus(dcgSessionKey, 'finished')
|
|
358
362
|
clearSentMediaKeys(msg.content.message_id)
|
|
363
|
+
setMsgStatus(dcgSessionKey, 'finished')
|
|
359
364
|
clearParamsMessage(dcgSessionKey)
|
|
360
|
-
|
|
365
|
+
sendText('会话已终止', outboundCtx)
|
|
361
366
|
sendFinal(outboundCtx, 'stop')
|
|
362
367
|
return
|
|
363
368
|
} else {
|
|
@@ -387,29 +392,35 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
387
392
|
dispatcher,
|
|
388
393
|
replyOptions: {
|
|
389
394
|
...replyOptions,
|
|
395
|
+
abortSignal: dispatchAbort!.signal,
|
|
390
396
|
onModelSelected: prefixContext.onModelSelected,
|
|
391
397
|
onAgentRunStart: (runId) => {
|
|
392
|
-
|
|
398
|
+
setActiveRunIdForSession(dcgSessionKey, runId)
|
|
393
399
|
},
|
|
394
400
|
onPartialReply: async (payload: ReplyPayload) => {
|
|
395
|
-
if (
|
|
401
|
+
if (isSessionStreamSuppressed(dcgSessionKey)) return
|
|
396
402
|
|
|
397
|
-
if (payload.text) {
|
|
398
|
-
completeText = payload.text
|
|
399
|
-
}
|
|
400
403
|
// --- Streaming text chunks ---
|
|
401
404
|
if (payload.text) {
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
+
const t = payload.text
|
|
406
|
+
let delta = ''
|
|
407
|
+
if (t.startsWith(lastStreamSnapshot)) {
|
|
408
|
+
delta = t.slice(lastStreamSnapshot.length)
|
|
409
|
+
lastStreamSnapshot = t
|
|
410
|
+
} else if (lastStreamSnapshot.startsWith(t)) {
|
|
411
|
+
// 快照缩短(模型修订等):不重复下发
|
|
412
|
+
} else {
|
|
413
|
+
// 与上一轮快照不衔接(常见于工具后快照从新的助手片段重新开始):整段下发
|
|
414
|
+
delta = t
|
|
415
|
+
lastStreamSnapshot = t
|
|
416
|
+
}
|
|
405
417
|
if (delta.trim()) {
|
|
406
418
|
const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
|
|
407
419
|
streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
|
|
408
420
|
sendChunk(delta, outboundCtx, prev)
|
|
409
421
|
}
|
|
410
|
-
streamedTextLen = payload.text.length
|
|
411
422
|
} else {
|
|
412
|
-
dcgLogger(`onPartialReply no text: ${JSON.stringify(payload)}
|
|
423
|
+
dcgLogger(`onPartialReply no text (media/tool metadata): ${JSON.stringify(payload)}`)
|
|
413
424
|
}
|
|
414
425
|
// --- Media from payload ---
|
|
415
426
|
const mediaList = resolveReplyMediaList(payload)
|
|
@@ -425,17 +436,37 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
425
436
|
})
|
|
426
437
|
}
|
|
427
438
|
} catch (err: unknown) {
|
|
428
|
-
dcgLogger(`
|
|
439
|
+
dcgLogger(`handleDcgchatMessage error: ${String(err)}`, 'error')
|
|
440
|
+
if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
|
|
441
|
+
setMsgStatus(dcgSessionKey, 'finished')
|
|
442
|
+
}
|
|
443
|
+
} finally {
|
|
444
|
+
releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
|
|
429
445
|
}
|
|
430
446
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
447
|
+
const inboundGenNow = inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0
|
|
448
|
+
if (inboundGenNow !== inboundGenAtStart) {
|
|
449
|
+
dcgLogger(`skip post-reply tail: sessionKey=${dcgSessionKey} (stale handler: inbound gen ${inboundGenAtStart}→${inboundGenNow})`)
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (![...systemCommand, ...stopCommand].includes(text?.trim())) {
|
|
454
|
+
if (isSessionStreamSuppressed(dcgSessionKey)) {
|
|
455
|
+
clearSessionStreamSuppression(dcgSessionKey)
|
|
434
456
|
}
|
|
435
457
|
}
|
|
436
458
|
clearSentMediaKeys(msg.content.message_id)
|
|
437
459
|
const storePath = core.channel.session.resolveStorePath(config.session?.store)
|
|
438
460
|
await waitUntilSubagentsIdle(dcgSessionKey, { timeoutMs: 600_000 })
|
|
461
|
+
// 等待子 agent 期间可能已有新入站消息(generation 已变),不能再 end/finished,否则会误结束新轮或放行错挂的正文。
|
|
462
|
+
if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) {
|
|
463
|
+
dcgLogger(
|
|
464
|
+
`skip post-wait tail: sessionKey=${dcgSessionKey} (stale handler after subagent wait: inbound gen ${inboundGenAtStart}→${inboundGenerationBySessionKey.get(dcgSessionKey)})`
|
|
465
|
+
)
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
clearActiveRunIdForSession(dcgSessionKey)
|
|
469
|
+
setMsgStatus(dcgSessionKey, 'finished')
|
|
439
470
|
sendFinal(outboundCtx, 'end')
|
|
440
471
|
dcgLogger(`record session route: updateLastRoute.to=${dcgSessionKey}, accountId=${route.accountId}`)
|
|
441
472
|
core.channel.session
|
|
@@ -458,6 +489,9 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
458
489
|
})
|
|
459
490
|
} catch (err) {
|
|
460
491
|
dcgLogger(` handle message failed: ${String(err)}`, 'error')
|
|
492
|
+
if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
|
|
493
|
+
setMsgStatus(dcgSessionKey, 'finished')
|
|
494
|
+
}
|
|
461
495
|
sendError(err instanceof Error ? err.message : String(err), outboundCtx)
|
|
462
496
|
}
|
|
463
497
|
}
|
package/src/channel.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
1
2
|
import type { ChannelPlugin, OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
|
|
2
3
|
import { createPluginRuntimeStore, DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk'
|
|
3
4
|
import type { ResolvedDcgchatAccount, DcgchatConfig } from './types.js'
|
|
@@ -8,12 +9,15 @@ import {
|
|
|
8
9
|
getDcgchatRuntime,
|
|
9
10
|
getInfoBySessionKey,
|
|
10
11
|
getOpenClawConfig,
|
|
11
|
-
hasSentMediaKey
|
|
12
|
+
hasSentMediaKey,
|
|
13
|
+
setCronMessageId
|
|
12
14
|
} from './utils/global.js'
|
|
13
15
|
import { isWsOpen, mergeDefaultParams, mergeSessionParams, sendFinal, wsSendRaw } from './transport.js'
|
|
14
16
|
import { dcgLogger, setLogger } from './utils/log.js'
|
|
15
17
|
import { getOutboundMsgParams, getParamsMessage } from './utils/params.js'
|
|
18
|
+
import { isSessionActiveForTool } from './tool.js'
|
|
16
19
|
import { startDcgchatGatewaySocket } from './gateway/socket.js'
|
|
20
|
+
import { getCronJobsPath, readCronJob } from './cron.js'
|
|
17
21
|
|
|
18
22
|
function dcgchatChannelCfg(): DcgchatConfig {
|
|
19
23
|
return (getOpenClawConfig()?.channels?.["dcgchat"] as DcgchatConfig | undefined) ?? {}
|
|
@@ -61,6 +65,8 @@ export type DcgchatMediaSendOptions = {
|
|
|
61
65
|
sessionKey: string
|
|
62
66
|
mediaUrl?: string
|
|
63
67
|
text?: string
|
|
68
|
+
/** 定时任务等场景须与 `getCronMessageId` 一致,避免沿用 map 里上一条用户消息的 messageId */
|
|
69
|
+
messageId?: string
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
function normalizeSessionTarget(rawTo: string): string {
|
|
@@ -69,6 +75,29 @@ function normalizeSessionTarget(rawTo: string): string {
|
|
|
69
75
|
return getParamsMessage(cleaned)?.sessionKey?.trim() || cleaned
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
/**
|
|
79
|
+
* OpenClaw 定时任务 `sessionTarget: isolated` 时,出站 `to` 常为 `agent:<code>:cron:<jobId>[:run:…]`,
|
|
80
|
+
* 与 `paramsMessageMap` / `getCronMessageId` 使用的 jobs.json `sessionKey`(mobook 用户会话)不一致,导致发文件时 sessionId、messageId 错位或缺省。
|
|
81
|
+
*/
|
|
82
|
+
function extractCronJobIdFromIsolatedSessionKey(sessionKey: string): string | null {
|
|
83
|
+
const parts = sessionKey.split(':').filter((p) => p.length > 0)
|
|
84
|
+
const i = parts.findIndex((p) => p.toLowerCase() === 'cron')
|
|
85
|
+
if (i < 0 || i + 1 >= parts.length) return null
|
|
86
|
+
return parts[i + 1] ?? null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveIsolatedCronSessionToJobSessionKey(sessionKey: string): string {
|
|
90
|
+
const jobId = extractCronJobIdFromIsolatedSessionKey(sessionKey)
|
|
91
|
+
if (!jobId) return sessionKey
|
|
92
|
+
const job = readCronJob(getCronJobsPath(), jobId)
|
|
93
|
+
const sk = job && typeof job.sessionKey === 'string' ? job.sessionKey.trim() : ''
|
|
94
|
+
if (!sk) {
|
|
95
|
+
dcgLogger(`dcgchat: cron job ${jobId} has no sessionKey in jobs.json, keep outbound key=${sessionKey}`, 'error')
|
|
96
|
+
return sessionKey
|
|
97
|
+
}
|
|
98
|
+
return sk
|
|
99
|
+
}
|
|
100
|
+
|
|
72
101
|
/** 出站返回的 chatId:含 `dcg-cron:` 时保留原始 `to`,便于下游识别定时投递 */
|
|
73
102
|
function outboundChatId(rawTo: string | undefined, normalizedTo: string): string {
|
|
74
103
|
const raw = rawTo?.trim() ?? ''
|
|
@@ -76,38 +105,78 @@ function outboundChatId(rawTo: string | undefined, normalizedTo: string): string
|
|
|
76
105
|
}
|
|
77
106
|
|
|
78
107
|
export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<void> {
|
|
79
|
-
const
|
|
80
|
-
const
|
|
108
|
+
const rawOpt = (opts.sessionKey ?? '').trim()
|
|
109
|
+
const strippedForCron = rawOpt.replace(/^dcg-cron:/i, '').trim()
|
|
110
|
+
const fromIsolatedCron = extractCronJobIdFromIsolatedSessionKey(strippedForCron) !== null
|
|
111
|
+
const fromDcgCronWrapper = rawOpt.toLowerCase().startsWith('dcg-cron:')
|
|
112
|
+
|
|
113
|
+
let sessionKey = normalizeSessionTarget(opts.sessionKey ?? '')
|
|
114
|
+
sessionKey = resolveIsolatedCronSessionToJobSessionKey(sessionKey)
|
|
115
|
+
|
|
116
|
+
/** 定时自动执行未走 onRunCronJob,须与 finishedDcgchatCron 共用同一 messageId,否则附件与气泡错位 */
|
|
117
|
+
if (!opts.messageId?.trim() && (fromIsolatedCron || fromDcgCronWrapper) && !getCronMessageId(sessionKey)) {
|
|
118
|
+
setCronMessageId(sessionKey, `${Date.now()}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const cronMid = getCronMessageId(sessionKey)
|
|
122
|
+
const baseCtx = getOutboundMsgParams(sessionKey)
|
|
123
|
+
const msgCtx = opts.messageId?.trim()
|
|
124
|
+
? { ...baseCtx, messageId: opts.messageId.trim() }
|
|
125
|
+
: cronMid
|
|
126
|
+
? { ...baseCtx, messageId: cronMid }
|
|
127
|
+
: baseCtx
|
|
81
128
|
if (!isWsOpen()) {
|
|
82
|
-
dcgLogger(`outbound media skipped -> ws not open: ${opts.mediaUrl ?? ''}`)
|
|
129
|
+
dcgLogger(`outbound media skipped -> ws not isWsOpen failed open: ${opts.mediaUrl ?? ''}`)
|
|
83
130
|
return
|
|
84
131
|
}
|
|
85
132
|
|
|
86
|
-
const mediaUrl = opts.mediaUrl
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl}`)
|
|
133
|
+
const mediaUrl = opts.mediaUrl?.trim()
|
|
134
|
+
if (!mediaUrl || !msgCtx.sessionId) {
|
|
135
|
+
dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
|
|
90
136
|
return
|
|
91
137
|
}
|
|
92
|
-
|
|
93
|
-
|
|
138
|
+
// 判断文件存在
|
|
139
|
+
try {
|
|
140
|
+
if (!fs.existsSync(mediaUrl)) {
|
|
141
|
+
dcgLogger(`dcgchat: sendMedia skipped (file not found): ${mediaUrl} sessionKey=${sessionKey}`, 'error')
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
dcgLogger(`dcgchat: sendMedia skipped (cannot stat path): ${mediaUrl} ${String(err)} sessionKey=${sessionKey}`, 'error')
|
|
94
146
|
}
|
|
95
147
|
|
|
96
|
-
|
|
148
|
+
if (mediaUrl && msgCtx.sessionId) {
|
|
149
|
+
if (hasSentMediaKey(msgCtx.sessionId, mediaUrl)) {
|
|
150
|
+
dcgLogger(`dcgchat: sendMedia skipped (hasSentMediaKey): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
addSentMediaKey(msgCtx.sessionId, mediaUrl)
|
|
154
|
+
}
|
|
97
155
|
|
|
156
|
+
const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
|
|
157
|
+
if (!msgCtx.sessionId) {
|
|
158
|
+
msgCtx.sessionId = sessionId
|
|
159
|
+
}
|
|
160
|
+
const fileName = mediaUrl?.split(/[\\/]/).pop() || ''
|
|
161
|
+
const notMessageId = `${msgCtx?.messageId}`?.length === 13 || !msgCtx?.messageId
|
|
98
162
|
try {
|
|
99
163
|
const botToken = msgCtx.botToken ?? getOpenClawConfig()?.channels?.["dcgchat"]?.botToken ?? ''
|
|
100
164
|
const url = opts.mediaUrl ? await ossUpload(opts.mediaUrl, botToken, 1) : ''
|
|
165
|
+
if (!msgCtx.agentId) {
|
|
166
|
+
msgCtx.agentId = agentId
|
|
167
|
+
}
|
|
101
168
|
wsSendRaw(msgCtx, {
|
|
102
169
|
response: opts.text ?? '',
|
|
103
|
-
|
|
170
|
+
is_finish: notMessageId ? -1 : 0,
|
|
171
|
+
message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
|
|
104
172
|
files: [{ url, name: fileName }]
|
|
105
173
|
})
|
|
106
174
|
dcgLogger(`dcgchat: sendMedia session=${sessionKey}, file=${fileName}`)
|
|
107
175
|
} catch (error) {
|
|
108
176
|
wsSendRaw(msgCtx, {
|
|
109
177
|
response: opts.text ?? '',
|
|
110
|
-
message_tags: {
|
|
178
|
+
message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
|
|
179
|
+
is_finish: notMessageId ? -1 : 0,
|
|
111
180
|
files: [{ url: opts.mediaUrl ?? '', name: fileName }]
|
|
112
181
|
})
|
|
113
182
|
dcgLogger(`dcgchat: error sendMedia session=${sessionKey}: ${String(error)}`, 'error')
|
|
@@ -254,25 +323,21 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
|
|
|
254
323
|
const isCron = ctx.to.indexOf('dcg-cron:') >= 0
|
|
255
324
|
const outboundCtx = getOutboundMsgParams(to)
|
|
256
325
|
const content: Record<string, unknown> = { response: ctx.text }
|
|
257
|
-
if (isCron) {
|
|
258
|
-
messageId = getCronMessageId(to) || `${Date.now()}`
|
|
259
|
-
const { sessionId, agentId } = getInfoBySessionKey(to)
|
|
260
|
-
content.is_finish = -1
|
|
261
|
-
content.message_tags = { source: 'cron' }
|
|
262
|
-
const merged = mergeDefaultParams({
|
|
263
|
-
agentId: agentId,
|
|
264
|
-
sessionId: `${sessionId}`,
|
|
265
|
-
messageId: messageId,
|
|
266
|
-
real_mobook: !sessionId ? 1 : ''
|
|
267
|
-
})
|
|
268
|
-
wsSendRaw(merged, content)
|
|
269
|
-
} else {
|
|
326
|
+
if (!isCron) {
|
|
270
327
|
if (outboundCtx?.sessionId) {
|
|
328
|
+
// 入站 handler 已将本轮标为 running;若已 sendFinal(end) 则应为 finished。
|
|
329
|
+
// 否则网关/Core 晚到的正文会仍用「当前 params map」里的 messageId,错挂到后一条用户消息上。
|
|
330
|
+
if (!isSessionActiveForTool(to)) {
|
|
331
|
+
dcgLogger(`channel sendText dropped (session not active): to=${to}`)
|
|
332
|
+
return {
|
|
333
|
+
channel: "dcgchat",
|
|
334
|
+
messageId: '',
|
|
335
|
+
chatId: outboundChatId(ctx.to, to)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
271
338
|
messageId = outboundCtx?.messageId || `${Date.now()}`
|
|
272
339
|
const newCtx = { ...outboundCtx, messageId }
|
|
273
340
|
wsSendRaw(newCtx, content)
|
|
274
|
-
} else {
|
|
275
|
-
dcgLogger(`channel sendText to ${ctx.to} -> sessionId not found`, 'error')
|
|
276
341
|
}
|
|
277
342
|
}
|
|
278
343
|
}
|
|
@@ -283,15 +348,19 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
|
|
|
283
348
|
}
|
|
284
349
|
},
|
|
285
350
|
sendMedia: async (ctx) => {
|
|
286
|
-
const
|
|
287
|
-
const
|
|
351
|
+
const normalizedFromTo = normalizeSessionTarget(ctx.to)
|
|
352
|
+
const isCron = ctx.to.indexOf('dcg-cron:') >= 0 || extractCronJobIdFromIsolatedSessionKey(normalizedFromTo) !== null
|
|
353
|
+
const to = resolveIsolatedCronSessionToJobSessionKey(normalizedFromTo)
|
|
288
354
|
const outboundCtx = getOutboundMsgParams(to)
|
|
289
355
|
const msgCtx = getParamsMessage(to) ?? outboundCtx
|
|
356
|
+
if (isCron && !getCronMessageId(to)) {
|
|
357
|
+
setCronMessageId(to, `${Date.now()}`)
|
|
358
|
+
}
|
|
290
359
|
const cronMsgId = getCronMessageId(to)
|
|
291
360
|
const fallbackMessageId = `${Date.now()}`
|
|
292
361
|
const messageId = cronMsgId || (isCron ? fallbackMessageId : msgCtx?.messageId || fallbackMessageId)
|
|
293
|
-
|
|
294
|
-
if (!
|
|
362
|
+
const { sessionId } = getInfoBySessionKey(to)
|
|
363
|
+
if (!sessionId) {
|
|
295
364
|
dcgLogger(`channel sendMedia to ${ctx.to} -> sessionId not found`, 'error')
|
|
296
365
|
return {
|
|
297
366
|
channel: "dcgchat",
|
|
@@ -301,7 +370,11 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
|
|
|
301
370
|
}
|
|
302
371
|
|
|
303
372
|
dcgLogger(`channel sendMedia to ${ctx.to}`)
|
|
304
|
-
await sendDcgchatMedia({
|
|
373
|
+
await sendDcgchatMedia({
|
|
374
|
+
sessionKey: to || '',
|
|
375
|
+
mediaUrl: ctx.mediaUrl || '',
|
|
376
|
+
...(isCron ? { messageId } : {})
|
|
377
|
+
})
|
|
305
378
|
return {
|
|
306
379
|
channel: "dcgchat",
|
|
307
380
|
messageId,
|
package/src/cron.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import type { IMsgParams } from './types.js'
|
|
4
|
-
import { isWsOpen, mergeDefaultParams, sendEventMessage, sendFinal } from './transport.js'
|
|
4
|
+
import { isWsOpen, mergeDefaultParams, sendEventMessage, sendFinal, wsSendRaw } from './transport.js'
|
|
5
5
|
import { getCronMessageId, getWorkspaceDir, getWsConnection, removeCronMessageId, setCronMessageId } from './utils/global.js'
|
|
6
6
|
import { ossUpload } from './request/oss.js'
|
|
7
7
|
import { dcgLogger } from './utils/log.js'
|
|
@@ -135,7 +135,7 @@ export const onRunCronJob = async (jobId: string, messageId: string) => {
|
|
|
135
135
|
)
|
|
136
136
|
sendMessageToGateway(JSON.stringify({ method: 'cron.run', params: { id: jobId, mode: 'force' } }))
|
|
137
137
|
}
|
|
138
|
-
export const finishedDcgchatCron = async (jobId: string) => {
|
|
138
|
+
export const finishedDcgchatCron = async (jobId: string, summary: string, hasFileOutput?: boolean) => {
|
|
139
139
|
const id = jobId?.trim()
|
|
140
140
|
if (!id) {
|
|
141
141
|
dcgLogger('finishedDcgchatCron: empty jobId', 'error')
|
|
@@ -147,24 +147,27 @@ export const finishedDcgchatCron = async (jobId: string) => {
|
|
|
147
147
|
dcgLogger(`finishedDcgchatCron: no sessionKey for job id=${id}`, 'error')
|
|
148
148
|
return
|
|
149
149
|
}
|
|
150
|
-
const
|
|
151
|
-
const messageId = getCronMessageId(sessionKey)
|
|
150
|
+
const messageId = getCronMessageId(sessionKey) || `${Date.now()}`
|
|
152
151
|
const sessionInfo = sessionKey.split(':')
|
|
153
152
|
const sessionId = sessionInfo.at(-1) ?? ''
|
|
154
153
|
const agentId = sessionInfo.at(-2) ?? ''
|
|
155
|
-
if (outboundCtx?.sessionId) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
sendFinal(merged, 'cron send')
|
|
154
|
+
// if (outboundCtx?.sessionId) {
|
|
155
|
+
|
|
156
|
+
const merged = mergeDefaultParams({
|
|
157
|
+
agentId: agentId,
|
|
158
|
+
sessionId: `${sessionId}`,
|
|
159
|
+
messageId: messageId || `${Date.now()}`,
|
|
160
|
+
real_mobook: !sessionId ? 1 : ''
|
|
161
|
+
})
|
|
162
|
+
const message_tags = { source: 'cron' } as Record<string, string | boolean>
|
|
163
|
+
if (hasFileOutput) {
|
|
164
|
+
message_tags.hasFile = true
|
|
167
165
|
}
|
|
166
|
+
wsSendRaw(merged, { response: summary, message_tags, is_finish: -1 })
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
sendFinal(merged, 'cron send')
|
|
169
|
+
}, 200)
|
|
170
|
+
// }
|
|
168
171
|
const ws = getWsConnection()
|
|
169
172
|
const baseContent = getParamsDefaults()
|
|
170
173
|
if (isWsOpen()) {
|
package/src/cronToolCall.ts
CHANGED
|
@@ -117,34 +117,44 @@ function needsBestEffort(delivery: CronDelivery): boolean {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
|
-
* 深拷贝 params
|
|
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
|
|
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
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
;(newParams.delivery as CronDelivery).to = `dcg-cron:${sk}`
|
|
129
|
-
;(newParams.delivery as CronDelivery).accountId = agentId
|
|
130
|
-
;(newParams.delivery as CronDelivery).channel = "dcgchat"
|
|
145
|
+
apply(newParams.delivery as CronDelivery)
|
|
131
146
|
newParams.sessionKey = sk
|
|
132
147
|
return newParams
|
|
133
148
|
}
|
|
134
149
|
|
|
135
|
-
// job.delivery(须改 job 内对象,勿写 newParams.delivery——顶层可能不存在)
|
|
136
150
|
const job = newParams.job as Record<string, unknown> | undefined
|
|
137
151
|
if (job?.delivery && typeof job.delivery === 'object') {
|
|
138
|
-
|
|
139
|
-
del.bestEffort = true
|
|
140
|
-
del.to = `dcg-cron:${sk}`
|
|
141
|
-
del.accountId = agentId
|
|
142
|
-
del.channel = "dcgchat"
|
|
152
|
+
apply(job.delivery as CronDelivery)
|
|
143
153
|
newParams.sessionKey = sk
|
|
144
154
|
return newParams
|
|
145
155
|
}
|
|
146
156
|
|
|
147
|
-
return
|
|
157
|
+
return null
|
|
148
158
|
}
|
|
149
159
|
|
|
150
160
|
export function cronToolCall(event: { toolName: any; params: any; toolCallId: any }, sk: string) {
|
|
@@ -155,34 +165,38 @@ export function cronToolCall(event: { toolName: any; params: any; toolCallId: an
|
|
|
155
165
|
const delivery = extractDelivery(params)
|
|
156
166
|
if (!delivery) {
|
|
157
167
|
dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) has no delivery config, skip.`)
|
|
158
|
-
return
|
|
168
|
+
return undefined
|
|
169
|
+
}
|
|
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.`)
|
|
173
|
+
return undefined
|
|
159
174
|
}
|
|
160
|
-
|
|
175
|
+
|
|
176
|
+
const patched = newParams.delivery ?? (newParams.job as Record<string, unknown>)?.delivery
|
|
177
|
+
if (needsBestEffort(delivery)) {
|
|
161
178
|
dcgLogger(
|
|
162
|
-
`[${LOG_TAG}] cron call (${toolCallId}) delivery
|
|
163
|
-
`
|
|
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)}`
|
|
164
186
|
)
|
|
165
|
-
return params
|
|
166
187
|
}
|
|
167
188
|
|
|
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
|
-
)
|
|
174
|
-
|
|
175
189
|
return { params: newParams }
|
|
176
190
|
} else if (toolName === 'exec') {
|
|
177
191
|
if (params.command.indexOf('cron create') > -1 || params.command.indexOf('cron add') > -1) {
|
|
178
192
|
const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
|
|
179
193
|
newParams.command =
|
|
180
|
-
params.command.replace('--json', '') + ` --session-key ${sk} --channel ${
|
|
194
|
+
params.command.replace('--json', '') + ` --session-key ${sk} --channel ${'dcgchat-test'} --to dcg-cron:${sk} --json`
|
|
181
195
|
return { params: newParams }
|
|
182
196
|
} else {
|
|
183
|
-
return
|
|
197
|
+
return undefined
|
|
184
198
|
}
|
|
185
199
|
}
|
|
186
200
|
|
|
187
|
-
return
|
|
201
|
+
return undefined
|
|
188
202
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话终止 / 抢占 / 网关 abort 的集中实现,便于单独调整策略与观测。
|
|
3
|
+
* 与 bot 的 generation、流式分片序号、入站业务上下文分离,仅负责:本地 AbortController、流抑制标记、
|
|
4
|
+
* 入站串行队尾、activeRunId、chat.abort 子会话→主会话。
|
|
5
|
+
*/
|
|
6
|
+
import { sendGatewayRpc } from './gateway/socket.js'
|
|
7
|
+
import { sendFinal } from './transport.js'
|
|
8
|
+
import type { IMsgParams } from './types.js'
|
|
9
|
+
import { dcgLogger } from './utils/log.js'
|
|
10
|
+
import { getDescendantSessionKeysForRequester, resetSubagentStateForRequesterSession } from './tool.js'
|
|
11
|
+
|
|
12
|
+
// --- 状态(仅本模块内修改,供 bot 通过下方 API 使用)---
|
|
13
|
+
|
|
14
|
+
/** 当前会话最近一次 agent run 的 runId(网关 chat.abort 主会话时携带) */
|
|
15
|
+
const activeRunIdBySessionKey = new Map<string, string>()
|
|
16
|
+
|
|
17
|
+
/** dispatchReplyFromConfig 使用的 AbortSignal,用于真正掐断工具与模型 */
|
|
18
|
+
const dispatchAbortBySessionKey = new Map<string, AbortController>()
|
|
19
|
+
|
|
20
|
+
/** 打断后抑制 deliver / onPartialReply 继续下发 */
|
|
21
|
+
const sessionStreamSuppressed = new Set<string>()
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 同 sessionKey 入站串行队尾;与 Core session lane 对齐,避免并发 dispatch 过早返回。
|
|
25
|
+
*/
|
|
26
|
+
const inboundTurnTailBySessionKey = new Map<string, Promise<void>>()
|
|
27
|
+
|
|
28
|
+
// --- activeRunId(供 bot 在 onAgentRunStart / 错误收尾时同步)---
|
|
29
|
+
|
|
30
|
+
export function setActiveRunIdForSession(sessionKey: string, runId: string): void {
|
|
31
|
+
activeRunIdBySessionKey.set(sessionKey, runId)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clearActiveRunIdForSession(sessionKey: string): void {
|
|
35
|
+
activeRunIdBySessionKey.delete(sessionKey)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- 流抑制 ---
|
|
39
|
+
|
|
40
|
+
export function isSessionStreamSuppressed(sessionKey: string): boolean {
|
|
41
|
+
return sessionStreamSuppressed.has(sessionKey)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clearSessionStreamSuppression(sessionKey: string): void {
|
|
45
|
+
sessionStreamSuppressed.delete(sessionKey)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function markSessionStreamSuppressed(sessionKey: string): void {
|
|
49
|
+
sessionStreamSuppressed.add(sessionKey)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- 入站串行队列 ---
|
|
53
|
+
|
|
54
|
+
/** /stop 入队前:掐断当前 in-process,并重置队尾,使本条 stop 不必等待已被 abort 的长 turn */
|
|
55
|
+
export function preemptInboundQueueForStop(sessionKey: string): void {
|
|
56
|
+
const c = dispatchAbortBySessionKey.get(sessionKey)
|
|
57
|
+
if (c) {
|
|
58
|
+
c.abort()
|
|
59
|
+
dispatchAbortBySessionKey.delete(sessionKey)
|
|
60
|
+
}
|
|
61
|
+
inboundTurnTailBySessionKey.set(sessionKey, Promise.resolve())
|
|
62
|
+
dcgLogger(`inbound queue: reset tail for /stop sessionKey=${sessionKey}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 将本轮入站处理挂到 sessionKey 队尾,保证同会话顺序执行 */
|
|
66
|
+
export async function runInboundTurnSequenced(sessionKey: string, run: () => Promise<void>): Promise<void> {
|
|
67
|
+
const prev = inboundTurnTailBySessionKey.get(sessionKey) ?? Promise.resolve()
|
|
68
|
+
const next = prev.catch(() => {}).then(run)
|
|
69
|
+
inboundTurnTailBySessionKey.set(sessionKey, next.catch(() => {}))
|
|
70
|
+
await next
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- 网关 abort ---
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 终止网关上仍可能活跃的 run(子会话自深到浅,再主会话)。
|
|
77
|
+
* supersede:仅当有子会话或 mainRunId 时发 RPC;interrupt:主会话始终 chat.abort。
|
|
78
|
+
*/
|
|
79
|
+
export async function abortGatewayRunsForSession(sessionKey: string, reason: 'interrupt' | 'supersede'): Promise<void> {
|
|
80
|
+
const prefix = reason === 'interrupt' ? 'interrupt' : 'supersede'
|
|
81
|
+
const descendantKeys = getDescendantSessionKeysForRequester(sessionKey)
|
|
82
|
+
const abortSubKeys = [...descendantKeys].reverse()
|
|
83
|
+
const mainRunId = activeRunIdBySessionKey.get(sessionKey)
|
|
84
|
+
|
|
85
|
+
if (reason === 'supersede' && abortSubKeys.length === 0 && !mainRunId) {
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (abortSubKeys.length > 0) {
|
|
90
|
+
dcgLogger(`${prefix}: chat.abort ${abortSubKeys.length} subagent session(s) (nested incl.)`)
|
|
91
|
+
}
|
|
92
|
+
for (const subKey of abortSubKeys) {
|
|
93
|
+
try {
|
|
94
|
+
await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: subKey } })
|
|
95
|
+
} catch (e) {
|
|
96
|
+
dcgLogger(`${prefix}: chat.abort subagent ${subKey}: ${String(e)}`, 'error')
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const shouldMainAbort = reason === 'interrupt' || Boolean(mainRunId)
|
|
101
|
+
if (shouldMainAbort) {
|
|
102
|
+
try {
|
|
103
|
+
await sendGatewayRpc({
|
|
104
|
+
method: 'chat.abort',
|
|
105
|
+
params: mainRunId ? { sessionKey, runId: mainRunId } : { sessionKey }
|
|
106
|
+
})
|
|
107
|
+
} catch (e) {
|
|
108
|
+
dcgLogger(`${prefix}: chat.abort main ${sessionKey}: ${String(e)}`, 'error')
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
activeRunIdBySessionKey.delete(sessionKey)
|
|
113
|
+
resetSubagentStateForRequesterSession(sessionKey)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- 本地 dispatch AbortController ---
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 新一轮非 /stop 用户消息:清除流抑制、abort 上一轮 controller、网关 supersede,并安装新的 AbortController。
|
|
120
|
+
* 调用方须在之前或之后自行 `streamChunkIdxBySessionKey.set(sessionKey, 0)`。
|
|
121
|
+
*/
|
|
122
|
+
export async function beginSupersedingUserTurn(sessionKey: string): Promise<AbortController> {
|
|
123
|
+
sessionStreamSuppressed.delete(sessionKey)
|
|
124
|
+
dispatchAbortBySessionKey.get(sessionKey)?.abort()
|
|
125
|
+
await abortGatewayRunsForSession(sessionKey, 'supersede')
|
|
126
|
+
const ac = new AbortController()
|
|
127
|
+
dispatchAbortBySessionKey.set(sessionKey, ac)
|
|
128
|
+
return ac
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** try/finally 中:仅当仍是当前 controller 时从 map 移除 */
|
|
132
|
+
export function releaseDispatchAbortIfCurrent(sessionKey: string, controller: AbortController | undefined): void {
|
|
133
|
+
if (controller && dispatchAbortBySessionKey.get(sessionKey) === controller) {
|
|
134
|
+
dispatchAbortBySessionKey.delete(sessionKey)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 用户发送 /stop:本地 abort、对「上一轮对话」发 abort final、标记流抑制、网关 interrupt。
|
|
140
|
+
* 之后的 clearSentMedia、params、UI「已终止」等由 bot 继续处理。
|
|
141
|
+
*/
|
|
142
|
+
export async function interruptLocalDispatchAndGateway(sessionKey: string, ctxForAbort: IMsgParams): Promise<void> {
|
|
143
|
+
dcgLogger(`interrupt command: sessionKey=${sessionKey}`)
|
|
144
|
+
const inFlight = dispatchAbortBySessionKey.get(sessionKey)
|
|
145
|
+
if (inFlight) {
|
|
146
|
+
dcgLogger(`interrupt: AbortController.abort() in-process run sessionKey=${sessionKey}`)
|
|
147
|
+
inFlight.abort()
|
|
148
|
+
dispatchAbortBySessionKey.delete(sessionKey)
|
|
149
|
+
}
|
|
150
|
+
const finalCtx = ctxForAbort.messageId?.trim() ? ctxForAbort : { ...ctxForAbort, messageId: `${Date.now()}` }
|
|
151
|
+
sendFinal(finalCtx, 'abort')
|
|
152
|
+
markSessionStreamSuppressed(sessionKey)
|
|
153
|
+
await abortGatewayRunsForSession(sessionKey, 'interrupt')
|
|
154
|
+
}
|
package/src/tool.ts
CHANGED
|
@@ -51,8 +51,30 @@ const eventList = [
|
|
|
51
51
|
{ event: 'after_tool_call', message: '' }
|
|
52
52
|
]
|
|
53
53
|
|
|
54
|
+
/** 子 agent 的 sessionKey 往往未写入 params map,回落到主会话 outbound 参数避免 messageId 缺失 */
|
|
55
|
+
function resolveOutboundParamsForSession(sk: string) {
|
|
56
|
+
const k = sk.trim()
|
|
57
|
+
let params = getEffectiveMsgParams(k)
|
|
58
|
+
if (params.messageId?.trim() || params.sessionId?.trim()) return params
|
|
59
|
+
const parent = requesterByChildSessionKey.get(k)
|
|
60
|
+
if (parent) {
|
|
61
|
+
const parentParams = getEffectiveMsgParams(parent)
|
|
62
|
+
if (parentParams.messageId?.trim() || parentParams.sessionId?.trim()) return parentParams
|
|
63
|
+
}
|
|
64
|
+
return params
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 主会话已 running 时,子会话上的工具/事件也应下发(否则子 key 无 running 状态会整段丢消息) */
|
|
68
|
+
export function isSessionActiveForTool(sk: string): boolean {
|
|
69
|
+
const k = sk.trim()
|
|
70
|
+
if (!k) return false
|
|
71
|
+
if (getMsgStatus(k) === 'running') return true
|
|
72
|
+
const parent = requesterByChildSessionKey.get(k)
|
|
73
|
+
return parent ? getMsgStatus(parent) === 'running' : false
|
|
74
|
+
}
|
|
75
|
+
|
|
54
76
|
function sendToolCallMessage(sk: string, text: string, toolCallId: string, isCover: number) {
|
|
55
|
-
const params =
|
|
77
|
+
const params = resolveOutboundParamsForSession(sk)
|
|
56
78
|
const content = { is_finish: -1, tool_call_id: toolCallId, is_cover: isCover, thinking_content: text, response: '' }
|
|
57
79
|
wsSendRaw(params, content, false)
|
|
58
80
|
}
|
|
@@ -158,6 +180,29 @@ export function getChildSessionKeysTrackedForRequester(requesterSessionKey: stri
|
|
|
158
180
|
return out
|
|
159
181
|
}
|
|
160
182
|
|
|
183
|
+
/**
|
|
184
|
+
* 自根 requester 起 BFS 收集所有已跟踪后代子会话(含嵌套)。网关 abort 时宜自深到浅,调用方对结果 `.reverse()` 后再逐个 chat.abort。
|
|
185
|
+
*/
|
|
186
|
+
export function getDescendantSessionKeysForRequester(rootRequesterSessionKey: string): string[] {
|
|
187
|
+
const root = rootRequesterSessionKey.trim()
|
|
188
|
+
if (!root) return []
|
|
189
|
+
const ordered: string[] = []
|
|
190
|
+
const seen = new Set<string>()
|
|
191
|
+
let frontier = getChildSessionKeysTrackedForRequester(root)
|
|
192
|
+
while (frontier.length > 0) {
|
|
193
|
+
const next: string[] = []
|
|
194
|
+
for (const sk of frontier) {
|
|
195
|
+
const k = sk.trim()
|
|
196
|
+
if (!k || seen.has(k)) continue
|
|
197
|
+
seen.add(k)
|
|
198
|
+
ordered.push(k)
|
|
199
|
+
next.push(...getChildSessionKeysTrackedForRequester(k))
|
|
200
|
+
}
|
|
201
|
+
frontier = next
|
|
202
|
+
}
|
|
203
|
+
return ordered
|
|
204
|
+
}
|
|
205
|
+
|
|
161
206
|
/**
|
|
162
207
|
* 打断后清空本地子 agent 跟踪(runId、父子映射、子 runId→sessionKey),并唤醒 waitUntilSubagentsIdle,避免永久挂起。
|
|
163
208
|
*/
|
|
@@ -280,6 +325,16 @@ function resolveHookSessionKey(
|
|
|
280
325
|
return (args?.sessionKey || '').trim()
|
|
281
326
|
}
|
|
282
327
|
|
|
328
|
+
/** 定时触发时会话往往非 running,但仍需跑 before_tool_call 以注入 sessionKey / delivery(见 cronToolCall) */
|
|
329
|
+
function shouldRunBeforeToolCallWithoutRunningSession(event: { toolName?: string; params?: { command?: string } }): boolean {
|
|
330
|
+
if (event?.toolName === 'cron') return true
|
|
331
|
+
const cmd = event?.params?.command
|
|
332
|
+
if (event?.toolName === 'exec' && typeof cmd === 'string') {
|
|
333
|
+
return cmd.includes('cron create') || cmd.includes('cron add')
|
|
334
|
+
}
|
|
335
|
+
return false
|
|
336
|
+
}
|
|
337
|
+
|
|
283
338
|
function trackSubagentLifecycle(eventName: string, event: any, args: any): void {
|
|
284
339
|
if (eventName === 'subagent_spawned') {
|
|
285
340
|
const runId = typeof event?.runId === 'string' ? event.runId : ''
|
|
@@ -306,14 +361,24 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
|
|
|
306
361
|
trackSubagentLifecycle(item.event, event, args)
|
|
307
362
|
const sk = resolveHookSessionKey(item.event, args ?? {})
|
|
308
363
|
if (sk) {
|
|
309
|
-
const
|
|
310
|
-
|
|
364
|
+
const toolHooksOk =
|
|
365
|
+
isSessionActiveForTool(sk) || (item.event === 'before_tool_call' && shouldRunBeforeToolCallWithoutRunningSession(event))
|
|
366
|
+
if (toolHooksOk) {
|
|
311
367
|
if (['after_tool_call', 'before_tool_call'].includes(item.event)) {
|
|
312
368
|
const { result: _result, ...rest } = event
|
|
313
369
|
dcgLogger(`工具调用结果: ~ event:${item.event} ~ params:${JSON.stringify(rest)}`)
|
|
314
370
|
|
|
315
371
|
if (item.event === 'before_tool_call') {
|
|
316
|
-
|
|
372
|
+
const hookResult = cronToolCall(rest, sk)
|
|
373
|
+
const text = JSON.stringify({
|
|
374
|
+
type: item.event,
|
|
375
|
+
specialIdentification: 'dcgchat_tool_call_special_identification',
|
|
376
|
+
callId: event.toolCallId || event.runId || Date.now().toString(),
|
|
377
|
+
...rest,
|
|
378
|
+
status: 'running'
|
|
379
|
+
})
|
|
380
|
+
sendToolCallMessage(sk, text, event.toolCallId || event.runId || Date.now().toString(), 0)
|
|
381
|
+
return hookResult
|
|
317
382
|
}
|
|
318
383
|
const text = JSON.stringify({
|
|
319
384
|
type: item.event,
|
|
@@ -329,7 +394,7 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
|
|
|
329
394
|
item.event === 'after_tool_call' ? 1 : 0
|
|
330
395
|
)
|
|
331
396
|
} else if (item.event) {
|
|
332
|
-
const msgCtx =
|
|
397
|
+
const msgCtx = resolveOutboundParamsForSession(sk)
|
|
333
398
|
if (item.event === 'llm_output') {
|
|
334
399
|
if (event.lastAssistant?.errorMessage === '1003-额度不足请充值') {
|
|
335
400
|
const message = '您的积分已消耗完,您可以通过充值积分来继续使用'
|
|
@@ -346,7 +411,7 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
|
|
|
346
411
|
params: item.message
|
|
347
412
|
})
|
|
348
413
|
sendToolCallMessage(sk, text, event.runId || Date.now().toString(), 0)
|
|
349
|
-
dcgLogger(`工具调用结果: ~ event:${item.event}
|
|
414
|
+
dcgLogger(`工具调用结果: ~ event:${item.event}`)
|
|
350
415
|
}
|
|
351
416
|
}
|
|
352
417
|
} else if (item.event !== 'before_tool_call') {
|
package/src/transport.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getWsConnection } from './utils/global.js'
|
|
1
|
+
import { clearSentMediaKeys, getWsConnection } from './utils/global.js'
|
|
2
2
|
import { dcgLogger } from './utils/log.js'
|
|
3
3
|
import type { IMsgParams } from './types.js'
|
|
4
4
|
import { getEffectiveMsgParams, getParamsDefaults } from './utils/params.js'
|
|
@@ -170,6 +170,7 @@ export function sendChunk(text: string, ctx: IMsgParams, chunkIdx: number): bool
|
|
|
170
170
|
|
|
171
171
|
export function sendFinal(ctx: IMsgParams, tag: string): boolean {
|
|
172
172
|
dcgLogger(` message handling complete state: to=${ctx.sessionId} final tag:${tag}`)
|
|
173
|
+
clearSentMediaKeys(ctx.sessionId)
|
|
173
174
|
return wsSend(ctx, { response: '', state: 'final' })
|
|
174
175
|
}
|
|
175
176
|
|
package/src/utils/constant.ts
CHANGED
|
@@ -2,6 +2,6 @@ export const ENV: 'production' | 'test' | 'develop' = 'production'
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
export const systemCommand = ['/new', '/status']
|
|
5
|
-
export const
|
|
5
|
+
export const stopCommand = ['/stop']
|
|
6
6
|
|
|
7
|
-
export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...
|
|
7
|
+
export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...stopCommand]
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import type { GatewayEvent } from '../gateway/index.js'
|
|
2
2
|
import { finishedDcgchatCron, sendDcgchatCron } from '../cron.js'
|
|
3
3
|
import { dcgLogger } from './log.js'
|
|
4
|
-
import { getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
|
|
5
|
-
import { sendChunk } from '../transport.js'
|
|
4
|
+
import { clearParamsMessage, getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
|
|
5
|
+
import { sendChunk, sendFinal, sendText } from '../transport.js'
|
|
6
|
+
import { resetSubagentStateForRequesterSession } from '../tool.js'
|
|
7
|
+
import { setMsgStatus } from './global.js'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* 处理网关 event 帧的副作用(agent 流式输出、cron 同步),并构造供上层分发的 GatewayEvent。
|
|
9
11
|
*/
|
|
10
12
|
export function handleGatewayEventMessage(msg: { event?: string; payload?: Record<string, unknown>; seq?: number }): GatewayEvent {
|
|
11
13
|
try {
|
|
14
|
+
// 子agent消息输出
|
|
12
15
|
if (msg.event === 'agent') {
|
|
13
16
|
const pl = msg.payload as { runId: string; data?: { delta?: unknown } }
|
|
14
17
|
const sessionKey = getSessionKeyBySubAgentRunId(pl.runId)
|
|
@@ -19,6 +22,7 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
|
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
}
|
|
25
|
+
// 定时任务
|
|
22
26
|
if (msg.event === 'cron') {
|
|
23
27
|
const p = msg.payload
|
|
24
28
|
dcgLogger(`[Gateway] 收到定时任务事件: ${JSON.stringify(p)}`)
|
|
@@ -31,8 +35,13 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
|
|
|
31
35
|
if (p?.action === 'removed') {
|
|
32
36
|
sendDcgchatCron(p?.jobId as string)
|
|
33
37
|
}
|
|
34
|
-
if (p?.action === 'finished') {
|
|
35
|
-
|
|
38
|
+
if (p?.action === 'finished' && p?.status === 'ok') {
|
|
39
|
+
const hasFileOutput = p.delivered === true && p.deliveryStatus === 'delivered'
|
|
40
|
+
let summary = p?.summary as string
|
|
41
|
+
if (summary.indexOf('HEARTBEAT_OK') >= 0 && summary !== 'HEARTBEAT_OK') {
|
|
42
|
+
summary = summary.replace('HEARTBEAT_OK', '')
|
|
43
|
+
}
|
|
44
|
+
finishedDcgchatCron(p?.jobId as string, summary, hasFileOutput)
|
|
36
45
|
}
|
|
37
46
|
}
|
|
38
47
|
} catch (error) {
|
package/src/utils/global.ts
CHANGED
|
@@ -82,11 +82,11 @@ const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
|
|
|
82
82
|
/** 已发送媒体去重:外层 messageId → 内层该会话下已发送的媒体 key(文件名) */
|
|
83
83
|
const sentMediaKeysBySession = new Map<string, Set<string>>()
|
|
84
84
|
|
|
85
|
-
function getSessionMediaSet(
|
|
86
|
-
let set = sentMediaKeysBySession.get(
|
|
85
|
+
function getSessionMediaSet(sessionId: string): Set<string> {
|
|
86
|
+
let set = sentMediaKeysBySession.get(sessionId)
|
|
87
87
|
if (!set) {
|
|
88
88
|
set = new Set<string>()
|
|
89
|
-
sentMediaKeysBySession.set(
|
|
89
|
+
sentMediaKeysBySession.set(sessionId, set)
|
|
90
90
|
}
|
|
91
91
|
return set
|
|
92
92
|
}
|
|
@@ -95,31 +95,48 @@ export function addSentMediaKey(messageId: string, url: string) {
|
|
|
95
95
|
getSessionMediaSet(messageId).add(getMediaKey(url))
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export function hasSentMediaKey(
|
|
99
|
-
return sentMediaKeysBySession.get(
|
|
98
|
+
export function hasSentMediaKey(sessionId: string, url: string): boolean {
|
|
99
|
+
return sentMediaKeysBySession.get(sessionId)?.has(getMediaKey(url)) ?? false
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/** 不传 messageId 时清空全部会话;传入则只清空该会话 */
|
|
103
|
-
export function clearSentMediaKeys(
|
|
104
|
-
if (
|
|
105
|
-
sentMediaKeysBySession.delete(
|
|
103
|
+
export function clearSentMediaKeys(sessionId?: string) {
|
|
104
|
+
if (sessionId) {
|
|
105
|
+
sentMediaKeysBySession.delete(sessionId)
|
|
106
106
|
} else {
|
|
107
107
|
sentMediaKeysBySession.clear()
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
/** 每个 sessionKey 下多个定时任务 messageId,入队在尾、出队在头(FIFO) */
|
|
112
|
+
const cronMessageIdMap = new Map<string, string[]>()
|
|
112
113
|
|
|
113
|
-
export function setCronMessageId(sk: string, messageId: string) {
|
|
114
|
-
|
|
114
|
+
export function setCronMessageId(sk: string, messageId: string | number | null | undefined) {
|
|
115
|
+
const mid = messageId != null && messageId !== '' ? String(messageId).trim() : ''
|
|
116
|
+
if (!sk?.trim() || !mid) return
|
|
117
|
+
let q = cronMessageIdMap.get(sk)
|
|
118
|
+
if (!q) {
|
|
119
|
+
q = []
|
|
120
|
+
cronMessageIdMap.set(sk, q)
|
|
121
|
+
}
|
|
122
|
+
q.push(mid)
|
|
115
123
|
}
|
|
116
124
|
|
|
125
|
+
/** 窥视队首 messageId(不移除),供发送中与 finished 配对使用 */
|
|
117
126
|
export function getCronMessageId(sk: string): string {
|
|
118
|
-
|
|
127
|
+
if (!sk?.trim()) return ''
|
|
128
|
+
return cronMessageIdMap.get(sk)?.[0] ?? ''
|
|
119
129
|
}
|
|
120
130
|
|
|
131
|
+
/** 弹出队首一条,与一次 finished 对应;队列为空时移除 key */
|
|
121
132
|
export function removeCronMessageId(sk: string) {
|
|
122
|
-
|
|
133
|
+
if (!sk?.trim()) return
|
|
134
|
+
const q = cronMessageIdMap.get(sk)
|
|
135
|
+
if (!q?.length) return
|
|
136
|
+
q.shift()
|
|
137
|
+
if (q.length === 0) {
|
|
138
|
+
cronMessageIdMap.delete(sk)
|
|
139
|
+
}
|
|
123
140
|
}
|
|
124
141
|
|
|
125
142
|
export const getSessionKey = (content: any, accountId: string) => {
|