@dcrays/dcgchat 0.4.29 → 0.5.1

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/src/bot.ts DELETED
@@ -1,500 +0,0 @@
1
- import { randomUUID } from 'node:crypto'
2
- import path from 'node:path'
3
- import type { ReplyPayload } from 'openclaw/plugin-sdk'
4
- import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk'
5
- import type { InboundMessage } from './types.js'
6
- import {
7
- clearSentMediaKeys,
8
- getDcgchatRuntime,
9
- getOpenClawConfig,
10
- getSessionKey,
11
- getWorkspaceDir,
12
- setMsgStatus
13
- } from './utils/global.js'
14
- import { normalizeOutboundMediaPaths, resolveAccount, sendDcgchatMedia } from './channel.js'
15
- import { generateSignUrl } from './request/api.js'
16
- import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
17
- import { dcgLogger } from './utils/log.js'
18
- import { channelInfo, systemCommand, stopCommand, ENV, ignoreToolCommand } from './utils/constant.js'
19
- import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
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'
32
-
33
- type MediaInfo = {
34
- path: string
35
- fileName: string
36
- contentType: string
37
- placeholder: string
38
- }
39
-
40
- type TFileInfo = { name: string; url: string }
41
-
42
- const mediaMaxBytes = 300 * 1024 * 1024
43
-
44
- /**
45
- * 每条入站消息(含 /stop)递增。用于丢弃「已被后续入站抢占」的处理在 dispatch 结束后的尾部逻辑。
46
- * 仅 /stop 时的 epoch 无法覆盖「新用户消息抢占上一轮」的并发 WS 场景,会导致旧 handler 仍执行 sendFinal(end)、
47
- * 而新消息被误判为 stale;且上一轮若未对网关 chat.abort,服务端 run 会继续,表现为「新消息秒结束、旧回复复活」。
48
- */
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
- }
56
-
57
- /** 各 sessionKey 当前轮回复的流式分片序号(仅统计实际下发的文本 chunk);每轮非打断消息开始时置 0 */
58
- const streamChunkIdxBySessionKey = new Map<string, number>()
59
-
60
- export function extractAgentIdFromConversationId(conversationId: string): string | null {
61
- const idx = conversationId.indexOf('::')
62
- if (idx <= 0) return null
63
- return conversationId.slice(0, idx)
64
- }
65
- function formatText(text: string): string {
66
- if (!text) return ''
67
- const str = String(text).replace(/\s/g, '')
68
- if (!str) return ''
69
- if (str.length <= 50) {
70
- return str
71
- }
72
- return str.slice(0, 25) + `...[此处省略${str.length - 50}字]....` + str.slice(-25)
73
- }
74
- async function resolveMediaFromUrls(files: TFileInfo[], botToken: string): Promise<MediaInfo[]> {
75
- const core = getDcgchatRuntime()
76
- const out: MediaInfo[] = []
77
- dcgLogger(`media: user upload files: ${JSON.stringify(files)}`)
78
-
79
- for (let i = 0; i < files.length; i++) {
80
- const file = files[i]
81
- try {
82
- let data = ''
83
- if (/^https?:\/\//i.test(file.url)) {
84
- data = file.url
85
- } else {
86
- data = await generateSignUrl(file.url, botToken)
87
- }
88
- dcgLogger(`media: generateSignUrl: ${data}`)
89
- const response = await fetch(data)
90
- if (!response.ok) {
91
- dcgLogger?.(`media: ${file.url} fetch failed with HTTP ${response.status}`, 'error')
92
- continue
93
- }
94
- const buffer = Buffer.from(await response.arrayBuffer())
95
-
96
- let contentType = response.headers.get('content-type') || ''
97
- if (!contentType) {
98
- contentType = (await core.media.detectMime({ buffer })) || ''
99
- }
100
- const fileName = file.name || path.basename(new URL(file.url).pathname) || 'file'
101
- const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, 'inbound', mediaMaxBytes, fileName)
102
- const isImage = contentType.startsWith('image/')
103
- out.push({
104
- path: saved.path,
105
- fileName,
106
- contentType: saved.contentType || '',
107
- placeholder: isImage ? '<media:image>' : '<media:file>'
108
- })
109
- } catch (err) {
110
- dcgLogger(`media: ${file.url} FAILED to process: ${String(err)}`, 'error')
111
- }
112
- }
113
- dcgLogger(`media: resolve complete, ${out.length}/${files.length} file(s) succeeded`)
114
- return out
115
- }
116
-
117
- type MediaPayload = {
118
- MediaPath?: string
119
- MediaFileName?: string
120
- MediaType?: string
121
- MediaUrl?: string
122
- MediaFileNames?: string[]
123
- MediaPaths?: string[]
124
- MediaUrls?: string[]
125
- MediaTypes?: string[]
126
- }
127
- function buildMediaPayload(mediaList: MediaInfo[]): MediaPayload {
128
- if (mediaList.length === 0) return {}
129
- const first = mediaList[0]
130
- const mediaFileNames = mediaList.map((m) => m.fileName).filter(Boolean)
131
- const mediaPaths = mediaList.map((m) => m.path)
132
- const mediaTypes = mediaList.map((m) => m.contentType).filter(Boolean)
133
- return {
134
- MediaPath: first?.path,
135
- MediaFileName: first?.fileName,
136
- MediaType: first?.contentType,
137
- MediaUrl: first?.path,
138
- MediaFileNames: mediaFileNames.length > 0 ? mediaFileNames : undefined,
139
- MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
140
- MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
141
- MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined
142
- }
143
- }
144
-
145
- function resolveReplyMediaList(payload: ReplyPayload): string[] {
146
- const p = payload as { mediaUrls?: unknown[]; mediaUrl?: unknown }
147
- if (p.mediaUrls != null && Array.isArray(p.mediaUrls) && p.mediaUrls.length > 0) {
148
- return normalizeOutboundMediaPaths(p.mediaUrls)
149
- }
150
- return normalizeOutboundMediaPaths(p.mediaUrl ?? null)
151
- }
152
-
153
- const typingCallbacks = createTypingCallbacks({
154
- start: async () => {},
155
- onStartError: (err) => {
156
- dcgLogger(`typing start error: ${String(err)}`, 'error')
157
- }
158
- })
159
-
160
- /**
161
- * 处理一条用户消息,调用 Agent 并返回回复(同会话串行见 sessionTermination.runInboundTurnSequenced)。
162
- */
163
- export async function handleDcgchatMessage(msg: InboundMessage, accountId: string): Promise<void> {
164
- const config = getOpenClawConfig()
165
- if (!config) {
166
- dcgLogger('no config available', 'error')
167
- return
168
- }
169
- const account = resolveAccount(config, accountId)
170
- const queueSessionKey = getSessionKey(msg.content, account.accountId)
171
- const queueText = (msg.content.text ?? '').trim()
172
- if (stopCommand.includes(queueText)) {
173
- preemptInboundQueueForStop(queueSessionKey)
174
- }
175
- await runInboundTurnSequenced(queueSessionKey, () => handleDcgchatMessageInboundTurn(msg, accountId))
176
- }
177
-
178
- async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: string): Promise<void> {
179
- const config = getOpenClawConfig()
180
- if (!config) {
181
- return
182
- }
183
- const account = resolveAccount(config, accountId)
184
- const userId = msg._userId.toString()
185
-
186
- const core = getDcgchatRuntime()
187
-
188
- const conversationId = msg.content.session_id?.trim()
189
- const real_mobook = msg.content.real_mobook?.toString().trim()
190
-
191
- const route = core.channel.routing.resolveAgentRoute({
192
- cfg: config,
193
- channel: "dcgchat",
194
- accountId: account.accountId,
195
- peer: { kind: 'direct', id: conversationId }
196
- })
197
-
198
- const embeddedAgentId = extractAgentIdFromConversationId(conversationId)
199
-
200
- const effectiveAgentId = embeddedAgentId ?? route.agentId
201
- const dcgSessionKey = getSessionKey(msg.content, account.accountId)
202
- const inboundGenAtStart = bumpInboundGeneration(dcgSessionKey)
203
-
204
- const mergedParams = {
205
- userId: msg._userId,
206
- botToken: msg.content.bot_token,
207
- sessionId: conversationId,
208
- messageId: msg.content.message_id,
209
- domainId: msg.content.domain_id,
210
- appId: config.channels?.["dcgchat"]?.appId || 100,
211
- botId: msg.content.bot_id ?? '',
212
- agentId: msg.content.agent_id ?? '',
213
- sessionKey: dcgSessionKey,
214
- real_mobook
215
- }
216
- /** 写入本条消息参数前快照:流式/abort 的 final 须对齐「上一轮」触发的对话 messageId,而非打断指令本身 */
217
- const priorOutboundCtx = getEffectiveMsgParams(dcgSessionKey)
218
- setParamsMessage(dcgSessionKey, mergedParams)
219
- dcgLogger(`target alias bound: aliasTarget=${userId} -> sessionKey=${dcgSessionKey}`)
220
- const outboundCtx = getEffectiveMsgParams(dcgSessionKey)
221
- const agentEntry =
222
- effectiveAgentId && effectiveAgentId !== 'main' ? config.agents?.list?.find((a) => a.id === effectiveAgentId) : undefined
223
- const agentDisplayName = agentEntry?.name ?? (effectiveAgentId && effectiveAgentId !== 'main' ? effectiveAgentId : undefined)
224
-
225
- let text = msg.content.text?.trim()
226
-
227
- if (!text) {
228
- sendTextMsg('你需要我帮你做什么呢?', outboundCtx)
229
- sendFinal(outboundCtx, 'not text')
230
- setMsgStatus(dcgSessionKey, 'finished')
231
- return
232
- }
233
-
234
- try {
235
- if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code && !ignoreToolCommand.includes(text?.trim())) {
236
- const workspaceDir = getWorkspaceDir()
237
- const skill = msg.content.skills_scope[0]
238
- const skillDir = `${workspaceDir}/skills/${skill.skill_code}`
239
- const skillText = `用户选择使用此技能:"${skill.skill_code}",技能路径是:"${skillDir}",在目录下查找并`
240
- text = skill.skill_code ? `${skillText} ${text}` : text
241
- dcgLogger(`skill: text: ${text}`)
242
- }
243
- // 处理用户上传的文件
244
- const files = msg.content.files ?? []
245
- let mediaPayload: Record<string, unknown> = {}
246
- if (files.length > 0) {
247
- const mediaList = await resolveMediaFromUrls(files, msg.content.bot_token)
248
- mediaPayload = buildMediaPayload(mediaList)
249
- }
250
-
251
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config)
252
- const messageBody = text
253
- const bodyFormatted = core.channel.reply.formatAgentEnvelope({
254
- channel: '书灵墨宝',
255
- from: userId,
256
- timestamp: new Date(),
257
- envelope: envelopeOptions,
258
- body: messageBody
259
- })
260
-
261
- const ctxPayload = core.channel.reply.finalizeInboundContext({
262
- Body: bodyFormatted,
263
- RawBody: text,
264
- CommandBody: text,
265
- From: userId,
266
- To: dcgSessionKey,
267
- SessionKey: dcgSessionKey,
268
- AccountId: route.accountId,
269
- ChatType: 'direct',
270
- SenderName: agentDisplayName,
271
- SenderId: userId,
272
- Provider: "dcgchat",
273
- Surface: "dcgchat",
274
- MessageSid: msg.content.message_id,
275
- Timestamp: Date.now(),
276
- WasMentioned: true,
277
- CommandAuthorized: true,
278
- OriginatingChannel: "dcgchat",
279
- OriginatingTo: dcgSessionKey,
280
- Target: dcgSessionKey,
281
- SourceTarget: dcgSessionKey,
282
- ...mediaPayload
283
- })
284
-
285
- const sentMediaKeys = new Set<string>()
286
- const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
287
- /** 与 Feishu snapshot 模式一致:payload.text 为当前轮助手全文快照,据此算增量,避免工具前后快照变短或非单调时丢字 */
288
- let lastStreamSnapshot = ''
289
-
290
- if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code && !ignoreToolCommand.includes(text?.trim())) {
291
- const workspaceDir = getWorkspaceDir()
292
- const skill = msg.content.skills_scope[0]
293
- const skillDir = `${workspaceDir}/skills/${skill.skill_code}`
294
- const skillText = `用户选择使用此技能:"${skill.skill_code}",技能路径是:"${skillDir}",在目录下查找并`
295
- text = skill.skill_code ? `${skillText} ${text}` : text
296
- dcgLogger(`skill: text: ${text}`)
297
- }
298
- const prefixContext = createReplyPrefixContext({
299
- cfg: config,
300
- agentId: effectiveAgentId ?? '',
301
- channel: "dcgchat",
302
- accountId: account.accountId
303
- })
304
-
305
- const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
306
- responsePrefix: prefixContext.responsePrefix,
307
- responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
308
- humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
309
- onReplyStart: async () => {},
310
- deliver: async (payload: ReplyPayload, info) => {
311
- if (isSessionStreamSuppressed(dcgSessionKey)) return
312
- const mediaList = resolveReplyMediaList(payload)
313
- for (const mediaUrl of mediaList) {
314
- const key = getMediaKey(mediaUrl)
315
- if (sentMediaKeys.has(key)) continue
316
- sentMediaKeys.add(key)
317
- await sendDcgchatMedia({ sessionKey: dcgSessionKey, mediaUrl, text: '' })
318
- }
319
- dcgLogger(`[deliver]: len=${payload?.text?.length} sessionId=${outboundCtx.sessionId} ${formatText(payload?.text ?? '')}`)
320
- },
321
- onError: (err: unknown, info: { kind: string }) => {
322
- setMsgStatus(dcgSessionKey, 'finished')
323
- sendFinal(outboundCtx, 'error')
324
- clearActiveRunIdForSession(dcgSessionKey)
325
- streamChunkIdxBySessionKey.delete(dcgSessionKey)
326
- const suppressed = isSessionStreamSuppressed(dcgSessionKey)
327
- dcgLogger(`${info.kind} reply failed${suppressed ? ' (stream suppressed)' : ''}: ${String(err)}`, 'error')
328
- },
329
- onIdle: () => {
330
- typingCallbacks.onIdle?.()
331
- }
332
- })
333
-
334
- let dispatchAbort: AbortController | undefined
335
- try {
336
- if (!stopCommand.includes(text?.trim())) {
337
- streamChunkIdxBySessionKey.set(dcgSessionKey, 0)
338
- dispatchAbort = await beginSupersedingUserTurn(dcgSessionKey)
339
- }
340
-
341
- if (systemCommand.includes(text?.trim())) {
342
- dcgLogger(`dispatching ${text?.trim()}`)
343
- await core.channel.reply.withReplyDispatcher({
344
- dispatcher,
345
- onSettled: () => markDispatchIdle(),
346
- run: () =>
347
- core.channel.reply.dispatchReplyFromConfig({
348
- ctx: ctxPayload,
349
- cfg: config,
350
- dispatcher,
351
- replyOptions: {
352
- ...replyOptions,
353
- abortSignal: dispatchAbort!.signal,
354
- onModelSelected: prefixContext.onModelSelected,
355
- onAgentRunStart: (runId) => {
356
- setActiveRunIdForSession(dcgSessionKey, runId)
357
- }
358
- }
359
- })
360
- })
361
- } else if (stopCommand.includes(text?.trim())) {
362
- const ctxForAbort = priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim() ? priorOutboundCtx : outboundCtx
363
- await interruptLocalDispatchAndGateway(dcgSessionKey, ctxForAbort)
364
- streamChunkIdxBySessionKey.delete(dcgSessionKey)
365
- clearSentMediaKeys(msg.content.message_id)
366
- setMsgStatus(dcgSessionKey, 'finished')
367
- clearParamsMessage(dcgSessionKey)
368
- sendText('会话已终止', outboundCtx)
369
- sendFinal(outboundCtx, 'stop')
370
- return
371
- } else {
372
- const params = getEffectiveMsgParams(dcgSessionKey)
373
- if (!ignoreToolCommand.includes(text?.trim())) {
374
- wsSendRaw(params, {
375
- is_finish: -1,
376
- tool_call_id: Date.now().toString(),
377
- is_cover: 0,
378
- thinking_content: JSON.stringify({
379
- type: 'message_received',
380
- specialIdentification: 'dcgchat_tool_call_special_identification',
381
- toolName: '',
382
- callId: Date.now().toString(),
383
- params: ''
384
- }),
385
- response: ''
386
- })
387
- }
388
- await core.channel.reply.withReplyDispatcher({
389
- dispatcher,
390
- onSettled: () => markDispatchIdle(),
391
- run: () =>
392
- core.channel.reply.dispatchReplyFromConfig({
393
- ctx: ctxPayload,
394
- cfg: config,
395
- dispatcher,
396
- replyOptions: {
397
- ...replyOptions,
398
- abortSignal: dispatchAbort!.signal,
399
- onModelSelected: prefixContext.onModelSelected,
400
- onAgentRunStart: (runId) => {
401
- setActiveRunIdForSession(dcgSessionKey, runId)
402
- },
403
- onPartialReply: async (payload: ReplyPayload) => {
404
- if (isSessionStreamSuppressed(dcgSessionKey)) return
405
-
406
- // --- Streaming text chunks ---
407
- if (payload.text) {
408
- const t = payload.text
409
- let delta = ''
410
- if (t.startsWith(lastStreamSnapshot)) {
411
- delta = t.slice(lastStreamSnapshot.length)
412
- lastStreamSnapshot = t
413
- } else if (lastStreamSnapshot.startsWith(t)) {
414
- // 快照缩短(模型修订等):不重复下发
415
- } else {
416
- // 与上一轮快照不衔接(常见于工具后快照从新的助手片段重新开始):整段下发
417
- delta = t
418
- lastStreamSnapshot = t
419
- }
420
- if (delta.trim()) {
421
- const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
422
- streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
423
- sendChunk(delta, outboundCtx, prev)
424
- }
425
- } else {
426
- dcgLogger(`onPartialReply no text (media/tool metadata): ${JSON.stringify(payload)}`)
427
- }
428
- // --- Media from payload ---
429
- const mediaList = resolveReplyMediaList(payload)
430
- for (const mediaUrl of mediaList) {
431
- const key = getMediaKey(mediaUrl)
432
- if (sentMediaKeys.has(key)) continue
433
- sentMediaKeys.add(key)
434
- await sendDcgchatMedia({ sessionKey: dcgSessionKey, mediaUrl, text: '' })
435
- }
436
- }
437
- }
438
- })
439
- })
440
- }
441
- } catch (err: unknown) {
442
- dcgLogger(`handleDcgchatMessage error: ${String(err)}`, 'error')
443
- if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
444
- setMsgStatus(dcgSessionKey, 'finished')
445
- }
446
- } finally {
447
- releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
448
- }
449
-
450
- const inboundGenNow = inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0
451
- if (inboundGenNow !== inboundGenAtStart) {
452
- dcgLogger(`skip post-reply tail: sessionKey=${dcgSessionKey} (stale handler: inbound gen ${inboundGenAtStart}→${inboundGenNow})`)
453
- return
454
- }
455
-
456
- if (![...systemCommand, ...stopCommand].includes(text?.trim())) {
457
- if (isSessionStreamSuppressed(dcgSessionKey)) {
458
- clearSessionStreamSuppression(dcgSessionKey)
459
- }
460
- }
461
- clearSentMediaKeys(msg.content.message_id)
462
- const storePath = core.channel.session.resolveStorePath(config.session?.store)
463
- await waitUntilSubagentsIdle(dcgSessionKey, { timeoutMs: 600_000 })
464
- // 等待子 agent 期间可能已有新入站消息(generation 已变),不能再 end/finished,否则会误结束新轮或放行错挂的正文。
465
- if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) {
466
- dcgLogger(
467
- `skip post-wait tail: sessionKey=${dcgSessionKey} (stale handler after subagent wait: inbound gen ${inboundGenAtStart}→${inboundGenerationBySessionKey.get(dcgSessionKey)})`
468
- )
469
- return
470
- }
471
- clearActiveRunIdForSession(dcgSessionKey)
472
- setMsgStatus(dcgSessionKey, 'finished')
473
- sendFinal(outboundCtx, 'end')
474
- dcgLogger(`record session route: updateLastRoute.to=${dcgSessionKey}, accountId=${route.accountId}`)
475
- core.channel.session
476
- .recordInboundSession({
477
- storePath,
478
- sessionKey: dcgSessionKey,
479
- ctx: ctxPayload,
480
- updateLastRoute: {
481
- sessionKey: dcgSessionKey,
482
- channel: "dcgchat",
483
- to: dcgSessionKey,
484
- accountId: route.accountId
485
- },
486
- onRecordError: (err) => {
487
- dcgLogger(` session record error: ${String(err)}`, 'error')
488
- }
489
- })
490
- .catch((err: unknown) => {
491
- dcgLogger(` recordInboundSession failed: ${String(err)}`, 'error')
492
- })
493
- } catch (err) {
494
- dcgLogger(` handle message failed: ${String(err)}`, 'error')
495
- if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
496
- setMsgStatus(dcgSessionKey, 'finished')
497
- }
498
- sendError(err instanceof Error ? err.message : String(err), outboundCtx)
499
- }
500
- }