@tencent-connect/openclaw-qqbot 1.6.4-alpha.5 → 1.6.4-alpha.6

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.
@@ -314,10 +314,12 @@ function markStartupGreetingSent(version) {
314
314
  }
315
315
  function markStartupGreetingFailed(version, reason) {
316
316
  const marker = readStartupMarker();
317
+ // 同版本已有失败记录时,不覆盖 lastFailureAt,避免冷却期被无限续期
318
+ const shouldPreserveTimestamp = marker.lastFailureVersion === version && marker.lastFailureAt;
317
319
  writeStartupMarker({
318
320
  ...marker,
319
321
  lastFailureVersion: version,
320
- lastFailureAt: new Date().toISOString(),
322
+ lastFailureAt: shouldPreserveTimestamp ? marker.lastFailureAt : new Date().toISOString(),
321
323
  lastFailureReason: reason,
322
324
  });
323
325
  }
@@ -435,8 +437,13 @@ export async function startGateway(ctx) {
435
437
  try {
436
438
  if (fs.existsSync(UPGRADE_GREETING_TARGET_FILE)) {
437
439
  const data = JSON.parse(fs.readFileSync(UPGRADE_GREETING_TARGET_FILE, "utf8"));
438
- if (data.openid)
439
- return data.openid;
440
+ if (!data.openid)
441
+ return undefined;
442
+ if (data.appId && data.appId !== account.appId)
443
+ return undefined;
444
+ if (data.accountId && data.accountId !== account.accountId)
445
+ return undefined;
446
+ return data.openid;
440
447
  }
441
448
  }
442
449
  catch { /* 文件损坏视为无 */ }
@@ -590,11 +597,12 @@ export async function startGateway(ctx) {
590
597
  finally {
591
598
  activeUsers.delete(peerId);
592
599
  userQueues.delete(peerId);
593
- // 处理完后,检查是否有等待并发槽位的用户
600
+ // 处理完后,唤醒等待的用户填满并发槽位
594
601
  for (const [waitingPeerId, waitingQueue] of userQueues) {
602
+ if (activeUsers.size >= MAX_CONCURRENT_USERS)
603
+ break; // 槽位已满
595
604
  if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) {
596
605
  drainUserQueue(waitingPeerId);
597
- break; // 每次只唤醒一个,避免瞬间并发激增
598
606
  }
599
607
  }
600
608
  }
@@ -777,33 +785,35 @@ export async function startGateway(ctx) {
777
785
  accountId: account.accountId,
778
786
  direction: "inbound",
779
787
  });
