@dcrays/dcgchat 0.2.25 → 0.2.34

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 CHANGED
@@ -1,36 +1,36 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import type {
4
- ClawdbotConfig,
5
- ReplyPayload,
6
- RuntimeEnv,
7
- } from "openclaw/plugin-sdk";
8
- import { createReplyPrefixContext } from "openclaw/plugin-sdk";
9
- import type { InboundMessage, OutboundReply } from "./types.js";
10
- import { getDcgchatRuntime, getWorkspaceDir } from "./runtime.js";
11
- import { resolveAccount, sendDcgchatMedia } from "./channel.js";
12
- import { setMsgStatus } from "./tool.js";
13
- import { generateSignUrl } from "./api.js";
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import type { ReplyPayload } from 'openclaw/plugin-sdk'
4
+ import { createReplyPrefixContext } from 'openclaw/plugin-sdk'
5
+ import type { InboundMessage } from './types.js'
6
+ import { clearSentMediaKeys, getDcgchatRuntime, getOpenClawConfig, getWorkspaceDir, setMsgStatus } from './utils/global.js'
7
+ import { resolveAccount, sendDcgchatMedia } from './channel.js'
8
+ import { generateSignUrl } from './request/api.js'
9
+ import { extractMobookFiles } from './utils/searchFile.js'
10
+ import { createMsgContext, sendChunk, sendFinal, sendText as sendTextMsg, sendError, sendText } from './transport.js'
11
+ import { dcgLogger } from './utils/log.js'
12
+ import { channelInfo, systemCommand, interruptCommand, ENV } from './utils/constant.js'
14
13
 
15
14
  type MediaInfo = {
16
- path: string;
17
- fileName: string;
18
- contentType: string;
19
- placeholder: string;
20
- };
15
+ path: string
16
+ fileName: string
17
+ contentType: string
18
+ placeholder: string
19
+ }
20
+
21
+ type TFileInfo = { name: string; url: string }
21
22
 
22
- const mediaMaxBytes = 300 * 1024 * 1024;
23
+ const mediaMaxBytes = 300 * 1024 * 1024
23
24
 
24
25
  /** Active LLM generation abort controllers, keyed by conversationId */
25
- const activeGenerations = new Map<string, AbortController>();
26
+ const activeGenerations = new Map<string, AbortController>()
26
27
 
27
28
  /** Abort an in-progress LLM generation for a given conversationId */
28
29
  export function abortMobookappGeneration(conversationId: string): void {
29
- const ctrl = activeGenerations.get(conversationId);
30
- console.log("🚀 ~ abortMobookappGeneration ~ ctrl:", ctrl)
30
+ const ctrl = activeGenerations.get(conversationId)
31
31
  if (ctrl) {
32
- ctrl.abort();
33
- activeGenerations.delete(conversationId);
32
+ ctrl.abort()
33
+ activeGenerations.delete(conversationId)
34
34
  }
35
35
  }
36
36
 
