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

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.
@@ -263,8 +263,10 @@ async function ensureImageServer(log, publicBaseUrl) {
263
263
  // 区分 gateway restart(进程重启)和 health-monitor 断线重连
264
264
  let isFirstReadyGlobal = true;
265
265
  const STARTUP_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.json");
266
- const STARTUP_GREETING_TEXT = `Haha,我的'灵魂'已上线,随时等你吩咐。`;
267
266
  const STARTUP_GREETING_RETRY_COOLDOWN_MS = 10 * 60 * 1000;
267
+ function getStartupGreetingText(version) {
268
+ return `🎉 QQBot 插件已更新至 v${version},在线等候你的吩咐。`;
269
+ }
268
270
  function readStartupMarker() {
269
271
  try {
270
272
  if (fs.existsSync(STARTUP_MARKER_FILE)) {
@@ -303,7 +305,7 @@ function getStartupGreetingPlan() {
303
305
  return { shouldSend: false, version: currentVersion, reason: "cooldown" };
304
306
  }
305
307
  }
306
- return { shouldSend: true, greeting: STARTUP_GREETING_TEXT, version: currentVersion };
308
+ return { shouldSend: true, greeting: getStartupGreetingText(currentVersion), version: currentVersion };
307
309
  }
308
310
  function markStartupGreetingSent(version) {
309
311
  writeStartupMarker({
@@ -314,10 +316,12 @@ function markStartupGreetingSent(version) {
314
316
  }
315
317
  function markStartupGreetingFailed(version, reason) {
316
318
  const marker = readStartupMarker();
319
+ // 同版本已有失败记录时,不覆盖 lastFailureAt,避免冷却期被无限续期
320
+ const shouldPreserveTimestamp = marker.lastFailureVersion === version && marker.lastFailureAt;
317
321
  writeStartupMarker({
318
322
  ...marker,
319
323
  lastFailureVersion: version,
320
- lastFailureAt: new Date().toISOString(),
324
+ lastFailureAt: shouldPreserveTimestamp ? marker.lastFailureAt : new Date().toISOString(),
321
325
  lastFailureReason: reason,
322
326
  });
323
327
  }
@@ -435,8 +439,13 @@ export async function startGateway(ctx) {
435
439
  try {
436
440
  if (fs.existsSync(UPGRADE_GREETING_TARGET_FILE)) {
437
441
  const data = JSON.parse(fs.readFileSync(UPGRADE_GREETING_TARGET_FILE, "utf8"));
438
- if (data.openid)
439
- return data.openid;
442
+ if (!data.openid)
443
+ return undefined;
444
+ if (data.appId && data.appId !== account.appId)
445
+ return undefined;
446
+ if (data.accountId && data.accountId !== account.accountId)
447
+ return undefined;
448
+ return data.openid;
440
449
  }
441
450
  }
442
451
  catch { /* 文件损坏视为无 */ }
@@ -590,11 +599,12 @@ export async function startGateway(ctx) {
590
599
  finally {
591
600
  activeUsers.delete(peerId);
592
601
  userQueues.delete(peerId);
593
- // 处理完后,检查是否有等待并发槽位的用户
602
+ // 处理完后,唤醒等待的用户填满并发槽位
594
603
  for (const [waitingPeerId, waitingQueue] of userQueues) {
604
+ if (activeUsers.size >= MAX_CONCURRENT_USERS)
605
+ break; // 槽位已满
595
606
  if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) {
596
607
  drainUserQueue(waitingPeerId);
597
- break; // 每次只唤醒一个,避免瞬间并发激增
598
608
  }
599
609
  }
600
610
  }
@@ -777,33 +787,35 @@ export async function startGateway(ctx) {
777
787
  accountId: account.accountId,
778
788
  direction: "inbound",
779
789
  });
780
- // 发送输入状态提示(非关键,失败不影响主流程)
781
- // 同时从响应中获取 ref_idx,用于缓存入站消息
782
- let inputNotifyRefIdx;
783
- try {
784
- let token = await getAccessToken(account.appId, account.clientSecret);
790
+ // 发送输入状态提示(fire-and-forget:不阻塞主流程)
791
+ // refIdx 通过 Promise 延迟获取,在真正需要时再 await
792
+ const inputNotifyPromise = (async () => {
785
793
  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);
794
+ let token = await getAccessToken(account.appId, account.clientSecret);
795
+ try {
795
796
  const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
796
- inputNotifyRefIdx = notifyResponse.refIdx;
797
+ log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}${notifyResponse.refIdx ? `, got refIdx=${notifyResponse.refIdx}` : ""}`);
798
+ return notifyResponse.refIdx;
797
799
  }
798
- else {
799
- throw notifyErr;
800
+ catch (notifyErr) {
801
+ const errMsg = String(notifyErr);
802
+ if (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")) {
803
+ log?.info(`[qqbot:${account.accountId}] InputNotify token expired, refreshing...`);
804
+ clearTokenCache(account.appId);
805
+ token = await getAccessToken(account.appId, account.clientSecret);
806
+ const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
807
+ return notifyResponse.refIdx;
808
+ }
809
+ else {
810
+ throw notifyErr;
811
+ }
800
812
  }
801
813
  }
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
- }
814
+ catch (err) {
815
+ log?.error(`[qqbot:${account.accountId}] sendC2CInputNotify error: ${err}`);
816
+ return undefined;
817
+ }
818
+ })();
807
819
  const isGroupChat = event.type === "guild" || event.type === "group";
808
820
  // peerId 只放纯 ID,类型信息由 peer.kind 表达
809
821
  // 群聊:用 groupOpenid(框架根据 kind:"group" 区分)
@@ -845,24 +857,15 @@ export async function startGateway(ctx) {
845
857
  const downloadDir = getQQBotDataDir("downloads");
846
858
  if (event.attachments?.length) {
847
859
  const otherAttachments = [];
848
- for (const att of event.attachments) {
849
- // 修复 QQ 返回的 // 前缀 URL
860
+ // Phase 1: 并行下载所有附件
861
+ const downloadTasks = event.attachments.map(async (att) => {
850
862
  const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
851
- // 语音附件:优先下载 WAV(voice_wav_url),减少 SILK→WAV 转换
852
863
  const isVoice = isVoiceAttachment(att);
853
- const asrReferText = typeof att.asr_refer_text === "string" ? att.asr_refer_text.trim() : "";
854
864
  const wavUrl = isVoice && att.voice_wav_url
855
865
  ? (att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url)
856
866
  : "";
857
- const voiceSourceUrl = wavUrl || attUrl;
858
- if (isVoice) {
859
- if (voiceSourceUrl)
860
- voiceAttachmentUrls.push(voiceSourceUrl);
861
- if (asrReferText)
862
- voiceAsrReferTexts.push(asrReferText);
863
- }
864
867
  let localPath = null;
865
- let audioPath = null; // 用于 STT 的音频路径
868
+ let audioPath = null;
866
869
  if (isVoice && wavUrl) {
867
870
  const wavLocalPath = await downloadFile(wavUrl, downloadDir);
868
871
  if (wavLocalPath) {
@@ -874,114 +877,146 @@ export async function startGateway(ctx) {
874
877
  log?.error(`[qqbot:${account.accountId}] Failed to download voice_wav_url, falling back to original URL`);
875
878
  }
876
879
  }
877
- // WAV 下载失败或不是语音附件:下载原始文件
878
880
  if (!localPath) {
879
881
  localPath = await downloadFile(attUrl, downloadDir, att.filename);
880
882
  }
883
+ return { att, attUrl, isVoice, localPath, audioPath };
884
+ });
885
+ const downloadResults = await Promise.all(downloadTasks);
886
+ // Phase 2: 并行处理语音转换+转录(非语音附件同步归类)
887
+ const processTasks = downloadResults.map(async ({ att, attUrl, isVoice, localPath, audioPath }) => {
888
+ const asrReferText = typeof att.asr_refer_text === "string" ? att.asr_refer_text.trim() : "";
889
+ const wavUrl = isVoice && att.voice_wav_url
890
+ ? (att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url)
891
+ : "";
892
+ const voiceSourceUrl = wavUrl || attUrl;
893
+ // 收集语音元数据(顺序无关)
894
+ const meta = {
895
+ voiceUrl: isVoice && voiceSourceUrl ? voiceSourceUrl : undefined,
896
+ asrReferText: isVoice && asrReferText ? asrReferText : undefined,
897
+ };
881
898
  if (localPath) {
882
899
  if (att.content_type?.startsWith("image/")) {
883
- imageUrls.push(localPath);
884
- imageMediaTypes.push(att.content_type);
900
+ log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
901
+ return { localPath, type: "image", contentType: att.content_type, meta };
885
902
  }
886
903
  else if (isVoice) {
887
- voiceAttachmentPaths.push(localPath);
888
- // 语音消息处理:先检查 STT 是否可用,避免无意义的转换开销
904
+ log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
905
+ // 语音处理:转换 + 转录
889
906
  const sttCfg = resolveSTTConfig(cfg);
890
907
  if (!sttCfg) {
891
908
  if (asrReferText) {
892
909
  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");
910
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
895
911
  }
896
912
  else {
897
913
  log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename} (STT not configured, skipping transcription)`);
898
- voiceTranscripts.push("[语音消息 - 语音识别未配置,无法转录]");
899
- voiceTranscriptSources.push("fallback");
914
+ return { localPath, type: "voice", transcript: "[语音消息 - 语音识别未配置,无法转录]", transcriptSource: "fallback", meta };
900
915
  }
901
916
  }
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 转录
917
+ // SILK→WAV 转换
918
+ if (!audioPath) {
919
+ log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename}, converting SILK→WAV...`);
932
920
  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");
921
+ const wavResult = await convertSilkToWav(localPath, downloadDir);
922
+ if (wavResult) {
923
+ audioPath = wavResult.wavPath;
924
+ log?.info(`[qqbot:${account.accountId}] Voice converted: ${wavResult.wavPath} (${formatDuration(wavResult.duration)})`);
943
925
  }
944
926
  else {
945
- log?.info(`[qqbot:${account.accountId}] STT returned empty result`);
946
- voiceTranscripts.push("[语音消息 - 转录结果为空]");
947
- voiceTranscriptSources.push("fallback");
927
+ audioPath = localPath;
948
928
  }
949
929
  }
950
- catch (sttErr) {
951
- log?.error(`[qqbot:${account.accountId}] STT failed: ${sttErr}`);
930
+ catch (convertErr) {
931
+ log?.error(`[qqbot:${account.accountId}] Voice conversion failed: ${convertErr}`);
952
932
  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");
933
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
956
934
  }
957
935
  else {
958
- voiceTranscripts.push("[语音消息 - 转录失败]");
959
- voiceTranscriptSources.push("fallback");
936
+ return { localPath, type: "voice", transcript: "[语音消息 - 格式转换失败]", transcriptSource: "fallback", meta };
960
937
  }
961
938
  }
962
939
  }
940
+ // STT 转录
941
+ try {
942
+ const transcript = await transcribeAudio(audioPath, cfg);
943
+ if (transcript) {
944
+ log?.info(`[qqbot:${account.accountId}] STT transcript: ${transcript.slice(0, 100)}...`);
945
+ return { localPath, type: "voice", transcript, transcriptSource: "stt", meta };
946
+ }
947
+ else if (asrReferText) {
948
+ log?.info(`[qqbot:${account.accountId}] STT returned empty result, using asr_refer_text fallback`);
949
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
950
+ }
951
+ else {
952
+ log?.info(`[qqbot:${account.accountId}] STT returned empty result`);
953
+ return { localPath, type: "voice", transcript: "[语音消息 - 转录结果为空]", transcriptSource: "fallback", meta };
954
+ }
955
+ }
956
+ catch (sttErr) {
957
+ log?.error(`[qqbot:${account.accountId}] STT failed: ${sttErr}`);
958
+ if (asrReferText) {
959
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
960
+ }
961
+ else {
962
+ return { localPath, type: "voice", transcript: "[语音消息 - 转录失败]", transcriptSource: "fallback", meta };
963
+ }
964
+ }
963
965
  }
964
966
  else {
965
- otherAttachments.push(`[附件: ${localPath}]`);
967
+ log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
968
+ return { localPath, type: "other", filename: att.filename, meta };
966
969
  }
967
- log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
968
- attachmentLocalPaths.push(localPath);
969
970
  }
970
971
  else {
971
- // 下载失败,fallback 到原始 URL
972
972
  log?.error(`[qqbot:${account.accountId}] Failed to download: ${attUrl}`);
973
973
  if (att.content_type?.startsWith("image/")) {
974
- imageUrls.push(attUrl);
975
- imageMediaTypes.push(att.content_type);
974
+ return { localPath: null, type: "image-fallback", attUrl, contentType: att.content_type, meta };
976
975
  }
977
976
  else if (isVoice && asrReferText) {
978
977
  log?.info(`[qqbot:${account.accountId}] Voice attachment download failed, using asr_refer_text fallback`);
979
- voiceTranscripts.push(asrReferText);
980
- voiceTranscriptSources.push("asr");
978
+ return { localPath: null, type: "voice-fallback", transcript: asrReferText, meta };
981
979
  }
982
980
  else {
983
- otherAttachments.push(`[附件: ${att.filename ?? att.content_type}] (下载失败)`);
981
+ return { localPath: null, type: "other-fallback", filename: att.filename ?? att.content_type, meta };
984
982
  }
983
+ }
984
+ });
985
+ const processResults = await Promise.all(processTasks);
986
+ // Phase 3: 按原始顺序归类结果(保持与附件数组一一对应)
987
+ for (const result of processResults) {
988
+ // 收集语音元数据
989
+ if (result.meta.voiceUrl)
990
+ voiceAttachmentUrls.push(result.meta.voiceUrl);
991
+ if (result.meta.asrReferText)
992
+ voiceAsrReferTexts.push(result.meta.asrReferText);
993
+ if (result.type === "image" && result.localPath) {
994
+ imageUrls.push(result.localPath);
995
+ imageMediaTypes.push(result.contentType);
996
+ attachmentLocalPaths.push(result.localPath);
997
+ }
998
+ else if (result.type === "voice" && result.localPath) {
999
+ voiceAttachmentPaths.push(result.localPath);
1000
+ voiceTranscripts.push(result.transcript);
1001
+ voiceTranscriptSources.push(result.transcriptSource);
1002
+ attachmentLocalPaths.push(result.localPath);
1003
+ }
1004
+ else if (result.type === "other" && result.localPath) {
1005
+ otherAttachments.push(`[附件: ${result.localPath}]`);
1006
+ attachmentLocalPaths.push(result.localPath);
1007
+ }
1008
+ else if (result.type === "image-fallback") {
1009
+ imageUrls.push(result.attUrl);
1010
+ imageMediaTypes.push(result.contentType);
1011
+ attachmentLocalPaths.push(null);
1012
+ }
1013
+ else if (result.type === "voice-fallback") {
1014
+ voiceTranscripts.push(result.transcript);
1015
+ voiceTranscriptSources.push("asr");
1016
+ attachmentLocalPaths.push(null);
1017
+ }
1018
+ else if (result.type === "other-fallback") {
1019
+ otherAttachments.push(`[附件: ${result.filename}] (下载失败)`);
985
1020
  attachmentLocalPaths.push(null);
986
1021
  }
987
1022
  }
@@ -1026,6 +1061,8 @@ export async function startGateway(ctx) {
1026
1061
  }
1027
1062
  // 2. 缓存当前消息自身的 msgIdx(供将来被引用时查找)
1028
1063
  // 优先使用推送事件中的 msgIdx(来自 message_scene.ext),否则使用 InputNotify 返回的 refIdx
1064
+ // inputNotifyPromise 在这里才 await,此时附件下载等工作已并行完成
1065
+ const inputNotifyRefIdx = await inputNotifyPromise;
1029
1066
  const currentMsgIdx = event.msgIdx ?? inputNotifyRefIdx;
1030
1067
  if (currentMsgIdx) {
1031
1068
  const attSummaries = buildAttachmentSummaries(event.attachments, attachmentLocalPaths);