780
- // 发送输入状态提示(非关键,失败不影响主流程)
781
- // 同时从响应中获取 ref_idx,用于缓存入站消息
782
- let inputNotifyRefIdx;
783
- try {
784
- let token = await getAccessToken(account.appId, account.clientSecret);
788
+ // 发送输入状态提示(fire-and-forget:不阻塞主流程)
789
+ // refIdx 通过 Promise 延迟获取,在真正需要时再 await
790
+ const inputNotifyPromise = (async () => {
785
791
  try {
786
- const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
787
- inputNotifyRefIdx = notifyResponse.refIdx;
788
- }
789
- catch (notifyErr) {
790
- const errMsg = String(notifyErr);
791
- if (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")) {
792
- log?.info(`[qqbot:${account.accountId}] InputNotify token expired, refreshing...`);
793
- clearTokenCache(account.appId);
794
- token = await getAccessToken(account.appId, account.clientSecret);
792
+ let token = await getAccessToken(account.appId, account.clientSecret);
793
+ try {
795
794
  const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
796
- inputNotifyRefIdx = notifyResponse.refIdx;
795
+ log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}${notifyResponse.refIdx ? `, got refIdx=${notifyResponse.refIdx}` : ""}`);
796
+ return notifyResponse.refIdx;
797
797
  }
798
- else {
799
- throw notifyErr;
798
+ catch (notifyErr) {
799
+ const errMsg = String(notifyErr);
800
+ if (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")) {
801
+ log?.info(`[qqbot:${account.accountId}] InputNotify token expired, refreshing...`);
802
+ clearTokenCache(account.appId);
803
+ token = await getAccessToken(account.appId, account.clientSecret);
804
+ const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
805
+ return notifyResponse.refIdx;
806
+ }
807
+ else {
808
+ throw notifyErr;
809
+ }
800
810
  }
801
811
  }
802
- log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}${inputNotifyRefIdx ? `, got refIdx=${inputNotifyRefIdx}` : ""}`);
803
- }
804
- catch (err) {
805
- log?.error(`[qqbot:${account.accountId}] sendC2CInputNotify error: ${err}`);
806
- }
812
+ catch (err) {
813
+ log?.error(`[qqbot:${account.accountId}] sendC2CInputNotify error: ${err}`);
814
+ return undefined;
815
+ }
816
+ })();
807
817
  const isGroupChat = event.type === "guild" || event.type === "group";
808
818
  // peerId 只放纯 ID,类型信息由 peer.kind 表达
809
819
  // 群聊:用 groupOpenid(框架根据 kind:"group" 区分)
@@ -845,24 +855,15 @@ export async function startGateway(ctx) {
845
855
  const downloadDir = getQQBotDataDir("downloads");
846
856
  if (event.attachments?.length) {
847
857
  const otherAttachments = [];
848
- for (const att of event.attachments) {
849
- // 修复 QQ 返回的 // 前缀 URL
858
+ // Phase 1: 并行下载所有附件
859
+ const downloadTasks = event.attachments.map(async (att) => {
850
860
  const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
851
- // 语音附件:优先下载 WAV(voice_wav_url),减少 SILK→WAV 转换
852
861
  const isVoice = isVoiceAttachment(att);
853
- const asrReferText = typeof att.asr_refer_text === "string" ? att.asr_refer_text.trim() : "";
854
862
  const wavUrl = isVoice && att.voice_wav_url
855
863
  ? (att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url)
856
864
  : "";
857
- const voiceSourceUrl = wavUrl || attUrl;
858
- if (isVoice) {
859
- if (voiceSourceUrl)
860
- voiceAttachmentUrls.push(voiceSourceUrl);
861
- if (asrReferText)
862
- voiceAsrReferTexts.push(asrReferText);
863
- }
864
865
  let localPath = null;
865
- let audioPath = null; // 用于 STT 的音频路径
866
+ let audioPath = null;
866
867
  if (isVoice && wavUrl) {
867
868
  const wavLocalPath = await downloadFile(wavUrl, downloadDir);
868
869
  if (wavLocalPath) {
@@ -874,114 +875,146 @@ export async function startGateway(ctx) {
874
875
  log?.error(`[qqbot:${account.accountId}] Failed to download voice_wav_url, falling back to original URL`);
875
876
  }
876
877
  }
877
- // WAV 下载失败或不是语音附件:下载原始文件
878
878
  if (!localPath) {
879
879
  localPath = await downloadFile(attUrl, downloadDir, att.filename);
880
880
  }
881
+ return { att, attUrl, isVoice, localPath, audioPath };
882
+ });
883
+ const downloadResults = await Promise.all(downloadTasks);
884
+ // Phase 2: 并行处理语音转换+转录(非语音附件同步归类)
885
+ const processTasks = downloadResults.map(async ({ att, attUrl, isVoice, localPath, audioPath }) => {
886
+ const asrReferText = typeof att.asr_refer_text === "string" ? att.asr_refer_text.trim() : "";
887
+ const wavUrl = isVoice && att.voice_wav_url
888
+ ? (att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url)
889
+ : "";
890
+ const voiceSourceUrl = wavUrl || attUrl;
891
+ // 收集语音元数据(顺序无关)
892
+ const meta = {
893
+ voiceUrl: isVoice && voiceSourceUrl ? voiceSourceUrl : undefined,
894
+ asrReferText: isVoice && asrReferText ? asrReferText : undefined,
895
+ };
881
896
  if (localPath) {
882
897
  if (att.content_type?.startsWith("image/")) {
883
- imageUrls.push(localPath);
884
- imageMediaTypes.push(att.content_type);
898
+ log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
899
+ return { localPath, type: "image", contentType: att.content_type, meta };
885
900
  }
886
901
  else if (isVoice) {
887
- voiceAttachmentPaths.push(localPath);
888
- // 语音消息处理:先检查 STT 是否可用,避免无意义的转换开销
902
+ log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
903
+ // 语音处理:转换 + 转录
889
904
  const sttCfg = resolveSTTConfig(cfg);
890
905
  if (!sttCfg) {
891
906
  if (asrReferText) {
892
907
  log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename} (STT not configured, using asr_refer_text fallback)`);
893
- voiceTranscripts.push(asrReferText);
894
- voiceTranscriptSources.push("asr");
908
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
895
909
  }
896
910
  else {
897
911
  log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename} (STT not configured, skipping transcription)`);
898
- voiceTranscripts.push("[语音消息 - 语音识别未配置,无法转录]");
899
- voiceTranscriptSources.push("fallback");
912
+ return { localPath, type: "voice", transcript: "[语音消息 - 语音识别未配置,无法转录]", transcriptSource: "fallback", meta };
900
913
  }
901
914
  }
902
- else {
903
- // 如果还没有 WAV 路径(voice_wav_url 不可用),需要 SILK→WAV 转换
904
- if (!audioPath) {
905
- const sttFormats = account.config?.audioFormatPolicy?.sttDirectFormats;
906
- log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename}, converting SILK→WAV...`);
907
- try {
908
- const wavResult = await convertSilkToWav(localPath, downloadDir);
909
- if (wavResult) {
910
- audioPath = wavResult.wavPath;
911
- log?.info(`[qqbot:${account.accountId}] Voice converted: ${wavResult.wavPath} (${formatDuration(wavResult.duration)})`);
912
- }
913
- else {
914
- audioPath = localPath; // 转换失败,尝试用原始文件
915
- }
916
- }
917
- catch (convertErr) {
918
- log?.error(`[qqbot:${account.accountId}] Voice conversion failed: ${convertErr}`);
919
- if (asrReferText) {
920
- log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename} (using asr_refer_text fallback after convert failure)`);
921
- voiceTranscripts.push(asrReferText);
922
- voiceTranscriptSources.push("asr");
923
- }
924
- else {
925
- voiceTranscripts.push("[语音消息 - 格式转换失败]");
926
- voiceTranscriptSources.push("fallback");
927
- }
928
- continue;
929
- }
930
- }
931
- // STT 转录
915
+ // SILK→WAV 转换
916
+ if (!audioPath) {
917
+ log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename}, converting SILK→WAV...`);
932
918
  try {
933
- const transcript = await transcribeAudio(audioPath, cfg);
934
- if (transcript) {
935
- log?.info(`[qqbot:${account.accountId}] STT transcript: ${transcript.slice(0, 100)}...`);
936
- voiceTranscripts.push(transcript);
937
- voiceTranscriptSources.push("stt");
938
- }
939
- else if (asrReferText) {
940
- log?.info(`[qqbot:${account.accountId}] STT returned empty result, using asr_refer_text fallback`);
941
- voiceTranscripts.push(asrReferText);
942
- voiceTranscriptSources.push("asr");
919
+ const wavResult = await convertSilkToWav(localPath, downloadDir);
920
+ if (wavResult) {
921
+ audioPath = wavResult.wavPath;
922
+ log?.info(`[qqbot:${account.accountId}] Voice converted: ${wavResult.wavPath} (${formatDuration(wavResult.duration)})`);
943
923
  }
944
924
  else {
945
- log?.info(`[qqbot:${account.accountId}] STT returned empty result`);
946
- voiceTranscripts.push("[语音消息 - 转录结果为空]");
947
- voiceTranscriptSources.push("fallback");
925
+ audioPath = localPath;
948
926
  }
949
927
  }
950
- catch (sttErr) {
951
- log?.error(`[qqbot:${account.accountId}] STT failed: ${sttErr}`);
928
+ catch (convertErr) {
929
+ log?.error(`[qqbot:${account.accountId}] Voice conversion failed: ${convertErr}`);
952
930
  if (asrReferText) {
953
- log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename} (using asr_refer_text fallback after STT failure)`);
954
- voiceTranscripts.push(asrReferText);
955
- voiceTranscriptSources.push("asr");
931
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
956
932
  }
957
933
  else {
958
- voiceTranscripts.push("[语音消息 - 转录失败]");
959
- voiceTranscriptSources.push("fallback");
934
+ return { localPath, type: "voice", transcript: "[语音消息 - 格式转换失败]", transcriptSource: "fallback", meta };
960
935
  }
961
936
  }
962
937
  }
