@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.
- package/dist/src/gateway.js +146 -109
- package/dist/src/slash-commands.js +219 -87
- package/dist/src/update-checker.d.ts +5 -0
- package/dist/src/update-checker.js +18 -0
- package/package.json +1 -1
- package/scripts/upgrade-via-npm.sh +30 -15
- package/src/gateway.ts +142 -103
- package/src/slash-commands.ts +226 -86
- package/src/update-checker.ts +17 -0
package/dist/src/gateway.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
//
|
|
782
|
-
|
|
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
|
-
|
|
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);
|
|
794
|
+
let token = await getAccessToken(account.appId, account.clientSecret);
|
|
795
|
+
try {
|
|
795
796
|
const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
|
|
796
|
-
|
|
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
|
-
|
|
799
|
-
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
849
|
-
|
|
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;
|
|
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
|
-
|
|
884
|
-
|
|
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
|
-
|
|
888
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
899
|
-
voiceTranscriptSources.push("fallback");
|
|
914
|
+
return { localPath, type: "voice", transcript: "[语音消息 - 语音识别未配置,无法转录]", transcriptSource: "fallback", meta };
|
|
900
915
|
}
|
|
901
916
|
}
|
|
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 转录
|
|
917
|
+
// SILK→WAV 转换
|
|
918
|
+
if (!audioPath) {
|
|
919
|
+
log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename}, converting SILK→WAV...`);
|
|
932
920
|
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");
|
|
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
|
-
|
|
946
|
-
voiceTranscripts.push("[语音消息 - 转录结果为空]");
|
|
947
|
-
voiceTranscriptSources.push("fallback");
|
|
927
|
+
audioPath = localPath;
|
|
948
928
|
}
|
|
949
929
|
}
|
|
950
|
-
catch (
|
|
951
|
-
log?.error(`[qqbot:${account.accountId}]
|
|
930
|
+
catch (convertErr) {
|
|
931
|
+
log?.error(`[qqbot:${account.accountId}] Voice conversion failed: ${convertErr}`);
|
|
952
932
|
if (asrReferText) {
|
|
953
|
-
|
|
954
|
-
voiceTranscripts.push(asrReferText);
|
|
955
|
-
voiceTranscriptSources.push("asr");
|
|
933
|
+
return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
|
|
956
934
|
}
|
|
957
935
|
else {
|
|
958
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
980
|
-
voiceTranscriptSources.push("asr");
|
|
978
|
+
return { localPath: null, type: "voice-fallback", transcript: asrReferText, meta };
|
|
981
979
|
}
|
|
982
980
|
else {
|
|
983
|
-
|
|
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);
|