@jerryliang122/openclaw-qqbot 1.0.9 → 2.0.0
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/README.md +15 -28
- package/README.zh.md +15 -28
- package/dist/index.cjs +51 -81
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -7
- package/package.json +1 -1
- package/src/middleware/attachment.ts +30 -19
- package/src/types.ts +21 -7
- package/src/utils/stt.ts +49 -92
package/README.md
CHANGED
|
@@ -810,44 +810,31 @@ The plugin processes messages through a carefully ordered middleware chain:
|
|
|
810
810
|
|
|
811
811
|
#### STT (Speech-to-Text) — Transcribe Incoming Voice Messages
|
|
812
812
|
|
|
813
|
-
STT
|
|
814
|
-
|
|
815
|
-
| Priority | Config Path | Scope |
|
|
816
|
-
|----------|------------|-------|
|
|
817
|
-
| 1 (highest) | `channels.qqbot.stt` | Plugin-specific |
|
|
818
|
-
| 2 (fallback) | `tools.media.audio.models[0]` | Framework-level |
|
|
813
|
+
Transcription runs through the **framework audio-understanding pipeline** (`openclaw/plugin-sdk/media-understanding-runtime`), so STT credentials come from the framework-level config (same as the built-in Telegram channel) — the plugin no longer ships its own OpenAI-compatible HTTP call:
|
|
819
814
|
|
|
820
815
|
```json
|
|
821
816
|
{
|
|
822
|
-
"
|
|
823
|
-
"
|
|
824
|
-
"
|
|
825
|
-
"provider": "your-provider",
|
|
826
|
-
"model": "your-stt-model"
|
|
817
|
+
"tools": {
|
|
818
|
+
"media": {
|
|
819
|
+
"audio": {
|
|
820
|
+
"models": [{ "provider": "your-provider", "model": "your-stt-model" }]
|
|
827
821
|
}
|
|
828
822
|
}
|
|
829
823
|
}
|
|
830
824
|
}
|
|
831
825
|
```
|
|
832
826
|
|
|
833
|
-
|
|
834
|
-
- Set `enabled: false` to disable
|
|
835
|
-
- When configured, incoming voice messages are automatically converted (SILK→WAV) and transcribed
|
|
836
|
-
- `asrFallback` — platform ASR (`asr_refer_text`) participation switch. Unless explicitly set to `true`, QQ's built-in platform transcript is **discarded in all cases**: not used as a fallback when your STT fails or returns empty, and not used as the sole source when STT is not configured at all (voice messages then render as `[Voice message - transcription unavailable]`; the audio URL is still referenced via the `- Voice:` line). The flag is read from `channels.qqbot.stt.asrFallback` regardless of whether STT credentials resolve — `stt: { "asrFallback": true }` alone restores the legacy platform-transcript behavior:
|
|
827
|
+
Voice message handling order:
|
|
837
828
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
```
|
|
829
|
+
1. **Framework STT configured** (`tools.media.audio.models` non-empty) → voice is downloaded, converted (SILK→WAV), and transcribed through the framework pipeline. On failure or empty transcript, falls back to the platform transcript.
|
|
830
|
+
2. **Framework STT not configured** → the **QQ platform transcript (`asr_refer_text`)** — QQ auto-STTs voice messages and ships the text in the event JSON — is used directly as the sole source, no download and no external call needed.
|
|
831
|
+
3. Neither available → placeholder text (`[Voice message - transcription unavailable]`); the audio URL is still referenced via the `- Voice:` line.
|
|
832
|
+
|
|
833
|
+
Plugin-level behavior switches under `channels.qqbot.stt`:
|
|
834
|
+
|
|
835
|
+
- `enabled: false` — never call external STT; use only the platform transcript (or the placeholder)
|
|
836
|
+
- `asrFallback: false` — strict mode: discard the platform transcript in **all** cases (restores the pre-2026-10 behavior)
|
|
837
|
+
- `provider` / `baseUrl` / `apiKey` / `model` — **deprecated and ignored** (2026-10); a one-time migration notice is logged if still present. Move credentials to `tools.media.audio.models`.
|
|
851
838
|
|
|
852
839
|
#### TTS (Text-to-Speech) — Send Voice Messages
|
|
853
840
|
|
package/README.zh.md
CHANGED
|
@@ -631,44 +631,31 @@ openclaw message send --channel "qqbot" \
|
|
|
631
631
|
|
|
632
632
|
#### STT(语音转文字)— 自动转录用户发来的语音消息
|
|
633
633
|
|
|
634
|
-
STT
|
|
635
|
-
|
|
636
|
-
| 优先级 | 配置路径 | 作用域 |
|
|
637
|
-
|--------|----------|--------|
|
|
638
|
-
| 1(highest) | `channels.qqbot.stt` | 插件专属 |
|
|
639
|
-
| 2(fallback) | `tools.media.audio.models[0]` | 框架级 |
|
|
634
|
+
转录统一走**框架音频理解管线**(`openclaw/plugin-sdk/media-understanding-runtime`),STT 凭证只认框架级配置(与内置 Telegram 通道一致)——插件不再自带 OpenAI 兼容 HTTP 调用:
|
|
640
635
|
|
|
641
636
|
```json
|
|
642
637
|
{
|
|
643
|
-
"
|
|
644
|
-
"
|
|
645
|
-
"
|
|
646
|
-
"provider": "your-provider",
|
|
647
|
-
"model": "your-stt-model"
|
|
638
|
+
"tools": {
|
|
639
|
+
"media": {
|
|
640
|
+
"audio": {
|
|
641
|
+
"models": [{ "provider": "your-provider", "model": "your-stt-model" }]
|
|
648
642
|
}
|
|
649
643
|
}
|
|
650
644
|
}
|
|
651
645
|
}
|
|
652
646
|
```
|
|
653
647
|
|
|
654
|
-
|
|
655
|
-
- 设置 `enabled: false` 可禁用
|
|
656
|
-
- 配置后,用户发来的语音消息会自动转换(SILK→WAV)并转录为文字
|
|
657
|
-
- `asrFallback` — 平台转写(`asr_refer_text`)参与开关。**未显式设为 `true` 时,平台转写在所有场景下都被丢弃**:自有 STT 失败或返回空时不作兜底,STT 未配置时也不作为唯一来源(此时语音消息渲染为 `[Voice message - transcription unavailable]` 占位文本,音频 URL 仍通过 `- Voice:` 行引用)。该开关从 `channels.qqbot.stt.asrFallback` 读取,与 STT 凭证是否解析成功无关——仅写 `stt: { "asrFallback": true }` 即可恢复平台转写参与旧行为:
|
|
648
|
+
语音消息处理顺序:
|
|
658
649
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
```
|
|
650
|
+
1. **框架 STT 已配置**(`tools.media.audio.models` 非空)→ 下载语音、转换(SILK→WAV)后经框架管线转录;转录失败或为空时兜底用平台转写。
|
|
651
|
+
2. **框架 STT 未配置** → **直接采用 QQ 平台转写**(`asr_refer_text`,QQ 平台对语音消息自动 STT 并随事件 JSON 下发)作为唯一来源——无需下载、不发起任何外部调用。
|
|
652
|
+
3. 两者都不可用 → 占位文本(`[Voice message - transcription unavailable]`),音频 URL 仍通过 `- Voice:` 行引用。
|
|
653
|
+
|
|
654
|
+
`channels.qqbot.stt` 下仅保留行为开关:
|
|
655
|
+
|
|
656
|
+
- `enabled: false` — 不调用外部 STT,语音只用平台转写(或无转写时占位文本)
|
|
657
|
+
- `asrFallback: false` — 严格模式:所有场景丢弃平台转写(恢复 2026-10 之前的旧行为)
|
|
658
|
+
- `provider` / `baseUrl` / `apiKey` / `model` — **已废弃并被忽略**(2026-10);检测到仍配置时会打一次性迁移提示日志,请把凭证迁移到 `tools.media.audio.models`。
|
|
672
659
|
|
|
673
660
|
#### TTS(文字转语音)— 机器人发送语音消息
|
|
674
661
|
|
package/dist/index.cjs
CHANGED
|
@@ -8259,7 +8259,7 @@ var init_outbound_service = __esm({
|
|
|
8259
8259
|
|
|
8260
8260
|
// src/utils/pkg-version.ts
|
|
8261
8261
|
function getPackageVersion() {
|
|
8262
|
-
return true ? "
|
|
8262
|
+
return true ? "2.0.0" : "unknown";
|
|
8263
8263
|
}
|
|
8264
8264
|
function getOpenclawVersion(runtimeVersion) {
|
|
8265
8265
|
return runtimeVersion && runtimeVersion !== "unknown" ? runtimeVersion : "unknown";
|
|
@@ -12904,70 +12904,42 @@ init_webhook_server_node();
|
|
|
12904
12904
|
init_webhook_verify();
|
|
12905
12905
|
|
|
12906
12906
|
// src/utils/stt.ts
|
|
12907
|
-
var fs18 = __toESM(require("fs"), 1);
|
|
12908
12907
|
var path16 = __toESM(require("path"), 1);
|
|
12908
|
+
var import_media_understanding_runtime = require("openclaw/plugin-sdk/media-understanding-runtime");
|
|
12909
12909
|
function shouldUsePlatformAsr(cfg) {
|
|
12910
12910
|
const channels = asRecord(cfg.channels);
|
|
12911
12911
|
const qqbot = asRecord(channels?.qqbot);
|
|
12912
|
-
return asRecord(qqbot?.stt)?.asrFallback
|
|
12912
|
+
return asRecord(qqbot?.stt)?.asrFallback !== false;
|
|
12913
12913
|
}
|
|
12914
|
-
function
|
|
12914
|
+
function isFrameworkSttConfigured(cfg) {
|
|
12915
12915
|
const channels = asRecord(cfg.channels);
|
|
12916
12916
|
const qqbot = asRecord(channels?.qqbot);
|
|
12917
|
-
|
|
12918
|
-
|
|
12919
|
-
return null;
|
|
12920
|
-
}
|
|
12921
|
-
const models = asRecord(cfg.models);
|
|
12922
|
-
const providers = asRecord(models?.providers);
|
|
12923
|
-
if (sttCfg) {
|
|
12924
|
-
const providerId = readString(sttCfg, "provider") ?? "openai";
|
|
12925
|
-
const providerCfg = asRecord(providers?.[providerId]);
|
|
12926
|
-
const baseUrl = readString(sttCfg, "baseUrl") ?? readString(providerCfg, "baseUrl");
|
|
12927
|
-
const apiKey = readString(sttCfg, "apiKey") ?? readString(providerCfg, "apiKey");
|
|
12928
|
-
const model = readString(sttCfg, "model") ?? "whisper-1";
|
|
12929
|
-
if (baseUrl && apiKey) {
|
|
12930
|
-
return { enabled: true, baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model };
|
|
12931
|
-
}
|
|
12917
|
+
if (asRecord(qqbot?.stt)?.enabled === false) {
|
|
12918
|
+
return false;
|
|
12932
12919
|
}
|
|
12933
12920
|
const tools = asRecord(cfg.tools);
|
|
12934
12921
|
const media = asRecord(tools?.media);
|
|
12935
12922
|
const audio = asRecord(media?.audio);
|
|
12936
|
-
|
|
12937
|
-
|
|
12938
|
-
if (audioModelEntry) {
|
|
12939
|
-
const providerId = readString(audioModelEntry, "provider") ?? "openai";
|
|
12940
|
-
const providerCfg = asRecord(providers?.[providerId]);
|
|
12941
|
-
const baseUrl = readString(audioModelEntry, "baseUrl") ?? readString(providerCfg, "baseUrl");
|
|
12942
|
-
const apiKey = readString(audioModelEntry, "apiKey") ?? readString(providerCfg, "apiKey");
|
|
12943
|
-
const model = readString(audioModelEntry, "model") ?? "whisper-1";
|
|
12944
|
-
if (baseUrl && apiKey) {
|
|
12945
|
-
return { enabled: true, baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model };
|
|
12946
|
-
}
|
|
12923
|
+
if (!audio || audio.enabled === false) {
|
|
12924
|
+
return false;
|
|
12947
12925
|
}
|
|
12948
|
-
return
|
|
12926
|
+
return Array.isArray(audio.models) && audio.models.length > 0;
|
|
12949
12927
|
}
|
|
12950
|
-
|
|
12951
|
-
const
|
|
12952
|
-
|
|
12953
|
-
|
|
12954
|
-
|
|
12955
|
-
|
|
12956
|
-
|
|
12957
|
-
|
|
12958
|
-
|
|
12959
|
-
|
|
12960
|
-
|
|
12961
|
-
|
|
12962
|
-
|
|
12963
|
-
|
|
12964
|
-
body: form
|
|
12928
|
+
function hasLegacySttCredentials(cfg) {
|
|
12929
|
+
const channels = asRecord(cfg.channels);
|
|
12930
|
+
const qqbot = asRecord(channels?.qqbot);
|
|
12931
|
+
const stt = asRecord(qqbot?.stt);
|
|
12932
|
+
if (!stt) return false;
|
|
12933
|
+
return ["provider", "baseUrl", "apiKey", "model"].some(
|
|
12934
|
+
(key2) => typeof stt[key2] === "string" && stt[key2].trim().length > 0
|
|
12935
|
+
);
|
|
12936
|
+
}
|
|
12937
|
+
async function transcribeAudioViaFramework(audioPath, cfg) {
|
|
12938
|
+
const result = await (0, import_media_understanding_runtime.transcribeAudioFile)({
|
|
12939
|
+
filePath: audioPath,
|
|
12940
|
+
cfg,
|
|
12941
|
+
mime: guessMimeType(audioPath)
|
|
12965
12942
|
});
|
|
12966
|
-
if (!resp.ok) {
|
|
12967
|
-
const detail = await resp.text().catch(() => "");
|
|
12968
|
-
throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
|
|
12969
|
-
}
|
|
12970
|
-
const result = await resp.json();
|
|
12971
12943
|
return result.text?.trim() || null;
|
|
12972
12944
|
}
|
|
12973
12945
|
function asRecord(value) {
|
|
@@ -12976,16 +12948,6 @@ function asRecord(value) {
|
|
|
12976
12948
|
}
|
|
12977
12949
|
return void 0;
|
|
12978
12950
|
}
|
|
12979
|
-
function readString(obj, key2) {
|
|
12980
|
-
const val = obj?.[key2];
|
|
12981
|
-
if (typeof val === "string" && val.trim()) {
|
|
12982
|
-
return val.trim();
|
|
12983
|
-
}
|
|
12984
|
-
return void 0;
|
|
12985
|
-
}
|
|
12986
|
-
function sanitizeFileName2(name) {
|
|
12987
|
-
return name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
12988
|
-
}
|
|
12989
12951
|
function guessMimeType(fileName) {
|
|
12990
12952
|
const ext = path16.extname(fileName).toLowerCase();
|
|
12991
12953
|
const mimeMap = {
|
|
@@ -13061,8 +13023,9 @@ function attachmentProcessor(opts) {
|
|
|
13061
13023
|
};
|
|
13062
13024
|
}
|
|
13063
13025
|
async function processAttachments(attachments, cfg, log5) {
|
|
13064
|
-
const sttCfg = resolveSTTConfig(cfg);
|
|
13065
13026
|
const usePlatformAsr = shouldUsePlatformAsr(cfg);
|
|
13027
|
+
const sttConfigured = isFrameworkSttConfigured(cfg);
|
|
13028
|
+
warnLegacySttCredentials(cfg, log5);
|
|
13066
13029
|
const audioPolicy = resolveAudioPolicy(cfg);
|
|
13067
13030
|
const imageUrls = [];
|
|
13068
13031
|
const otherParts = [];
|
|
@@ -13080,7 +13043,7 @@ async function processAttachments(attachments, cfg, log5) {
|
|
|
13080
13043
|
return { type: "image", localPath, url, contentType: att.content_type ?? "image/png", filename: att.filename };
|
|
13081
13044
|
}
|
|
13082
13045
|
if (isVoice) {
|
|
13083
|
-
const transcript = await processVoiceAttachment(att,
|
|
13046
|
+
const transcript = await processVoiceAttachment(att, cfg, usePlatformAsr, sttConfigured, audioPolicy, log5);
|
|
13084
13047
|
return { type: "voice", transcript };
|
|
13085
13048
|
}
|
|
13086
13049
|
if (url) {
|
|
@@ -13153,16 +13116,25 @@ function kindFromContentType(contentType) {
|
|
|
13153
13116
|
if (contentType?.startsWith("audio/")) return "audio";
|
|
13154
13117
|
return "document";
|
|
13155
13118
|
}
|
|
13156
|
-
|
|
13119
|
+
var legacySttWarned = false;
|
|
13120
|
+
function warnLegacySttCredentials(cfg, log5) {
|
|
13121
|
+
if (!legacySttWarned && hasLegacySttCredentials(cfg)) {
|
|
13122
|
+
legacySttWarned = true;
|
|
13123
|
+
log5?.info(
|
|
13124
|
+
"Voice: channels.qqbot.stt credentials (provider/baseUrl/apiKey/model) are deprecated and ignored; configure tools.media.audio.models instead \u2014 platform asr_refer_text is used when framework STT is absent"
|
|
13125
|
+
);
|
|
13126
|
+
}
|
|
13127
|
+
}
|
|
13128
|
+
async function processVoiceAttachment(att, cfg, usePlatformAsr, sttConfigured, audioPolicy, log5) {
|
|
13157
13129
|
const rawAsrText = att.asr_refer_text?.trim() || void 0;
|
|
13158
13130
|
const asrReferText = usePlatformAsr ? rawAsrText : void 0;
|
|
13159
13131
|
const remoteUrl = normalizeUrl(att.voice_wav_url) || normalizeUrl(att.url) || void 0;
|
|
13160
|
-
if (!
|
|
13132
|
+
if (!sttConfigured) {
|
|
13161
13133
|
if (!usePlatformAsr && rawAsrText) {
|
|
13162
|
-
log5?.info(`Voice: STT not configured; platform asr_refer_text discarded (asrFallback
|
|
13134
|
+
log5?.info(`Voice: framework STT not configured; platform asr_refer_text discarded (asrFallback: false)`);
|
|
13163
13135
|
}
|
|
13164
13136
|
if (asrReferText) {
|
|
13165
|
-
log5?.debug?.(`Voice: using asr_refer_text (STT not configured
|
|
13137
|
+
log5?.debug?.(`Voice: using platform asr_refer_text (framework STT not configured)`);
|
|
13166
13138
|
return { text: asrReferText, source: "asr", asrReferText, remoteUrl };
|
|
13167
13139
|
}
|
|
13168
13140
|
return {
|
|
@@ -13209,16 +13181,17 @@ async function processVoiceAttachment(att, sttCfg, usePlatformAsr, audioPolicy,
|
|
|
13209
13181
|
}
|
|
13210
13182
|
if (localPath) {
|
|
13211
13183
|
try {
|
|
13212
|
-
const transcript = await
|
|
13184
|
+
const transcript = await transcribeAudioViaFramework(localPath, cfg);
|
|
13213
13185
|
if (transcript) {
|
|
13214
|
-
log5?.debug?.(`Voice STT: ${transcript.slice(0, 80)}...`);
|
|
13186
|
+
log5?.debug?.(`Voice STT (framework): ${transcript.slice(0, 80)}...`);
|
|
13215
13187
|
return { text: transcript, source: "stt", duration, localPath, remoteUrl, asrReferText };
|
|
13216
13188
|
}
|
|
13217
13189
|
} catch (err) {
|
|
13218
|
-
log5?.error(`Voice STT failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
13190
|
+
log5?.error(`Voice STT (framework) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
13219
13191
|
}
|
|
13220
13192
|
}
|
|
13221
13193
|
if (asrReferText) {
|
|
13194
|
+
log5?.debug?.(`Voice: falling back to platform asr_refer_text after framework STT failure`);
|
|
13222
13195
|
return { text: asrReferText, source: "asr", duration, localPath, remoteUrl, asrReferText };
|
|
13223
13196
|
}
|
|
13224
13197
|
return {
|
|
@@ -13248,9 +13221,6 @@ function normalizeFormats(formats) {
|
|
|
13248
13221
|
return lower.startsWith(".") ? lower : `.${lower}`;
|
|
13249
13222
|
});
|
|
13250
13223
|
}
|
|
13251
|
-
function cfg2stt(sttCfg) {
|
|
13252
|
-
return { channels: { qqbot: { stt: sttCfg } } };
|
|
13253
|
-
}
|
|
13254
13224
|
function normalizeUrl(url) {
|
|
13255
13225
|
if (!url) return "";
|
|
13256
13226
|
return url.startsWith("//") ? `https:${url}` : url;
|
|
@@ -15891,7 +15861,7 @@ function getQQBotApprovalCapability() {
|
|
|
15891
15861
|
|
|
15892
15862
|
// src/features/proactive.ts
|
|
15893
15863
|
init_plugin_logger();
|
|
15894
|
-
var
|
|
15864
|
+
var fs18 = __toESM(require("fs"), 1);
|
|
15895
15865
|
var path20 = __toESM(require("path"), 1);
|
|
15896
15866
|
init_bot_instance();
|
|
15897
15867
|
init_config();
|
|
@@ -15902,8 +15872,8 @@ var KNOWN_USERS_FILE = path20.join(STORAGE_DIR, "known-users.json");
|
|
|
15902
15872
|
var knownUsersCache = null;
|
|
15903
15873
|
var cacheLastModified = 0;
|
|
15904
15874
|
function ensureStorageDir() {
|
|
15905
|
-
if (!
|
|
15906
|
-
|
|
15875
|
+
if (!fs18.existsSync(STORAGE_DIR)) {
|
|
15876
|
+
fs18.mkdirSync(STORAGE_DIR, { recursive: true });
|
|
15907
15877
|
}
|
|
15908
15878
|
}
|
|
15909
15879
|
function getUserKey(type, openid, accountId) {
|
|
@@ -15912,7 +15882,7 @@ function getUserKey(type, openid, accountId) {
|
|
|
15912
15882
|
function loadKnownUsers() {
|
|
15913
15883
|
if (knownUsersCache !== null) {
|
|
15914
15884
|
try {
|
|
15915
|
-
const stat =
|
|
15885
|
+
const stat = fs18.statSync(KNOWN_USERS_FILE);
|
|
15916
15886
|
if (stat.mtimeMs <= cacheLastModified) {
|
|
15917
15887
|
return knownUsersCache;
|
|
15918
15888
|
}
|
|
@@ -15922,14 +15892,14 @@ function loadKnownUsers() {
|
|
|
15922
15892
|
}
|
|
15923
15893
|
const users = /* @__PURE__ */ new Map();
|
|
15924
15894
|
try {
|
|
15925
|
-
if (
|
|
15926
|
-
const data =
|
|
15895
|
+
if (fs18.existsSync(KNOWN_USERS_FILE)) {
|
|
15896
|
+
const data = fs18.readFileSync(KNOWN_USERS_FILE, "utf-8");
|
|
15927
15897
|
const parsed = JSON.parse(data);
|
|
15928
15898
|
for (const user of parsed) {
|
|
15929
15899
|
const key2 = getUserKey(user.type, user.openid, user.accountId);
|
|
15930
15900
|
users.set(key2, user);
|
|
15931
15901
|
}
|
|
15932
|
-
cacheLastModified =
|
|
15902
|
+
cacheLastModified = fs18.statSync(KNOWN_USERS_FILE).mtimeMs;
|
|
15933
15903
|
}
|
|
15934
15904
|
} catch (err) {
|
|
15935
15905
|
log3.error(`Failed to load known users: ${err}`);
|
|
@@ -15941,7 +15911,7 @@ function saveKnownUsers(users) {
|
|
|
15941
15911
|
try {
|
|
15942
15912
|
ensureStorageDir();
|
|
15943
15913
|
const data = Array.from(users.values());
|
|
15944
|
-
|
|
15914
|
+
fs18.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
15945
15915
|
cacheLastModified = Date.now();
|
|
15946
15916
|
knownUsersCache = users;
|
|
15947
15917
|
} catch (err) {
|