938
+ // STT 转录
939
+ try {
940
+ const transcript = await transcribeAudio(audioPath, cfg);
941
+ if (transcript) {
942
+ log?.info(`[qqbot:${account.accountId}] STT transcript: ${transcript.slice(0, 100)}...`);
943
+ return { localPath, type: "voice", transcript, transcriptSource: "stt", meta };
944
+ }
945
+ else if (asrReferText) {
946
+ log?.info(`[qqbot:${account.accountId}] STT returned empty result, using asr_refer_text fallback`);
947
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
948
+ }
949
+ else {
950
+ log?.info(`[qqbot:${account.accountId}] STT returned empty result`);
951
+ return { localPath, type: "voice", transcript: "[语音消息 - 转录结果为空]", transcriptSource: "fallback", meta };
952
+ }
953
+ }
954
+ catch (sttErr) {
955
+ log?.error(`[qqbot:${account.accountId}] STT failed: ${sttErr}`);
956
+ if (asrReferText) {
957
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
958
+ }
959
+ else {
960
+ return { localPath, type: "voice", transcript: "[语音消息 - 转录失败]", transcriptSource: "fallback", meta };
961
+ }
962
+ }
963
963
  }
964
964
  else {
965
- otherAttachments.push(`[附件: ${localPath}]`);
965
+ log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
966
+ return { localPath, type: "other", filename: att.filename, meta };
966
967
  }