@@ -38,92 +38,71 @@ export function abortMobookappGeneration(conversationId: string): void {
38
38
  * Extract agentId from conversation_id formatted as "agentId::suffix".
39
39
  * Returns null if the conversation_id does not contain the "::" separator.
40
40
  */
41
- export function extractAgentIdFromConversationId(
42
- conversationId: string,
43
- ): string | null {
44
- const idx = conversationId.indexOf("::");
45
- if (idx <= 0) return null;
46
- return conversationId.slice(0, idx);
41
+ export function extractAgentIdFromConversationId(conversationId: string): string | null {
42
+ const idx = conversationId.indexOf('::')
43
+ if (idx <= 0) return null
44
+ return conversationId.slice(0, idx)
47
45
  }
48
46
 
49
- async function resolveMediaFromUrls(
50
- files: { name: string; url: string }[],
51
- botToken: string,
52
- log: (message: string) => void,
53
- ): Promise<MediaInfo[]> {
54
- const core = getDcgchatRuntime();
55
- const out: MediaInfo[] = [];
56
- log(
57
- `dcgchat media: starting resolve for ${files.length} file(s): ${JSON.stringify(files)}`,
58
- );
47
+ async function resolveMediaFromUrls(files: TFileInfo[], botToken: string): Promise<MediaInfo[]> {
48
+ const core = getDcgchatRuntime()
49
+ const out: MediaInfo[] = []
50
+ dcgLogger(`media: user upload files: ${JSON.stringify(files)}`)
59
51
 
60
52
  for (let i = 0; i < files.length; i++) {
61
- const file = files[i];
53
+ const file = files[i]
62
54
  try {
63
- let data = "";
55
+ let data = ''
64
56
  if (/^https?:\/\//i.test(file.url)) {
65
- data = file.url;
57
+ data = file.url
66
58
  } else {
67
- data = await generateSignUrl(file.url, botToken);
59
+ data = await generateSignUrl(file.url, botToken)
68
60
  }
69
- log(`dcgchat media: [${i + 1}/${files.length}] generateSignUrl: ${data}`);
70
- const response = await fetch(data);
61
+ dcgLogger(`media: generateSignUrl: ${data}`)
62
+ const response = await fetch(data)
71
63
  if (!response.ok) {
72
- log?.(
73
- `dcgchat media: [${i + 1}/${files.length}] fetch failed with HTTP ${response.status}, skipping`,
74
- );
75
- continue;
64
+ dcgLogger?.(`media: ${file.url} fetch failed with HTTP ${response.status}`, 'error')
65
+ continue
76
66
  }
77
- const buffer = Buffer.from(await response.arrayBuffer());
67
+ const buffer = Buffer.from(await response.arrayBuffer())
78
68
 
79
- let contentType = response.headers.get("content-type") || "";
69
+ let contentType = response.headers.get('content-type') || ''
80
70
  if (!contentType) {
81
- contentType = (await core.media.detectMime({ buffer })) || "";
71
+ contentType = (await core.media.detectMime({ buffer })) || ''
82
72
  }
83
- const fileName =
84
- file.name || path.basename(new URL(file.url).pathname) || "file";
85
- const saved = await core.channel.media.saveMediaBuffer(
86
- buffer,
87
- contentType,
88
- "inbound",
89
- mediaMaxBytes,
90
- fileName,
91
- );
92
- const isImage = contentType.startsWith("image/");
73
+ const fileName = file.name || path.basename(new URL(file.url).pathname) || 'file'
74
+ const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, 'inbound', mediaMaxBytes, fileName)
75
+ const isImage = contentType.startsWith('image/')
93
76
  out.push({
94
77
  path: saved.path,
95
78
  fileName,
96
- contentType: saved.contentType || "",
97
- placeholder: isImage ? "<media:image>" : "<media:file>",
98
- });
79
+ contentType: saved.contentType || '',
80
+ placeholder: isImage ? '<media:image>' : '<media:file>'
81
+ })
99
82
  } catch (err) {
100
- log(
101
- `dcgchat media: [${i + 1}/${files.length}] FAILED to process ${file.url}: ${String(err)}`,
102
- );
83
+ dcgLogger(`media: ${file.url} FAILED to process: ${String(err)}`, 'error')
103
84
  }
104
85
  }
105
- log(
106
- `dcgchat media: resolve complete, ${out.length}/${files.length} file(s) succeeded`,
107
- );
108
-
109
- return out;
86
+ dcgLogger(`media: resolve complete, ${out.length}/${files.length} file(s) succeeded`)
87
+ return out
110
88
  }
111
89
 
