@tencent-connect/openclaw-qqbot 1.6.4-alpha.4 → 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.
- package/dist/src/gateway.js +142 -107
- package/dist/src/slash-commands.js +154 -77
- package/package.json +1 -1
- package/scripts/upgrade-via-npm.sh +30 -15
- package/src/gateway.ts +137 -101
- package/src/slash-commands.ts +153 -76
package/dist/src/gateway.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
782
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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
|
-
|
|
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
|
-
|
|
799
|
-
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
849
|
-
|
|
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;
|
|
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
|
-
|
|
884
|
-
|
|
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
|
-
|
|
888
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
899
|
-
voiceTranscriptSources.push("fallback");
|
|
912
|
+
return { localPath, type: "voice", transcript: "[语音消息 - 语音识别未配置,无法转录]", transcriptSource: "fallback", meta };
|
|
900
913
|
}
|
|
901
914
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
|
934
|
-
if (
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
946
|
-
voiceTranscripts.push("[语音消息 - 转录结果为空]");
|
|
947
|
-
voiceTranscriptSources.push("fallback");
|
|
925
|
+
audioPath = localPath;
|
|
948
926
|
}
|
|
949
927
|
}
|
|
950
|
-
catch (
|
|
951
|
-
log?.error(`[qqbot:${account.accountId}]
|
|
928
|
+
catch (convertErr) {
|
|
929
|
+
log?.error(`[qqbot:${account.accountId}] Voice conversion failed: ${convertErr}`);
|
|
952
930
|
if (asrReferText) {
|
|
953
|
-
|
|
954
|
-
voiceTranscripts.push(asrReferText);
|
|
955
|
-
voiceTranscriptSources.push("asr");
|
|
931
|
+
return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
|
|
956
932
|
}
|
|
957
933
|
else {
|
|
958
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
980
|
-
voiceTranscriptSources.push("asr");
|
|
976
|
+
return { localPath: null, type: "voice-fallback", transcript: asrReferText, meta };
|
|
981
977
|
}
|
|
982
978
|
else {
|
|
983
|
-
|
|
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) →
|
|
209
|
+
* 执行热更新:执行脚本(--no-restart) → 立即触发 gateway restart
|
|
210
210
|
*
|
|
211
211
|
* fire-and-forget 操作:
|
|
212
212
|
* - 异步执行升级脚本(--no-restart,只做文件替换)
|
|
213
|
-
* -
|
|
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,
|
|
235
|
+
}, (error, stdout, _stderr) => {
|
|
232
236
|
if (error) {
|
|
233
237
|
return;
|
|
234
238
|
}
|
|
235
|
-
//
|
|
236
|
-
|
|
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
|
-
*
|
|
330
|
-
* 1.
|
|
331
|
-
* 2.
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
371
|
-
if (!logDirs.includes(defaultLogDir)) {
|
|
372
|
-
logDirs.push(defaultLogDir);
|
|
403
|
+
catch {
|
|
404
|
+
// 无权限或不存在,跳过
|
|
373
405
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
471
|
+
const totalFileLines = allLines.length;
|
|
472
|
+
const tail = allLines.slice(-MAX_LINES_PER_FILE);
|
|
404
473
|
if (tail.length > 0) {
|
|
405
|
-
|
|
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 `⚠️
|
|
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
|
|
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: `📋
|
|
503
|
+
text: `📋 ${summaryText}\n📂 来源:${topSources.join(" | ")}`,
|
|
427
504
|
filePath: tmpFile,
|
|
428
505
|
};
|
|
429
506
|
},
|