967
- log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
968
- attachmentLocalPaths.push(localPath);
969
968
  }
970
969
  else {
971
- // 下载失败,fallback 到原始 URL
972
970
  log?.error(`[qqbot:${account.accountId}] Failed to download: ${attUrl}`);
973
971
  if (att.content_type?.startsWith("image/")) {
974
- imageUrls.push(attUrl);
975
- imageMediaTypes.push(att.content_type);
972
+ return { localPath: null, type: "image-fallback", attUrl, contentType: att.content_type, meta };
976
973
  }
977
974
  else if (isVoice && asrReferText) {
978
975
  log?.info(`[qqbot:${account.accountId}] Voice attachment download failed, using asr_refer_text fallback`);
979
- voiceTranscripts.push(asrReferText);
980
- voiceTranscriptSources.push("asr");
976
+ return { localPath: null, type: "voice-fallback", transcript: asrReferText, meta };
981
977
  }
982
978
  else {
983
- otherAttachments.push(`[附件: ${att.filename ?? att.content_type}] (下载失败)`);
979
+ return { localPath: null, type: "other-fallback", filename: att.filename ?? att.content_type, meta };
984
980
  }
981
+ }
982
+ });
983
+ const processResults = await Promise.all(processTasks);
984
+ // Phase 3: 按原始顺序归类结果(保持与附件数组一一对应)
985
+ for (const result of processResults) {
986
+ // 收集语音元数据
987
+ if (result.meta.voiceUrl)
988
+ voiceAttachmentUrls.push(result.meta.voiceUrl);
989
+ if (result.meta.asrReferText)
990
+ voiceAsrReferTexts.push(result.meta.asrReferText);
991
+ if (result.type === "image" && result.localPath) {
992
+ imageUrls.push(result.localPath);
993
+ imageMediaTypes.push(result.contentType);
994
+ attachmentLocalPaths.push(result.localPath);
995
+ }
996
+ else if (result.type === "voice" && result.localPath) {
997
+ voiceAttachmentPaths.push(result.localPath);
998
+ voiceTranscripts.push(result.transcript);
999
+ voiceTranscriptSources.push(result.transcriptSource);
1000
+ attachmentLocalPaths.push(result.localPath);
1001
+ }
1002
+ else if (result.type === "other" && result.localPath) {
1003
+ otherAttachments.push(`[附件: ${result.localPath}]`);
1004
+ attachmentLocalPaths.push(result.localPath);
1005
+ }
1006
+ else if (result.type === "image-fallback") {
1007
+ imageUrls.push(result.attUrl);
1008
+ imageMediaTypes.push(result.contentType);
1009
+ attachmentLocalPaths.push(null);
1010
+ }
1011
+ else if (result.type === "voice-fallback") {
1012
+ voiceTranscripts.push(result.transcript);
1013
+ voiceTranscriptSources.push("asr");
1014
+ attachmentLocalPaths.push(null);
1015
+ }
1016
+ else if (result.type === "other-fallback") {
1017
+ otherAttachments.push(`[附件: ${result.filename}] (下载失败)`);
985
1018
  attachmentLocalPaths.push(null);
986
1019
  }
987
1020
  }