112
- function buildMediaPayload(mediaList: MediaInfo[]): {
113
- MediaPath?: string;
114
- MediaFileName?: string;
115
- MediaType?: string;
116
- MediaUrl?: string;
117
- MediaFileNames?: string[];
118
- MediaPaths?: string[];
119
- MediaUrls?: string[];
120
- MediaTypes?: string[];
121
- } {
122
- if (mediaList.length === 0) return {};
123
- const first = mediaList[0];
124
- const mediaFileNames = mediaList.map((m) => m.fileName).filter(Boolean);
125
- const mediaPaths = mediaList.map((m) => m.path);
126
- const mediaTypes = mediaList.map((m) => m.contentType).filter(Boolean);
90
+ type MediaPayload = {
91
+ MediaPath?: string
92
+ MediaFileName?: string
93
+ MediaType?: string
94
+ MediaUrl?: string
95
+ MediaFileNames?: string[]
96
+ MediaPaths?: string[]
97
+ MediaUrls?: string[]
98
+ MediaTypes?: string[]
99
+ }
100
+ function buildMediaPayload(mediaList: MediaInfo[]): MediaPayload {
101
+ if (mediaList.length === 0) return {}
102
+ const first = mediaList[0]
103
+ const mediaFileNames = mediaList.map((m) => m.fileName).filter(Boolean)
104
+ const mediaPaths = mediaList.map((m) => m.path)
105
+ const mediaTypes = mediaList.map((m) => m.contentType).filter(Boolean)
127
106
  return {
128
107
  MediaPath: first?.path,
129
108
  MediaFileName: first?.fileName,
@@ -132,333 +111,92 @@ function buildMediaPayload(mediaList: MediaInfo[]): {
132
111
  MediaFileNames: mediaFileNames.length > 0 ? mediaFileNames : undefined,
133
112
  MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
134
113
  MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
135
- MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
136
- };
114
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined
115
+ }
137
116
  }
138
117
 
139
118
  function resolveReplyMediaList(payload: ReplyPayload): string[] {
140
- if (payload.mediaUrls?.length) return payload.mediaUrls.filter(Boolean);
141
- return payload.mediaUrl ? [payload.mediaUrl] : [];
119
+ if (payload.mediaUrls?.length) return payload.mediaUrls.filter(Boolean)
120
+ return payload.mediaUrl ? [payload.mediaUrl] : []
142
121
  }
143
122
 
144
-
145
- /**
146
- * 从文本中提取 /mobook 目录下的文件
147
- * @param {string} text
148
- * @returns {string[]}
149
- */
150
- const EXT_LIST = [
151
- // 文档类
152
- "doc",
153
- "docx",
154
- "xls",
155
- "xlsx",
156
- "ppt",
157
- "pptx",
158
- "pdf",
159
- "txt",
160
- "rtf",
161
- "odt",
162
-
163
- // 数据/开发
164
- "json",
165
- "xml",
166
- "csv",
167
- "yaml",
168
- "yml",
169
-
170
- // 前端/文本
171
- "html",
172
- "htm",
173
- "md",
174
- "markdown",
175
- "css",
176
- "js",
177
- "ts",
178
-
179
- // 图片
180
- "png",
181
- "jpg",
182
- "jpeg",
183
- "gif",
184
- "bmp",
185
- "webp",
186
- "svg",
187
- "ico",
188
- "tiff",
189
-
190
- // 音频
191
- "mp3",
192
- "wav",
193
- "ogg",
194
- "aac",
195
- "flac",
196
- "m4a",
197
-
198
- // 视频
199
- "mp4",
200
- "avi",
201
- "mov",
202
- "wmv",
203
- "flv",
204
- "mkv",
205
- "webm",
206
-
207
- // 压缩包
208
- "zip",
209
- "rar",
210
- "7z",
211
- "tar",
212
- "gz",
213
- "bz2",
214
- "xz",
215
-
216
- // 可执行/程序
217
- "exe",
218
- "dmg",
219
- "pkg",
220
- "apk",
221
- "ipa",
222
-
223
- // 其他常见
224
- "log",
225
- "dat",
226
- "bin",
227
- ];
228
-
229
123
  /**
230
- * 扩展名按长度降序,用于正则交替,避免 xls 抢先匹配 xlsx、htm 抢先匹配 html 等
124
+ * 处理一条用户消息,调用 Agent 并返回回复
231
125
  */
232
- const EXT_SORTED_FOR_REGEX = [...EXT_LIST].sort((a, b) => b.length - a.length);
126
+ export async function handleDcgchatMessage(msg: InboundMessage, accountId: string): Promise<void> {
127
+ const msgCtx = createMsgContext(msg)
233
128
 
234
- /** 去除控制符、零宽字符等常见脏值 */
235
- function stripMobookNoise(s: string) {
236
- return s.replace(/[\u0000-\u001F\u007F\u200B-\u200D\u200E\u200F\uFEFF]/g, "");
237
- }
129
+ let finalSent = false
238
130
 
239
- /**
240
- * 从文本中扫描 `/mobook/` 片段,按最长后缀匹配合法扩展名(兜底,不依赖 FILE_NAME 字符集)
241
- */
242
- function collectMobookPathsByScan(text: string, result: Set<string>): void {
243
- const lower = text.toLowerCase();
244
- const needle = "/mobook/";
245
- let from = 0;
246
- while (from < text.length) {
247
- const i = lower.indexOf(needle, from);
248
- if (i < 0) break;
249
- const start = i + needle.length;
250
- const tail = text.slice(start);
251
- const seg = tail.match(/^([^\s\]\)'"}\u3002,,]+)/);
252
- if (!seg) {
253
- from = start + 1;
254
- continue;
255
- }
256
- let raw = stripMobookNoise(seg[1]).trim();
257
- if (!raw || raw.includes("\uFFFD")) {
258
- from = start + 1;
259
- continue;
260
- }
261
- const low = raw.toLowerCase();
262
- let matchedExt: string | undefined;
263
- for (const ext of EXT_SORTED_FOR_REGEX) {
264
- if (low.endsWith(`.${ext}`)) {
265
- matchedExt = ext;
266
- break;
267
- }
268
- }
269
- if (!matchedExt) {
270
- from = start + 1;
271
- continue;
272
- }
273
- const base = raw.slice(0, -(matchedExt.length + 1));
274
- const fileName = `${base}.${matchedExt}`;
275
- if (isValidFileName(fileName)) {
276
- result.add(normalizePath(`/mobook/${fileName}`));
277
- }
278
- from = start + 1;
131
+ const safeSendFinal = () => {
132
+ if (finalSent) return
133
+ finalSent = true
134
+ sendFinal(msgCtx)
279
135
  }
280
- }
281
136
 
282
- function extractMobookFiles(text = "") {
283
- if (typeof text !== "string" || !text.trim()) return [];
284
- const result = new Set<string>();
285
- // 扩展名(必须长扩展名优先,见 EXT_SORTED_FOR_REGEX)
286
- const EXT = `(${EXT_SORTED_FOR_REGEX.join("|")})`;
287
- // ✅ 文件名字符(增强:支持中文、符号)
288
- const FILE_NAME = `[\\w\\u4e00-\\u9fa5::《》()()\\-\\s]+?`;
289
- try {
290
- // 1️⃣ `xxx.xxx`
291
- const backtickReg = new RegExp(`\`([^\\\`]+?\\.${EXT})\``, "gi");
292
- (text.match(backtickReg) || []).forEach((item) => {
293
- const name = item.replace(/`/g, "").trim();
294
- if (isValidFileName(name)) {
295
- result.add(`/mobook/${name}`);
296
- }
297
- });
298
- // 2️⃣ /mobook/xxx.xxx
299
- const fullPathReg = new RegExp(`/mobook/${FILE_NAME}\\.${EXT}`, "gi");
300
- (text.match(fullPathReg) || []).forEach((p) => {
301
- result.add(normalizePath(p));
302
- });
303
- // 3️⃣ mobook下的 xxx.xxx
304
- const inlineReg = new RegExp(`mobook下的\\s*(${FILE_NAME}\\.${EXT})`, "gi");
305
- (text.match(inlineReg) || []).forEach((item) => {
306
- const match = item.match(new RegExp(`${FILE_NAME}\\.${EXT}`, "i"));
307
- if (match && isValidFileName(match[0])) {
308
- result.add(`/mobook/${match[0].trim()}`);
309
- }
310
- });
311
- // 🆕 4️⃣ **xxx.xxx**
312
- const boldReg = new RegExp(`\\*\\*(${FILE_NAME}\\.${EXT})\\*\\*`, "gi");
313
- (text.match(boldReg) || []).forEach((item) => {
314
- const name = item.replace(/\*\*/g, "").trim();
315
- if (isValidFileName(name)) {
316
- result.add(`/mobook/${name}`);
317
- }
318
- });
319
- // 🆕 5️⃣ xxx.xxx (123字节)
320
- const looseReg = new RegExp(`(${FILE_NAME}\\.${EXT})\\s*\\(`, "gi");
321
- (text.match(looseReg) || []).forEach((item) => {
322
- const name = item.replace(/\s*\(.+$/, "").trim();
323
- if (isValidFileName(name)) {
324
- result.add(`/mobook/${name}`);
325
- }
326
- });
327
- // 6️⃣ 兜底:绝对路径等 `.../mobook/<文件名>.<扩展名>` + 最长后缀匹配 + 去脏字符
328
- collectMobookPathsByScan(text, result);
329
- } catch (e) {
330
- console.warn("extractMobookFiles error:", e);
137
+ let completeText = ''
138
+ const config = getOpenClawConfig()
139
+ if (!config) {
140
+ dcgLogger('no config available', 'error')
141
+ return
331
142
  }
332
- return [...result];
333
- }
334
-
335
- /**
336
- * 校验文件名是否合法(避免脏数据)
337
- */
338
- function isValidFileName(name: string) {
339
- if (!name) return false;
340
- const cleaned = stripMobookNoise(name).trim();
341
- if (!cleaned) return false;
342
- if (cleaned.includes("\uFFFD")) return false;
343
- // 过滤异常字符
344
- if (/[\/\\<>:"|?*]/.test(cleaned)) return false;
345
- // 长度限制(防止异常长字符串)
346
- if (cleaned.length > 200) return false;
347
- return true;
348
- }
349
-
350
- /**
351
- * 规范路径(去重用)
352
- */
353
- function normalizePath(path: string) {
354
- return path
355
- .replace(/\/+/g, "/") // 多斜杠 → 单斜杠
356
- .replace(/\/$/, ""); // 去掉结尾 /
357
- }
358
-
359
- /**
360
- * 处理一条用户消息,调用 Agent 并返回回复
361
- */
362
- export async function handleDcgchatMessage(params: {
363
- cfg: ClawdbotConfig;
364
- msg: InboundMessage;
365
- accountId: string;
366
- runtime?: RuntimeEnv;
367
- onChunk: (reply: OutboundReply) => void;
368
- }): Promise<void> {
369
- const { cfg, msg, accountId, runtime } = params;
370
- const log = runtime?.log ?? console.log;
371
- const error = runtime?.error ?? console.error;
372
- // 完整的文本
373
- let completeText = "";
374
-
375
- const account = resolveAccount(cfg, accountId);
376
- const userId = msg._userId.toString();
377
- const text = msg.content.text?.trim();
143
+ const account = resolveAccount(config, accountId)
144
+ const userId = msg._userId.toString()
145
+ const text = msg.content.text?.trim()
378
146
 
379
147
  if (!text) {
380
- params.onChunk({
381
- messageType: "openclaw_bot_chat",
382
- _userId: msg._userId,
383
- source: "client",
384
- // @ts-ignore
385
- content: {
386
- bot_token: msg.content.bot_token,
387
- domain_id: msg.content.domain_id,
388
- app_id: msg.content.app_id,
389
- bot_id: msg.content.bot_id,
390
- agent_id: msg.content.agent_id,
391
- session_id: msg.content.session_id,
392
- message_id: msg.content.message_id,
393
- response: "你需要我帮你做什么呢?",
394
- },
395
- });
396
- return;
148
+ sendTextMsg(msgCtx, '你需要我帮你做什么呢?')
149
+ safeSendFinal()
150
+ return
397
151
  }
398
152
 
399
153
  try {
400
- const core = getDcgchatRuntime();
154
+ const core = getDcgchatRuntime()
401
155
 
402
- const conversationId = msg.content.session_id?.trim();
156
+ const conversationId = msg.content.session_id?.trim()
403
157
 
404
158
  const route = core.channel.routing.resolveAgentRoute({
405
- cfg,
159
+ cfg: config,
406
160
  channel: "dcgchat",
407
161
  accountId: account.accountId,
408
- peer: { kind: "direct", id: conversationId },
409
- });
162
+ peer: { kind: 'direct', id: conversationId }
163
+ })
410
164
 
411
165
  // If conversation_id encodes an agentId prefix ("agentId::suffix"), override the route.
412
- const embeddedAgentId = extractAgentIdFromConversationId(conversationId);
413
- const effectiveAgentId = embeddedAgentId ?? route.agentId;
166
+ const embeddedAgentId = extractAgentIdFromConversationId(conversationId)
167
+ const effectiveAgentId = embeddedAgentId ?? route.agentId
414
168
  const effectiveSessionKey = embeddedAgentId
415
169
  ? `agent:${embeddedAgentId}:mobook:direct:${conversationId}`.toLowerCase()
416
- : route.sessionKey;
170
+ : route.sessionKey
417
171
 
418
172
  const agentEntry =
419
- effectiveAgentId && effectiveAgentId !== "main"
420
- ? cfg.agents?.list?.find((a) => a.id === effectiveAgentId)
421
- : undefined;
422
- const agentDisplayName =
423
- agentEntry?.name ??
424
- (effectiveAgentId && effectiveAgentId !== "main"
425
- ? effectiveAgentId
426
- : undefined);
173
+ effectiveAgentId && effectiveAgentId !== 'main' ? config.agents?.list?.find((a) => a.id === effectiveAgentId) : undefined
174
+ const agentDisplayName = agentEntry?.name ?? (effectiveAgentId && effectiveAgentId !== 'main' ? effectiveAgentId : undefined)
427
175
 
428
176
  // Abort any existing generation for this conversation, then start a new one
429
- const existingCtrl = activeGenerations.get(conversationId);
430
- if (existingCtrl) existingCtrl.abort();
431
- const genCtrl = new AbortController();
432
- const genSignal = genCtrl.signal;
433
- activeGenerations.set(conversationId, genCtrl);
177
+ const existingCtrl = activeGenerations.get(conversationId)
178
+ if (existingCtrl) existingCtrl.abort()
179
+ const genCtrl = new AbortController()
180
+ const genSignal = genCtrl.signal
181
+ activeGenerations.set(conversationId, genCtrl)
434
182
 
435
183
  // 处理用户上传的文件
436
- const files = msg.content.files ?? [];
437
- let mediaPayload: Record<string, unknown> = {};
184
+ const files = msg.content.files ?? []
185
+ let mediaPayload: Record<string, unknown> = {}
438
186
  if (files.length > 0) {
439
- const mediaList = await resolveMediaFromUrls(
440
- files,
441
- msg.content.bot_token,
442
- log,
443
- );
444
- mediaPayload = buildMediaPayload(mediaList);
445
- log(
446
- `dcgchat[${accountId}]: media resolved ${mediaList.length}/${files.length} file(s), payload=${JSON.stringify(mediaList)}`,
447
- );
187
+ const mediaList = await resolveMediaFromUrls(files, msg.content.bot_token)
188
+ mediaPayload = buildMediaPayload(mediaList)
448
189
  }
449
190
 
450
- const envelopeOptions =
451
- core.channel.reply.resolveEnvelopeFormatOptions(cfg);
452
- // const messageBody = `${userId}: ${text}`;
453
- // 补充消息
454
- const messageBody = text;
191
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config)
192
+ const messageBody = text
455
193
  const bodyFormatted = core.channel.reply.formatAgentEnvelope({
456
- channel: "书灵墨宝",
194
+ channel: '书灵墨宝',
457
195
  from: userId,
458
196
  timestamp: new Date(),
459
197
  envelope: envelopeOptions,
460
- body: messageBody,
461
- });
198
+ body: messageBody
199
+ })
462
200
 
463
201
  const ctxPayload = core.channel.reply.finalizeInboundContext({
464
202
  Body: bodyFormatted,
@@ -468,260 +206,173 @@ export async function handleDcgchatMessage(params: {
468
206
  To: conversationId,
469
207
  SessionKey: effectiveSessionKey,
470
208
  AccountId: route.accountId,
471
- ChatType: "direct",
209
+ ChatType: 'direct',
472
210
  SenderName: agentDisplayName,
473
211
  SenderId: userId,
474
- Provider: "dcgchat" as const,
475
- Surface: "dcgchat" as const,
212
+ Provider: "dcgchat",
213
+ Surface: "dcgchat",
476
214
  MessageSid: msg.content.message_id,
477
215
  Timestamp: Date.now(),
478
216
  WasMentioned: true,
479
217
  CommandAuthorized: true,
480
- OriginatingChannel: "dcgchat" as const,
218
+ OriginatingChannel: "dcgchat",
481
219
  OriginatingTo: `user:${userId}`,
482
- ...mediaPayload,
483
- });
484
-
485
- log(`dcgchat[${accountId}]: ctxPayload=${JSON.stringify(ctxPayload)}`);
220
+ ...mediaPayload
221
+ })
486
222
 
487
223
  const sentMediaKeys = new Set<string>()
488
224
  const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
489
- let textChunk = ''
225
+ let streamedTextLen = 0
490
226
 
491
227
  const prefixContext = createReplyPrefixContext({
492
- cfg,
493
- agentId: effectiveAgentId ?? "",
228
+ cfg: config,
229
+ agentId: effectiveAgentId ?? '',
494
230
  channel: "dcgchat",
495
- accountId: account.accountId,
496
- });
497
-
498
- const { dispatcher, replyOptions, markDispatchIdle } =
499
- core.channel.reply.createReplyDispatcherWithTyping({
500
- responsePrefix: prefixContext.responsePrefix,
501
- responsePrefixContextProvider:
502
- prefixContext.responsePrefixContextProvider,
503
- humanDelay: core.channel.reply.resolveHumanDelayConfig(
504
- cfg,
505
- route.agentId,
506
- ),
507
- onReplyStart: async () => {},
508
- deliver: async (payload: ReplyPayload) => {
509
- log(
510
- `dcgchat[${accountId}][deliver]: received chunk, text length=${payload.text?.length || 0}`,
511
- );
512
- },
513
- onError: (err: any, info: { kind: any }) => {
514
- error(
515
- `dcgchat[${accountId}] ${info.kind} reply failed: ${String(err)}`,
516
- );
517
- },
518
- onIdle: () => {},
519
- });
231
+ accountId: account.accountId
232
+ })
233
+
234
+ const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = core.channel.reply.createReplyDispatcherWithTyping({
235
+ responsePrefix: prefixContext.responsePrefix,
236
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
237
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
238
+ onReplyStart: async () => {},
239
+ deliver: async (payload: ReplyPayload, info) => {
240
+ const mediaList = resolveReplyMediaList(payload)
241
+ for (const mediaUrl of mediaList) {
242
+ const key = getMediaKey(mediaUrl)
243
+ if (sentMediaKeys.has(key)) continue
244
+ sentMediaKeys.add(key)
245
+ await sendDcgchatMedia({ msgCtx, mediaUrl, text: '' })
246
+ }
247
+ },
248
+ onError: (err: unknown, info: { kind: string }) => {
249
+ safeSendFinal()
250
+ dcgLogger(`${info.kind} reply failed: ${String(err)}`, 'error')
251
+ },
252
+ onIdle: () => {
253
+ safeSendFinal()
254
+ }
255
+ })
520
256
 
521
- let wasAborted = false;
522
- try {
523
- if (text === '/new') {
524
- log(`dcgchat[${accountId}]: skipping agent dispatch for /new`);
525
- await core.channel.reply.dispatchReplyFromConfig({
526
- ctx: ctxPayload,
527
- cfg,
528
- dispatcher,
529
- replyOptions: {
530
- ...replyOptions,
531
- onModelSelected: prefixContext.onModelSelected
532
- },
533
- });
534
- } else {
535
- log(
536
- `dcgchat[${accountId}]: dispatching to agent (session=${route.sessionKey})`,
537
- );
257
+ let wasAborted = false
258
+ try {
259
+ if (systemCommand.includes(text?.trim())) {
260
+ dcgLogger(`dispatching /new`)
538
261
  await core.channel.reply.dispatchReplyFromConfig({
539
262
  ctx: ctxPayload,
540
- cfg,
263
+ cfg: config,
264
+ dispatcher,
265
+ replyOptions: {
266
+ ...replyOptions,
267
+ onModelSelected: prefixContext.onModelSelected
268
+ }
269
+ })
270
+ } else if (interruptCommand.includes(text?.trim())) {
271
+ dcgLogger(`interrupt command: ${text}`)
272
+ abortMobookappGeneration(conversationId)
273
+ safeSendFinal()
274
+ return
275
+ } else {
276
+ dcgLogger(`dispatching to agent (session=${route.sessionKey})`)
277
+ await core.channel.reply.dispatchReplyFromConfig({
278
+ ctx: ctxPayload,
279
+ cfg: config,
541
280
  dispatcher,
542
281
  replyOptions: {
543
282
  ...replyOptions,
544
283
  abortSignal: genSignal,
545
284
  onModelSelected: prefixContext.onModelSelected,
546
285
  onPartialReply: async (payload: ReplyPayload) => {
547
- log(
548
- `dcgchat[${accountId}][deliver]: received chunk, text length=${payload.text?.length || 0}`,
549
- );
286
+ // Accumulate full text
550
287
  if (payload.text) {
551
- completeText = payload.text;
552
- }
553
- const mediaList = resolveReplyMediaList(payload);
554
- if (mediaList.length > 0) {
555
- for (let i = 0; i < mediaList.length; i++) {
556
- const mediaUrl = mediaList[i];
557
- const key = getMediaKey(mediaUrl);
558
- if (sentMediaKeys.has(key)) continue;
559
- sentMediaKeys.add(key);
560
- await sendDcgchatMedia({
561
- cfg,
562
- accountId,
563
- log,
564
- mediaUrl,
565
- text: "",
566
- });
567
- }
568
- log(
569
- `dcgchat[${accountId}][deliver]: sent ${mediaList.length} media file(s) through channel adapter`,
570
- );
288
+ completeText = payload.text
571
289
  }
290
+ // --- Streaming text chunks ---
572
291
  if (payload.text) {
573
- const nextTextChunk = payload.text.replace(textChunk, "");
574
- if (nextTextChunk.trim()) {
575
- log(
576
- `dcgchat[${accountId}][deliver]: sending chunk to user ${msg._userId}, text="${nextTextChunk.slice(0, 50)}..."`,
577
- );
578
- params.onChunk({
579
- messageType: "openclaw_bot_chat",
580
- _userId: msg._userId,
581
- source: "client",
582
- content: {
583
- bot_token: msg.content.bot_token,
584
- domain_id: msg.content.domain_id,
585
- app_id: msg.content.app_id,
586
- bot_id: msg.content.bot_id,
587
- agent_id: msg.content.agent_id,
588
- session_id: msg.content.session_id,
589
- message_id: msg.content.message_id,
590
- response: nextTextChunk,
591
- state: "chunk",
592
- },
593
- });
594
- log(
595
- `dcgchat[${accountId}][deliver]: chunk sent successfully`,
596
- );
292
+ const delta = payload.text.startsWith(completeText.slice(0, streamedTextLen))
293
+ ? payload.text.slice(streamedTextLen)
294
+ : payload.text
295
+ if (delta.trim()) {
296
+ sendChunk(msgCtx, delta)
297
+ dcgLogger(`[stream]: chunk ${delta.length} chars to user ${msg._userId} ${delta.slice(0, 100)}`)
597
298
  }
598
- textChunk = payload.text;
599
- } else {
600
- log(`dcgchat[${accountId}][deliver]: skipping empty chunk`);
299
+ streamedTextLen = payload.text.length
300
+ }
301
+ // --- Media from payload ---
302
+ const mediaList = resolveReplyMediaList(payload)
303
+ for (const mediaUrl of mediaList) {
304
+ const key = getMediaKey(mediaUrl)
305
+ if (sentMediaKeys.has(key)) continue
306
+ sentMediaKeys.add(key)
307
+ await sendDcgchatMedia({ msgCtx, mediaUrl, text: '' })
601
308
  }
602
- },
603
- },
604
- });
309
+ }
310
+ }
311
+ })
605
312
  }
606
- } catch (err: unknown) {
607
- if (genSignal.aborted) {
608
- wasAborted = true;
609
- log(
610
- `dcgchat[${accountId}]: generation aborted for conversationId=${conversationId}`,
611
- );
612
- } else if (err instanceof Error && err.name === "AbortError") {
613
- wasAborted = true;
614
- log(
615
- `dcgchat[${accountId}]: generation aborted by session for conversationId=${conversationId}`,
616
- );
617
- } else {
618
- error(
619
- `dcgchat[${accountId}]: dispatchReplyFromConfig error: ${String(err)}`,
620
- );
621
- }
622
- } finally {
623
- if (activeGenerations.get(conversationId) === genCtrl) {
624
- activeGenerations.delete(conversationId);
625
- }
626
- if (wasAborted) {
627
- //TODO:是否需要发消息给移动端,通知停止生成
628
- }
313
+ } catch (err: unknown) {
314
+ if (genSignal.aborted) {
315
+ wasAborted = true
316
+ dcgLogger(` generation aborted for conversationId=${conversationId}`)
317
+ } else if (err instanceof Error && err.name === 'AbortError') {
318
+ wasAborted = true
319
+ dcgLogger(` generation aborted for conversationId=${conversationId}`)
320
+ } else {
321
+ dcgLogger(` dispatchReplyFromConfig error: ${String(err)}`, 'error')
629
322
  }
630
-
631
- const mobookFiles = extractMobookFiles(completeText);
632
- if (mobookFiles.length > 0) {
633
- for (let i = 0; i < mobookFiles.length; i++) {
634
- let url = mobookFiles[i] as string;
635
- const key = getMediaKey(url);
636
- if (sentMediaKeys.has(key)) {
637
- log(
638
- `dcgchat[${accountId}]: mobookFiles already sent, skipping: ${url}`,
639
- );
640
- continue;
641
- }
642
- if (!fs.existsSync(url)) {
643
- url = path.join(getWorkspaceDir(), url);
644
- if (!fs.existsSync(url)) {
645
- log(
646
- `dcgchat[${accountId}]: mobookFiles file not found, skipping: ${url}`,
647
- );
648
- continue;
323
+ } finally {
324
+ if (activeGenerations.get(conversationId) === genCtrl) {
325
+ activeGenerations.delete(conversationId)
326
+ }
327
+ }
328
+ try {
329
+ markRunComplete()
330
+ markDispatchIdle()
331
+ } catch (err) {
332
+ dcgLogger(` markRunComplete||markRunComplete error: ${String(err)}`, 'error')
333
+ }
334
+ if (![...systemCommand, ...interruptCommand].includes(text?.trim())) {
335
+ for (const file of extractMobookFiles(completeText)) {
336
+ const candidates: string[] = [file]
337
+ candidates.push(path.join(getWorkspaceDir(), file))
338
+ candidates.push(path.join(getWorkspaceDir(), file.replace(/^\//, '')))
339
+ if (process.platform === 'win32') {
340
+ const underMobook = file.replace(/^\/mobook\//i, '').replace(/^mobook[\\/]/i, '')
341
+ if (underMobook) {
342
+ candidates.push(path.join('C:\\', 'mobook', underMobook))
649
343
  }
650
344
  }
651
- sentMediaKeys.add(key);
652
- await sendDcgchatMedia({
653
- cfg,
654
- accountId,
655
- log,
656
- mediaUrl: url,
657
- text: "",
658
- });
345
+ const resolved = candidates.find((p) => fs.existsSync(p))
346
+ if (!resolved) continue
347
+ try {
348
+ await sendDcgchatMedia({ msgCtx, mediaUrl: resolved, text: '' })
349
+ } catch (err) {
350
+ dcgLogger(` sendDcgchatMedia error: ${String(err)}`, 'error')
351
+ }
659
352
  }
660
- log(
661
- `dcgchat[${accountId}][deliver]: sent ${mobookFiles.length} media file(s) through channel adapter`,
662
- );
663
353
  }
664
- log(`dcgchat[${accountId}]: dispatch complete, sending final state`);
665
- params.onChunk({
666
- messageType: "openclaw_bot_chat",
667
- _userId: msg._userId,
668
- source: "client",
669
- content: {
670
- bot_token: msg.content.bot_token,
671
- domain_id: msg.content.domain_id,
672
- app_id: msg.content.app_id,
673
- bot_id: msg.content.bot_id,
674
- agent_id: msg.content.agent_id,
675
- session_id: msg.content.session_id,
676
- message_id: msg.content.message_id,
677
- response: "",
678
- state: "final",
679
- },
680
- });
681
-
682
- setMsgStatus("finished");
683
- textChunk = "";
684
- log(`dcgchat[${accountId}]: final state sent`);
685
-
686
- markDispatchIdle();
687
- log(`dcgchat[${accountId}]: message handling complete`);
354
+ safeSendFinal()
355
+ clearSentMediaKeys(msg.content.message_id)
356
+ setMsgStatus('finished')
688
357
 
689
358
  // Record session metadata
690
- const storePath = core.channel.session.resolveStorePath(cfg.session?.store);
359
+ const storePath = core.channel.session.resolveStorePath(config.session?.store)
691
360
  core.channel.session
692
361
  .recordInboundSession({
693
362
  storePath,
694
363
  sessionKey: effectiveSessionKey,
695
364
  ctx: ctxPayload,
696
365
  onRecordError: (err) => {
697
- log(
698
- `easyclawapp[${account.accountId}]: session record error: ${String(err)}`,
699
- );
700
- },
366
+ dcgLogger(` session record error: ${String(err)}`, 'error')
367
+ }
701
368
  })
702
369
  .catch((err: unknown) => {
703
- log(
704
- `easyclawapp[${account.accountId}]: recordInboundSession failed: ${String(err)}`,
705
- );
706
- });
370
+ dcgLogger(` recordInboundSession failed: ${String(err)}`, 'error')
371
+ })
707
372
  } catch (err) {
708
- error(`dcgchat[${accountId}]: handle message failed: ${String(err)}`);
709
- params.onChunk({
710
- messageType: "openclaw_bot_chat",
711
- _userId: msg._userId,
712
- source: "client",
713
- content: {
714
- bot_token: msg.content.bot_token,
715
- domain_id: msg.content.domain_id,
716
- app_id: msg.content.app_id,
717
- bot_id: msg.content.bot_id,
718
- agent_id: msg.content.agent_id,
719
- session_id: msg.content.session_id,
720
- message_id: msg.content.message_id,
721
- response: `[错误] ${err instanceof Error ? err.message : String(err)}`,
722
- state: "final",
723
- },
724
- });
373
+ dcgLogger(` handle message failed: ${String(err)}`, 'error')
374
+ sendError(msgCtx, err instanceof Error ? err.message : String(err))
375
+ } finally {
376
+ safeSendFinal()
725
377
  }
726
378
  }
727
-