@zaofan/dsh-qqbot 1.5.7 → 1.5.8
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 +38 -0
- package/client/qqbot-settings.js +5 -0
- package/client/qqbot-settings.js.bak-ga-041943 +4775 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +27 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +0 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +52 -26
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/settings-host.js +308 -33
package/settings-host.js
CHANGED
|
@@ -128,25 +128,66 @@ function readEntryConfigFromPatch(entryId) {
|
|
|
128
128
|
if (ci < 0) return null;
|
|
129
129
|
const cfgIndent = (block[ci].match(/^\s*/) || [''])[0].length;
|
|
130
130
|
const out = {};
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
131
|
+
const parseScalar = (v) => {
|
|
132
|
+
const s = String(v).trim().replace(/^['"]|['"]$/g, '');
|
|
133
|
+
if (s === 'true') return true;
|
|
134
|
+
if (s === 'false') return false;
|
|
135
|
+
if (s === 'null' || s === '~') return null;
|
|
136
|
+
if (/^-?\d+$/.test(s)) return Number(s);
|
|
137
|
+
return s;
|
|
138
|
+
};
|
|
139
|
+
// 递归解析(2026-09-24:之前只取一层,groupAdmin 这类嵌套对象的子字段全被跳过 →
|
|
140
|
+
// 前端读回来是 undefined,勾选后"保存了却跳回去")
|
|
141
|
+
const parseBlock = (from, to, indent) => {
|
|
142
|
+
const obj = {};
|
|
143
|
+
for (let i = from; i < to; i++) {
|
|
144
|
+
const raw = block[i];
|
|
145
|
+
if (!raw.trim() || /^\s*#/.test(raw)) continue;
|
|
146
|
+
if (((raw.match(/^\s*/) || [''])[0]).length !== indent) continue;
|
|
147
|
+
const m = /^\s*([A-Za-z0-9_-]+):\s*(.*)$/.exec(raw);
|
|
148
|
+
if (!m) continue;
|
|
149
|
+
const key = m[1];
|
|
150
|
+
const rest = m[2].trim();
|
|
151
|
+
let subEnd = i + 1;
|
|
152
|
+
while (subEnd < to) {
|
|
153
|
+
const l = block[subEnd];
|
|
154
|
+
if (l.trim() && ((l.match(/^\s*/) || [''])[0]).length <= indent) break;
|
|
155
|
+
subEnd++;
|
|
156
|
+
}
|
|
157
|
+
if (rest === '') {
|
|
158
|
+
const inner = block.slice(i + 1, subEnd);
|
|
159
|
+
if (inner.some((l) => /^\s*-\s/.test(l))) {
|
|
160
|
+
const arr = [];
|
|
161
|
+
for (const l of inner) {
|
|
162
|
+
const am = /^\s*-\s*(.*)$/.exec(l);
|
|
163
|
+
if (am && am[1].trim()) arr.push(parseScalar(am[1]));
|
|
164
|
+
}
|
|
165
|
+
obj[key] = arr;
|
|
166
|
+
} else {
|
|
167
|
+
obj[key] = parseBlock(i + 1, subEnd, indent + 2);
|
|
168
|
+
}
|
|
169
|
+
} else if (rest === '[]') {
|
|
170
|
+
obj[key] = [];
|
|
171
|
+
} else if (rest.startsWith('!!js')) {
|
|
172
|
+
obj[key] = rest; // 表达式:原样返回(前端只读展示)
|
|
173
|
+
} else {
|
|
174
|
+
obj[key] = parseScalar(rest);
|
|
175
|
+
}
|
|
176
|
+
i = subEnd - 1;
|
|
177
|
+
}
|
|
178
|
+
return obj;
|
|
179
|
+
};
|
|
180
|
+
const parsed = parseBlock(ci + 1, block.length, cfgIndent + 2);
|
|
181
|
+
const redact = (o) => {
|
|
182
|
+
if (!o || typeof o !== 'object') return o;
|
|
183
|
+
for (const k of Object.keys(o)) {
|
|
184
|
+
if (/secret|token|password|_key$/i.test(k)) o[k] = '__REDACTED__';
|
|
185
|
+
else if (o[k] && typeof o[k] === 'object') redact(o[k]);
|
|
186
|
+
}
|
|
187
|
+
return o;
|
|
188
|
+
};
|
|
189
|
+
redact(parsed);
|
|
190
|
+
for (const [k, v] of Object.entries(parsed)) out[k] = v;
|
|
150
191
|
return Object.keys(out).length ? out : null;
|
|
151
192
|
} catch { return null; }
|
|
152
193
|
}
|
|
@@ -163,6 +204,75 @@ function viewOf(settings, ns) {
|
|
|
163
204
|
return { value: undefined, revision: undefined };
|
|
164
205
|
}
|
|
165
206
|
|
|
207
|
+
/**
|
|
208
|
+
* ── 插件自有设置存储(2026-09-24)──
|
|
209
|
+
*
|
|
210
|
+
* 为什么不再写 profile 的 `cordis.patch.yml`:
|
|
211
|
+
* ① 0.1.7 下写 patch 会被宿主当**热更新**提交 → 插件重新 apply → 本插件启动时若凭据无效会再写
|
|
212
|
+
* → 自反馈闭环(实测把 dsh 启动刷死、无限弹二维码);
|
|
213
|
+
* ② 手拼 YAML 保不住字段类型(实测 appId 写成裸数字、owners 空数组写成空串 → 宿主 schema 校验失败);
|
|
214
|
+
* ③ 不支持"新增键"(groupAdmin.pollJoinRequests 这类写不进去)。
|
|
215
|
+
*
|
|
216
|
+
* 改用插件自己的 JSON 存储:不触发任何热更新、JSON 天生有类型、随便加键,
|
|
217
|
+
* 而且**用户不需要手改任何配置文件**(可视化的初衷)。
|
|
218
|
+
* 位置:{DSH_HOME}/qqbot-settings/<instanceNs>.json
|
|
219
|
+
*/
|
|
220
|
+
const OWN_SETTINGS_DIR = join(DSH_HOME_DIR, 'qqbot-settings');
|
|
221
|
+
function ownSettingsPath(ns) {
|
|
222
|
+
const safe = String(ns || NS).replace(/[^A-Za-z0-9_-]/g, '_');
|
|
223
|
+
return join(OWN_SETTINGS_DIR, safe + '.json');
|
|
224
|
+
}
|
|
225
|
+
function readOwnSettings(ns) {
|
|
226
|
+
try {
|
|
227
|
+
const p = ownSettingsPath(ns);
|
|
228
|
+
if (!existsSync(p)) return {};
|
|
229
|
+
const o = JSON.parse(readFileSync(p, 'utf8'));
|
|
230
|
+
return o && typeof o === 'object' ? o : {};
|
|
231
|
+
} catch { return {}; }
|
|
232
|
+
}
|
|
233
|
+
function writeOwnSettings(ns, obj) {
|
|
234
|
+
const p = ownSettingsPath(ns);
|
|
235
|
+
try { mkdirSync(OWN_SETTINGS_DIR, { recursive: true }); } catch { /* ignore */ }
|
|
236
|
+
try { if (existsSync(p)) writeFileSync(p + '.bak-' + Date.now(), readFileSync(p)); } catch { /* 备份失败不阻塞 */ }
|
|
237
|
+
writeFileSync(p, JSON.stringify(obj, null, 2), 'utf8');
|
|
238
|
+
return p;
|
|
239
|
+
}
|
|
240
|
+
/** 深合并一层(够用:本插件嵌套只到 groupAdmin / sticker.gates 这一层)。 */
|
|
241
|
+
function mergeDeepOne(cur, patch) {
|
|
242
|
+
const out = Object.assign({}, cur);
|
|
243
|
+
for (const [k, v] of Object.entries(patch || {})) {
|
|
244
|
+
if (v === undefined) continue;
|
|
245
|
+
if (v === '__REDACTED__') continue; // 脱敏占位符绝不写回
|
|
246
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && out[k] && typeof out[k] === 'object' && !Array.isArray(out[k])) {
|
|
247
|
+
out[k] = Object.assign({}, out[k], v);
|
|
248
|
+
} else {
|
|
249
|
+
out[k] = v;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 按 (条目, 缩进层级, 键名) 去掉重复键,保留**最后一次**出现(YAML 语义)。
|
|
256
|
+
* 2026-09-24 兜底:写 patch 前必跑。重复键会让宿主的 YAML 严格解析直接抛错
|
|
257
|
+
* (YAMLException: duplicated mapping key)→ dsh 完全启动不了。 */
|
|
258
|
+
function dedupeYamlKeys(text) {
|
|
259
|
+
const lines = String(text).split(/\r?\n/);
|
|
260
|
+
const scope = ['']; const seen = new Map(); const keep = new Array(lines.length).fill(true);
|
|
261
|
+
let cur = '';
|
|
262
|
+
for (let i = 0; i < lines.length; i++) {
|
|
263
|
+
const l = lines[i];
|
|
264
|
+
if (!l.trim() || l.trimStart().startsWith('#')) continue;
|
|
265
|
+
const mId = /^(\s*)-\s*id:\s*(\S+)\s*$/.exec(l);
|
|
266
|
+
if (mId) { cur = mId[2]; seen.clear(); continue; }
|
|
267
|
+
const mK = /^(\s*)([A-Za-z0-9_-]+):/.exec(l);
|
|
268
|
+
if (!mK) continue;
|
|
269
|
+
const sig = cur + '|' + mK[1].length + '|' + mK[2];
|
|
270
|
+
if (seen.has(sig)) keep[seen.get(sig)] = false;
|
|
271
|
+
seen.set(sig, i);
|
|
272
|
+
}
|
|
273
|
+
return lines.filter((_, i) => keep[i]).join('\n');
|
|
274
|
+
}
|
|
275
|
+
|
|
166
276
|
/** 请求里的目录参数(dataDir 显式传; 空=primary 主账号) */
|
|
167
277
|
function dirOf(u, key) {
|
|
168
278
|
return (u?.searchParams?.get(key) || '').trim() || undefined;
|
|
@@ -232,22 +342,37 @@ export function apply(ctx) {
|
|
|
232
342
|
// ── 设置面板读写桥(多账号: ?ns= 选实例命名空间; 缺省 im-qqbot 主账号) ──
|
|
233
343
|
route(ctx, 'GET', '/api/qqbot-settings/read', async (req, res) => {
|
|
234
344
|
const u = new URL(req.url ?? '/', 'http://x');
|
|
235
|
-
|
|
345
|
+
const ns = u.searchParams.get('ns') || NS;
|
|
346
|
+
const base = viewOf(ctx.settings, ns) || {};
|
|
347
|
+
const own = readOwnSettings(ns);
|
|
348
|
+
// 自有存储优先:它就是用户在设置页保存的那份
|
|
349
|
+
const value = Object.assign({}, base.value || {}, own);
|
|
350
|
+
writeJson(res, 200, { value, revision: base.revision, source: Object.keys(own).length ? 'own' : 'patch' });
|
|
236
351
|
});
|
|
237
352
|
route(ctx, 'POST', '/api/qqbot-settings/update', async (req, res) => {
|
|
238
353
|
const body = await readJsonBody(req);
|
|
239
354
|
if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'body must be JSON object' });
|
|
240
355
|
const ns = typeof body.ns === 'string' && body.ns ? body.ns : NS;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
356
|
+
// ⚠️ 2026-09-24 起:保存一律写**插件自有存储**({DSH_HOME}/qqbot-settings/<ns>.json),
|
|
357
|
+
// 绝不写 profile 的 cordis.patch.yml —— 写 patch 会被宿主当热更新提交,本插件启动时
|
|
358
|
+
// 若凭据无效会再写配置,形成自反馈闭环(实测把 dsh 启动刷死、无限弹二维码)。
|
|
359
|
+
// 自有存储还让「用户不必手改任何配置文件」(可视化的初衷)。
|
|
360
|
+
try {
|
|
361
|
+
const cur = readOwnSettings(ns);
|
|
362
|
+
const merged = mergeDeepOne(cur, body.patch ?? {});
|
|
363
|
+
const file = writeOwnSettings(ns, merged);
|
|
364
|
+
// 通知插件实例重新读取(桥与插件同进程;不写宿主配置 → 不触发任何热更新)
|
|
365
|
+
try { ctx.emit('qqbot/settings-changed', ns); } catch { /* 无监听者也无妨 */ }
|
|
366
|
+
const base = viewOf(ctx.settings, ns) || {};
|
|
367
|
+
writeJson(res, 200, {
|
|
368
|
+
value: Object.assign({}, base.value || {}, merged),
|
|
369
|
+
revision: base.revision,
|
|
370
|
+
mode: 'own',
|
|
371
|
+
file,
|
|
372
|
+
note: '已保存(插件自有存储,热生效)',
|
|
373
|
+
});
|
|
247
374
|
} catch (e) {
|
|
248
|
-
|
|
249
|
-
if (code === 'SETTINGS_CONFLICT' || code === 'SettingsConflictError') return writeJson(res, 409, { error: e.message });
|
|
250
|
-
writeJson(res, 400, { error: String(e?.message ?? e) });
|
|
375
|
+
writeJson(res, 500, { error: '保存失败:' + String(e?.message ?? e) });
|
|
251
376
|
}
|
|
252
377
|
});
|
|
253
378
|
|
|
@@ -577,11 +702,22 @@ export function apply(ctx) {
|
|
|
577
702
|
|
|
578
703
|
if (orig) {
|
|
579
704
|
const kv = {};
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
705
|
+
// ✅ 2026-09-24:已有账号的 appId/appSecret/preset/cwd 只写**自有存储**,不再写 patch。
|
|
706
|
+
// 原因:patch 里 - insert: 下挂多条目时,updateKeysInBlock 的块边界会跨到隔壁条目,
|
|
707
|
+
// 实测把 im-qqbot-2 的凭据写进了 mcp-chrome、并给 im-qqbot-3 追加重复键。
|
|
708
|
+
// 插件启动时自有存储会覆盖 patch 值,所以只写自有存储完全等效,且绝不会写歪。
|
|
709
|
+
// disabled 例外:它是宿主的 entry 属性、不在 config 里,必须继续写 patch。
|
|
710
|
+
const ownKv = {};
|
|
711
|
+
if (inst.appId !== undefined) ownKv.appId = String(inst.appId);
|
|
712
|
+
if (secret) ownKv.appSecret = String(secret);
|
|
713
|
+
if (inst.preset !== undefined && String(inst.preset) !== '') ownKv.preset = String(inst.preset);
|
|
714
|
+
if (inst.cwd !== undefined && String(inst.cwd) !== '') ownKv.cwd = String(inst.cwd);
|
|
715
|
+
try {
|
|
716
|
+
const nsKey = String(inst.settingsNs || inst.id);
|
|
717
|
+
writeOwnSettings(nsKey, Object.assign(readOwnSettings(nsKey), ownKv));
|
|
718
|
+
} catch { /* 自有存储失败 → 退化到写 patch(下面仍会处理 disabled) */ }
|
|
584
719
|
if (inst.disabled !== undefined) kv.disabled = inst.disabled ? 'true' : 'false';
|
|
720
|
+
if (Object.keys(kv).length) updates.push({ id: String(inst.id), kv });
|
|
585
721
|
updates.push({ id: String(inst.id), kv });
|
|
586
722
|
} else {
|
|
587
723
|
appends.push(renderBotBlock({ ...inst, appSecret: secret, dataRoot }));
|
|
@@ -603,6 +739,27 @@ export function apply(ctx) {
|
|
|
603
739
|
let out = lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
|
|
604
740
|
if (appends.length) out += (out.trimEnd() ? '\n' : '') + appends.join('\n') + '\n';
|
|
605
741
|
writeFileSync(`${PATCH_FILE}.bak`, raw, 'utf8');
|
|
742
|
+
// ✅ 2026-09-24 兜底①:写前按 (条目,缩进,键) 去重。
|
|
743
|
+
// 起因:`- insert:` 下挂多条目时块边界会跨到隔壁条目,曾把凭据写进 mcp-chrome、
|
|
744
|
+
// 并给 im-qqbot-3 追加重复键;重复键会让宿主 YAML 解析抛错、dsh 完全起不来。
|
|
745
|
+
out = dedupeYamlKeys(out);
|
|
746
|
+
// ✅ 2026-09-24 兜底②:把凭据同步写进插件自有存储。
|
|
747
|
+
// 插件启动时自有存储**覆盖** patch 的值,所以即便 patch 被写歪,机器人照样用正确凭据连上。
|
|
748
|
+
try {
|
|
749
|
+
for (const inst of instances) {
|
|
750
|
+
if (inst.remove) continue;
|
|
751
|
+
const nsKey = String(inst.settingsNs || inst.id);
|
|
752
|
+
const cur = readOwnSettings(nsKey);
|
|
753
|
+
let sec = String(inst.appSecret ?? '');
|
|
754
|
+
if (!sec || sec === SECRET_MASK) sec = cur.appSecret ? String(cur.appSecret) : '';
|
|
755
|
+
if (inst.appId !== undefined) cur.appId = inst.appId;
|
|
756
|
+
if (sec) cur.appSecret = sec;
|
|
757
|
+
if (inst.preset) cur.preset = String(inst.preset);
|
|
758
|
+
if (inst.cwd) cur.cwd = String(inst.cwd);
|
|
759
|
+
if (inst.disabled !== undefined) cur.disabled = !!inst.disabled;
|
|
760
|
+
writeOwnSettings(nsKey, cur);
|
|
761
|
+
}
|
|
762
|
+
} catch { /* 自有存储失败不阻塞 patch 写入 */ }
|
|
606
763
|
writeFileSync(PATCH_FILE, out, 'utf8');
|
|
607
764
|
return { ok: true, file: PATCH_FILE, needRestart: true };
|
|
608
765
|
}
|
|
@@ -1089,6 +1246,124 @@ export function apply(ctx) {
|
|
|
1089
1246
|
return { ok: true, backup: bak, newId, from: 'document' };
|
|
1090
1247
|
}
|
|
1091
1248
|
|
|
1249
|
+
/** YAML 标量序列化。forceQuote=true 时强制加引号(保留原字段的字符串类型 —— 例如 appId
|
|
1250
|
+
* 的 schema 是 string,裸写数字会被 YAML 解析成 number → 宿主 schema 校验失败)。 */
|
|
1251
|
+
function yScalar(v, forceQuote) {
|
|
1252
|
+
if (v === null || v === undefined) return "''";
|
|
1253
|
+
if (typeof v === 'boolean') return String(v);
|
|
1254
|
+
const s = String(v);
|
|
1255
|
+
if (typeof v === 'number' && !forceQuote) return s; // 真正的数字字段(historyLimit 等)保持数字
|
|
1256
|
+
if (forceQuote) return "'" + s.replace(/'/g, "''") + "'";
|
|
1257
|
+
if (typeof v === 'string') {
|
|
1258
|
+
if (s === '' || /[:#'"\[\]{}&*!|>%@`,]/.test(s) || /^\s|\s$/.test(s) || /^(true|false|null|~|-?\d)/i.test(s)) {
|
|
1259
|
+
return "'" + s.replace(/'/g, "''") + "'";
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return s;
|
|
1263
|
+
}
|
|
1264
|
+
/** 把值序列化成"一行 YAML"(数组走流式,避免多行插入)。keepType 提示按原字段类型保真。 */
|
|
1265
|
+
function yValue(v, keepQuoted, origWasArray) {
|
|
1266
|
+
if (Array.isArray(v)) {
|
|
1267
|
+
if (!v.length) return '[]';
|
|
1268
|
+
return '[' + v.map((it) => yScalar(it, typeof it === 'string')).join(', ') + ']';
|
|
1269
|
+
}
|
|
1270
|
+
if (v === '' || v === null || v === undefined) {
|
|
1271
|
+
// ⚠️ 空值必须看原字段类型:原本是数组的(owners)写成 `''` 会让宿主 schema 校验失败
|
|
1272
|
+
return origWasArray ? '[]' : "''";
|
|
1273
|
+
}
|
|
1274
|
+
return yScalar(v, keepQuoted || typeof v === 'string');
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* 把一批字段合并进 profile patch 里某个 entry 的 `config`(dsh 0.1.7 路径,2026-09-24)。
|
|
1279
|
+
*
|
|
1280
|
+
* 为什么需要:0.1.7 的 `settings.update()` 只接受 **schema 里声明为 volatile** 的字段
|
|
1281
|
+
* (源码:`const form = volatileForm(schema); if (!form) throw new Error('Plugin entry "x" has no volatile fields')`),
|
|
1282
|
+
* 本插件用自定义 config、没有 schemastery schema → 保存必被拒。这里改为直接改 profile patch:
|
|
1283
|
+
* **已存在的字段就地替换值**;patch 里没有的字段记入 `missing` 返回给前端(不擅自新增结构)。
|
|
1284
|
+
* 支持一层嵌套对象。写前自动备份,换行逐行保真。
|
|
1285
|
+
*/
|
|
1286
|
+
function mergeEntryConfigInPatch(entryId, patch) {
|
|
1287
|
+
try {
|
|
1288
|
+
if (!existsSync(PATCH_FILE)) return { ok: false, error: 'profile patch 不存在' };
|
|
1289
|
+
if (!patch || typeof patch !== 'object') return { ok: false, error: 'patch 必须是对象' };
|
|
1290
|
+
const raw = readFileSync(PATCH_FILE, 'utf8');
|
|
1291
|
+
const parts = raw.split(/(\r\n|\n)/);
|
|
1292
|
+
const lineTexts = [];
|
|
1293
|
+
const eols = [];
|
|
1294
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1295
|
+
if (i % 2 === 0) lineTexts.push(parts[i]);
|
|
1296
|
+
else eols.push(parts[i]);
|
|
1297
|
+
}
|
|
1298
|
+
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1299
|
+
const headRe = new RegExp('^\\s*-\\s*id:\\s*[\'"]?' + esc(entryId) + '[\'"]?\\s*$');
|
|
1300
|
+
const start = lineTexts.findIndex((l) => headRe.test(l));
|
|
1301
|
+
if (start < 0) return { ok: false, error: '在 profile patch 里找不到 entry ' + entryId };
|
|
1302
|
+
const headIndent = (lineTexts[start].match(/^\s*/) || [''])[0].length;
|
|
1303
|
+
let end = lineTexts.length;
|
|
1304
|
+
for (let i = start + 1; i < lineTexts.length; i++) {
|
|
1305
|
+
const ind = (lineTexts[i].match(/^\s*/) || [''])[0].length;
|
|
1306
|
+
if (/- /.test(lineTexts[i]) && ind <= headIndent) { end = i; break; }
|
|
1307
|
+
}
|
|
1308
|
+
let ci = -1;
|
|
1309
|
+
for (let i = start + 1; i < end; i++) {
|
|
1310
|
+
if (/^\s*config:\s*$/.test(lineTexts[i])) { ci = i; break; }
|
|
1311
|
+
}
|
|
1312
|
+
if (ci < 0) return { ok: false, error: 'entry ' + entryId + ' 在 profile patch 里没有 config: 段' };
|
|
1313
|
+
const cfgIndent = (lineTexts[ci].match(/^\s*/) || [''])[0].length;
|
|
1314
|
+
const keyIndent = cfgIndent + 2;
|
|
1315
|
+
let cEnd = ci + 1;
|
|
1316
|
+
for (let i = ci + 1; i < end; i++) {
|
|
1317
|
+
if (lineTexts[i].trim() && ((lineTexts[i].match(/^\s*/) || [''])[0]).length <= cfgIndent) break;
|
|
1318
|
+
cEnd = i + 1;
|
|
1319
|
+
}
|
|
1320
|
+
const missing = [];
|
|
1321
|
+
const setKey = (key, value, from, to, indent) => {
|
|
1322
|
+
// ⚠️ 脱敏占位符绝不写回(2026-09-24 实测事故):设置页读到的是 viewOf 打码过的
|
|
1323
|
+
// `__REDACTED__`,若原样写入就会把真密钥覆盖掉。这个值一律视为"未修改"。
|
|
1324
|
+
if (value === '__REDACTED__') return true;
|
|
1325
|
+
const re = new RegExp('^\\s{' + indent + '}' + esc(key) + ':\\s*(.*)$');
|
|
1326
|
+
for (let i = from; i < to; i++) {
|
|
1327
|
+
if (!re.test(lineTexts[i])) continue;
|
|
1328
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
1329
|
+
let subEnd = i + 1;
|
|
1330
|
+
for (let j = i + 1; j < to; j++) {
|
|
1331
|
+
if (lineTexts[j].trim() && ((lineTexts[j].match(/^\s*/) || [''])[0]).length <= indent) break;
|
|
1332
|
+
subEnd = j + 1;
|
|
1333
|
+
}
|
|
1334
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1335
|
+
if (v === undefined) continue;
|
|
1336
|
+
if (!setKey(k, v, i + 1, subEnd, indent + 2)) missing.push(key + '.' + k);
|
|
1337
|
+
}
|
|
1338
|
+
return true;
|
|
1339
|
+
}
|
|
1340
|
+
// 按**原字段类型**保真写入(引号风格 / 数组 / 空值),避免写坏宿主 schema:
|
|
1341
|
+
// 实测踩坑:appId 裸写数字 → "expected string";owners 空值写成 '' → "expected array"
|
|
1342
|
+
const origRaw = String(lineTexts[i] || '');
|
|
1343
|
+
const origVal = ((origRaw.match(/:\s*(.*)$/) || [])[1] || '').trim();
|
|
1344
|
+
const origWasArray = origVal === '[]' || origVal.startsWith('- ');
|
|
1345
|
+
const keepQuoted = /^['"]/.test(origVal);
|
|
1346
|
+
lineTexts[i] = ' '.repeat(indent) + key + ': ' + yValue(value, keepQuoted, origWasArray);
|
|
1347
|
+
return true;
|
|
1348
|
+
}
|
|
1349
|
+
return false;
|
|
1350
|
+
};
|
|
1351
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
1352
|
+
if (v === undefined) continue;
|
|
1353
|
+
if (!setKey(k, v, ci + 1, cEnd, keyIndent)) missing.push(k);
|
|
1354
|
+
}
|
|
1355
|
+
let out = '';
|
|
1356
|
+
for (let i = 0; i < lineTexts.length; i++) {
|
|
1357
|
+
out += lineTexts[i];
|
|
1358
|
+
if (i < eols.length) out += eols[i];
|
|
1359
|
+
}
|
|
1360
|
+
const bak = PATCH_FILE + '.bak-cfg-' + Date.now();
|
|
1361
|
+
try { writeFileSync(bak, raw, 'utf8'); } catch { /* 备份失败不阻塞 */ }
|
|
1362
|
+
writeFileSync(PATCH_FILE, out, 'utf8');
|
|
1363
|
+
return { ok: true, backup: bak, missing };
|
|
1364
|
+
} catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1092
1367
|
/** 写回人设正文(只替换那段块标量,写前自动备份整个 patch)。
|
|
1093
1368
|
* ⚠️ 换行符**逐行保真**:patch 可能 LF/CRLF 混用(实测踩过),统一 eol 会改掉别处字节。 */
|
|
1094
1369
|
function writePresetPersona(presetId, text) {
|