@@ -1026,6 +1059,8 @@ export async function startGateway(ctx) {
1026
1059
  }
1027
1060
  // 2. 缓存当前消息自身的 msgIdx(供将来被引用时查找)
1028
1061
  // 优先使用推送事件中的 msgIdx(来自 message_scene.ext),否则使用 InputNotify 返回的 refIdx
1062
+ // inputNotifyPromise 在这里才 await,此时附件下载等工作已并行完成
1063
+ const inputNotifyRefIdx = await inputNotifyPromise;
1029
1064
  const currentMsgIdx = event.msgIdx ?? inputNotifyRefIdx;
1030
1065
  if (currentMsgIdx) {
1031
1066
  const attSummaries = buildAttachmentSummaries(event.attachments, attachmentLocalPaths);
@@ -206,12 +206,16 @@ function findBash() {
206
206
  }
207
207
  }
208
208
  /**
209
- * 执行热更新:执行脚本(--no-restart) → 触发 gateway restart
209
+ * 执行热更新:执行脚本(--no-restart) → 立即触发 gateway restart
210
210
  *
211
211
  * fire-and-forget 操作:
212
212
  * - 异步执行升级脚本(--no-restart,只做文件替换)
213
- * - 脚本完成后触发 gateway restart(当前进程会被杀掉)
213
+ * - 脚本完成后**立即**触发 gateway restart(当前进程会被杀掉)
214
214
  * - 新进程启动时 getStartupGreeting() 检测到版本变更,自动通知管理员
215
+ *
216
+ * 注意:gateway restart 必须在文件替换完成后尽快执行,
217
+ * 否则 openclaw 的配置热加载轮询(~1s)会不断检测到插件目录
218
+ * 已变更但进程未重启,从而产生 "plugin not found" warning 刷屏。
215
219
  */
216
220
  function fireHotUpgrade(targetVersion) {
217
221
  const scriptPath = getUpgradeScriptPath();
@@ -228,12 +232,28 @@ function fireHotUpgrade(targetVersion) {
228
232
  timeout: 120_000,
229
233
  env: { ...process.env },
230
234
  ...(isWindows() ? { windowsHide: true } : {}),
231
- }, (error, _stdout, _stderr) => {
235
+ }, (error, stdout, _stderr) => {
232
236
  if (error) {
233
237
  return;
234
238
  }
235
- // 文件替换成功,触发 gateway restart
236
- execFile(cli, ["gateway", "restart"], { timeout: 30_000 }, () => { });
239
+ // 从脚本输出中提取版本号,验证文件替换是否成功
240
+ const versionMatch = stdout.match(/QQBOT_NEW_VERSION=(\S+)/);
241
+ const newVersion = versionMatch?.[1];
242
+ if (newVersion === "unknown") {
243
+ // 文件替换异常,不执行 restart 以保持现有服务
244
+ return;
245
+ }
246
+ // 文件替换成功,立即触发 gateway restart(不再等后续步骤)
247
+ execFile(cli, ["gateway", "restart"], { timeout: 30_000 }, (restartErr) => {
248
+ if (restartErr) {
249
+ // restart 失败,尝试 stop + start 作为 fallback
250
+ execFile(cli, ["gateway", "stop"], { timeout: 10_000 }, () => {
251
+ setTimeout(() => {
252
+ execFile(cli, ["gateway", "start"], { timeout: 30_000 }, () => { });
253
+ }, 1000);
254
+ });
255
+ }
256
+ });
237
257
  });
