dsh-fish-soul 0.16.0 → 0.16.1
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 +1 -1
- package/lib/channels/douyin.js +99 -4
- package/lib/channels/vendor/douyin-bridge/douyin-dm.mjs +53 -2
- package/lib/client.douyin.js +37 -0
- package/lib/client.js +6 -3
- package/lib/gateway-util.js +23 -0
- package/lib/gateway.js +219 -9
- package/lib/index.js +10 -1
- package/lib/panel.css +6 -4
- package/lib/routes-douyin.js +21 -3
- package/lib/routes.js +6 -9
- package/lib/soul-memory.js +7 -1
- package/package.json +2 -2
package/README.md
CHANGED
package/lib/channels/douyin.js
CHANGED
|
@@ -183,6 +183,41 @@ export function occurrenceIndexes(prints) {
|
|
|
183
183
|
});
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* 一条入站消息的「时间 + 去重 ID」。
|
|
188
|
+
*
|
|
189
|
+
* 入库去重键是 `UNIQUE(channel_id, message_id)` + `INSERT OR IGNORE`,所以 ID 必须满足
|
|
190
|
+
* 两个互相拉扯的要求:
|
|
191
|
+
*
|
|
192
|
+
* · **同一条消息重复读** ⇒ 同一个 ID(否则每次轮询都新入库一遍,重复回复 + 烧 token)
|
|
193
|
+
* · **同一个人后来又说了同样的话 / 又发了同一张图** ⇒ 不同的 ID(否则第二句被吞,等于失联)
|
|
194
|
+
*
|
|
195
|
+
* 所以分三级,越靠前越准:
|
|
196
|
+
* ① 有 `seq`(接口路,平台序号) → `s<seq>`
|
|
197
|
+
* ② 有平台时间(接口路本来有;DOM 路现在从气泡文字反解) → `<指纹>-<timeMs>-<occ>`
|
|
198
|
+
* ③ 两者都没有(极少) → `<指纹>-<自然日>-<occ>`
|
|
199
|
+
*
|
|
200
|
+
* ⚠️ ③ 是**已知取舍**:同一天内、既没 seq 又认不出时间的同内容消息仍会撞。
|
|
201
|
+
* 别为了省事把 ② 也降级到 ③ —— 那会让「同一天说两次『在』」第二句收不到。
|
|
202
|
+
*
|
|
203
|
+
* @param {object} m 入站消息(可能带 `time` / `seq`)
|
|
204
|
+
* @param {{print?: string, occ?: number, fallbackTime?: number, dayKey?: string}} ctx
|
|
205
|
+
* @returns {{timeMs: number, messageId: string}}
|
|
206
|
+
*/
|
|
207
|
+
export function messageIdFor(m, { print = '', occ = 0, fallbackTime = Date.now(), dayKey = '' } = {}) {
|
|
208
|
+
const rawTime = Number(m?.time);
|
|
209
|
+
const hasTime = rawTime > 1e12 || rawTime > 1e9;
|
|
210
|
+
const timeMs = rawTime > 1e12 ? rawTime : rawTime > 1e9 ? rawTime * 1000 : fallbackTime;
|
|
211
|
+
const seq = String(m?.seq ?? '').trim();
|
|
212
|
+
const day = dayKey || new Date().toISOString().slice(0, 10);
|
|
213
|
+
const messageId = seq !== ''
|
|
214
|
+
? `s${seq}`
|
|
215
|
+
: hasTime
|
|
216
|
+
? `${print}-${timeMs}-${occ}`
|
|
217
|
+
: `${print}-${day}-${occ}`;
|
|
218
|
+
return { timeMs, messageId };
|
|
219
|
+
}
|
|
220
|
+
|
|
186
221
|
/**
|
|
187
222
|
* 这个会话是不是群聊:接口里的会话类型 = 2、会话 ID 不是单聊的 0:1:xxx:yyy 形状、
|
|
188
223
|
* 对方的消息来自两个以上不同的人,或者设置页「抖音群聊」里填了这个会话名。
|
|
@@ -965,6 +1000,10 @@ export class DouyinChannel {
|
|
|
965
1000
|
// 这个抖音号自己是谁。插件能从任意一条私信里学到自己是谁(`learnSelfUid`),
|
|
966
1001
|
// 这里把结果交出去 —— 注意这是**机器人自己**的 uid,不是用户的。
|
|
967
1002
|
selfUids: this.selfUids(),
|
|
1003
|
+
// 🔴 配置错填(填了跟真实 uid 对不上的值,比如账号短号)—— 要显式报出来,
|
|
1004
|
+
// 否则「填了但用不上」会静默失效,用户看到的是「她不回我」,
|
|
1005
|
+
// 完全想不到是配置。真机踩过:填了短号 → 认领分支比对不上 → 昵称候选永远不弹。
|
|
1006
|
+
masterUidMismatch: this.masterUidMismatch(),
|
|
968
1007
|
conversationIdSupported: true
|
|
969
1008
|
},
|
|
970
1009
|
// 推送路的运行计数,用来确认它真的在跑、解出了多少帧。
|
|
@@ -1279,11 +1318,20 @@ export class DouyinChannel {
|
|
|
1279
1318
|
const fresh = messages.filter((m) => m.fromMe !== true && !selfIds.has(String(m.senderId ?? '').trim()));
|
|
1280
1319
|
if (!fresh.length) return 0;
|
|
1281
1320
|
const normalized = [];
|
|
1321
|
+
// 推送路的 ID 走和轮询路同一套分级(见 messageIdFor):seq → 平台时间 → 自然日兜底。
|
|
1322
|
+
// 以前这里是「没有时间就用内容指纹」—— 同一天发两次同一张表情会撞键(第二次收不到),
|
|
1323
|
+
// 而纯 Date.now() 又会让同一条推送重发时新入库一遍。两头的病都躲不掉。
|
|
1324
|
+
const dayKey = new Date().toISOString().slice(0, 10);
|
|
1325
|
+
const pushOcc = new Map();
|
|
1282
1326
|
for (const m of fresh) {
|
|
1283
|
-
|
|
1327
|
+
// 指纹用「内容 + 推送内出现次数」:推送帧重放同一批时 occ 一致 ⇒ ID 一致(去重);
|
|
1328
|
+
// 同一批里真有两张一模一样的图 ⇒ occ 递增 ⇒ ID 不同(都收得到)。
|
|
1329
|
+
const contentKey = `push-${convId}-${sha1(`${m.text ?? ''}|${m.type ?? ''}|${m.media?.src ?? m.media?.data ?? ''}`).slice(0, 16)}`;
|
|
1330
|
+
const n = pushOcc.get(contentKey) ?? 0;
|
|
1331
|
+
pushOcc.set(contentKey, n + 1);
|
|
1332
|
+
const { timeMs, messageId } = messageIdFor(m, { print: contentKey, occ: n, dayKey });
|
|
1284
1333
|
// 轮询路读的时候这条已经在窗口里了 → 已经收过,别重复收
|
|
1285
1334
|
if (skipBefore > 0 && timeMs <= skipBefore) continue;
|
|
1286
|
-
const messageId = m.seq ? `s${m.seq}` : `push-${convId}-${timeMs}`;
|
|
1287
1335
|
const norm = normalizeDouyinMessage(m, { name, timeMs, messageId, identity, convId });
|
|
1288
1336
|
if (norm !== null) { norm.idConfidence = m.seq ? 'platform' : 'heuristic'; normalized.push(norm); }
|
|
1289
1337
|
}
|
|
@@ -1500,6 +1548,40 @@ export class DouyinChannel {
|
|
|
1500
1548
|
return out;
|
|
1501
1549
|
}
|
|
1502
1550
|
|
|
1551
|
+
/**
|
|
1552
|
+
* 配置里的「主人身份」跟已学到的会话身份对不上时的警告;对得上 / 没填 / 还学不到则返回 null。
|
|
1553
|
+
*
|
|
1554
|
+
* 🔴 为什么需要这条:**短号和真实 uid 都是纯数字**,用户分不出来。
|
|
1555
|
+
* 界面写「填数字 uid 就行」,他就把短号填进去了 —— 之后认领分支比对不上,
|
|
1556
|
+
* 静默什么都不发生(真机踩过:昵称候选永远不弹,用户看到「她不理我」)。
|
|
1557
|
+
*
|
|
1558
|
+
* 判据:`douyin.conv.*` 游标里的 `identity`(真实 uid,从会话 ID 推出来的)。
|
|
1559
|
+
* 已学到的所有身份里都不等于 `masterUid` ⇒ 它是个错值(短号 / 抄错了 / 换号了)。
|
|
1560
|
+
* 一条都没学到 ⇒ 不警告(那时不能证明它错)。
|
|
1561
|
+
*
|
|
1562
|
+
* @returns {{filled: string, learned: string[]}|null}
|
|
1563
|
+
*/
|
|
1564
|
+
masterUidMismatch() {
|
|
1565
|
+
// 直接读 getConfig(),不走 cfg() —— 那个只返回 {enabled, autoReply},
|
|
1566
|
+
// 没 masterUid(在它上面读会得到 undefined,永远判成「没填」)。
|
|
1567
|
+
const filled = String(this.getConfig()?.masterUid ?? '').trim();
|
|
1568
|
+
if (filled === '') return null;
|
|
1569
|
+
const norm = filled.replace(/^douyin:/, '').replace(/^uid:/, '');
|
|
1570
|
+
if (!/^\d+$/.test(norm)) return null;
|
|
1571
|
+
// 已学到的会话身份(真实 uid)
|
|
1572
|
+
const learned = new Set();
|
|
1573
|
+
try {
|
|
1574
|
+
const rows = this.db.prepare("SELECT value FROM kv WHERE key LIKE 'douyin.conv.%' AND key NOT LIKE 'douyin.convId.%'").all();
|
|
1575
|
+
for (const r of rows) {
|
|
1576
|
+
const v = JSON.parse(r.value);
|
|
1577
|
+
const id = String(v?.identity ?? '').replace(/^douyin:/, '');
|
|
1578
|
+
if (/^\d+$/.test(id)) learned.add(id);
|
|
1579
|
+
}
|
|
1580
|
+
} catch { /* 查不到就当没学到 */ }
|
|
1581
|
+
if (learned.size === 0) return null; // 还没学到 ⇒ 不判
|
|
1582
|
+
return learned.has(norm) ? null : { filled: `douyin:${norm}`, learned: [...learned].slice(0, 5) };
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1503
1585
|
/**
|
|
1504
1586
|
* 身份是否已经可用(能可靠回答「这条消息是谁发的、这条回复发给谁」)。
|
|
1505
1587
|
*
|
|
@@ -1759,11 +1841,24 @@ export class DouyinChannel {
|
|
|
1759
1841
|
if (res.needsPageRead) cursor.needsPageRead = true;
|
|
1760
1842
|
const normalized = [];
|
|
1761
1843
|
const fallbackTime = convTimeMs(c.time) || Date.now();
|
|
1844
|
+
// 没有平台时间时,ID 里不能塞 Date.now():每读一次就变一次,UNIQUE(channel_id,message_id) 形同虚设
|
|
1845
|
+
// (真机:同一张图被入库五六遍,既重复回复又烧 token)。
|
|
1846
|
+
//
|
|
1847
|
+
// 🔴 但也**不能只按自然日兜底**:那样「同一天说了两次『在』」「同一天发了两次同一张表情」
|
|
1848
|
+
// 会算出同一个 ID,第二句被 UNIQUE 直接吞掉 —— 用户看到的是「她没回我」,
|
|
1849
|
+
// 比重复回复严重得多(重复只是烦,漏回是失联)。
|
|
1850
|
+
//
|
|
1851
|
+
// 正确的分级:
|
|
1852
|
+
// ① 有 `seq`(接口路,最准) → `s<seq>`
|
|
1853
|
+
// ② 有平台时间(DOM 路现在会从气泡反解,或接口路本来就有) → `<指纹>-<timeMs>-<occ>`
|
|
1854
|
+
// ③ 两者都没有(极少) → `<指纹>-<自然日>-<occ>`(保底不重复入库)
|
|
1855
|
+
const dayKey = new Date().toISOString().slice(0, 10);
|
|
1762
1856
|
// 同一指纹在这次读到的列表里是第几次出现:对方连发同一句话时,没 seq 的 ID 也不会撞
|
|
1763
1857
|
const occ = occurrenceIndexes(prints);
|
|
1764
1858
|
for (const [i, m] of fresh.entries()) {
|
|
1765
|
-
const timeMs
|
|
1766
|
-
|
|
1859
|
+
const { timeMs, messageId } = messageIdFor(m, {
|
|
1860
|
+
print: prints[freshIndexes[i]], occ: occ[freshIndexes[i]], fallbackTime, dayKey,
|
|
1861
|
+
});
|
|
1767
1862
|
const norm = normalizeDouyinMessage(m, { name, timeMs, messageId, group, members, identity, convId });
|
|
1768
1863
|
if (norm !== null) { norm.idConfidence = m.seq ? 'platform' : 'heuristic'; normalized.push(norm); }
|
|
1769
1864
|
}
|
|
@@ -579,7 +579,52 @@ async function readDMDom({ browser, limit = 30, uname = '', scrollBack = 0 }) {
|
|
|
579
579
|
|| /^\d{1,2}:\d{2}$/.test(s)
|
|
580
580
|
|| s === '已读' || s === '未读'
|
|
581
581
|
|
|
582
|
+
/**
|
|
583
|
+
* 从气泡的 meta 文本反解时间戳(毫秒)。
|
|
584
|
+
*
|
|
585
|
+
* 🔴 为什么需要:DOM 路以前**不带 time**,而接口路(protobuf)的图片/表情/语音
|
|
586
|
+
* 在 protobuf 里是别的 kind、接口路一个都不给 —— 于是这些消息只有 DOM 这一条来源,
|
|
587
|
+
* 且 time 恒空。上层(storePushMessages / readConversation)在 time 缺失时
|
|
588
|
+
* 只能拿 Date.now() 兜底当去重键,**每读一次就换一个 ID**,同一条消息被入库
|
|
589
|
+
* 五六遍(真机 2026-11-13:同一张图连进 5~6 行、每行都触发一轮回复)。
|
|
590
|
+
*
|
|
591
|
+
* 抖音气泡里的时间写法就那几种(刚刚 / N分钟前 / N小时前 / 昨天 / HH:MM / M月D日),
|
|
592
|
+
* 能反解就反解;反解不出返回 0,交给上层兜底(不要在这里编一个时间)。
|
|
593
|
+
*
|
|
594
|
+
* @param {string[]} metas 被 isMeta 判为「时间/已读」的那些行
|
|
595
|
+
* @param {number} now 参考时间(毫秒)
|
|
596
|
+
* @returns {number} 毫秒时间戳;认不出返回 0
|
|
597
|
+
*/
|
|
598
|
+
const timeFromMeta = (metas, now) => {
|
|
599
|
+
for (const s of metas) {
|
|
600
|
+
let m
|
|
601
|
+
if (s === '刚刚') return now
|
|
602
|
+
if ((m = /^(\d+)秒前$/.exec(s))) return now - Number(m[1]) * 1000
|
|
603
|
+
if ((m = /^(\d+)分钟前$/.exec(s))) return now - Number(m[1]) * 60000
|
|
604
|
+
if ((m = /^(\d+)小时前$/.exec(s))) return now - Number(m[1]) * 3600000
|
|
605
|
+
if ((m = /^(\d+)天前$/.exec(s))) return now - Number(m[1]) * 86400000
|
|
606
|
+
if (s === '昨天') return now - 86400000
|
|
607
|
+
if (s === '前天') return now - 2 * 86400000
|
|
608
|
+
// HH:MM —— 只可能是今天(更早的会显示成「昨天」/「M月D日」)
|
|
609
|
+
if ((m = /^(\d{1,2}):(\d{2})$/.exec(s))) {
|
|
610
|
+
const d = new Date(now)
|
|
611
|
+
d.setHours(Number(m[1]), Number(m[2]), 0, 0)
|
|
612
|
+
// 未来时间说明是昨天的(跨零点后「23:50」还没变成「昨天」的那一小段)
|
|
613
|
+
return d.getTime() > now + 60000 ? d.getTime() - 86400000 : d.getTime()
|
|
614
|
+
}
|
|
615
|
+
// M月D日 —— 当年;若算出未来则算去年(跨年)
|
|
616
|
+
if ((m = /^(\d{1,2})月(\d{1,2})日$/.exec(s))) {
|
|
617
|
+
const d = new Date(now)
|
|
618
|
+
d.setMonth(Number(m[1]) - 1, Number(m[2]))
|
|
619
|
+
d.setHours(0, 0, 0, 0)
|
|
620
|
+
return d.getTime() > now + 86400000 ? new Date(d.getFullYear() - 1, Number(m[1]) - 1, Number(m[2])).getTime() : d.getTime()
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return 0
|
|
624
|
+
}
|
|
625
|
+
|
|
582
626
|
const out = []
|
|
627
|
+
const nowRef = Date.now()
|
|
583
628
|
for (const box of boxes) {
|
|
584
629
|
const r = box.getBoundingClientRect()
|
|
585
630
|
|
|
@@ -597,10 +642,12 @@ async function readDMDom({ browser, limit = 30, uname = '', scrollBack = 0 }) {
|
|
|
597
642
|
|
|
598
643
|
// 清理文本:去掉零宽字符、时间和已读标记
|
|
599
644
|
const t = (box.innerText || '').trim()
|
|
600
|
-
const
|
|
645
|
+
const allLines = t.split(String.fromCharCode(10))
|
|
601
646
|
.map((s) => s.replace(/\u200b/g, '').trim())
|
|
602
647
|
.filter(Boolean)
|
|
603
|
-
|
|
648
|
+
const lines = allLines.filter((s) => !isMeta(s))
|
|
649
|
+
// 时间:从被滤掉的那些行里反解(反解不出就是 0,上层兜底)
|
|
650
|
+
const time = timeFromMeta(allLines.filter((s) => isMeta(s) && s !== '已读' && s !== '未读'), nowRef)
|
|
604
651
|
|
|
605
652
|
// fish-soul:群聊里对方气泡上面有昵称(单聊没有)。认出来就单独拿出来,别混进正文
|
|
606
653
|
let senderName = ''
|
|
@@ -659,6 +706,10 @@ async function readDMDom({ browser, limit = 30, uname = '', scrollBack = 0 }) {
|
|
|
659
706
|
text: text.slice(0, 500),
|
|
660
707
|
media,
|
|
661
708
|
senderName,
|
|
709
|
+
// ⭐ 从气泡上的时间文字反解出来的时间(0 = 认不出)。
|
|
710
|
+
// 上层拿它当去重键,**不能**再用 Date.now() —— 那样每读一次就换一个 ID,
|
|
711
|
+
// 同一条消息会被入库好几遍(真机 2026-11-13:同一张图连进 5~6 行)。
|
|
712
|
+
time,
|
|
662
713
|
// ⭐ y 坐标用于排序(DOM 顺序不可靠)
|
|
663
714
|
y: Math.round(r.top),
|
|
664
715
|
})
|
package/lib/client.douyin.js
CHANGED
|
@@ -54,6 +54,16 @@ window.__ModuleLoader__.load({
|
|
|
54
54
|
const [showShot, setShowShot] = react.useState(false);
|
|
55
55
|
const [framesPerDay, setFramesPerDay] = react.useState("");
|
|
56
56
|
react.useEffect(() => { if (framesPerDay === "" && br.framesPerDay !== undefined) setFramesPerDay(String(br.framesPerDay)); }, [br.framesPerDay]);
|
|
57
|
+
// 待确认的抖音身份(昵称配了但还没主人确认过)。
|
|
58
|
+
// 放在面板里而不是自动绑定 —— 主人是唯一能看懂 uid 的人。
|
|
59
|
+
const [candidates, setCandidates] = react.useState([]);
|
|
60
|
+
const loadCandidates = react.useCallback(async () => {
|
|
61
|
+
try {
|
|
62
|
+
const r = await api("POST", "/douyin/uin-candidates", {});
|
|
63
|
+
setCandidates(Array.isArray(r?.candidates) ? r.candidates : []);
|
|
64
|
+
} catch { setCandidates([]); }
|
|
65
|
+
}, [api]);
|
|
66
|
+
react.useEffect(() => { void loadCandidates(); }, [loadCandidates]);
|
|
57
67
|
const [form, , patch] = useForm(data, () => ({
|
|
58
68
|
masterName: d.masterName ?? "", masterUid: d.masterUid ?? "", allowList: (d.allowList ?? []).join("\n"), links: (d.links ?? []).join("\n"),
|
|
59
69
|
groups: (d.groups ?? []).join("\n"), callNames: (d.callNames ?? []).join("\n"),
|
|
@@ -152,6 +162,33 @@ window.__ModuleLoader__.load({
|
|
|
152
162
|
Row({ title: "主人身份(可选)", desc: `填**你自己**的抖音数字 uid,她就认得出「你是主人」:私聊里标「(主人)」,可以主动给你发视频卡片、把 B站/抖音会话并进你的 QQ 私聊。**不填也能正常收私信、记记忆、回复** —— 认人靠的是会话 ID,不是这个字段。${(d.channel?.identityState?.selfUids ?? []).length ? `\n注意:上面显示的是这个抖音号(机器人自己)的 uid,不是你的。` : ""}昵称可被同名冒充、改名又会对不上,所以不参与授权。只填数字就行,前缀会自动补。` },
|
|
153
163
|
Input({ ...bind("masterUid"), placeholder: "你的抖音数字 uid" })),
|
|
154
164
|
Row({ title: "主人的名字", desc: "仅作显示(提示词里怎么称呼);不授予任何权限" }, Input({ ...bind("masterName"), placeholder: "她私信列表里你的名字" })),
|
|
165
|
+
// 🔴 填了「主人身份」但跟已学到的真实 uid 对不上 ⇒ 显式警告。
|
|
166
|
+
// 静默失效是最坏的失败:用户看到的是「她不理我」,不会想到是配置。
|
|
167
|
+
// 短号和真实 uid 都是纯数字,用户分不出来 —— 必须在这里点破。
|
|
168
|
+
d.channel?.identityState?.masterUidMismatch
|
|
169
|
+
? Row({ title: "这个 uid 认不出来" }, State("warning",
|
|
170
|
+
`你填的 ${d.channel.identityState.masterUidMismatch.filled} 跟已学到的抖音身份都对不上。`
|
|
171
|
+
+ `多半是填成了账号短号(那个不是 uid)。**留空就行** —— 认人靠的是会话 ID,不靠这个字段。`
|
|
172
|
+
+ `已学到的身份:${d.channel.identityState.masterUidMismatch.learned.join(" / ")}`))
|
|
173
|
+
: null,
|
|
174
|
+
// 待确认的抖音身份:昵称对应表只能「定位」,绑定必须主人亲手确认一次
|
|
175
|
+
// (自动绑 = 谁先来谁被绑,冒充者改名就能顶身份;实测确认过这个洞)。
|
|
176
|
+
(candidates ?? []).length === 0 ? null : Group({ title: "待确认的抖音身份" },
|
|
177
|
+
Muted("这些昵称你配了对应表,但还没确认过真人。**看一眼 uid 是不是你自己**——对就点「是我」,不对就别点(那是别人冒用这个名字)。"),
|
|
178
|
+
...(candidates ?? []).map((c) => Row({ key: c.name, title: `${c.name} → QQ ${c.qq}`,
|
|
179
|
+
desc: `抖音 uid ${c.uid}${c.at ? ` · ${new Date(c.at).toLocaleString()}` : ""}` },
|
|
180
|
+
Button({ variant: "primary", disabled: busy, onClick: async () => {
|
|
181
|
+
setBusy(true);
|
|
182
|
+
try {
|
|
183
|
+
const r = await api("POST", "/douyin/uin-confirm", { name: c.name });
|
|
184
|
+
if (r?.ok) { notify(`已确认:${c.name}`); await loadCandidates(); refresh(); }
|
|
185
|
+
else notify(r?.error ?? "没确认成");
|
|
186
|
+
} finally { setBusy(false); }
|
|
187
|
+
} }, "是我"),
|
|
188
|
+
Button({ variant: "outline", disabled: busy, onClick: async () => {
|
|
189
|
+
setBusy(true);
|
|
190
|
+
try { await api("POST", "/douyin/uin-dismiss", { name: c.name }); await loadCandidates(); refresh(); } finally { setBusy(false); }
|
|
191
|
+
} }, "不是我")))),
|
|
155
192
|
Row({ title: "回所有能私信她的人", desc: d.replyAll ? "建议在抖音隐私设置里把「谁可以私信我」设为互关好友" : null },
|
|
156
193
|
sw("回所有能私信她的人", d.replyAll === true, (v) => save({ top: { replyAll: v } }))),
|
|
157
194
|
Row({ title: "超过多久的消息只记不回", desc: "断线补拉 / 重启后积压的老消息,超过这个时间就只记进记忆、不回复" },
|
package/lib/client.js
CHANGED
|
@@ -114,7 +114,9 @@ window.__ModuleLoader__.load({
|
|
|
114
114
|
h("span", { className: "fs-switch-thumb" }));
|
|
115
115
|
}
|
|
116
116
|
const Button = ({ variant, ...props }, ...kids) => primitives?.Button
|
|
117
|
-
|
|
117
|
+
// ⚠️ 不要给 primary 加自定义 class 去改底色 —— 宿主原生是深色实心
|
|
118
|
+
// (--dsw-alias-button-primary-fill + 反色字),改色就是「近似模拟」。
|
|
119
|
+
? h(primitives.Button, { ...props, variant: variant === "danger" ? "outline" : variant ?? "outline", size: "sm", className: cx("fs-native-btn", variant === "danger" && "fs-native-danger", props.className) }, ...kids)
|
|
118
120
|
: h("button", { type: "button", ...props, className: cx("fs-btn", variant) }, ...kids);
|
|
119
121
|
const IconButton = (icon, label, onClick, extra) => primitives?.Button
|
|
120
122
|
? h(primitives.Button, { variant: "ghost", size: "sm", className: "fs-icon-btn fs-native-icon", "aria-label": label, title: label, onClick, ...extra }, Icon(icon, 14))
|
|
@@ -630,7 +632,7 @@ window.__ModuleLoader__.load({
|
|
|
630
632
|
const [showCookie, setShowCookie] = react.useState(false);
|
|
631
633
|
const [busy, setBusy] = react.useState(false);
|
|
632
634
|
const [form, setForm, patch] = useForm(data, () => ({
|
|
633
|
-
masterUid: b.masterUid ?? "", allowList: (b.allowList ?? []).join("
|
|
635
|
+
masterUid: b.masterUid ?? "", allowList: (b.allowList ?? []).join("\n"), links: (b.links ?? []).join("\n"), replyWindowMinutes: b.replyWindowMinutes ?? 120,
|
|
634
636
|
perDay: br.perDay ?? 3, videosPerSession: br.videosPerSession ?? 3, favoritePerDay: br.favoritePerDay ?? 2, searchKeywords: (br.searchKeywords ?? []).join(","), searchPerDay: br.searchPerDay ?? 2, frames: br.frames === true, framesPerDay: br.framesPerDay ?? 1, framesPerDayChat: br.framesPerDayChat ?? 10,
|
|
635
637
|
maxLinks: br.maxLinks ?? 3, subtitleChars: br.subtitleChars ?? 1200, comment: br.comment === true, commentPerDay: br.commentPerDay ?? 1, descChars: br.descChars ?? 300, hotCommentChars: br.hotCommentChars ?? 80,
|
|
636
638
|
postPerDay: b.post?.perDay ?? 1, commentsPerHour: b.comments?.perHour ?? 6, commentsReplyChars: b.comments?.replyChars ?? 40,
|
|
@@ -709,7 +711,8 @@ window.__ModuleLoader__.load({
|
|
|
709
711
|
Group({ title: "私信" },
|
|
710
712
|
Row({ title: "自动回复" }, b.autoReply ? null : Muted("只记不回"), sw("自动回复", b.autoReply === true, (v) => save({ top: { autoReply: v } }))),
|
|
711
713
|
Row({ title: "主人 UID", desc: "填了之后,可以在 QQ 私聊里让她去 B站 给你发一条私信" }, field("masterUid", { inputMode: "numeric" })),
|
|
712
|
-
Row({ title: "白名单"
|
|
714
|
+
Row({ title: "白名单", stack: true, desc: "在名单里的人,不用互关也会回。填 B站数字 UID,一行一个。" },
|
|
715
|
+
Area({ value: form.allowList, placeholder: "B站 UID,一行一个", onChange: (e) => patch({ allowList: e.target.value }), onBlur: () => save() })),
|
|
713
716
|
Row({ title: "互关的人也回" }, sw("互关的人也回", b.replyMutual !== false, (v) => save({ top: { replyMutual: v } }))),
|
|
714
717
|
Row({ title: "超过多久的消息只记不回", desc: "断线补拉 / 重启后积压的老消息,超过这个时间就只记进记忆、不回复" },
|
|
715
718
|
Num({ min: 1, max: 1440, value: form.replyWindowMinutes, "aria-label": "分钟", onChange: (e) => patch({ replyWindowMinutes: e.target.value }), onBlur: () => save({ top: { replyWindowMinutes: form.replyWindowMinutes === "" ? 120 : Number(form.replyWindowMinutes) } }) }), Muted("分钟")),
|
package/lib/gateway-util.js
CHANGED
|
@@ -73,6 +73,29 @@ export function parseClosing(text) {
|
|
|
73
73
|
return { text: raw.split(CLOSING_MARK).join('').replace(/\n{3,}/g, '\n\n').trim(), closing: true };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* 回复时限(分钟)的默认值。
|
|
78
|
+
*
|
|
79
|
+
* 10 分钟太短:主人离线一晚回来,整晚的消息全被判成「太旧了」,只记不回。
|
|
80
|
+
*/
|
|
81
|
+
export const REPLY_WINDOW_DEFAULT = 120;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 回复时限(分钟):夹到 1~1440;没配 / 填了不是数字的按默认 120。
|
|
85
|
+
*
|
|
86
|
+
* 下限不是 0:判定式是「经过时间 > 分钟数」,0 会把几乎所有消息都判成超时
|
|
87
|
+
* (跟「不限」正好相反)。要支持 0 得改四处判定语义 + 重发队列窗口,不值当。
|
|
88
|
+
*
|
|
89
|
+
* ⚠️ 「没配」和「配了但越界」是两回事:没配 → 默认值;配了 0 → 夹到 1
|
|
90
|
+
* (直接当默认值等于偷偷改掉用户填的数)。
|
|
91
|
+
*/
|
|
92
|
+
export function clampReplyWindow(v) {
|
|
93
|
+
if (v === undefined || v === null || v === '') return REPLY_WINDOW_DEFAULT;
|
|
94
|
+
const n = Math.floor(Number(v));
|
|
95
|
+
if (!Number.isFinite(n)) return REPLY_WINDOW_DEFAULT;
|
|
96
|
+
return Math.min(1440, Math.max(1, n));
|
|
97
|
+
}
|
|
98
|
+
|
|
76
99
|
/** 设置里的 qq.interject → 规整后的插话参数(默认关)。 */
|
|
77
100
|
export function interjectConfig(raw) {
|
|
78
101
|
const v = raw ?? {};
|
package/lib/gateway.js
CHANGED
|
@@ -19,7 +19,13 @@
|
|
|
19
19
|
* (概率 + 冷却 + 每小时上限,睡觉时不插,插完对方接话就接着听);戳一戳当成点名;没回复前撤回的消息不回;
|
|
20
20
|
* 连发按字数估打字时间;每个聊天能单独暂停(设置页 / 主人发"/鱼 暂停");每一步判定记进"动态"。
|
|
21
21
|
*/
|
|
22
|
-
import { randomUUID } from 'node:crypto';
|
|
22
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
/** 抖音 uid 绑定表的 kv key(`douyin.uin.<纯数字uid>`);uid 不合法返回 ''。 */
|
|
25
|
+
const normUidKey = (uid) => {
|
|
26
|
+
const s = String(uid ?? '').trim().replace(/^uid:/, '').replace(/^douyin:/, '');
|
|
27
|
+
return /^\d+$/.test(s) ? `douyin.uin.${s}` : '';
|
|
28
|
+
};
|
|
23
29
|
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
24
30
|
import { extname, join, resolve } from 'node:path';
|
|
25
31
|
import {
|
|
@@ -30,7 +36,7 @@ import { normalizeMessageEvent, sanitizeName, sanitizeUserText } from './channel
|
|
|
30
36
|
import { applyAttachments, attachmentConfig } from './attachments.js';
|
|
31
37
|
import { parseCollect } from './stickers.js';
|
|
32
38
|
import { UNHEARD } from './voice.js';
|
|
33
|
-
import { typingDelayMs, interjectScore, interjectConfig, platformOf, splitChannelId, sessionChannelOf, isGroupChannelId, service, parseClosing } from './gateway-util.js';
|
|
39
|
+
import { typingDelayMs, interjectScore, interjectConfig, platformOf, splitChannelId, sessionChannelOf, isGroupChannelId, service, parseClosing, clampReplyWindow } from './gateway-util.js';
|
|
34
40
|
import { mediaMethods } from './gateway-media.js';
|
|
35
41
|
import { Outbox, classifySendError } from './outbox.js';
|
|
36
42
|
import { safeDownload } from './safe-download.js';
|
|
@@ -38,7 +44,7 @@ import { behaviorMethods } from './gateway-behavior.js';
|
|
|
38
44
|
import { findRepeatedParagraphs, normSentText } from './behavior.js';
|
|
39
45
|
import { 该收这条, 提取正文, 提取图块, 拆成消息段, 抠正文图 } from './channels/vendor/qq-bridge.js';
|
|
40
46
|
|
|
41
|
-
export { typingDelayMs, interjectScore, interjectConfig, platformOf, splitChannelId, sessionChannelOf };
|
|
47
|
+
export { typingDelayMs, interjectScore, interjectConfig, platformOf, splitChannelId, sessionChannelOf, clampReplyWindow };
|
|
42
48
|
export const SOURCE_QQ = 'fish-soul/qq';
|
|
43
49
|
export const SOURCE_PROACTIVE = 'fish-soul/proactive';
|
|
44
50
|
export const SOURCE_BILI = 'fish-soul/bili';
|
|
@@ -148,7 +154,7 @@ export class ChatGateway {
|
|
|
148
154
|
/** v0.11.1:QQ 没发出去的回复排队重发(落在 soul.db,重启也不丢) */
|
|
149
155
|
this.outbox = new Outbox({
|
|
150
156
|
db, channel, log: this.log,
|
|
151
|
-
windowMs: () => (
|
|
157
|
+
windowMs: () => clampReplyWindow(this.qqConfig().replyWindowMinutes) * 60_000,
|
|
152
158
|
onSent: (id, item) => {
|
|
153
159
|
this.rememberOwn(id, item.text);
|
|
154
160
|
this.note('sent', { channelId: item.channel_id, title: item.title, text: item.text || '[图片]', reason: `重发成功(第 ${item.attempts + 1} 次)` });
|
|
@@ -220,7 +226,7 @@ export class ChatGateway {
|
|
|
220
226
|
followUpSeconds: Number.isFinite(Number(q.followUpSeconds)) ? Number(q.followUpSeconds) : 120,
|
|
221
227
|
followUpSmart: q.followUpSmart === true, // #59 她说"这轮聊完了"就不接着听(默认关)
|
|
222
228
|
batchMs: Number.isFinite(Number(q.batchMs)) ? Number(q.batchMs) : 3000,
|
|
223
|
-
replyWindowMinutes:
|
|
229
|
+
replyWindowMinutes: clampReplyWindow(q.replyWindowMinutes),
|
|
224
230
|
callNames: Array.isArray(q.callNames) ? q.callNames.map((x) => String(x).trim()).filter(Boolean) : [],
|
|
225
231
|
interject: interjectConfig(q.interject)
|
|
226
232
|
};
|
|
@@ -307,7 +313,7 @@ export class ChatGateway {
|
|
|
307
313
|
replyMutual: b.replyMutual !== false,
|
|
308
314
|
proactive: b.proactive === true,
|
|
309
315
|
batchMs: num(b.batchMs, 3000),
|
|
310
|
-
replyWindowMinutes:
|
|
316
|
+
replyWindowMinutes: clampReplyWindow(b.replyWindowMinutes)
|
|
311
317
|
};
|
|
312
318
|
}
|
|
313
319
|
|
|
@@ -329,7 +335,7 @@ export class ChatGateway {
|
|
|
329
335
|
groupReply: d.groupReply === true,
|
|
330
336
|
callNames: Array.isArray(d.callNames) ? d.callNames.map((x) => String(x).trim()).filter(Boolean) : [],
|
|
331
337
|
batchMs: num(d.batchMs, 3000),
|
|
332
|
-
replyWindowMinutes:
|
|
338
|
+
replyWindowMinutes: clampReplyWindow(d.replyWindowMinutes)
|
|
333
339
|
};
|
|
334
340
|
}
|
|
335
341
|
|
|
@@ -350,17 +356,221 @@ export class ChatGateway {
|
|
|
350
356
|
personKeyOf(chat) {
|
|
351
357
|
if (chat?.chatType === 'group' || isGroupChannelId(chat?.channelId)) return '';
|
|
352
358
|
const [kind, id] = splitChannelId(chat?.channelId);
|
|
353
|
-
if (kind === 'douyin') return ''; // 昵称关联和旧记录均未验证,不能共享会话
|
|
354
359
|
let uin = '';
|
|
355
360
|
if (kind === 'private') uin = id;
|
|
356
361
|
// B站主人 UID 可按现有配置关联主人 QQ。
|
|
357
362
|
else if (kind === 'bili') uin = this.biliLinks().get(id) ?? (id === this.biliConfig().masterUid ? this.masterQq() : '');
|
|
363
|
+
// 抖音:只有能明确对上主人时才并会话(见 douyinUinOf)。
|
|
364
|
+
else if (kind === 'douyin') uin = this.douyinUinOf(chat);
|
|
358
365
|
uin = String(uin).trim();
|
|
359
|
-
//
|
|
366
|
+
// 身份可以查询记忆里挂着的既有 QQ 关联。
|
|
360
367
|
if (!/^\d{5,12}$/.test(uin)) uin = this.linkedQqOf(chat);
|
|
361
368
|
return /^\d{5,12}$/.test(uin) ? `user:${uin}` : '';
|
|
362
369
|
}
|
|
363
370
|
|
|
371
|
+
/**
|
|
372
|
+
* 抖音会话对应的 QQ 号。算不出返回 ''(不共享会话)。
|
|
373
|
+
*
|
|
374
|
+
* 🔴 **核心安全原则:昵称只用来「定位」,绝不用来「授权」。**
|
|
375
|
+
*
|
|
376
|
+
* 并会话 = 两边的对话进同一个 session(同一份记忆、同一个模型上下文),
|
|
377
|
+
* 所以「这条会话是谁」必须由**对方改不了的东西**决定:数字 uid。
|
|
378
|
+
* 昵称可以随便改,只拿昵称当身份 ⇒ 任何人改名就能读/写别人的私聊会话
|
|
379
|
+
* (真机验证过,PR #96 原版确实能并进去)。
|
|
380
|
+
*
|
|
381
|
+
* 但主人习惯在设置页写「昵称 = QQ 号」(真机就是 `未云=3630958828`)。
|
|
382
|
+
* 所以这里做成**两级**:
|
|
383
|
+
*
|
|
384
|
+
* ① **数字 uid 对应表**(`2874562949425280=3630958828`)—— 直接认,最硬;
|
|
385
|
+
* ② **昵称对应表**(`未云=3630958828`)—— 只用来**定位候选**,
|
|
386
|
+
* 还必须拿到这个会话的**真实 uid**,且该 uid 已在**绑定表**里被确认过
|
|
387
|
+
* (`douyin.uin.<uid>` → QQ 号)。
|
|
388
|
+
*
|
|
389
|
+
* 绑定表怎么来:第一次见到「昵称匹配 + 真实 uid 拿得到」时写入(见 {@link bindDouyinUin})。
|
|
390
|
+
* 之后昵称再改也不影响(uid 不变),而**冒充者即使改成同名,uid 不同 ⇒ 查不到绑定 ⇒ 不并**。
|
|
391
|
+
* 这既保留了主人的配置,也堵死了冒充。
|
|
392
|
+
*
|
|
393
|
+
* @param {{channelId?: string, userId?: string}} chat
|
|
394
|
+
* @returns {string} QQ 号;认不出返回 ''
|
|
395
|
+
*/
|
|
396
|
+
douyinUinOf(chat) {
|
|
397
|
+
// 只取 kind —— 冒号后段是昵称,**不当身份用**。
|
|
398
|
+
const [kind] = splitChannelId(chat?.channelId);
|
|
399
|
+
if (kind !== 'douyin') return '';
|
|
400
|
+
const cfg = this.douyinConfig();
|
|
401
|
+
const raw = this.getConfig().douyin ?? {};
|
|
402
|
+
const links = Array.isArray(raw.links) ? raw.links : [];
|
|
403
|
+
const norm = (v) => String(v ?? '').trim().replace(/^uid:/, '').replace(/^douyin:/, '');
|
|
404
|
+
// 真实 uid:优先 chat.userId;没有就从会话游标反查
|
|
405
|
+
// (`douyin.conv.<hash>.identity` 是轮询路用会话 ID 学出来的,不是昵称)。
|
|
406
|
+
// ⚠️ 这条兜底必须留着:`lib/index.js` 的工具 guard 只拿得到 channelId。
|
|
407
|
+
let uid = norm(chat?.userId);
|
|
408
|
+
if (!/^\d+$/.test(uid) && typeof this.douyinIdentityOfChannel === 'function') {
|
|
409
|
+
uid = norm(this.douyinIdentityOfChannel(chat?.channelId));
|
|
410
|
+
}
|
|
411
|
+
const hasUid = /^\d+$/.test(uid);
|
|
412
|
+
|
|
413
|
+
// ① 主人身份(数字 uid)—— 最硬的一条
|
|
414
|
+
const master = norm(cfg.masterUid);
|
|
415
|
+
if (hasUid && /^\d+$/.test(master) && uid === master) return this.masterQq();
|
|
416
|
+
|
|
417
|
+
// ② 数字 uid 对应表
|
|
418
|
+
if (hasUid) {
|
|
419
|
+
for (const line of links) {
|
|
420
|
+
const m = String(line).match(/^\s*(?:douyin:|uid:)?(\d+)\s*[=::]\s*(\d{5,12})\s*$/);
|
|
421
|
+
if (m && uid === m[1]) return m[2];
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ③ 昵称对应表:只定位候选。**绑定必须由主人亲手确认一次**,不是自动发生。
|
|
426
|
+
//
|
|
427
|
+
// 为什么要这样:
|
|
428
|
+
//
|
|
429
|
+
// 用户**拿不到自己的 16 位抖音 uid**(只有短号),所以配置只能抄昵称。
|
|
430
|
+
// 但「第一次见到就自动绑定」有个真漏洞:你把「未云=QQ」写进配置认领自己,
|
|
431
|
+
// 冒充者把昵称改成「未云」→ 第一次见到 → **自动绑定成 QQ**。
|
|
432
|
+
// 冒充者没配错任何东西,只是冒名顶替成功——这不是配置错误,是代码把人放进去了。
|
|
433
|
+
// 实测原方案里确实中招(uid=9999999999 立刻绑到 QQ)。
|
|
434
|
+
//
|
|
435
|
+
// 所以改成**两段**:
|
|
436
|
+
// a. 第一次见到「昵称匹配 + 真实 uid 拿得到」⇒ **不并**,只是把这个候选
|
|
437
|
+
// 挂出来,等主人确认(面板里点「这个是我」);冒充者怎么改昵称都不影响。
|
|
438
|
+
// b. 只有主人**亲手点过确认**之后,才把 `uid → QQ` 写进绑定表(见 bindDouyinUin)。
|
|
439
|
+
//
|
|
440
|
+
// 主人是唯一能看懂 uid 的人 → 他知道这个 uid 到底是不是自己。
|
|
441
|
+
// 冒充者能冒充昵称,但能控制的就是不了,它就是他也知道。
|
|
442
|
+
// 如果那条候选一直没人点,就一直不生效——安全的降级,不报错。
|
|
443
|
+
if (!hasUid) return ''; // 只有昵称、拿不到真实 uid ⇒ 不敢并(宁可少并,不可串号)
|
|
444
|
+
|
|
445
|
+
// 🔴 **绑定表优先,跟昵称无关**:主人确认过一次之后,这个人改了昵称也照样认。
|
|
446
|
+
// 这一步必须在「昵称匹配」之前 —— 否则改了昵称就掉线(真机踩过)。
|
|
447
|
+
const boundQq = this.douyinUinBinding(uid);
|
|
448
|
+
if (boundQq !== '') return boundQq;
|
|
449
|
+
|
|
450
|
+
const name = String(splitChannelId(chat?.channelId)[1] ?? '').trim();
|
|
451
|
+
if (name === '') return '';
|
|
452
|
+
for (const line of links) {
|
|
453
|
+
const m = String(line).match(/^\s*([^\d=::\s][^=::]*?)\s*[=::]\s*(\d{5,12})\s*$/);
|
|
454
|
+
if (!m || m[1] !== name) continue;
|
|
455
|
+
// 走到这里说明「绑定表里没有这个 uid」(上面已经查过)⇒ 还没被主人确认过。
|
|
456
|
+
// 挂一条候选给主人看,**不自动绑**。冒充者怎么改昵称都只能影响这条候选,
|
|
457
|
+
// 而候选不点确认就不生效。
|
|
458
|
+
this.noteDouyinUinCandidate({ name, uid, qq: m[2] });
|
|
459
|
+
return '';
|
|
460
|
+
}
|
|
461
|
+
return '';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 待确认的候选:「这个 uid 是不是你这个昵称对应的人」。
|
|
466
|
+
*
|
|
467
|
+
* 不是自动绑定,是给主人看一眼——主人是唯一能看懂 uid 的人,
|
|
468
|
+
* 他知道这个 uid 到底是不是自己。
|
|
469
|
+
*/
|
|
470
|
+
noteDouyinUinCandidate({ name, uid, qq }) {
|
|
471
|
+
const key = `douyin.uinCandidate.${name}`;
|
|
472
|
+
try {
|
|
473
|
+
const old = getKv(this.db, key, null);
|
|
474
|
+
if (old?.uid === uid && old?.qq === qq) return; // 重复的同一条,不再弹
|
|
475
|
+
setKv(this.db, key, { uid, qq, at: Date.now() });
|
|
476
|
+
this.note?.('douyin-uin-candidate', { name, uid, qq });
|
|
477
|
+
this.log?.('info', `[fish-soul] 有一条待确认的抖音身份:「${name}」→ uid ${uid}(配了 QQ ${qq}),等主人确认`);
|
|
478
|
+
} catch { /* 记不下来不影响这一轮 */ }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* `person` 表里这个抖音 uid 挂着的 QQ 号(认人阶段写的,是已确认的事实)。
|
|
483
|
+
* 没这个人 / 没挂 QQ 返回 ''。
|
|
484
|
+
*/
|
|
485
|
+
douyinPersonQq(uid) {
|
|
486
|
+
const u = String(uid ?? '').trim().replace(/^uid:/, '').replace(/^douyin:/, '');
|
|
487
|
+
if (!/^\d+$/.test(u)) return '';
|
|
488
|
+
try {
|
|
489
|
+
const row = this.db.prepare('SELECT qq_uin FROM person WHERE douyin_uid = ? OR douyin_uid = ? LIMIT 1')
|
|
490
|
+
.get(`douyin:${u}`, `uid:${u}`);
|
|
491
|
+
const qq = String(row?.qq_uin ?? '').trim();
|
|
492
|
+
return /^\d{5,12}$/.test(qq) ? qq : '';
|
|
493
|
+
} catch { return ''; }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** 主人确认之后:把「真实 uid → QQ 号」写进绑定表,并清掉那条候选。 */
|
|
497
|
+
confirmDouyinUin(name) {
|
|
498
|
+
const key = `douyin.uinCandidate.${String(name ?? '').trim()}`;
|
|
499
|
+
const c = getKv(this.db, key, null);
|
|
500
|
+
if (!c?.uid || !/^\d+$/.test(String(c.uid)) || !/^\d{5,12}$/.test(String(c.qq))) return { ok: false, error: '没有这条候选(或已被确认过)' };
|
|
501
|
+
this.bindDouyinUin(String(c.uid), String(c.qq));
|
|
502
|
+
// 绑定已写进绑定表,这条候选没用了(留着会误导主人以为还没确认)
|
|
503
|
+
try { setKv(this.db, key, null); } catch { /* 清不掉也不影响 */ }
|
|
504
|
+
this.log?.('info', `[fish-soul] 已确认:「${name}」的抖音 uid ${c.uid} = QQ ${c.qq}`);
|
|
505
|
+
return { ok: true, uid: String(c.uid), qq: String(c.qq) };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** 主人要清空某条候选(错认了,或想重配)。 */
|
|
509
|
+
dismissDouyinUinCandidate(name) {
|
|
510
|
+
const key = `douyin.uinCandidate.${String(name ?? '').trim()}`;
|
|
511
|
+
try { setKv(this.db, key, null); return { ok: true }; } catch { return { ok: false }; }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** 面板读:有哪些待确认的候选。 */
|
|
515
|
+
douyinUinCandidates() {
|
|
516
|
+
try {
|
|
517
|
+
return this.db.prepare("SELECT key, value FROM kv WHERE key LIKE 'douyin.uinCandidate.%' AND value IS NOT NULL AND value != 'null'")
|
|
518
|
+
.all()
|
|
519
|
+
.map((r) => ({ name: String(r.key).slice('douyin.uinCandidate.'.length), ...(JSON.parse(r.value) ?? {}) }))
|
|
520
|
+
.filter((x) => x.uid && x.qq);
|
|
521
|
+
} catch { return []; }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** 抖音真实 uid 绑定的 QQ 号(`douyin.uin.<uid>`);没绑过返回 ''。 */
|
|
525
|
+
douyinUinBinding(uid) {
|
|
526
|
+
const key = normUidKey(uid);
|
|
527
|
+
if (key === '') return '';
|
|
528
|
+
try {
|
|
529
|
+
const v = String(this.db.prepare('SELECT value FROM kv WHERE key = ?').get(key)?.value ?? '').replace(/"/g, '').trim();
|
|
530
|
+
return /^\d{5,12}$/.test(v) ? v : '';
|
|
531
|
+
} catch { return ''; }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* 把「真实 uid → QQ 号」记进绑定表。
|
|
536
|
+
*
|
|
537
|
+
* 只在「昵称匹配 + 真实 uid 拿得到」的第一次写入;之后一切判断只看这张表,
|
|
538
|
+
* 所以昵称改了不影响,冒充者 uid 不同也进不来。
|
|
539
|
+
*/
|
|
540
|
+
bindDouyinUin(uid, qq) {
|
|
541
|
+
const key = normUidKey(uid);
|
|
542
|
+
if (key === '' || !/^\d{5,12}$/.test(String(qq))) return;
|
|
543
|
+
try {
|
|
544
|
+
this.db.prepare('INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)').run(key, JSON.stringify(String(qq)));
|
|
545
|
+
} catch { /* 记不下来就这轮不并,下一轮再试 */ }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* 抖音通道 id(`douyin:<昵称>`)→ 这个会话**真实的对端 uid**(`douyin:<uid>`)。
|
|
550
|
+
*
|
|
551
|
+
* 来源是轮询路学出来的游标:`douyin.conv.<sha1(昵称)前16>` 的值里有
|
|
552
|
+
* `{ name, convId, identity }`,而 `identity` 是**用会话 ID 推出来的**
|
|
553
|
+
* (`0:1:<我方>:<对方>` → `douyin:<对方>`),不是昵称。
|
|
554
|
+
*
|
|
555
|
+
* 用途:`douyinUinOf` 在拿不到 `chat.userId` 时反查真实身份。
|
|
556
|
+
* **不能退回昵称** —— 那正是 PR #96 的冒充洞。
|
|
557
|
+
*
|
|
558
|
+
* @param {string} channelId `douyin:<昵称>`
|
|
559
|
+
* @returns {string} `douyin:<uid>`;查不到返回 ''
|
|
560
|
+
*/
|
|
561
|
+
douyinIdentityOfChannel(channelId) {
|
|
562
|
+
const [kind, name] = splitChannelId(channelId);
|
|
563
|
+
if (kind !== 'douyin' || !name) return '';
|
|
564
|
+
try {
|
|
565
|
+
const key = `douyin.conv.${createHash('sha1').update(String(name)).digest('hex').slice(0, 16)}`;
|
|
566
|
+
const row = this.db.prepare('SELECT value FROM kv WHERE key = ?').get(key);
|
|
567
|
+
if (!row?.value) return '';
|
|
568
|
+
const cur = JSON.parse(row.value);
|
|
569
|
+
// 只认 `douyin:<数字uid>`;游标里的 name 是昵称,不能用
|
|
570
|
+
return /^douyin:\d+$/.test(String(cur?.identity ?? '')) ? String(cur.identity) : '';
|
|
571
|
+
} catch { return ''; }
|
|
572
|
+
}
|
|
573
|
+
|
|
364
574
|
/** 记忆里这个人挂着的 QQ 号(认人用的是真实 ID,改昵称也不丢);没有就返回 ''。 */
|
|
365
575
|
linkedQqOf(chat) {
|
|
366
576
|
const [kind, id] = splitChannelId(chat?.channelId);
|
package/lib/index.js
CHANGED
|
@@ -1158,7 +1158,16 @@ async function startup(ctx, config, { log, gate }) {
|
|
|
1158
1158
|
const id = exec?.agent?.id;
|
|
1159
1159
|
const activeChannel = gateway?.turns?.get(String(id))?.channelId;
|
|
1160
1160
|
const boundChannel = gateway?.chatForSession(String(id))?.channel_id;
|
|
1161
|
-
if ((activeChannel && platformOf(activeChannel) === 'douyin') || (boundChannel && platformOf(boundChannel) === 'douyin'))
|
|
1161
|
+
if ((activeChannel && platformOf(activeChannel) === 'douyin') || (boundChannel && platformOf(boundChannel) === 'douyin')) {
|
|
1162
|
+
// 抖音会话被并进主人 QQ 私聊会话时,身份就是主人,跟 QQ 那边一样放行;
|
|
1163
|
+
// 认不出主人(陌生私信)才照旧拦。
|
|
1164
|
+
const douyinChannel = activeChannel && platformOf(activeChannel) === 'douyin'
|
|
1165
|
+
? activeChannel
|
|
1166
|
+
: boundChannel && platformOf(boundChannel) === 'douyin' ? boundChannel : '';
|
|
1167
|
+
const douyinUin = douyinChannel === '' ? '' : String(gateway?.douyinUinOf?.({ channelId: douyinChannel }) ?? '');
|
|
1168
|
+
const masterUin = String(currentConfig.masterQqUin ?? '').trim();
|
|
1169
|
+
if (douyinUin === '' || masterUin === '' || douyinUin !== masterUin) return '抖音身份尚未验证,暂不开放聊天工具';
|
|
1170
|
+
}
|
|
1162
1171
|
const ours = id !== undefined && id !== null && gateway?.isOurSession(id) === true;
|
|
1163
1172
|
if (!ours && WEB_TOOLS.includes(exec?.name)) markHostWebSeen();
|
|
1164
1173
|
if (!ours) return exec?.name === OWN_WEB_TOOL ? `${OWN_WEB_TOOL} 只给鱼的聊天会话用(DSH 自己的会话请用 web_search)` : undefined;
|
package/lib/panel.css
CHANGED
|
@@ -115,12 +115,14 @@ select.fs-input{appearance:none;-webkit-appearance:none;padding-right:32px;curso
|
|
|
115
115
|
.fs-switch[aria-checked=false] .fs-switch-thumb{background:var(--fg-tertiary)}
|
|
116
116
|
.fs-switch[aria-checked=true] .fs-switch-thumb{background:var(--bg-control);transform:translateX(12px)}
|
|
117
117
|
|
|
118
|
-
/*
|
|
118
|
+
/* 官方控件只补充插件布局与危险态,**视觉主体一律来自宿主**。
|
|
119
|
+
🔴 不要在这里覆盖 primary 的底色。
|
|
120
|
+
宿主原生是 `background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-inverted)`
|
|
121
|
+
(见 asar 里 dsh-client-ui-settings-account 的 AccountSection 按钮样式:深色实心 + 反色字)。
|
|
122
|
+
这里曾经硬写成 `#4663f3`,看着"更蓝更亮",但那是**近似模拟**,跟设置页其它按钮对不上。
|
|
123
|
+
⇒ 要么用原生,要么别显示这个按钮。 */
|
|
119
124
|
.fs-native-btn{flex:none}
|
|
120
125
|
.fs-native-btn.fs-native-danger{color:var(--status-error)}
|
|
121
|
-
/* 白字主按钮使用稍深的蓝,确保浅色宿主中仍有足够对比度。 */
|
|
122
|
-
.fs .fs-native-btn.fs-native-primary:not(:disabled){background:#4663f3;color:#fff}
|
|
123
|
-
@media (hover:hover){.fs .fs-native-btn.fs-native-primary:hover:not(:disabled){background:#3b57e7}}
|
|
124
126
|
|
|
125
127
|
|
|
126
128
|
/* Button(sm):outline / primary / ghost / danger */
|
package/lib/routes-douyin.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { existsSync, statSync } from 'node:fs';
|
|
6
6
|
import { sharpReady } from './imageconv.js';
|
|
7
|
+
import { clampReplyWindow } from './gateway-util.js';
|
|
7
8
|
|
|
8
9
|
const NAME_MAX = 40;
|
|
9
10
|
|
|
@@ -27,7 +28,7 @@ export function douyinState({ getConfig, life, clock, douyin, douyinHost, douyin
|
|
|
27
28
|
shareCard: d.shareCard === true,
|
|
28
29
|
allowList: Array.isArray(d.allowList) ? d.allowList : [],
|
|
29
30
|
replyAll: d.replyAll === true,
|
|
30
|
-
replyWindowMinutes: d.replyWindowMinutes
|
|
31
|
+
replyWindowMinutes: clampReplyWindow(d.replyWindowMinutes),
|
|
31
32
|
links: Array.isArray(d.links) ? d.links : [],
|
|
32
33
|
groups: Array.isArray(d.groups) ? d.groups : [],
|
|
33
34
|
groupReply: d.groupReply === true,
|
|
@@ -66,7 +67,7 @@ const names = (list) => (Array.isArray(list) ? list : []).map((x) => String(x).t
|
|
|
66
67
|
|
|
67
68
|
/** 处理 /channels/douyin-config 和 /douyin/*;不是抖音的路由返回 false。 */
|
|
68
69
|
export async function handleDouyinRoute({ method, path, req, send, readBody, sameOrigin, deps }) {
|
|
69
|
-
const { getConfig, saveConfig, log, douyin, douyinHost, douyinBrowser, douyinSocial } = deps;
|
|
70
|
+
const { getConfig, saveConfig, log, douyin, douyinHost, douyinBrowser, douyinSocial, gateway } = deps;
|
|
70
71
|
if (path !== '/channels/douyin-config' && !path.startsWith('/douyin/')) return false;
|
|
71
72
|
if (method !== 'POST') { send(405, { ok: false, error: 'method not allowed' }); return true; }
|
|
72
73
|
if (!sameOrigin(req)) { send(403, { ok: false, error: 'forbidden' }); return true; }
|
|
@@ -117,7 +118,7 @@ export async function handleDouyinRoute({ method, path, req, send, readBody, sam
|
|
|
117
118
|
allowList,
|
|
118
119
|
replyAll: body.replyAll === undefined ? cur.replyAll === true : body.replyAll === true,
|
|
119
120
|
// 超过多久的旧消息只记不回(分钟)。1~1440:0 会让窗口变成 0ms,全算旧消息。
|
|
120
|
-
replyWindowMinutes:
|
|
121
|
+
replyWindowMinutes: clampReplyWindow(body.replyWindowMinutes ?? cur.replyWindowMinutes),
|
|
121
122
|
links,
|
|
122
123
|
groups,
|
|
123
124
|
groupReply: body.groupReply === undefined ? cur.groupReply === true : body.groupReply === true,
|
|
@@ -170,6 +171,23 @@ export async function handleDouyinRoute({ method, path, req, send, readBody, sam
|
|
|
170
171
|
send(200, { ok: true });
|
|
171
172
|
return true;
|
|
172
173
|
}
|
|
174
|
+
// ── 待确认的抖音身份(昵称配了、uid 拿得到但还没绑)──────────────
|
|
175
|
+
//
|
|
176
|
+
// 这些挂在{douyinUinOf}(不是 DouyinChannel)——身份合并是网关层的事。
|
|
177
|
+
if (path === '/douyin/uin-candidates') {
|
|
178
|
+
send(200, { ok: true, candidates: gateway?.douyinUinCandidates?.() ?? [] });
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
if (path === '/douyin/uin-confirm') {
|
|
182
|
+
const r = gateway?.confirmDouyinUin?.(body.name);
|
|
183
|
+
send(200, r ?? { ok: false, error: '抖音模块没加载' });
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (path === '/douyin/uin-dismiss') {
|
|
187
|
+
const r = gateway?.dismissDouyinUinCandidate?.(body.name);
|
|
188
|
+
send(200, r ?? { ok: false, error: '抖音模块没加载' });
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
173
191
|
if (path === '/douyin/browser/start') {
|
|
174
192
|
if ((getConfig().douyin ?? {}).enabled !== true) { send(200, { ok: false, error: '先打开抖音总开关' }); return true; }
|
|
175
193
|
const ok = await douyinHost.start({ resume: true });
|
package/lib/routes.js
CHANGED
|
@@ -22,7 +22,7 @@ import { stickerConfig } from './stickers.js';
|
|
|
22
22
|
import { slangConfig } from './slang.js';
|
|
23
23
|
import { importMemories } from './importer.js';
|
|
24
24
|
import { bulkMemory, filterWhere } from './memory-bulk.js';
|
|
25
|
-
import { dedicatedWorkspacePath, interjectConfig } from './gateway.js';
|
|
25
|
+
import { dedicatedWorkspacePath, interjectConfig, clampReplyWindow } from './gateway.js';
|
|
26
26
|
import { DEFAULT_DAILY_MAX, DEFAULT_IDLE_REAL_GAP_MIN } from './life.js';
|
|
27
27
|
import { qrSvg } from './channels/qrcode.js';
|
|
28
28
|
import { normalizeConfig } from './qq-group-admin.js';
|
|
@@ -122,7 +122,7 @@ export function registerRoutes(ctx, deps) {
|
|
|
122
122
|
links: Array.isArray(b.links) ? b.links : [],
|
|
123
123
|
proactive: b.proactive === true,
|
|
124
124
|
pollSeconds: b.pollSeconds ?? 20,
|
|
125
|
-
replyWindowMinutes: b.replyWindowMinutes
|
|
125
|
+
replyWindowMinutes: clampReplyWindow(b.replyWindowMinutes),
|
|
126
126
|
browse: {
|
|
127
127
|
enabled: br.enabled !== false,
|
|
128
128
|
perDay: br.perDay ?? 3,
|
|
@@ -555,7 +555,7 @@ export function registerRoutes(ctx, deps) {
|
|
|
555
555
|
followUpSeconds: q.followUpSeconds ?? 120,
|
|
556
556
|
followUpSmart: q.followUpSmart === true,
|
|
557
557
|
// 回显:设置页的数字框要能读回来,否则改完刷新又弹回去(写进去读不出来)
|
|
558
|
-
replyWindowMinutes: q.replyWindowMinutes
|
|
558
|
+
replyWindowMinutes: clampReplyWindow(q.replyWindowMinutes),
|
|
559
559
|
batchMs: q.batchMs ?? 3000,
|
|
560
560
|
callNames: Array.isArray(q.callNames) ? q.callNames : [],
|
|
561
561
|
interject: interjectConfig(q.interject),
|
|
@@ -664,10 +664,8 @@ export function registerRoutes(ctx, deps) {
|
|
|
664
664
|
followUpCount: Math.min(20, Math.max(0, Math.floor(Number(body.followUpCount ?? cur.followUpCount ?? 3)) || 0)),
|
|
665
665
|
followUpSeconds: Math.min(1800, Math.max(0, Math.floor(Number(body.followUpSeconds ?? cur.followUpSeconds ?? 120)) || 0)),
|
|
666
666
|
followUpSmart: body.followUpSmart === undefined ? cur.followUpSmart === true : body.followUpSmart === true,
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
// 界面看着开了自动回复却一条都不回。要只记不回,去关「自动回复」。
|
|
670
|
-
replyWindowMinutes: Math.min(1440, Math.max(1, Math.floor(Number(body.replyWindowMinutes ?? cur.replyWindowMinutes ?? 120)) || 1)),
|
|
667
|
+
// 超过多久的旧消息只记不回(分钟)。夹取规则见 clampReplyWindow 的注释。
|
|
668
|
+
replyWindowMinutes: clampReplyWindow(body.replyWindowMinutes ?? cur.replyWindowMinutes),
|
|
671
669
|
batchMs: Math.min(15000, Math.max(0, Math.floor(Number(body.batchMs ?? cur.batchMs ?? 3000)) || 0)),
|
|
672
670
|
callNames: Array.isArray(body.callNames) ? body.callNames.map((x) => String(x).trim()).filter(Boolean).slice(0, 10) : (cur.callNames ?? []),
|
|
673
671
|
interject: interjectConfig(body.interject === undefined ? cur.interject : { ...(cur.interject ?? {}), ...body.interject }),
|
|
@@ -745,8 +743,7 @@ export function registerRoutes(ctx, deps) {
|
|
|
745
743
|
links,
|
|
746
744
|
proactive: body.proactive === undefined ? cur.proactive === true : body.proactive === true,
|
|
747
745
|
pollSeconds: clamp(body.pollSeconds, cur.pollSeconds ?? 20, 10, 300),
|
|
748
|
-
|
|
749
|
-
replyWindowMinutes: Math.min(1440, Math.max(1, Math.floor(Number(body.replyWindowMinutes ?? cur.replyWindowMinutes ?? 120)) || 1)),
|
|
746
|
+
replyWindowMinutes: clampReplyWindow(body.replyWindowMinutes ?? cur.replyWindowMinutes),
|
|
750
747
|
browse: {
|
|
751
748
|
enabled: br.enabled === undefined ? cb.enabled !== false : br.enabled === true,
|
|
752
749
|
perDay: clamp(br.perDay, cb.perDay ?? 3, 0, 12),
|
package/lib/soul-memory.js
CHANGED
|
@@ -536,11 +536,17 @@ export class ChatDigester {
|
|
|
536
536
|
try { parsed = JSON.parse(m[0]); } catch { return null; }
|
|
537
537
|
|
|
538
538
|
const channel = channelId.startsWith('douyin-group:') ? 'douyin-group' : isGroup ? 'qq-group' : channelId.startsWith('bili:') ? 'bili-private' : channelId.startsWith('douyin:') ? 'douyin-private' : 'qq-private';
|
|
539
|
+
const inTurn = new Set(ownerIds);
|
|
539
540
|
const ownerOfName = (name) => {
|
|
540
541
|
const n = String(name ?? '').trim();
|
|
541
542
|
if (n === '') return null;
|
|
542
543
|
if (owners.has(n)) return owners.get(n);
|
|
543
|
-
|
|
544
|
+
// 🔴 署名归属只认「这一轮真的在场说话的人」。
|
|
545
|
+
// findOwnerByName 是按 display_name 跨行查的 —— 昵称能随便改,
|
|
546
|
+
// 冒充者改成主人的名字,聊天里提到「主人」就会落到主人 person 行上。
|
|
547
|
+
// 所以跨行查名之前必须先确认:查出来的这个人,这轮在场吗?不在场 = 第三者,不记。
|
|
548
|
+
const o = this.findOwnerByName?.(n) ?? null;
|
|
549
|
+
return o !== null && inTurn.has(o) ? o : null;
|
|
544
550
|
};
|
|
545
551
|
const single = ownerIds.length === 1 ? ownerIds[0] : null;
|
|
546
552
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-fish-soul",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记、每周记忆存档与可选周记(DSH 插件,单角色 feiyu ,v0.15.37)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"scripts": {
|
|
67
|
-
"test": "node test/safe-download.mjs && node test/douyin-exact-id.mjs && node test/douyin-pb-init.mjs && node test/douyin-conversation.mjs && node test/douyin-inbox.mjs && node test/douyin-quarantine.mjs && node test/douyin-send-target.mjs && node test/douyin-send-exclusive.mjs && node test/douyin-gap-dedupe.mjs && node test/douyin-read-fail-retry.mjs && node test/douyin-read-cache.mjs && node test/douyin-dm-media-conv.mjs && node test/douyin-read-fail-retry-poll.mjs && node test/douyin-inbox-contract.mjs && node test/person-attribution.mjs && node test/memory-owner-reassign.mjs && node test/douyin-poll-and-timer-safety.mjs && node test/p2-limits.mjs && node test/resource-safety.mjs && node test/leftover-edge-cases.mjs && node test/douyin-master-uid.mjs && node test/douyin-identity-e2e.mjs && node test/douyin-post-jobs.mjs && node test/retrieval-filtered-vectors.mjs && node test/security-boundaries.mjs && node test/douyin-cdp-cursor.mjs && node test/smoke.mjs && node test/douyin-browser.mjs && node test/douyin-music.mjs && node test/startup-gate.mjs && node test/outbox.mjs && node test/import-coverage.mjs && node test/update-check.mjs && node test/issue-fixes.mjs && node test/import-resume.mjs && node test/maibot-extras.mjs && node test/worldinfo.mjs && node test/memory-bulk.mjs && node test/memory-tiers.mjs && node test/memory-weekly.mjs && node test/issue-98-self-archive.mjs && node test/issue-99-memory-order.mjs && node test/issue-78-diagnostic.mjs && node test/memory-storage.mjs && node test/memory-storage-restore.mjs && node test/backup-restore.mjs && node test/douyin-group.mjs && node test/review-fixes.mjs && node test/at-reads-video.mjs && node test/search-source.mjs && node test/followups.mjs && node test/detail-config.mjs && node test/qq-send-any-image.mjs && node test/qq-read-attachment.mjs && node test/qq-batch-window.mjs && node test/comment-video.mjs && node test/frames.mjs && node test/face-name.mjs && node test/comment-reply.mjs && node test/chat-comment.mjs && node test/at-target.mjs && node test/quota-caps.mjs && node test/client-lint.mjs && node test/client-panels.mjs && node test/dsh-desktop.mjs && node test/issue-59-interject.mjs && node test/issue-59-diagnose.mjs && node test/sticker-manage.mjs && node test/bili-emotes.mjs && node test/qq-outbound-sanitize.mjs && node test/person-delete.mjs && node test/qq-group-admin.mjs && node test/douyin-recovery.mjs && node test/session-preset.mjs && node test/qq-connect-error.mjs && node test/douyin-comment-bridge.mjs && node test/bili-comment-emoji.mjs && node test/douyin-state-echo.mjs && node test/issue-75-proactive.mjs && node test/issue-76-capability.mjs && node test/douyin-http-browse.mjs && node test/douyin-verify-backoff.mjs && node test/douyin-digg-bridge.mjs && node test/memory-evidence-transcript.mjs && node test/chat-archive-default.mjs && node test/dream-transcript-wire.mjs && node test/douyin-frames-collect.mjs && node test/master-qq-uin-rebind.mjs && node test/douyin-session-health.mjs && node test/douyin-delivered.mjs && node test/douyin-card-share.mjs && node test/douyin-push-drop.mjs && node test/douyin-stranger-dm.mjs && node test/douyin-uid-binding.mjs && node test/image-webp-fallback.mjs && node test/douyin-emoji-fallback.mjs && node test/relay-other-platform.mjs && node test/voice-volcengine.mjs && node test/voice-minimax-dashscope.mjs && node test/bili-switch-persist.mjs && node test/cross-channel-context.mjs && node test/reply-target.mjs && node test/shared-session.mjs && node test/memory-source-label.mjs && node test/pending-delivery.mjs && node test/recall-window-78.mjs && node test/client-syntax.mjs && node test/diag-log.mjs && node test/check-rings.mjs && node test/sticker-import-gif.mjs && node test/sticker-real-gif.mjs && node test/sticker-upload-binary.mjs && node test/sticker-vision-tags.mjs && node test/sticker-manual-tags.mjs && node test/sticker-auto-name.mjs && node test/self-update.mjs && node test/group-list.mjs && node test/douyin-inbox-noise.mjs && node test/douyin-inbox-diag.mjs && node test/douyin-inbox-stuck.mjs && node test/reply-window.mjs && node test/douyin-sticker-heuristic.mjs",
|
|
67
|
+
"test": "node test/safe-download.mjs && node test/douyin-exact-id.mjs && node test/douyin-pb-init.mjs && node test/douyin-conversation.mjs && node test/douyin-inbox.mjs && node test/douyin-quarantine.mjs && node test/douyin-send-target.mjs && node test/douyin-send-exclusive.mjs && node test/douyin-gap-dedupe.mjs && node test/douyin-message-id-dedupe.mjs && node test/douyin-read-fail-retry.mjs && node test/douyin-read-cache.mjs && node test/douyin-dm-media-conv.mjs && node test/douyin-read-fail-retry-poll.mjs && node test/douyin-inbox-contract.mjs && node test/person-attribution.mjs && node test/memory-owner-reassign.mjs && node test/douyin-poll-and-timer-safety.mjs && node test/p2-limits.mjs && node test/resource-safety.mjs && node test/leftover-edge-cases.mjs && node test/douyin-master-uid.mjs && node test/douyin-identity-e2e.mjs && node test/douyin-post-jobs.mjs && node test/retrieval-filtered-vectors.mjs && node test/security-boundaries.mjs && node test/douyin-cdp-cursor.mjs && node test/smoke.mjs && node test/douyin-browser.mjs && node test/douyin-music.mjs && node test/startup-gate.mjs && node test/outbox.mjs && node test/import-coverage.mjs && node test/update-check.mjs && node test/issue-fixes.mjs && node test/import-resume.mjs && node test/maibot-extras.mjs && node test/worldinfo.mjs && node test/memory-bulk.mjs && node test/memory-tiers.mjs && node test/memory-weekly.mjs && node test/issue-98-self-archive.mjs && node test/issue-99-memory-order.mjs && node test/issue-78-diagnostic.mjs && node test/memory-storage.mjs && node test/memory-storage-restore.mjs && node test/backup-restore.mjs && node test/douyin-group.mjs && node test/review-fixes.mjs && node test/at-reads-video.mjs && node test/search-source.mjs && node test/followups.mjs && node test/detail-config.mjs && node test/qq-send-any-image.mjs && node test/qq-read-attachment.mjs && node test/qq-batch-window.mjs && node test/comment-video.mjs && node test/frames.mjs && node test/face-name.mjs && node test/comment-reply.mjs && node test/chat-comment.mjs && node test/at-target.mjs && node test/quota-caps.mjs && node test/client-lint.mjs && node test/client-panels.mjs && node test/dsh-desktop.mjs && node test/issue-59-interject.mjs && node test/issue-59-diagnose.mjs && node test/sticker-manage.mjs && node test/bili-emotes.mjs && node test/qq-outbound-sanitize.mjs && node test/person-delete.mjs && node test/qq-group-admin.mjs && node test/douyin-recovery.mjs && node test/session-preset.mjs && node test/qq-connect-error.mjs && node test/douyin-comment-bridge.mjs && node test/bili-comment-emoji.mjs && node test/douyin-state-echo.mjs && node test/issue-75-proactive.mjs && node test/issue-76-capability.mjs && node test/douyin-http-browse.mjs && node test/douyin-verify-backoff.mjs && node test/douyin-digg-bridge.mjs && node test/memory-evidence-transcript.mjs && node test/chat-archive-default.mjs && node test/dream-transcript-wire.mjs && node test/douyin-frames-collect.mjs && node test/master-qq-uin-rebind.mjs && node test/douyin-session-health.mjs && node test/douyin-delivered.mjs && node test/douyin-card-share.mjs && node test/douyin-push-drop.mjs && node test/douyin-stranger-dm.mjs && node test/douyin-uid-binding.mjs && node test/image-webp-fallback.mjs && node test/douyin-emoji-fallback.mjs && node test/relay-other-platform.mjs && node test/voice-volcengine.mjs && node test/voice-minimax-dashscope.mjs && node test/bili-switch-persist.mjs && node test/cross-channel-context.mjs && node test/reply-target.mjs && node test/shared-session.mjs && node test/memory-source-label.mjs && node test/pending-delivery.mjs && node test/recall-window-78.mjs && node test/client-syntax.mjs && node test/diag-log.mjs && node test/check-rings.mjs && node test/release-workflow.mjs && node test/sticker-import-gif.mjs && node test/sticker-real-gif.mjs && node test/sticker-upload-binary.mjs && node test/sticker-vision-tags.mjs && node test/sticker-manual-tags.mjs && node test/sticker-auto-name.mjs && node test/self-update.mjs && node test/group-list.mjs && node test/douyin-inbox-noise.mjs && node test/douyin-inbox-diag.mjs && node test/douyin-inbox-stuck.mjs && node test/reply-window.mjs && node test/douyin-sticker-heuristic.mjs",
|
|
68
68
|
"eval": "node test/eval/persona-eval.mjs"
|
|
69
69
|
}
|
|
70
70
|
}
|