@zaofan/dsh-qqbot 1.8.1 → 1.8.3
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/features/ns-guard.d.ts +17 -0
- package/dist/features/ns-guard.d.ts.map +1 -0
- package/dist/features/ns-guard.js +93 -0
- package/dist/features/ns-guard.js.map +1 -0
- package/dist/gateway/debounce.d.ts +1 -0
- package/dist/gateway/debounce.d.ts.map +1 -1
- package/dist/gateway/debounce.js +48 -11
- package/dist/gateway/debounce.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -1
- package/dist/session/session-manager.d.ts.map +1 -1
- package/dist/session/session-manager.js +9 -0
- package/dist/session/session-manager.js.map +1 -1
- package/dist/transport/quote-text.d.ts +8 -3
- package/dist/transport/quote-text.d.ts.map +1 -1
- package/dist/transport/quote-text.js +17 -5
- package/dist/transport/quote-text.js.map +1 -1
- package/examples/ext-tools/README.md +81 -65
- package/examples/ext-tools/bing_search.mjs +330 -0
- package/package.json +1 -1
- package/settings-host.js +59 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bing_search.mjs — 用 Bing 搜网页, 返回 标题 / 链接 / 摘要 / 来源域名
|
|
3
|
+
*
|
|
4
|
+
* 这是一个"纯工具型"扩展示例: 不依赖 env.qq、不碰会话状态, 只把一件事做好 —— 搜索。
|
|
5
|
+
* 它顺手示范了三个**通用**技巧, 写别的检索类工具时可以直接抄:
|
|
6
|
+
*
|
|
7
|
+
* ① 查询词卫生: 把 "<词>是什么 / 是什么意思 / 怎么用" 这类长尾巴剥掉再搜。
|
|
8
|
+
* 长词形查询在搜索引擎里常被切碎(只抓住其中一两个字), 剥掉后结果会明显更准。
|
|
9
|
+
* ② 相关性自检 + 自动回退: 多词混排(如 "DeepSeek Harness 插件市场 dshmarket")很容易搜回一堆
|
|
10
|
+
* 不沾边的东西。这里先算一次命中率, 不过关就依次回退 —— 加引号做完全匹配 → 只搜最长的那个
|
|
11
|
+
* 核心词, 并把"这次实际用了哪一档"写在结果开头, 不让人误信。
|
|
12
|
+
* ③ 结果带来源域名 + 同域限流: 每条标出站点, 同一域名最多 3 条; 既能判断信息源, 也防刷屏。
|
|
13
|
+
*
|
|
14
|
+
* 装配: 放进 {数据根}/.qqbot-extensions/tools/ 后发 /tools-reload(等价于让 AI 调 tools_reload);
|
|
15
|
+
* 插件会把工具注册进所有活会话, 之后新建的会话自动带上。
|
|
16
|
+
* 网络: 先走 Bing 的 RSS 端点(?format=rss, 解析最稳、不需要 key), 失败再回退 HTML 抓取;
|
|
17
|
+
* 国内可直连 cn.bing.com。
|
|
18
|
+
* 暴露: 见下方 expose —— 声明 'all' 即对全部会话可见(含 web / 终端等非 QQ 通道); 删掉该行就只在 QQ 会话可见。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const DEFAULT_COUNT = 8;
|
|
22
|
+
const MAX_COUNT = 20;
|
|
23
|
+
const TIMEOUT_MS = 12000;
|
|
24
|
+
const SNIPPET_CHARS = 180;
|
|
25
|
+
const PER_DOMAIN_MAX = 3;
|
|
26
|
+
/** 相关性门槛: 低于它才认为"这批结果跟查询不沾边", 触发回退 */
|
|
27
|
+
const REL_FLOOR = 0.34;
|
|
28
|
+
|
|
29
|
+
const UA =
|
|
30
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
|
31
|
+
|
|
32
|
+
/** 查询词尾巴(疑问/解释性后缀), 剥掉后再搜 */
|
|
33
|
+
const TAIL_RE =
|
|
34
|
+
/(是什么意思啊?|是怎么个意思|什么意思啊?|是啥意思|是什么样子的?|是什么样的|是什么|是啥|是哪个|是哪种|怎么使用呀?|怎么使用|如何使用|该咋用|怎么用|用法|怎么样|如何|为什么|为啥|原因是什么|有哪些|有什么|的科普|科普一下|科普|的解释|的解释是|的解释吗|介绍一下|介绍下|介绍|的定义|解释)$/;
|
|
35
|
+
|
|
36
|
+
const PREFIX_RE =
|
|
37
|
+
/^(请问一下|请问|帮我查一下|帮我查|帮忙查一下|帮忙查|查一下|查查|搜一下|搜搜|搜索一下|搜索|search)[\s::,,]*/i;
|
|
38
|
+
|
|
39
|
+
const ENTITY_MAP = {
|
|
40
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ensp: ' ', emsp: ' ',
|
|
41
|
+
middot: '·', hellip: '…', mdash: '—', ndash: '–', ldquo: '“', rdquo: '”',
|
|
42
|
+
lsquo: '‘', rsquo: '’', laquo: '«', raquo: '»', '#39': "'", '#x27': "'", '#x2F': '/',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function decodeEntities(input) {
|
|
46
|
+
const s = String(input ?? '');
|
|
47
|
+
return s
|
|
48
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
49
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
|
|
50
|
+
.replace(/&([a-zA-Z#0-9]+);/g, (m, name) => (name in ENTITY_MAP ? ENTITY_MAP[name] : m));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function plain(input) {
|
|
54
|
+
return decodeEntities(String(input ?? '').replace(/<!\[CDATA\[|\]\]>/g, '').replace(/<[^>]*>/g, ' '))
|
|
55
|
+
.replace(/\s+/g, ' ')
|
|
56
|
+
.trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clip(s, n) {
|
|
60
|
+
const t = String(s ?? '').trim();
|
|
61
|
+
return t.length > n ? t.slice(0, n) + '…' : t;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function domainOf(url) {
|
|
65
|
+
try {
|
|
66
|
+
return new URL(url).hostname.replace(/^www\./i, '');
|
|
67
|
+
} catch {
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 解开 Bing 的跳转包装(www.bing.com/ck/a?...&u=a1<base64url>) */
|
|
73
|
+
function unwrapBingLink(url) {
|
|
74
|
+
const u = String(url ?? '').trim();
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(u);
|
|
77
|
+
if (!/(^|\.)bing\.com$/i.test(parsed.hostname)) return u;
|
|
78
|
+
const b64 = parsed.searchParams.get('u');
|
|
79
|
+
if (!b64 || !b64.startsWith('a1')) return u;
|
|
80
|
+
const raw = b64.slice(2).replace(/-/g, '+').replace(/_/g, '/');
|
|
81
|
+
const pad = raw + '='.repeat((4 - (raw.length % 4)) % 4);
|
|
82
|
+
const decoded = Buffer.from(pad, 'base64').toString('utf8');
|
|
83
|
+
return /^https?:\/\//i.test(decoded) ? decoded : u;
|
|
84
|
+
} catch {
|
|
85
|
+
return u;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 查询词卫生: 剥前缀、去问号、循环剥疑问尾巴 —— 至少留 2 个字 */
|
|
90
|
+
function sanitizeQuery(raw) {
|
|
91
|
+
let q = String(raw ?? '').trim();
|
|
92
|
+
q = q.replace(PREFIX_RE, '').replace(/[??。!!]+$/g, '').trim();
|
|
93
|
+
let guard = 0;
|
|
94
|
+
let changed = true;
|
|
95
|
+
while (changed && guard++ < 8) {
|
|
96
|
+
changed = false;
|
|
97
|
+
const next = q.replace(TAIL_RE, '').trim();
|
|
98
|
+
if (next !== q && next.length >= 2) {
|
|
99
|
+
q = next;
|
|
100
|
+
changed = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
q = q.replace(/[??]+$/g, '').trim();
|
|
104
|
+
return q;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 拆词: 空白/逗号/加号/竖线分隔; 只留长度 >= 2 的片段 */
|
|
108
|
+
function tokenize(q) {
|
|
109
|
+
return String(q ?? '')
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.split(/[\s,,、+||/]+/)
|
|
112
|
+
.map((t) => t.replace(/^["'“”]+|["'“”]+$/g, '').trim())
|
|
113
|
+
.filter((t) => t.length >= 2);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 相关性: 有多少条结果的标题/摘要里真的出现了查询片段。
|
|
118
|
+
* 词多的时候不能"命中一个就算" —— "DeepSeek Harness 插件市场 dshmarket" 只命中
|
|
119
|
+
* 一个 deepseek 就判合格的话, 回退逻辑永远不会触发, 用户拿到的还是官网首页那种泛结果。
|
|
120
|
+
* 所以: 词 >= 3 个时要求至少命中 2 个, 且必须命中一个"强词"(长英文词 / 3 字以上中文)。
|
|
121
|
+
*/
|
|
122
|
+
function relevance(query, results) {
|
|
123
|
+
const toks = tokenize(query);
|
|
124
|
+
if (toks.length === 0) return results.length ? 1 : 0;
|
|
125
|
+
if (results.length === 0) return 0;
|
|
126
|
+
const strong = toks.filter((t) => t.length >= 5 || /[\u4e00-\u9fa5]{3,}/.test(t));
|
|
127
|
+
const need = toks.length >= 3 ? 2 : 1;
|
|
128
|
+
let hit = 0;
|
|
129
|
+
for (const r of results) {
|
|
130
|
+
const hay = `${r.title} ${r.snippet}`.toLowerCase();
|
|
131
|
+
const matched = toks.filter((t) => hay.includes(t));
|
|
132
|
+
const strongOk = strong.length === 0 || strong.some((t) => hay.includes(t));
|
|
133
|
+
if (matched.length >= need && strongOk) hit++;
|
|
134
|
+
}
|
|
135
|
+
return hit / results.length;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseRss(xml) {
|
|
139
|
+
const out = [];
|
|
140
|
+
const items = xml.match(/<item>[\s\S]*?<\/item>/gi) || [];
|
|
141
|
+
for (const item of items) {
|
|
142
|
+
const title = plain((item.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '');
|
|
143
|
+
const link = unwrapBingLink(
|
|
144
|
+
decodeEntities(
|
|
145
|
+
((item.match(/<link>([\s\S]*?)<\/link>/i) || [])[1] || '').replace(/<!\[CDATA\[|\]\]>/g, '').trim()
|
|
146
|
+
)
|
|
147
|
+
);
|
|
148
|
+
const snippet = plain((item.match(/<description[^>]*>([\s\S]*?)<\/description>/i) || [])[1] || '');
|
|
149
|
+
if (title && /^https?:\/\//i.test(link)) out.push({ title, url: link, snippet });
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseHtml(html) {
|
|
155
|
+
const out = [];
|
|
156
|
+
const blocks = html.match(/<li class="b_algo"[\s\S]*?<\/li>/gi) || [];
|
|
157
|
+
for (const block of blocks) {
|
|
158
|
+
const a = block.match(/<h2[^>]*>[\s\S]*?<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
|
159
|
+
if (!a) continue;
|
|
160
|
+
const url = unwrapBingLink(decodeEntities(a[1]));
|
|
161
|
+
const title = plain(a[2]);
|
|
162
|
+
const p =
|
|
163
|
+
block.match(/<p[^>]*>([\s\S]*?)<\/p>/i) ||
|
|
164
|
+
block.match(/<div class="b_caption"[^>]*>[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/i);
|
|
165
|
+
const snippet = plain(p ? p[1] : '');
|
|
166
|
+
if (title && /^https?:\/\//i.test(url)) out.push({ title, url, snippet });
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function fetchWithTimeout(url) {
|
|
172
|
+
const ctl = new AbortController();
|
|
173
|
+
const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
|
|
174
|
+
try {
|
|
175
|
+
const res = await fetch(url, {
|
|
176
|
+
redirect: 'follow',
|
|
177
|
+
signal: ctl.signal,
|
|
178
|
+
headers: {
|
|
179
|
+
'user-agent': UA,
|
|
180
|
+
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
181
|
+
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const text = await res.text();
|
|
185
|
+
return { status: res.status, text };
|
|
186
|
+
} finally {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 依次尝试 RSS / HTML 两个端点, 返回 { results, endpoint, errors } */
|
|
192
|
+
async function searchBing(query, count, mkt) {
|
|
193
|
+
const q = encodeURIComponent(query);
|
|
194
|
+
const tries = [
|
|
195
|
+
{ url: `https://cn.bing.com/search?q=${q}&format=rss&count=${count}&mkt=${mkt}`, kind: 'rss' },
|
|
196
|
+
{ url: `https://www.bing.com/search?q=${q}&format=rss&count=${count}&mkt=${mkt}`, kind: 'rss' },
|
|
197
|
+
{ url: `https://cn.bing.com/search?q=${q}&count=${count}&mkt=${mkt}&setlang=zh-CN`, kind: 'html' },
|
|
198
|
+
{ url: `https://www.bing.com/search?q=${q}&count=${count}&mkt=${mkt}`, kind: 'html' },
|
|
199
|
+
];
|
|
200
|
+
const errors = [];
|
|
201
|
+
for (const t of tries) {
|
|
202
|
+
try {
|
|
203
|
+
const { status, text } = await fetchWithTimeout(t.url);
|
|
204
|
+
if (status !== 200 || !text) {
|
|
205
|
+
errors.push(`${t.kind}:${status}`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const results = t.kind === 'rss' ? parseRss(text) : parseHtml(text);
|
|
209
|
+
if (results.length > 0) return { results, endpoint: t.kind, errors };
|
|
210
|
+
errors.push(`${t.kind}:0 条`);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
errors.push(`${t.kind}:${e?.name === 'AbortError' ? '超时' : (e?.message ?? String(e))}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { results: [], endpoint: '', errors };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function dedupe(results) {
|
|
219
|
+
const seenUrl = new Set();
|
|
220
|
+
const perDomain = new Map();
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const r of results) {
|
|
223
|
+
const key = r.url.replace(/[#?].*$/, '').replace(/\/$/, '').toLowerCase();
|
|
224
|
+
if (seenUrl.has(key)) continue;
|
|
225
|
+
const dom = domainOf(r.url);
|
|
226
|
+
const used = perDomain.get(dom) || 0;
|
|
227
|
+
if (dom && used >= PER_DOMAIN_MAX) continue;
|
|
228
|
+
seenUrl.add(key);
|
|
229
|
+
perDomain.set(dom, used + 1);
|
|
230
|
+
out.push({ ...r, domain: dom || '(未知来源)' });
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 一轮搜索 + 打分 */
|
|
236
|
+
async function attempt(query, label, count, mkt) {
|
|
237
|
+
const { results, endpoint, errors } = await searchBing(query, count, mkt);
|
|
238
|
+
const list = dedupe(results).slice(0, count);
|
|
239
|
+
return { label, query, list, endpoint, errors, rel: relevance(query, list) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 带自检的搜索:
|
|
244
|
+
* 1) 卫生处理后的查询
|
|
245
|
+
* 2) 相关性不过关 → 加引号完全匹配
|
|
246
|
+
* 3) 还不过关 → 只搜最长的那个核心词
|
|
247
|
+
* 取相关性最高的一轮结果返回。
|
|
248
|
+
*/
|
|
249
|
+
async function searchSmart(baseQuery, count, mkt) {
|
|
250
|
+
const tried = [];
|
|
251
|
+
let best = await attempt(baseQuery, '主查询', count, mkt);
|
|
252
|
+
tried.push(best);
|
|
253
|
+
if (best.list.length > 0 && best.rel >= REL_FLOOR) return { best, tried, fallback: false };
|
|
254
|
+
|
|
255
|
+
const quoted = await attempt(`"${baseQuery}"`, '加引号', count, mkt);
|
|
256
|
+
tried.push(quoted);
|
|
257
|
+
if (quoted.list.length > 0 && quoted.rel > best.rel) best = quoted;
|
|
258
|
+
if (best.list.length > 0 && best.rel >= REL_FLOOR) return { best, tried, fallback: true };
|
|
259
|
+
|
|
260
|
+
const toks = tokenize(baseQuery).sort((a, b) => b.length - a.length);
|
|
261
|
+
if (toks.length > 1) {
|
|
262
|
+
const core = await attempt(toks[0], `核心词 ${toks[0]}`, count, mkt);
|
|
263
|
+
tried.push(core);
|
|
264
|
+
if (core.list.length > 0 && core.rel >= best.rel) best = core;
|
|
265
|
+
}
|
|
266
|
+
return { best, tried, fallback: true };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export default {
|
|
270
|
+
name: 'bing_search',
|
|
271
|
+
/**
|
|
272
|
+
* 暴露范围(dsh-qqbot 1.8.0+):'all' = 注册进全部会话, 不止 QQ 通道 ——
|
|
273
|
+
* web、终端等非 QQ 会话也能拿到本工具; 不写/写 'qq' 就只在 QQ 会话可见。
|
|
274
|
+
* (注册名会自动带人设归属, 如 bing_search__whale-girl)
|
|
275
|
+
*/
|
|
276
|
+
expose: 'all',
|
|
277
|
+
description:
|
|
278
|
+
'用 Bing 搜网页, 返回标题+链接+摘要+来源域名。查询词已做卫生处理(自动剥掉"是什么/是什么意思"这类长尾巴再搜, 长词形查询不再被切碎); 多词混排导致结果跑偏时会自动回退(加引号/只搜核心词)并注明。查事实/梗/人物/新闻/资料时用它, 比模型记忆可靠。',
|
|
279
|
+
inputSchema: {
|
|
280
|
+
query: { type: 'string', required: true, description: '搜索词, 越短越准, 如 "性转马尔福"' },
|
|
281
|
+
count: { type: 'integer', description: `返回条数, 默认 ${DEFAULT_COUNT}, 上限 ${MAX_COUNT}` },
|
|
282
|
+
site: { type: 'string', description: '限定站点, 如 zh.moegirl.org.cn(会拼成 site:xxx 前缀)' },
|
|
283
|
+
quote: { type: 'boolean', description: 'true=直接给查询词加英文双引号做完全匹配' },
|
|
284
|
+
raw: { type: 'boolean', description: 'true=不做查询词卫生, 原样搜(排查对比用)' },
|
|
285
|
+
mkt: { type: 'string', description: '市场/语言, 默认 zh-CN, 查英文资料可传 en-US' },
|
|
286
|
+
},
|
|
287
|
+
run: async (args) => {
|
|
288
|
+
const a = args || {};
|
|
289
|
+
const original = String(a.query ?? '').trim();
|
|
290
|
+
if (!original) return { ok: false, msg: '缺少 query(要搜什么?)' };
|
|
291
|
+
|
|
292
|
+
let used = a.raw ? original : sanitizeQuery(original);
|
|
293
|
+
if (!used || used.length < 2) used = original;
|
|
294
|
+
if (a.site) used = `site:${String(a.site).trim()} ${used}`;
|
|
295
|
+
if (a.quote && !used.startsWith('"')) used = `"${used}"`;
|
|
296
|
+
|
|
297
|
+
const n = Math.max(1, Math.min(MAX_COUNT, Math.round(Number(a.count) || DEFAULT_COUNT)));
|
|
298
|
+
const mkt = String(a.mkt || 'zh-CN').trim() || 'zh-CN';
|
|
299
|
+
|
|
300
|
+
const { best, fallback } = await searchSmart(used, n, mkt);
|
|
301
|
+
|
|
302
|
+
if (!best.list.length) {
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
msg: `Bing 没有可用的结果(查询词: "${used}")。尝试记录: ${best.errors.join('; ') || '无'}\n换个更短的核心词再试一次。`,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const notes = [];
|
|
310
|
+
if (used !== original) notes.push(`卫生处理: "${original}" → "${used}"`);
|
|
311
|
+
if (fallback) notes.push(`已自动回退, 实际用的是【${best.label}: ${best.query}】`);
|
|
312
|
+
if (best.rel < REL_FLOOR) {
|
|
313
|
+
notes.push(
|
|
314
|
+
fallback
|
|
315
|
+
? `按核心词的相关性约 ${Math.round(best.rel * 100)}%(有些结果没直接提到该词), 自行判断够不够用`
|
|
316
|
+
: `相关性偏低(${Math.round(best.rel * 100)}% 命中), 结果可能不沾边, 建议换更短的核心词`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const lines = [
|
|
321
|
+
`Bing 搜到 ${best.list.length} 条(${notes.length ? notes.join(';') : '主查询直出'};${(best.endpoint || 'rss').toUpperCase()} 通道):`,
|
|
322
|
+
];
|
|
323
|
+
best.list.forEach((r, i) => {
|
|
324
|
+
lines.push(`${i + 1}. [${r.domain}] ${r.title}`);
|
|
325
|
+
lines.push(` ${r.url}`);
|
|
326
|
+
if (r.snippet) lines.push(` ${clip(r.snippet, SNIPPET_CHARS)}`);
|
|
327
|
+
});
|
|
328
|
+
return { ok: true, msg: lines.join('\n') };
|
|
329
|
+
},
|
|
330
|
+
};
|
package/package.json
CHANGED
package/settings-host.js
CHANGED
|
@@ -231,11 +231,57 @@ function readOwnSettings(ns) {
|
|
|
231
231
|
return o && typeof o === 'object' ? o : {};
|
|
232
232
|
} catch { return {}; }
|
|
233
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* 写入者指纹(2026-10-11):`<ns>.writer.json` 记「这个设置槽是哪个 profile 在写」。
|
|
236
|
+
*
|
|
237
|
+
* 起因(主人实测):web 与 desktop 两个 profile 挂了**同一个 settingsNs**,于是共用
|
|
238
|
+
* `{DSH_HOME}/qqbot-settings/<ns>.json` —— 谁最后保存谁生效,凭据(appId/appSecret)/preset/cwd
|
|
239
|
+
* 一起被换掉(web 的配置被 desktop 顶替,紧接着 QQ 连接也被顶掉、收不到消息)。
|
|
240
|
+
*
|
|
241
|
+
* 指纹放**单独文件**而不塞进设置 JSON:配置结构保持干净,前端无感。
|
|
242
|
+
* 规则:槽里已记着"别的 profile"、而本实例在另一个 profile → **拒绝静默覆盖**(抛 NS_CONFLICT)。
|
|
243
|
+
* 想强行接管:删掉 `<ns>.writer.json` 再保存,或干脆给其中一个 profile 换 settingsNs。
|
|
244
|
+
*/
|
|
245
|
+
function nsWriterPath(ns) {
|
|
246
|
+
return ownSettingsPath(ns).replace(/\.json$/, '.writer.json');
|
|
247
|
+
}
|
|
248
|
+
function readNsWriter(ns) {
|
|
249
|
+
try {
|
|
250
|
+
const p = nsWriterPath(ns);
|
|
251
|
+
if (!existsSync(p)) return null;
|
|
252
|
+
const o = JSON.parse(readFileSync(p, 'utf8'));
|
|
253
|
+
return o && typeof o === 'object' ? o : null;
|
|
254
|
+
} catch { return null; }
|
|
255
|
+
}
|
|
256
|
+
function myProfileName() {
|
|
257
|
+
return String(process.env.DSH_PROFILE || '').trim() || 'unknown';
|
|
258
|
+
}
|
|
259
|
+
/** 该设置槽是否正被**别的** profile 占用(供 /read 与 /update 共用) */
|
|
260
|
+
function nsConflictOf(ns) {
|
|
261
|
+
const prev = readNsWriter(ns);
|
|
262
|
+
const me = myProfileName();
|
|
263
|
+
if (!prev || !prev.profile) return null;
|
|
264
|
+
if (prev.profile === 'unknown' || me === 'unknown' || prev.profile === me) return null;
|
|
265
|
+
return { prev: String(prev.profile), me };
|
|
266
|
+
}
|
|
234
267
|
function writeOwnSettings(ns, obj) {
|
|
235
268
|
const p = ownSettingsPath(ns);
|
|
269
|
+
const me = myProfileName();
|
|
270
|
+
const conflict = nsConflictOf(ns);
|
|
271
|
+
if (conflict) {
|
|
272
|
+
const err = new Error(
|
|
273
|
+
`设置槽 "${ns}" 已被 profile "${conflict.prev}" 占用,而本实例在 profile "${conflict.me}":` +
|
|
274
|
+
`两个 profile 共用同一个 settingsNs 会互相覆盖(凭据/preset/cwd 一起被换)。` +
|
|
275
|
+
`修法:给其中一个 profile 换 settingsNs(如 "${ns}-${conflict.me}");` +
|
|
276
|
+
`确实要让本 profile 接管,就先删掉 ${nsWriterPath(ns)} 再保存。`,
|
|
277
|
+
);
|
|
278
|
+
err.code = 'NS_CONFLICT';
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
236
281
|
try { mkdirSync(OWN_SETTINGS_DIR, { recursive: true }); } catch { /* ignore */ }
|
|
237
282
|
try { if (existsSync(p)) writeFileSync(p + '.bak-' + Date.now(), readFileSync(p)); } catch { /* 备份失败不阻塞 */ }
|
|
238
283
|
writeFileSync(p, JSON.stringify(obj, null, 2), 'utf8');
|
|
284
|
+
try { writeFileSync(nsWriterPath(ns), JSON.stringify({ profile: me, at: Date.now(), file: p }, null, 2), 'utf8'); } catch { /* 指纹写失败不阻塞 */ }
|
|
239
285
|
return p;
|
|
240
286
|
}
|
|
241
287
|
/** 深合并一层(够用:本插件嵌套只到 groupAdmin / sticker.gates 这一层)。 */
|
|
@@ -412,7 +458,17 @@ export function apply(ctx) {
|
|
|
412
458
|
const own = readOwnSettings(ns);
|
|
413
459
|
// 自有存储优先:它就是用户在设置页保存的那份
|
|
414
460
|
const value = Object.assign({}, base.value || {}, own);
|
|
415
|
-
|
|
461
|
+
// 2026-10-11: 顺手把"这个槽是谁在写"告诉前端 —— 被**别的 profile**占用时设置页能一眼看到(判据同 writeOwnSettings)
|
|
462
|
+
const w = readNsWriter(ns);
|
|
463
|
+
const conflict = nsConflictOf(ns);
|
|
464
|
+
writeJson(res, 200, {
|
|
465
|
+
value,
|
|
466
|
+
revision: base.revision,
|
|
467
|
+
source: Object.keys(own).length ? 'own' : 'patch',
|
|
468
|
+
writerProfile: w && w.profile ? String(w.profile) : '',
|
|
469
|
+
writerAt: w && w.at ? Number(w.at) : 0,
|
|
470
|
+
nsConflict: conflict ? { writtenBy: conflict.prev, currentProfile: conflict.me } : null,
|
|
471
|
+
});
|
|
416
472
|
});
|
|
417
473
|
route(ctx, 'POST', '/api/qqbot-settings/update', async (req, res) => {
|
|
418
474
|
const body = await readJsonBody(req);
|
|
@@ -437,6 +493,8 @@ export function apply(ctx) {
|
|
|
437
493
|
note: '已保存(插件自有存储,热生效)',
|
|
438
494
|
});
|
|
439
495
|
} catch (e) {
|
|
496
|
+
// 2026-10-11: 设置槽被**别的 profile** 占用 → 409 + 原样文案(前端直接显示;绝不静默覆盖)
|
|
497
|
+
if (e && e.code === 'NS_CONFLICT') return writeJson(res, 409, { error: String(e.message ?? e), code: 'NS_CONFLICT' });
|
|
440
498
|
writeJson(res, 500, { error: '保存失败:' + String(e?.message ?? e) });
|
|
441
499
|
}
|
|
442
500
|
});
|