238
258
  return { ok: true };
239
259
  }
@@ -326,104 +346,161 @@ registerCommand({
326
346
  /**
327
347
  * /bot-logs — 导出本地日志文件
328
348
  *
329
- * 日志路径检测策略(兼容特殊安装路径和 --profile/--dev 模式):
330
- * 1. OPENCLAW_STATE_DIR 环境变量指定的目录
331
- * 2. 扫描 home 目录下所有 .openclaw-xxx/logs/ 目录,取最近修改的 gateway.log
349
+ * 日志定位策略(兼容腾讯云/各云厂商不同安装路径):
350
+ * 1. 优先使用 *_STATE_DIR 环境变量(OPENCLAW/CLAWDBOT/MOLTBOT)
351
+ * 2. 扫描常见状态目录:~/.openclaw, ~/.clawdbot, ~/.moltbot 及其 logs 子目录
352
+ * 3. 扫描 home/cwd/AppData 下名称包含 openclaw/clawdbot/moltbot 的目录
353
+ * 4. 在候选目录中选取最近更新的日志文件(gateway/openclaw/clawdbot/moltbot)
332
354
  */
333
- registerCommand({
334
- name: "bot-logs",
335
- description: "导出本地日志文件",
336
- handler: () => {
337
- const homeDir = getHomeDir();
338
- // 收集所有可能的日志目录
339
- const logDirs = [];
340
- // 优先:环境变量指定的状态目录
341
- const stateDir = process.env.OPENCLAW_STATE_DIR;
342
- if (stateDir) {
343
- logDirs.push(path.join(stateDir, "logs"));
355
+ function collectCandidateLogDirs() {
356
+ const homeDir = getHomeDir();
357
+ const dirs = new Set();
358
+ const pushDir = (p) => {
359
+ if (!p)
360
+ return;
361
+ const normalized = path.resolve(p);
362
+ dirs.add(normalized);
363
+ };
364
+ const pushStateDir = (stateDir) => {
365
+ if (!stateDir)
366
+ return;
367
+ pushDir(stateDir);
368
+ pushDir(path.join(stateDir, "logs"));
369
+ };
370
+ for (const [key, value] of Object.entries(process.env)) {
371
+ if (!value)
372
+ continue;
373
+ if (/STATE_DIR$/i.test(key) && /(OPENCLAW|CLAWDBOT|MOLTBOT)/i.test(key)) {
374
+ pushStateDir(value);
344
375
  }
345
- // 扫描搜索根目录列表(兼容 Windows APPDATA 路径)
346
- const searchRoots = new Set([homeDir]);
347
- const appData = process.env.APPDATA; // Windows: C:\Users\xxx\AppData\Roaming
348
- if (appData)
349
- searchRoots.add(appData);
350
- const localAppData = process.env.LOCALAPPDATA; // Windows: C:\Users\xxx\AppData\Local
351
- if (localAppData)
352
- searchRoots.add(localAppData);
353
- for (const root of searchRoots) {
354
- try {
355
- const entries = fs.readdirSync(root, { withFileTypes: true });
356
- for (const entry of entries) {
357
- if (entry.isDirectory() && (entry.name.startsWith(".openclaw") || entry.name.startsWith("openclaw"))) {
358
- const candidate = path.join(root, entry.name, "logs");
359
- if (!logDirs.includes(candidate)) {
360
- logDirs.push(candidate);
361
- }
362
- }
363
- }
364
- }
365
- catch {
366
- // 无权限或不存在,跳过
376
+ }
377
+ for (const name of [".openclaw", ".clawdbot", ".moltbot", "openclaw", "clawdbot", "moltbot"]) {
378
+ pushDir(path.join(homeDir, name));
379
+ pushDir(path.join(homeDir, name, "logs"));
380
+ }
381
+ const searchRoots = new Set([
382
+ homeDir,
383
+ process.cwd(),
384
+ path.dirname(process.cwd()),
385
+ ]);
386
+ if (process.env.APPDATA)
387
+ searchRoots.add(process.env.APPDATA);
388
+ if (process.env.LOCALAPPDATA)
389
+ searchRoots.add(process.env.LOCALAPPDATA);
390
+ for (const root of searchRoots) {
391
+ try {
392
+ const entries = fs.readdirSync(root, { withFileTypes: true });
393
+ for (const entry of entries) {
394
+ if (!entry.isDirectory())
395
+ continue;
396
+ if (!/(openclaw|clawdbot|moltbot)/i.test(entry.name))
397
+ continue;
398
+ const base = path.join(root, entry.name);
399
+ pushDir(base);
400
+ pushDir(path.join(base, "logs"));
367
401
  }
368
402
  }
369
- // 兜底:默认路径
370
- const defaultLogDir = path.join(homeDir, ".openclaw", "logs");
371
- if (!logDirs.includes(defaultLogDir)) {
372
- logDirs.push(defaultLogDir);
403
+ catch {
404
+ // 无权限或不存在,跳过
373
405
  }
374
- // 从所有候选目录中找到存在且最近修改的 gateway.log
375
- let bestLogDir = null;
376
- let bestMtime = 0;
377
- for (const logDir of logDirs) {
378
- const gatewayLog = path.join(logDir, "gateway.log");
379
- try {
380
- const stat = fs.statSync(gatewayLog);
381
- if (stat.mtimeMs > bestMtime) {
382
- bestMtime = stat.mtimeMs;
383
- bestLogDir = logDir;
384
- }
385
- }
386
- catch {
387
- // 不存在或无权限,跳过
406
+ }
407
+ return Array.from(dirs);
408
+ }
409
+ function collectRecentLogFiles(logDirs) {
410
+ const candidates = [];
411
+ const dedupe = new Set();
412
+ const pushFile = (filePath, sourceDir) => {
413
+ const normalized = path.resolve(filePath);
414
+ if (dedupe.has(normalized))
415
+ return;
416
+ try {
417
+ const stat = fs.statSync(normalized);
418
+ if (!stat.isFile())
419
+ return;
420
+ dedupe.add(normalized);
421
+ candidates.push({ filePath: normalized, sourceDir, mtimeMs: stat.mtimeMs });
422
+ }
423
+ catch {
424
+ // 文件不存在或无权限
425
+ }
426
+ };
427
+ for (const dir of logDirs) {
428
+ pushFile(path.join(dir, "gateway.log"), dir);
429
+ pushFile(path.join(dir, "gateway.err.log"), dir);
430
+ pushFile(path.join(dir, "openclaw.log"), dir);
431
+ pushFile(path.join(dir, "clawdbot.log"), dir);
432
+ pushFile(path.join(dir, "moltbot.log"), dir);
433
+ try {
434
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
435
+ for (const entry of entries) {
436
+ if (!entry.isFile())
437
+ continue;
438
+ if (!/\.(log|txt)$/i.test(entry.name))
439
+ continue;
440
+ if (!/(gateway|openclaw|clawdbot|moltbot)/i.test(entry.name))
441
+ continue;
442
+ pushFile(path.join(dir, entry.name), dir);
388
443
  }
389
444
  }
390
- if (!bestLogDir) {
445
+ catch {
446
+ // 无权限或不存在,跳过
447
+ }
448
+ }
449
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
450
+ return candidates;
451
+ }
452
+ registerCommand({
453
+ name: "bot-logs",
454
+ description: "导出本地日志文件",
455
+ handler: () => {
456
+ const logDirs = collectCandidateLogDirs();
457
+ const recentFiles = collectRecentLogFiles(logDirs).slice(0, 4);
458
+ if (recentFiles.length === 0) {
391
459
  const searched = logDirs.map(d => ` - ${d}`).join("\n");
392
460
  return `⚠️ 未找到日志文件\n\n已搜索以下路径:\n${searched}`;
393
461
  }
394
- const gatewayLog = path.join(bestLogDir, "gateway.log");
395
- const errLog = path.join(bestLogDir, "gateway.err.log");
396
462
  const lines = [];
397
- for (const logFile of [gatewayLog, errLog]) {
398
- if (!fs.existsSync(logFile))
399
- continue;
463
+ let totalIncluded = 0;
464
+ let totalOriginal = 0;
465
+ let truncatedCount = 0;
466
+ const MAX_LINES_PER_FILE = 1000;
467
+ for (const logFile of recentFiles) {
400
468
  try {
401
- const content = fs.readFileSync(logFile, "utf8");
469
+ const content = fs.readFileSync(logFile.filePath, "utf8");
402
470
  const allLines = content.split("\n");
403
- const tail = allLines.slice(-1000);
471
+ const totalFileLines = allLines.length;
472
+ const tail = allLines.slice(-MAX_LINES_PER_FILE);
404
473
  if (tail.length > 0) {
405
- lines.push(`\n========== ${path.basename(logFile)} (last ${tail.length} lines) ==========\n`);
474
+ const fileName = path.basename(logFile.filePath);
475
+ lines.push(`\n========== ${fileName} (last ${tail.length} of ${totalFileLines} lines) ==========`);
476
+ lines.push(`from: ${logFile.sourceDir}`);
406
477
  lines.push(...tail);
478
+ totalIncluded += tail.length;
479
+ totalOriginal += totalFileLines;
480
+ if (totalFileLines > MAX_LINES_PER_FILE)
481
+ truncatedCount++;
407
482
  }
408
483
  }
409
484
  catch {
410
- lines.push(`[读取 ${path.basename(logFile)} 失败]`);
485
+ lines.push(`[读取 ${path.basename(logFile.filePath)} 失败]`);
411
486
  }
412
487
  }
413
488
  if (lines.length === 0) {
414
- return `⚠️ 日志文件为空(路径:${bestLogDir})`;
415
- }
416
- // 写入临时文件
417
- const tmpDir = path.join(homeDir, ".openclaw", "qqbot", "downloads");
418
- if (!fs.existsSync(tmpDir)) {
419
- fs.mkdirSync(tmpDir, { recursive: true });
489
+ return `⚠️ 找到日志文件但读取失败,请检查文件权限`;
420
490
  }
491
+ const tmpDir = getQQBotDataDir("downloads");
421
492
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
422
493
  const tmpFile = path.join(tmpDir, `bot-logs-${timestamp}.txt`);
423
494
  fs.writeFileSync(tmpFile, lines.join("\n"), "utf8");
424
- const totalLines = lines.filter(l => !l.startsWith("=")).length;
495
+ const fileCount = recentFiles.length;
496
+ const topSources = Array.from(new Set(recentFiles.map(item => item.sourceDir))).slice(0, 3);
497
+ // 紧凑摘要:N 个日志文件,共 X 行(如有截断则注明)
498
+ let summaryText = `${fileCount} 个日志文件,共 ${totalIncluded} 行`;
499
+ if (truncatedCount > 0) {
500
+ summaryText += `(${truncatedCount} 个文件因过长仅保留最后 ${MAX_LINES_PER_FILE} 行,原始共 ${totalOriginal} 行)`;
501
+ }
425
502
  return {
426
- text: `📋 日志已打包(约 ${totalLines} 行),正在发送文件...\n📂 来源:${bestLogDir}`,
503
+ text: `📋 ${summaryText}\n📂 来源:${topSources.join(" | ")}`,
427
504
  filePath: tmpFile,
428
505
  };
429
506
  },