dsh-fish-soul 0.15.26 → 0.15.28

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  让一个 QQ 小号背后有"同一个人":固定人格、按人记记忆、有自己的生活节奏、会主动开口;同一个"她"同时出现在 QQ、B站、抖音。
4
4
 
5
- DSH 插件(单角色 `feiyu`)。版本 **0.15.26**。
5
+ DSH 插件(单角色 `feiyu`)。版本 **0.15.28**。
6
6
 
7
7
  QQ 交流群:**1125962189**
8
8
 
@@ -24,6 +24,8 @@ QQ 交流群:**1125962189**
24
24
 
25
25
  ## 安装
26
26
 
27
+ **第一次装、想照着一步步走**:[从零跑起来](https://github.com/Britneycode/dsh-fish-soul/blob/main/docs/QUICKSTART.md) —— 五步到她在 QQ 里回你第一句话,每步都写了怎么确认成功、卡住了是什么原因。
28
+
27
29
  需要 Node ≥ 22.5。抖音专用浏览器需要一台常开的 Windows 桌面。
28
30
 
29
31
  从 npm:
@@ -120,4 +122,4 @@ MIT
120
122
 
121
123
  容量界面显示数据库、存档、备份、聊天文本的占用和磁盘剩余空间,并允许设置告警阈值;上下文指标仅按字符估计记忆片段的预算占用,连续接近上限时在页面标题栏提示,不代表模型完整上下文 Token 用量。预算不够放下某条匹配记忆时,会尝试后续较短的匹配结果,而不是直接停止。磁盘不足时停止创建存档。存档和备份位于本地,未加密,可能包含私密记忆;删除在线记录不会删除先前的存档或备份,请单独管理。
122
124
 
123
- 「聊天文本存档」默认关闭,必须明确输入要保存的会话 ID(如 `private:123`、`group:456`)并设置保留天数。仅保存启用后插件实际接入并写入聊天日志的文本,不包含附件、未被接入的消息或历史消息,不能保证平台聊天全量;历史请另行导入。文本单独存于未加密的本地数据库,可分会话查看或删除,过期文本在插件运行期间清理;离线期间不会清理。缩短期限可能立即删除过期文本;用户自行制作的备份和复制件需要另行删除。
125
+ 「聊天文本存档」**默认开启**(`chatArchive.enabled`,v0.15.25 起):会话列表**留空 = 记下所有会话**;只有填了会话 ID(如 `private:123`、`group:456`)才按白名单过滤。**不想要就在设置页关掉,或按会话删**。仅保存插件实际接入并写入聊天日志的文本,不包含附件、未被接入的消息或历史消息,不能保证平台聊天全量;历史请另行导入。文本单独存于未加密的本地数据库,可分会话查看或删除,默认保留 30 天、过期文本在插件运行期间清理;离线期间不会清理。缩短期限可能立即删除过期文本;用户自行制作的备份和复制件需要另行删除。
package/lib/browse.js CHANGED
@@ -3,15 +3,16 @@
3
3
  * 读标题、简介、字幕节选和热评,用她自己的口吻写一两句感受,记进"自己的记忆"(owner_id='self'),
4
4
  * 聊天时就能自然说出"我今天刷到……"。默认不点赞、不投币、不评论(点赞可在设置页打开)。
5
5
  */
6
- import { tokenizeForBm25 } from './segment.js';
6
+
7
7
  import { getKv, setKv } from './db.js';
8
+ import { sleep } from './time-util.js';
9
+ import { tokenizeForBm25 } from './segment.js';
8
10
 
9
11
  const BV_RE = /BV[0-9A-Za-z]{10}/g;
10
12
  const SHORT_RE = /https?:\/\/(?:b23\.tv|bili2233\.cn)\/[0-9A-Za-z]+/g;
11
13
  const DESCRIBE_TTL_MS = 3600_000;
12
14
  const DAY_MS = 86_400_000;
13
15
 
14
- const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });
15
16
  const oneLine = (s, n) => String(s ?? '').replace(/\s+/g, ' ').replace(/【/g, '〔').replace(/】/g, '〕').trim().slice(0, n);
16
17
 
17
18
  /** 文本里的 BV 号与 b23 短链。 */
@@ -1,3 +1,4 @@
1
+ import { sleep } from '../time-util.js';
1
2
  /**
2
3
  * bilibili.js —— B站网页接口客户端(扫码 / Cookie 登录、刷视频、私信收发)与私信轮询渠道。
3
4
  *
@@ -96,7 +97,6 @@ export function parseBiliJson(text) {
96
97
  }
97
98
 
98
99
  const stemOf = (url) => String(url ?? '').slice(String(url).lastIndexOf('/') + 1, String(url).lastIndexOf('.'));
99
- const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });
100
100
 
101
101
  function errorOf(json, fallback) {
102
102
  return new Error(`${fallback}:${json?.code ?? '?'} ${json?.message ?? json?.msg ?? ''}`.trim());
@@ -1,3 +1,4 @@
1
+ import { sleep } from '../time-util.js';
1
2
  /**
2
3
  * douyin.js —— 抖音小号:专用浏览器(Edge / Chrome …,CDP 控制)+ 网页版私信渠道。
3
4
  *
@@ -41,7 +42,6 @@ const RECENT_MS = 15 * 60_000;
41
42
  const WINDOW_KEEP = 30;
42
43
  const INJECT_ON_FIRST_READ = 3;
43
44
 
44
- const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });
45
45
  const errText = (e) => String(e instanceof Error ? e.message : e);
46
46
  const sha1 = (s) => createHash('sha1').update(String(s)).digest('hex');
47
47
 
@@ -182,6 +182,8 @@ export function normalizeDouyinMessage(m, { name, timeMs, messageId, group = fal
182
182
  groupId: name,
183
183
  userId: who.userId,
184
184
  nickname: who.nickname,
185
+ // 群聊的「会话名」是整个群,不是发言的人 —— 跟私信的语义对齐,出口不用改
186
+ peerName: name,
185
187
  subType: '',
186
188
  messageId,
187
189
  mentioned: false,
@@ -192,14 +194,33 @@ export function normalizeDouyinMessage(m, { name, timeMs, messageId, group = fal
192
194
  timeMs
193
195
  };
194
196
  }
197
+ // 🔴 单聊认人:优先用**接口给的 senderId**(真实数字 ID,改名也不变),
198
+ // 拿不到才退回会话名。
199
+ //
200
+ // 以前这里硬编码 `userId: name`(会话名 = 抖音昵称),于是「主人是谁」靠昵称比对 ——
201
+ // 主人一改昵称就认不出来,别人起个同名就能冒充。实测(2026-11-08)接口路**是给
202
+ // senderId 的**(如 7702399256389125000),只有 DOM 路没有。所以能稳就稳。
203
+ //
204
+ // ⚠️ 必须是 `uid:<id>` 这个格式,**不能是裸 id** —— 群聊那条路
205
+ // (groupSender)早就用 `uid:<id>` 存 douyin_uid 了。格式不一致的话,
206
+ // 同一个人在群里是 `uid:7702399256389125000`、在私信里是 `7702399256389125000`,
207
+ // 会被当成两个人,各记一套记忆。
208
+ //
209
+ // ⚠️ `channelId` **仍然按会话名**:私信面板是按名字打开会话的,
210
+ // 改成 ID 会导致 sendDM/enterChat 全部找不到人。这两件事必须分开:
211
+ // 认人用 ID,找会话用名字。
212
+ const sid = String(m?.senderId ?? '').trim();
195
213
  return {
196
214
  channel: 'douyin',
197
215
  chatType: 'private',
198
216
  channelId: `douyin:${name}`,
199
217
  targetId: name,
200
218
  groupId: null,
201
- userId: name,
219
+ userId: sid !== '' ? `uid:${sid}` : name,
202
220
  nickname: name,
221
+ // 会话名单独留一份:出口要靠它找会话,认人不能靠它。
222
+ // 老数据里 douyin_uid 存的是这个名字,认人时会拿它当兜底(见 resolveDouyinPerson)。
223
+ peerName: name,
203
224
  subType: '',
204
225
  messageId,
205
226
  mentioned: true,
@@ -949,13 +970,25 @@ export class DouyinChannel {
949
970
  }
950
971
 
951
972
  /**
952
- * 发一组内容(串行、每段之间模拟打字)。parts: [{type:'text', text} | {type:'image', path} | {type:'emoji', name}]
953
- * @returns {Promise<boolean>} 全部发出去了
973
+ * 发一组内容(串行、每段之间模拟打字)。
974
+ * parts: [{type:'text', text} | {type:'image', path} | {type:'emoji', name}]
975
+ *
976
+ * @returns {Promise<{ok: boolean, delivered: boolean, parts: Array<object>}>}
977
+ * `ok` —— 每段的发送动作都执行完、driver 没报错
978
+ * `delivered` —— 至少有一段落到了「回读能看见」的证据
979
+ *
980
+ * 🔴 为什么要把这两个分开:
981
+ * 抖音风控会**静默吞消息** —— Enter 按了、UI 没报错,但消息根本没进会话。
982
+ * 以前 send() 只返回一个 allOk,被吞的时候照样 true,上层以为发出去了。
983
+ * 现在 driver 的回读结果(sendDM 的 `verified` / sendImage 的 `verified`)
984
+ * 一路带上来,上层才能说清「动作做了」和「对方真能看见」是两回事。
954
985
  */
955
986
  send(name, parts) {
956
987
  const run = async () => {
957
988
  if (!this.host.running) throw new Error('抖音浏览器没在运行');
958
989
  let allOk = true;
990
+ let delivered = false;
991
+ const details = [];
959
992
  for (const part of parts) {
960
993
  await sleep(this.typingDelay(part.type === 'text' ? part.text : ''));
961
994
  const r = await this.host.exclusive('发私信', async () => {
@@ -967,13 +1000,23 @@ export class DouyinChannel {
967
1000
  }
968
1001
  return await this.driver.sendText(name, part.text);
969
1002
  }, { maxMs: 90_000 }).catch((error) => ({ ok: false, error: errText(error) }));
1003
+ // 回读确认:文字(sendDM)/ 图片(sendImage)/ 表情(sendEmoji)三条都会
1004
+ // 在发完之后数一遍气泡,真的多出来才回 verified。所以这里统一认 verified。
1005
+ // ⚠️ 表情走文字兜底(面板里没这个名字)时就没有 verified 了 ——
1006
+ // 那条是当普通消息发的,`type` 仍是 emoji,只能按「没回读」算。
1007
+ const seen = r?.ok === true && r.verified === true;
1008
+ if (seen) delivered = true;
970
1009
  if (!r?.ok) {
971
1010
  allOk = false;
972
1011
  this.log('error', `[fish-soul] 发抖音私信给「${name}」失败:${r?.error ?? '未知'}`);
1012
+ } else if (part.type !== 'emoji' && r.verified !== true) {
1013
+ // 动作做了但回读没看见 —— 大概率被风控吞了,这条要留下来
1014
+ this.log('warn', `[fish-soul] 发给「${name}」的${part.type === 'image' ? '图片' : '消息'}没在列表里回读到,可能被风控吞了`);
973
1015
  }
1016
+ details.push({ type: part.type, ok: r?.ok === true, delivered: seen, error: r?.error ?? '' });
974
1017
  }
975
1018
  // 自己发出去的消息进窗口,下一轮不当成新消息(下一轮读的时候它们是"我"发的,本来也不会回)
976
- return allOk;
1019
+ return { ok: allOk, delivered, parts: details };
977
1020
  };
978
1021
  const p = this.sendChain.then(run, run);
979
1022
  this.sendChain = p.catch(() => {});
@@ -143,7 +143,7 @@ window.__ModuleLoader__.load({
143
143
  Group({ title: "私信" },
144
144
  Row({ title: "抖音接入" }, poll, sw("抖音接入", d.enabled === true, (v) => save({ top: { enabled: v } }))),
145
145
  Row({ title: "自动回复" }, d.autoReply ? null : Muted("只记不回"), sw("自动回复", d.autoReply === true, (v) => save({ top: { autoReply: v } }))),
146
- Row({ title: "主人的名字" }, Input({ ...bind("masterName"), placeholder: "她私信列表里你的名字" })),
146
+ Row({ title: "主人的名字", desc: "填了之后,可以在 QQ 私聊里让她去抖音给你发一条私信" }, Input({ ...bind("masterName"), placeholder: "她私信列表里你的名字" })),
147
147
  Row({ title: "回所有能私信她的人", desc: d.replyAll ? "建议在抖音隐私设置里把「谁可以私信我」设为互关好友" : null },
148
148
  sw("回所有能私信她的人", d.replyAll === true, (v) => save({ top: { replyAll: v } }))),
149
149
  d.replyAll ? null : Row({ title: "白名单", stack: true }, Area({ ...bind("allowList"), placeholder: "私信列表里显示的名字,每行一个" })),
package/lib/client.js CHANGED
@@ -670,7 +670,7 @@ window.__ModuleLoader__.load({
670
670
  Row({ title: "B站接入" }, poll, sw("B站接入", b.enabled === true, (v) => save({ top: { enabled: v } })))),
671
671
  Group({ title: "私信" },
672
672
  Row({ title: "自动回复" }, b.autoReply ? null : Muted("只记不回"), sw("自动回复", b.autoReply === true, (v) => save({ top: { autoReply: v } }))),
673
- Row({ title: "主人 UID" }, field("masterUid", { inputMode: "numeric" })),
673
+ Row({ title: "主人 UID", desc: "填了之后,可以在 QQ 私聊里让她去 B站 给你发一条私信" }, field("masterUid", { inputMode: "numeric" })),
674
674
  Row({ title: "白名单" }, field("allowList", { placeholder: "UID,逗号分隔" })),
675
675
  Row({ title: "互关的人也回" }, sw("互关的人也回", b.replyMutual !== false, (v) => save({ top: { replyMutual: v } }))),
676
676
  Row({ title: "主动私信主人" }, sw("主动私信主人", b.proactive === true, (v) => save({ top: { proactive: v } }))),
@@ -545,7 +545,10 @@ window.__ModuleLoader__.load({
545
545
  reply: ["要回", "info"], interject: ["插话", "info"], sent: ["已发", "success"], silent: ["没说", "outline"],
546
546
  memorize: ["只记", "outline"], paused: ["暂停", "warning"], resumed: ["恢复", "success"], failed: ["失败", "danger"],
547
547
  recalled: ["撤回", "outline"], command: ["命令", "info"], proactive: ["主动", "info"], digest: ["记下", "success"],
548
- filtered: ["改写", "outline"], diary: ["日记", "success"], browse: ["刷视频", "outline"], share: ["分享", "success"], post: ["发动态", "success"], comment: ["评论区", "info"], mood: ["心情", "info"], poke: ["戳回", "info"], react: ["回应", "info"]
548
+ filtered: ["改写", "outline"], diary: ["日记", "success"], browse: ["刷视频", "outline"], share: ["分享", "success"], post: ["发动态", "success"], comment: ["评论区", "info"], mood: ["心情", "info"], poke: ["戳回", "info"], react: ["回应", "info"],
549
+ // 发出去了但回读没看到 —— 抖音风控会静默吞消息,这种要跟「失败」区分开
550
+ unconfirmed: ["可能被吞", "warning"],
551
+ relay: ["转达", "success"]
549
552
  };
550
553
  /** 读本地图片 → base64(去掉 data: 前缀);读不出来给 null。 */
551
554
  function readBase64(file) {
package/lib/db.js CHANGED
@@ -445,11 +445,74 @@ export function ensureMasterPerson(db, nowMs) {
445
445
  * @returns {object} person 行
446
446
  */
447
447
  export function resolveOrCreatePerson(db, { qqUin, nickname, nowMs, masterQqUin }) {
448
- const isMaster = masterQqUin !== undefined && masterQqUin !== '' && qqUin === masterQqUin;
448
+ // ⚠️ 一律转字符串再比、再写。
449
+ // OneBot 事件里的 user_id 是**数字**,配置里的 masterQqUin 是**字符串**;
450
+ // 混着用会出现两种诡异结果:
451
+ // ① `10001 === '10001'` 为假 → 主人认不出来(原本就有这个隐患)
452
+ // ② 数字写进 TEXT 列会存成 "10001.0" → 跟字符串 "10001" 永远对不上
453
+ // 所以入口统一 String(),后面的比较和写入都用同一个值。
454
+ qqUin = String(qqUin ?? '');
455
+ const master = masterQqUin === undefined || masterQqUin === null ? undefined : String(masterQqUin);
456
+ const isMaster = master !== undefined && master !== '' && qqUin === master;
457
+ masterQqUin = master;
449
458
  const canonicalId = isMaster ? 'master' : randomCanonicalId();
450
459
  const initialStage = isMaster ? 'close' : 'stranger';
451
- // 主人先从 B站来过(master 行还没有 QQ 号):补上 QQ 号,下面的 Upsert 走更新分支而不是主键冲突
460
+ // 主人换过 QQ 号:master 行上还挂着旧号。
461
+ // 旧号占着 master 的 qq_uin,新号 INSERT 时既撞 qq_uin 的 UNIQUE、又撞 canonical_id='master' 主键,
462
+ // 而下面的 Upsert 只兜得住 qq_uin —— 于是每轮消息都在认人阶段抛
463
+ // `UNIQUE constraint failed: person.canonical_id`,整条 QQ 通道一个字都不回。
464
+ //
465
+ // 修法:**只在「master 行恰好挂着这个号、但这个号已经不是主人」时**把那一行处理掉。
466
+ //
467
+ // 🔴 这个判据必须写死成「master 行挂着 qqUin」,不能放宽成「只要不是当前 master 就摘」:
468
+ // 放宽后随便哪个陌生人来一条消息都会把主人的 qq_uin 清空(smoke 里 55555 就这么干过)。
469
+ //
470
+ // 🔴 也不能只在 isMaster 时处理 —— 旧号自己那条消息先到时 isMaster 是假,
471
+ // 不处理就永远卡在「旧号顶着 master、新号撞主键」的死循环里。
472
+ //
473
+ // ⚠️ 参数必须是字符串:数字写进 TEXT 列会变成 "10001.0",跟 "10001" 永远对不上。
474
+ // 入口已经统一 String() 过了。
475
+ if (!isMaster && master !== undefined && master !== '') {
476
+ const stale = db.prepare("SELECT * FROM person WHERE qq_uin = ? AND canonical_id = 'master'").get(qqUin) ?? null;
477
+ if (stale !== null) {
478
+ // master 行上还挂着别的平台身份(主人先从 B站 / 抖音来过)⇒ 只摘 QQ 号,不搬行
479
+ if (stale.bili_uid !== null || stale.douyin_uid !== null) {
480
+ db.prepare("UPDATE person SET qq_uin = NULL WHERE canonical_id = 'master' AND qq_uin = ?").run(qqUin);
481
+ } else {
482
+ // 整行都是「曾经的主人」⇒ 给它一个新身份退回陌生人,记忆跟着走,不丢
483
+ const newCid = randomCanonicalId();
484
+ db.prepare("UPDATE person SET canonical_id = ?, relation_stage = 'stranger' WHERE canonical_id = 'master' AND qq_uin = ?").run(newCid, qqUin);
485
+ db.prepare('UPDATE memory SET owner_id = ? WHERE owner_id = ?').run(`user:${newCid}`, 'user:master');
486
+ db.prepare('UPDATE pending SET owner_id = ? WHERE owner_id = ?').run(`user:${newCid}`, 'user:master');
487
+ // 腾出来的 master 主键补一行,给真正的主人接管
488
+ db.prepare(`
489
+ INSERT INTO person (canonical_id, qq_uin, display_name, relation_stage, interaction_count, first_seen_at, last_seen_at)
490
+ VALUES ('master', NULL, '主人', 'close', 0, ?, ?)
491
+ `).run(nowMs, nowMs);
492
+ }
493
+ }
494
+ }
495
+ // 主人先从 B站来过 / 刚腾出位置:master 行没有 QQ 号就补上,下面的 Upsert 才不会撞主键
452
496
  if (isMaster) db.prepare("UPDATE person SET qq_uin = ? WHERE canonical_id = 'master' AND qq_uin IS NULL").run(qqUin);
497
+
498
+ // 🔴 非主人这条消息如果正好命中 master 行(上面刚摘成 NULL、但 canonical_id 还是 master 的那行,
499
+ // 或从没设过主人的老库),下面那句 `ON CONFLICT(qq_uin)` 只会更新名字和次数,
500
+ // **canonical_id 仍然是 'master'** —— 旧号于是继续顶着主人的身份,新号反而接管不了。
501
+ // 所以在 INSERT 之前把它挪成一个独立的陌生人身份(记忆跟着搬,不丢)。
502
+ //
503
+ // ⚠️ **只在「这一行除了 qq_uin 没有别的身份」时才搬**。
504
+ // master 行上还可能挂着 bili_uid / douyin_uid(主人先从那两个平台来过),
505
+ // 把整行搬走会连带把主人的 B站 / 抖音身份一起搬给陌生人 —— smoke 里
506
+ // 「B站私信认主人」那条就是这么红的。这种情况只摘 qq_uin,不搬行。
507
+ if (!isMaster) {
508
+ const hijack = db.prepare("SELECT * FROM person WHERE qq_uin IS NOT NULL AND canonical_id = 'master' AND qq_uin = ?").get(qqUin) ?? null;
509
+ if (hijack !== null && hijack.bili_uid === null && hijack.douyin_uid === null) {
510
+ const newCid = randomCanonicalId();
511
+ db.prepare("UPDATE person SET canonical_id = ?, relation_stage = 'stranger' WHERE canonical_id = 'master' AND qq_uin = ?").run(newCid, qqUin);
512
+ db.prepare('UPDATE memory SET owner_id = ? WHERE owner_id = ?').run(`user:${newCid}`, 'user:master');
513
+ db.prepare('UPDATE pending SET owner_id = ? WHERE owner_id = ?').run(`user:${newCid}`, 'user:master');
514
+ }
515
+ }
453
516
  return db.prepare(`
454
517
  INSERT INTO person (canonical_id, qq_uin, display_name, relation_stage, interaction_count, first_seen_at, last_seen_at)
455
518
  VALUES (@canonicalId, @qqUin, @nickname, @initialStage, 1, @nowMs, @nowMs)
@@ -1,3 +1,4 @@
1
+ import { sleep } from './time-util.js';
1
2
  /**
2
3
  * douyin-browse.js —— 鱼自己刷抖音:空闲时拿一批推荐,像人一样看几条(每条停 5–20 秒)再划走,
3
4
  * 然后挑出来的几条读详情和热评,用她自己的口吻写一两句感受,记进"自己的记忆"(owner_id='self')。
@@ -24,7 +25,6 @@ const DESCRIBE_TTL_MS = 3600_000;
24
25
  const DAY_MS = 86_400_000;
25
26
  const MOBILE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
26
27
 
27
- const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });
28
28
  const oneLine = (s, n) => String(s ?? '').replace(/\s+/g, ' ').replace(/【/g, '〔').replace(/】/g, '〕').trim().slice(0, n);
29
29
  const errText = (e) => String(e instanceof Error ? e.message : e);
30
30
 
@@ -22,6 +22,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
22
  import { dshHome } from './db.js';
23
23
  import { join } from 'node:path';
24
24
  import { generateABogus } from './vendor/douyin-bogus.js';
25
+ import { sessionHealth } from './session-health.js';
25
26
 
26
27
  export const DOUYIN_WEB_ORIGIN = 'https://www.douyin.com';
27
28
 
@@ -101,6 +102,26 @@ export function douyinCookiePath() {
101
102
  return join(dshHome(), 'fish-soul', 'douyin-cookie.json');
102
103
  }
103
104
 
105
+ /**
106
+ * 从落盘的 cookie 文件里读登录态还剩多久(**只读文件,不联网、不抓浏览器**)。
107
+ *
108
+ * 为什么单独开一个:诊断页每 10 秒轮询一次,不能为了看一眼过期时间去连 CDP。
109
+ * 文件里有 `sid_guard` 就够了 —— 它自带签发时间和有效期。
110
+ *
111
+ * @returns {{remainingSec: number, expiresAt: number} | null}
112
+ * 没登录 / 没这个文件 / 没有 sid_guard 都返回 null(诊断页照常显示其它项)
113
+ */
114
+ export function douyinSessionHealth() {
115
+ try {
116
+ const p = douyinCookiePath();
117
+ if (!existsSync(p)) return null;
118
+ const data = JSON.parse(readFileSync(p, 'utf8'));
119
+ return sessionHealth(data?.cookies ?? null);
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
104
125
  /**
105
126
  * 抖音网页端的纯 HTTP 客户端。
106
127
  */
@@ -1,3 +1,4 @@
1
+ import { sleep } from './time-util.js';
1
2
  /**
2
3
  * douyin-social.js —— 鱼在抖音自己发图文作品、回作品下的评论(v0.10,默认都关,尽力而为)。
3
4
  *
@@ -32,7 +33,6 @@ const SEL = {
32
33
  commentBtn: '[data-e2e="feed-comment-icon"]'
33
34
  };
34
35
 
35
- const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });
36
36
  const oneLine = (s, n) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
37
37
  const errText = (e) => String(e instanceof Error ? e.message : e);
38
38
 
@@ -58,18 +58,25 @@ export const mediaMethods = {
58
58
  if (!cfg.enabled || m.userId === '') return;
59
59
  if (m.messageId !== '' && !this.markSeen(`${m.channelId}#${m.messageId}`)) return;
60
60
  if (m.chatType === 'group') { await this.onDouyinGroupMessage(m, cfg); return; }
61
- const person = this.soul.resolveDouyinPerson(m, { masterName: cfg.masterName, linkedQq: this.douyinLinks().get(m.userId) ?? '' });
61
+ // 单聊里的两个身份要分清(见 normalizeDouyinMessage 的注释):
62
+ // m.userId —— 认人用的 ID(接口有就给真实数字 ID,没有才退回名字)
63
+ // m.peerName —— 会话名(私信面板按它找会话,出口全靠它)
64
+ //
65
+ // 主人 / 白名单 / 设置页填的都是**会话名**,所以判定继续用会话名;
66
+ // 只有「这是谁」交给 resolveDouyinPerson(它会用 ID 建 person 行)。
67
+ const peer = m.peerName || m.userId;
68
+ const person = this.soul.resolveDouyinPerson(m, { masterName: cfg.masterName, linkedQq: this.douyinLinks().get(peer) ?? '' });
62
69
  const stale = Date.now() - m.timeMs > cfg.replyWindowMinutes * 60_000;
63
70
  const paused = this.isPaused(m.channelId);
64
71
  const trusted = cfg.autoReply && !stale && !paused && !this.douyin.replyPaused &&
65
- (m.userId === cfg.masterName || cfg.allowList.includes(m.userId) || cfg.replyAll);
72
+ (peer === cfg.masterName || cfg.allowList.includes(peer) || cfg.replyAll);
66
73
  if (!trusted) {
67
74
  this.soul.memorizeUnanswered(person, m);
68
- this.note(paused ? 'paused' : 'memorize', { channelId: m.channelId, who: m.userId, text: m.text, title: `抖音·${sanitizeName(m.userId, '抖音用户')}`,
75
+ this.note(paused ? 'paused' : 'memorize', { channelId: m.channelId, who: peer, text: m.text, title: `抖音·${sanitizeName(peer, '抖音用户')}`,
69
76
  reason: paused ? '这个聊天暂停着' : !cfg.autoReply ? '自动回复关着' : stale ? '超过回复时限的旧消息' : this.douyin.replyPaused ? '页面自检没过,先不回' : '不是主人 / 白名单' });
70
77
  return;
71
78
  }
72
- this.note('reply', { channelId: m.channelId, who: m.userId, text: m.text });
79
+ this.note('reply', { channelId: m.channelId, who: peer, text: m.text });
73
80
  this.enqueue(m, person, cfg);
74
81
  },
75
82
 
@@ -262,7 +269,14 @@ export const mediaMethods = {
262
269
  }
263
270
  const out = [];
264
271
  for (const f of files.slice(0, MAX_DM_IMAGES)) {
265
- const img = await prepareImage(f, { cacheDir, canvasConvert: this.canvasConvert, log: this.log });
272
+ // keepWebpIfNoConverter:这台机器没有 sharp(loadSharp 返回 null),
273
+ // webp 只能靠浏览器 canvas 兜底;兜不住时以前是**静默丢掉**,
274
+ // 表现就是「表情包明明在库里,发出去什么都没有」。
275
+ // 现在原样交给 sendImage —— 私信走 DOM.setFileInputFiles,它绕过文件框的
276
+ // accept 过滤,webp 是能发的(抖音官方实现里 webp 走普通图片分支)。
277
+ const img = await prepareImage(f, {
278
+ cacheDir, canvasConvert: this.canvasConvert, log: this.log, keepWebpIfNoConverter: true
279
+ });
266
280
  if (img !== null) out.push(img);
267
281
  else this.log('warn', `[fish-soul] 这张图发不了(格式不支持或者太大):${f}`);
268
282
  }
@@ -364,21 +378,80 @@ export const mediaMethods = {
364
378
  return allOk;
365
379
  },
366
380
 
367
- /** 抖音私信:文字按空行拆条;单独一段 [名字] 发成抖音自带表情;图片(≤2 张)跟在最后。 */
381
+ /**
382
+ * 抖音私信:文字按空行拆条;单独一段 [名字] 发成抖音自带表情;图片(≤2 张)跟在最后。
383
+ *
384
+ * @returns {Promise<{ok: boolean, delivered: boolean, parts: Array<object>}>}
385
+ * `ok` = 动作都做了;`delivered` = 至少一条回读到证据。
386
+ * 🔴 两者要分开报:抖音风控静默吞消息时 `ok` 仍然是 true,
387
+ * 但对方根本收不到 —— 只报 ok 等于撒谎。
388
+ */
368
389
  async sendDouyin(row, text, images = []) {
369
- if (this.douyin === null) return false;
390
+ const fail = (error) => {
391
+ this.log('error', `[fish-soul] 发到 ${row.title || row.channel_id} 失败:${String(error instanceof Error ? error.message : error)}`);
392
+ return { ok: false, delivered: false, parts: [] };
393
+ };
394
+ if (this.douyin === null) return { ok: false, delivered: false, parts: [] };
370
395
  const name = splitChannelId(row.channel_id)[1];
371
396
  const parts = this.dmParagraphs(text).map((p) => {
372
397
  const e = p.match(DOUYIN_EMOJI_RE);
373
398
  return e ? { type: 'emoji', name: e[1] } : { type: 'text', text: p };
374
399
  });
375
400
  for (const img of images) parts.push({ type: 'image', path: img.path });
376
- if (parts.length === 0) return true;
401
+ if (parts.length === 0) return { ok: true, delivered: true, parts: [] };
377
402
  try {
378
- return await this.douyin.send(name, parts);
403
+ const r = await this.douyin.send(name, parts);
404
+ // 兼容老返回(布尔):真出现布尔就当「动作做了、回读未知」
405
+ if (typeof r === 'boolean') return { ok: r, delivered: false, parts: [] };
406
+ return r ?? { ok: false, delivered: false, parts: [] };
379
407
  } catch (error) {
380
- this.log('error', `[fish-soul] 发到 ${row.title || row.channel_id} 失败:${String(error instanceof Error ? error.message : error)}`);
381
- return false;
408
+ return fail(error);
409
+ }
410
+ },
411
+
412
+ /**
413
+ * 跨平台转达:主人在 QQ 私聊里让她去抖音 / B站 私信自己一条。
414
+ * 只发主人自己的账号 —— 小号主动私信真人 = 封号 + 社交双重风险(见 DECISIONS D11)。
415
+ *
416
+ * @returns {Promise<{ok: boolean, delivered: boolean, platform: string, reason: string}>}
417
+ * `ok` = 动作做了;`delivered` = 至少有一条回读到证据(抖音风控吞消息时 ok 仍是 true)。
418
+ */
419
+ async relayTo(platform, text) {
420
+ const said = String(text ?? '').trim();
421
+ if (said === '') return { ok: false, delivered: false, platform, reason: '内容是空的' };
422
+ try {
423
+ if (platform === 'bili') {
424
+ const cfg = this.biliConfig();
425
+ if (!cfg.enabled) return { ok: false, delivered: false, platform, reason: 'B站那边没开' };
426
+ if (this.bili === null || !this.bili.connected) return { ok: false, delivered: false, platform, reason: 'B站没连上' };
427
+ if (cfg.masterUid === '') return { ok: false, delivered: false, platform, reason: '没填主人的 B站 UID(设置页「平台」→ B站)' };
428
+ const channelId = 'bili:' + cfg.masterUid;
429
+ const ok = await this.sendBili({ channel_id: channelId, title: 'B站·' + cfg.masterUid }, said, []);
430
+ if (ok) this.afterRelay(channelId, said, 'bili');
431
+ return { ok, delivered: ok, platform, reason: ok ? '' : '发送时报错了,看日志' };
432
+ }
433
+ if (platform === 'douyin') {
434
+ const cfg = this.douyinConfig();
435
+ if (!cfg.enabled) return { ok: false, delivered: false, platform, reason: '抖音那边没开' };
436
+ if (this.douyin === null || !this.douyin.connected) return { ok: false, delivered: false, platform, reason: '抖音没连上' };
437
+ if (cfg.masterName === '') return { ok: false, delivered: false, platform, reason: '没填主人抖音的会话名(设置页「平台」→ 抖音)' };
438
+ const channelId = 'douyin:' + cfg.masterName;
439
+ const r = await this.sendDouyin({ channel_id: channelId, title: '抖音·' + cfg.masterName }, said, []);
440
+ if (r.ok === true) this.afterRelay(channelId, said, 'douyin');
441
+ return { ok: r.ok === true, delivered: r.delivered === true, platform, reason: r.ok === true ? '' : '发送时报错了,看日志' };
442
+ }
443
+ return { ok: false, delivered: false, platform, reason: '只支持 douyin / bili' };
444
+ } catch (error) {
445
+ // 绝不往上抛:工具层会把异常当成"这次没发成",这里自己收住并把原因带给主人
446
+ const msg = String(error instanceof Error ? error.message : error);
447
+ this.log('error', `[fish-soul] 转达消息失败:${msg}`);
448
+ return { ok: false, delivered: false, platform, reason: msg.slice(0, 80) };
382
449
  }
450
+ },
451
+
452
+ /** 转达发出去了:记进聊天日志 + 记一条「动态」。 */
453
+ afterRelay(channelId, text, platform) {
454
+ try { this.soul.journalOut?.(channelId, text); } catch { /* 记日志失败不影响已经发出去的消息 */ }
455
+ this.note('relay', { channelId, text, reason: `主人让去${platform === 'bili' ? 'B站' : '抖音'}发的` });
383
456
  }
384
457
  };
package/lib/gateway.js CHANGED
@@ -46,6 +46,10 @@ const OUR_SOURCES = new Set([SOURCE_QQ, SOURCE_PROACTIVE, SOURCE_BILI, SOURCE_DO
46
46
 
47
47
  /** QQ 会话里模型能用的全部工具(宿主 PTC 传输 run_code 不算工具本身,单独放行)。 */
48
48
  export const QQ_SESSION_TOOLS = ['remember', 'recall', 'forget'];
49
+ /** 跨平台转达:主人在 QQ 私聊里让她去抖音 / B站 私信自己一条。 */
50
+ export const RELAY_TOOL = 'send_elsewhere';
51
+ /** 聊天会话里放行的插件工具(记忆 + 跨平台转达)。 */
52
+ export const CHAT_TOOLS = [...QQ_SESSION_TOOLS, RELAY_TOOL];
49
53
  /** 宿主的联网工具:设置里打开「让她能上网查」才放行(护栏按天限次数)。 */
50
54
  export const WEB_TOOLS = ['web_search', 'web_fetch'];
51
55
  /** 自己填 key 的搜索服务(插件注册的工具) */
@@ -463,7 +467,7 @@ export class ChatGateway {
463
467
  allowedTools(agentCtx) {
464
468
  const has = (reg, n) => { try { return typeof reg?.get === 'function' && reg.get(n) !== undefined; } catch { return false; } };
465
469
  const tools = this.ctx.tools;
466
- return [...QQ_SESSION_TOOLS, ...WEB_TOOLS, OWN_WEB_TOOL].filter((n) =>
470
+ return [...CHAT_TOOLS, ...WEB_TOOLS, OWN_WEB_TOOL].filter((n) =>
467
471
  WEB_TOOLS.includes(n) || typeof tools?.get !== 'function' || has(tools, n) || has(agentCtx?.tools, n));
468
472
  }
469
473
 
@@ -1332,8 +1336,22 @@ export class ChatGateway {
1332
1336
  // [语音] 段:主人的话转成语音发到 QQ 私聊,其余去掉标记当文字发
1333
1337
  const dmText = (await this.relayDmVoice(row, 净文字)).split(/\n{2,}/).map((p) => p.replace(VOICE_RE, '')).filter((p) => p.trim() !== '').join('\n\n');
1334
1338
  if (dmText === '' && images.length === 0) return true;
1335
- const ok = platform === 'bili' ? await this.sendBili(row, dmText, images) : await this.sendDouyin(row, dmText, images);
1336
- this.note(ok ? 'sent' : 'failed', { channelId: row.channel_id, title: row.title, text: dmText || '[图片]', reason: ok ? '' : '发送失败', memories });
1339
+ let ok;
1340
+ let delivered;
1341
+ if (platform === 'bili') {
1342
+ ok = await this.sendBili(row, dmText, images);
1343
+ delivered = ok; // B站 那条是直连接口,onMessage 就带 message_id,没有「静默被吞」这回事
1344
+ } else {
1345
+ const r = await this.sendDouyin(row, dmText, images);
1346
+ ok = r.ok === true;
1347
+ delivered = r.delivered === true;
1348
+ }
1349
+ // 🔴 「动作做了」和「对方真能看见」分开记:
1350
+ // 抖音风控会静默吞消息(Enter 按了、UI 没报错、消息没进会话)。
1351
+ // 只记 sent 会让动态页/日记看起来一切正常,实际上对方什么都没收到。
1352
+ const kind = !ok ? 'failed' : (delivered ? 'sent' : 'unconfirmed');
1353
+ const reason = !ok ? '发送失败' : (delivered ? '' : '发出去了但回读没看到,可能被风控吞了');
1354
+ this.note(kind, { channelId: row.channel_id, title: row.title, text: dmText || '[图片]', reason, memories });
1337
1355
  if (ok && dmText) this.soul.journalOut?.(row.channel_id, dmText);
1338
1356
  return ok;
1339
1357
  }
package/lib/imageconv.js CHANGED
@@ -133,13 +133,18 @@ export function sharpReady() {
133
133
 
134
134
  /**
135
135
  * 图片整理:返回能直接发的 {path, bytes, type, width, height};发不了返回 null。
136
+ *
136
137
  * @param {string} file 本地路径
137
138
  * @param {object} opts
138
139
  * @param {string} opts.cacheDir 转换结果放这里
139
140
  * @param {((bytes: Buffer) => Promise<Buffer|null>)|null} [opts.canvasConvert] 浏览器 canvas 兜底(转 png)
140
141
  * @param {(level: string, msg: string) => void} [opts.log]
142
+ * @param {boolean} [opts.keepWebpIfNoConverter] webp 转不了时**原样交出去**,别丢。
143
+ * (抖音私信走 `DOM.setFileInputFiles`,它绕过文件框的 accept 过滤,
144
+ * webp 是能直接发的 —— 官方客户端实现里 webp 走的是普通图片分支。
145
+ * 但这条没真机验过,所以调用方做好「原样发失败再退文字」的准备。)
141
146
  */
142
- export async function prepareImage(file, { cacheDir, canvasConvert = null, log = () => {} } = {}) {
147
+ export async function prepareImage(file, { cacheDir, canvasConvert = null, log = () => {}, keepWebpIfNoConverter = false } = {}) {
143
148
  let bytes;
144
149
  try {
145
150
  if (!existsSync(file) || statSync(file).size > MAX_INPUT_BYTES) return null;
@@ -171,7 +176,15 @@ export async function prepareImage(file, { cacheDir, canvasConvert = null, log =
171
176
  if (out === null && typeof canvasConvert === 'function') {
172
177
  try { out = await canvasConvert(bytes); ext = 'png'; } catch { out = null; }
173
178
  }
174
- if (out === null || sniffImage(out) !== ext) return null;
179
+ // 转不出来(没 sharp、canvas 也不行)
180
+ if (out === null || sniffImage(out) !== ext) {
181
+ if (keepWebpIfNoConverter) {
182
+ // 别丢:原样交出去,让上层试一次。webp 的宽高自己解析得出来。
183
+ log('warn', `[fish-soul] ${file} 转不了格式(没有 sharp / canvas 也不行),按原样试试`);
184
+ return { path: file, bytes, type: 'webp', ...imageSize(bytes), converted: false };
185
+ }
186
+ return null;
187
+ }
175
188
  if (out.length > MAX_OUTPUT_BYTES) {
176
189
  log('warn', `[fish-soul] ${file} 转出来 ${(out.length / 1048576).toFixed(1)} MB,太大不发`);
177
190
  return null;
@@ -179,5 +192,5 @@ export async function prepareImage(file, { cacheDir, canvasConvert = null, log =
179
192
  mkdirSync(cacheDir, { recursive: true });
180
193
  const path = join(cacheDir, `${hash}.${ext}`);
181
194
  writeFileSync(path, out);
182
- return { path, bytes: out, type: ext, ...imageSize(out) };
195
+ return { path, bytes: out, type: ext, ...imageSize(out), converted: true };
183
196
  }
package/lib/index.js CHANGED
@@ -45,7 +45,7 @@ import { DouyinHttp } from './douyin-http.js';
45
45
  import { fishSoulConfigSchema } from './config-schema.js';
46
46
  import { loadSharp, sharpReady } from './imageconv.js';
47
47
  import {
48
- ChatGateway, SOURCE_QQ, SOURCE_PROACTIVE, SOURCE_BILI, SOURCE_DOUYIN, QQ_SESSION_TOOLS, WEB_TOOLS, OWN_WEB_TOOL, splitChannelId, sessionChannelOf
48
+ ChatGateway, SOURCE_QQ, SOURCE_PROACTIVE, SOURCE_BILI, SOURCE_DOUYIN, CHAT_TOOLS, RELAY_TOOL, WEB_TOOLS, OWN_WEB_TOOL, splitChannelId, sessionChannelOf
49
49
  } from './gateway.js';
50
50
  import { VirtualClock } from './clock.js';
51
51
  import { EmbeddingClient, VectorWorker, recall, recallSync, vectorRanks } from './retrieval.js';
@@ -147,6 +147,21 @@ function parseRoleIso(iso) {
147
147
 
148
148
  const UNKNOWN_OWNER = 'user:unknown';
149
149
 
150
+ /**
151
+ * 抖音自带表情里**确认存在**的兜底名字。
152
+ *
153
+ * 🔴 以前这里写的是 `[比心] [捂脸]` —— 而 `[捂脸]` **不在抖音表情面板里**(2026-11-09 实测)。
154
+ * 后果不是报错:`sendEmoji` 找不到就退回 `sendText`,把 `[捂脸]` 当**纯文字**发出去,
155
+ * 对方看到一串方括号。模型被提示词引到一个不存在的名字上,锅在提示词这边。
156
+ *
157
+ * 下面这几个都来自 2026-11-09 真机抓的面板(`emojiNames` 为空时的兜底):
158
+ * 续火花 比心 在干嘛 笑死 麻了 躺平 摇骰子 猜拳 开心 嗨 吹泡泡 黑人问号
159
+ * 早上好 晚上好 早点睡 爱心 便便 戳一戳 绝了 已阅 生日祝福
160
+ * 挑的是**语义通用、不挑场合**的几个;`[摇骰子]` 那种只在特定场景才合适,不放进来。
161
+ * ⚠️ 这份清单要跟面板对得上 —— `test/douyin-emoji-fallback.mjs` 会核对。
162
+ */
163
+ const DOUYIN_EMOJI_FALLBACK = ['比心', '笑死', '绝了', '爱心'];
164
+
150
165
  /** 启动步骤(顺序即 apply 里的顺序),日志和 503 里的"第 N 步"按这个数。 */
151
166
  const STARTUP_STEPS = [
152
167
  '底座(soul.db / 虚拟钟)', '检索', 'QQ 渠道', '人格', 'B站', '给主人的提醒', '抖音', '自走层', '上网查',
@@ -737,10 +752,12 @@ async function startup(ctx, config, { log, gate }) {
737
752
  ? '[抖音群聊回复规则] 你的最后一条回复会原样发到这个抖音群里(群消息格式是"昵称:内容",你回的时候直接说、前面别加自己的名字;记的是谁的事,remember 时 speaker 就填谁的署名);空一行会拆成多条消息;'
738
753
  : '[抖音私信回复规则] 你的最后一条回复会原样发到抖音私信;空一行会拆成多条消息;') + stickerRule +
739
754
  // 抖音的表情按**名字**匹配面板格子,所以知道名字就能发。
740
- // 已知名字通常是空的(要读完私信发现有不认识的表情才会去抓面板),空了就用原来的说法
755
+ // 已知名字通常是空的(要读完私信发现有不认识的表情才会去抓面板),
756
+ // 空了就用 DOUYIN_EMOJI_FALLBACK —— 那几个是**真机核对过**存在的名字。
757
+ // ⚠️ 别在这儿现写名字:写错不会报错,只会把 `[某某]` 当纯文字发出去。
741
758
  (douyinNames.length > 0
742
759
  ? `单独一段只写表情名会发成表情,这些确认能用:${douyinNames.map((n) => `[${n}]`).join(' ')};`
743
- : '单独一段只写 [比心] [捂脸] 这类抖音自带表情名会发成表情;') +
760
+ : `单独一段只写表情名会发成表情,这些确认能用:${DOUYIN_EMOJI_FALLBACK.map((n) => `[${n}]`).join(' ')};`) +
744
761
  '不想接话就只输出 [不回]。');
745
762
  }
746
763
  if ((isQq || isBili || isDouyin) && biliCfg().enabled === true) {
@@ -863,10 +880,46 @@ async function startup(ctx, config, { log, gate }) {
863
880
  life.noteInteraction(ownerOf(person));
864
881
  return person;
865
882
  },
866
- /** 抖音私信认人:按会话里显示的名字;主人 / "抖音名字=QQ号"对应上的归到同一个人。 */
883
+ /**
884
+ * 抖音私信认人。
885
+ *
886
+ * v0.15.27 起优先按**真实用户 ID**(`uid:<senderId>`)认,改名也不丢;
887
+ * 老的库里存的是昵称,所以这里做一次「新旧键合并」:
888
+ * 新键 `uid:xxx` 找到人 → 用它;
889
+ * 找不到但**旧昵称键**能找到人 → 把旧行改挂到新键上(记忆跟着走,不新增一条);
890
+ * 都找不到 → 建新的。
891
+ *
892
+ * 主人判定仍然走 `masterName`(会话名)—— 设置页里填的就是那个名字。
893
+ */
867
894
  resolveDouyinPerson(m, { masterName, linkedQq }) {
895
+ const uid = String(m.userId ?? '');
896
+ const peer = String(m.peerName ?? m.nickname ?? '');
897
+ // 迁移:老库里 douyin_uid 存的是**昵称**,新键是 `uid:<senderId>`。
898
+ // 这两者指同一个人时要并成一行,否则改名后立刻分裂成两个「人」、各记一套记忆。
899
+ // 只在新键还没人用、旧昵称键确实有人时才搬。
900
+ // 只在「新键是 uid: 格式、且跟旧昵称不同」时才谈得上迁移
901
+ if (uid.startsWith('uid:') && peer !== '' && uid !== peer) {
902
+ const byOld = findPersonByDouyin(db, peer);
903
+ const byNew = findPersonByDouyin(db, uid);
904
+ if (byOld !== null && byNew === null) {
905
+ try { db.prepare('UPDATE person SET douyin_uid = ? WHERE canonical_id = ?').run(uid, byOld.canonical_id); }
906
+ catch { /* 新键被占:让下面常规路径自己收拢 */ }
907
+ }
908
+ }
909
+ // 主人判定:
910
+ // 设置页填的 `masterName` 是**会话名**,而新的 userId 是 `uid:<id>`,两者永远不相等 ——
911
+ // 照原样传 masterName 的话,换到 ID 绑定之后主人立刻认不出来。
912
+ // 所以「会话名 == masterName」时,直接把 masterId 传成当前 uid:
913
+ // resolveOrCreatePlatformPerson 内部是 `uid === masterId`,这样就落到 master 那行,
914
+ // 同时把它的 douyin_uid 更新成真实 ID(这正好是我们要的迁移)。
915
+ //
916
+ // ⚠️ 残留风险(如实记):**迁移发生前**,认主人仍然靠会话名,
917
+ // 也就是说「别人把昵称改成跟你一样」在第一次迁移前还是能冒充。
918
+ // 一旦那行迁到 `uid:<id>`,之后再比对的就是 ID 了,改名/同名都免疫。
919
+ const isMasterPeer = masterName !== undefined && masterName !== '' && peer === String(masterName);
920
+ const effectiveMasterId = isMasterPeer ? uid : masterName;
868
921
  const person = resolveOrCreatePlatformPerson(db, {
869
- platform: 'douyin', platformId: m.userId, nickname: m.nickname, nowMs: clock.now(), masterId: masterName, linkedQq
922
+ platform: 'douyin', platformId: uid, nickname: m.nickname, nowMs: clock.now(), masterId: effectiveMasterId, linkedQq
870
923
  });
871
924
  life.noteInteraction(ownerOf(person));
872
925
  return person;
@@ -989,7 +1042,7 @@ async function startup(ctx, config, { log, gate }) {
989
1042
  // #37:tools.sessionAllow = 'master' / 'all' 时,放开的会话不再拦宿主工具(run_code 仍然拦)。
990
1043
  try {
991
1044
  if (typeof ctx.tools.guard === 'function') {
992
- const allowed = new Set(QQ_SESSION_TOOLS);
1045
+ const allowed = new Set(CHAT_TOOLS);
993
1046
  const sessionOpen = (id) => {
994
1047
  const row = gateway?.chatForSession(id);
995
1048
  return row !== null && row !== undefined && gateway.sessionOpenFor(row.channel_id) === true;
@@ -1011,7 +1064,7 @@ async function startup(ctx, config, { log, gate }) {
1011
1064
  }
1012
1065
  if (exec.name === 'run_code') return `聊天会话里不能运行代码(放开工具也不放 run_code:它能跑任意程序)`;
1013
1066
  if (sessionOpen(id)) return undefined; // #37 主人/全部放开:宿主工具都放
1014
- return allowed.has(exec.name) ? undefined : `聊天会话(QQ / B站 / 抖音)里不能用 ${exec.name}(只开放记忆工具和上网查;设置页「模型」里可以放开)`;
1067
+ return allowed.has(exec.name) ? undefined : `聊天会话(QQ / B站 / 抖音)里不能用 ${exec.name}(只开放记忆工具、跨平台转达和上网查;设置页「模型」里可以放开)`;
1015
1068
  }), 'dsh-fish-soul: qq tool guard');
1016
1069
  } else {
1017
1070
  log('warn', '[fish-soul] 宿主没有 tools.guard:QQ 会话的工具限制只靠建会话时的 restrict');
@@ -1181,6 +1234,37 @@ async function startup(ctx, config, { log, gate }) {
1181
1234
  }
1182
1235
  })), 'dsh-fish-soul: forget');
1183
1236
 
1237
+ // ── 工具:send_elsewhere(主人在 QQ 私聊里让她去抖音 / B站 私信自己一条)──
1238
+ ctx.effect(() => ctx.tools.register(defineTool({
1239
+ name: RELAY_TOOL,
1240
+ description: '在另一个平台(抖音私信 / B站私信)给主人发一条消息。只有主人明确要求(「去抖音给我发」「去B站说一声」这类)时才用,别自己找机会用。' +
1241
+ '话由你自己写,像平时发私信那样自然,直接写要发出去的那一条。只能在主人的私聊里用。',
1242
+ parameters: objectSchema({
1243
+ platform: { type: 'string', enum: ['douyin', 'bili'], required: true, description: "'douyin' | 'bili'" },
1244
+ text: { type: 'string', required: true, description: '要发出去的原话(一段,别加引号)' }
1245
+ }),
1246
+ output: textOutput,
1247
+ async execute(args, toolCtx) {
1248
+ const session = sessionForTool(toolCtx);
1249
+ const channelId = String(session.channelId ?? '');
1250
+ if (channelId === '' || gateway === null) return '不知道现在是在哪个聊天里,先不发。';
1251
+ if (!gateway.isMasterChannel(channelId)) return '这个只能在主人的私聊里用。';
1252
+ const text = String(args.text ?? '').trim();
1253
+ if (text === '') return '内容是空的,没发。';
1254
+ const platform = String(args.platform ?? '');
1255
+ const where = platform === 'bili' ? 'B站' : '抖音';
1256
+ try {
1257
+ const r = await gateway.relayTo(platform, text);
1258
+ if (!r.ok) return `没发成:${r.reason}。`;
1259
+ return r.delivered ? `已经发到${where}了。` : `发到${where}了,但回读没看到,可能被风控吞了。`;
1260
+ } catch (error) {
1261
+ const msg = String(error instanceof Error ? error.message : error);
1262
+ log('warn', `[fish-soul] 跨平台转达失败:${msg}`);
1263
+ return `没发成:${msg.slice(0, 120)}。`;
1264
+ }
1265
+ }
1266
+ })), 'dsh-fish-soul: send_elsewhere');
1267
+
1184
1268
  // ── 工具:fish_search(自己填 key 的搜索服务;只在鱼的聊天会话里能用,开关 / 次数由上面的护栏管)──
1185
1269
  ctx.effect(() => ctx.tools.register(defineTool({
1186
1270
  name: OWN_WEB_TOOL,
package/lib/routes.js CHANGED
@@ -9,6 +9,8 @@
9
9
  import { readFileSync, existsSync } from 'node:fs';
10
10
  import { readVecMeta, writeVecMeta, listChatSessions } from './db.js';
11
11
  import { douyinState, handleDouyinRoute } from './routes-douyin.js';
12
+ import { douyinSessionHealth } from './douyin-http.js';
13
+ import { describeHealth } from './session-health.js';
12
14
  import { handleSoulRoute, sourceLabel } from './routes-soul.js';
13
15
  import { handleV07Route } from './routes-v07.js';
14
16
  import { handleV09Route } from './routes-v09.js';
@@ -563,6 +565,15 @@ export function registerRoutes(ctx, deps) {
563
565
  item('qq', 'QQ 登录态', qqSt?.connected === true, qqSt?.connected === true
564
566
  ? `已登录 ${qqSt.nickname || qqSt.selfId || ''}${qqSt.friendCount != null ? ` · ${qqSt.friendCount} 位好友` : ''}`.trim()
565
567
  : (qqOn ? `没连上${qqSt?.lastError ? ':' + qqSt.lastError : ''}` : 'QQ 没开'));
568
+ // 抖音登录态还剩多久(只读本地 cookie 文件里的 sid_guard,不联网不抓浏览器)
569
+ //
570
+ // 为什么值得单独一项:抖音掉登录的表现和「签名错了」「被风控」一模一样
571
+ // (都是 status_code 8 / 空 body),查的时候分不清。sid_guard 自带签发时间,
572
+ // 一眼就能看出是「登录快没了」还是「别的问题」。
573
+ const dyHealth = douyinSessionHealth();
574
+ const dyDesc = describeHealth(dyHealth, { warnHours: 24 });
575
+ item('douyin-session', '抖音登录', dyDesc.ok, dyDesc.detail,
576
+ dyHealth === null ? '没抓过 cookie,或者抖音还没登录过' : undefined);
566
577
  // 记忆条数
567
578
  try {
568
579
  const n = db.prepare("SELECT COUNT(*) AS n FROM memory WHERE status = 'active' AND valid_to IS NULL").get().n;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 登录态「还能活多久」——从 cookie 里读,不用等它坏掉才知道。
3
+ *
4
+ * 为什么要有这个:
5
+ * 以前唯一的信号是「请求开始失败」。抖音这边掉登录的表现跟「签名错了」「风控了」
6
+ * 一模一样(都是 status_code 8 / 空 body),排查时根本分不清是哪一种。
7
+ * `sid_guard` 这个 cookie 自带签发时间和有效期,看一眼就知道还剩多久。
8
+ *
9
+ * 格式(实测,抖音):
10
+ * sid_guard = <session>|<签发秒>|<有效期秒>|<GMT 字符串>
11
+ * 例:bfc8e3...%7C1794133354%7C5184001%7CThu,+07-Jan-2027+10:22:35+GMT
12
+ * 值是 URL 编码的,要 decodeURIComponent 之后再按 `|` 拆。
13
+ *
14
+ * 只读:这里只解析字符串,不碰网络、不改 cookie。
15
+ */
16
+
17
+ /** 把秒数说成人话:还剩 3 天 / 12 小时 / 8 分钟。 */
18
+ export function humanizeSeconds(sec) {
19
+ const s = Math.floor(Number(sec));
20
+ if (!Number.isFinite(s)) return '';
21
+ if (s <= 0) return '已过期';
22
+ const day = Math.floor(s / 86400);
23
+ const hour = Math.floor((s % 86400) / 3600);
24
+ const min = Math.floor((s % 3600) / 60);
25
+ if (day > 0) return `${day} 天${hour > 0 ? ` ${hour} 小时` : ''}`;
26
+ if (hour > 0) return `${hour} 小时${min > 0 ? ` ${min} 分钟` : ''}`;
27
+ return `${Math.max(1, min)} 分钟`;
28
+ }
29
+
30
+ /**
31
+ * 从 sid_guard 的值算出还剩多少秒。
32
+ * @param {string} raw cookie 原始值(可能是 URL 编码的)
33
+ * @returns {{remainingSec: number, expiresAt: number, issuedAt: number, ttlSec: number} | null}
34
+ * 解不出来返回 null(格式变了、或者根本不是 sid_guard)
35
+ */
36
+ export function parseSidGuard(raw) {
37
+ if (raw === undefined || raw === null || raw === '') return null;
38
+ let text = String(raw);
39
+ try { text = decodeURIComponent(text); } catch { /* 编码坏了就按原样拆 */ }
40
+ const parts = text.split('|');
41
+ if (parts.length < 3) return null;
42
+ const issuedAt = Number(parts[1]);
43
+ const ttlSec = Number(parts[2]);
44
+ if (!Number.isFinite(issuedAt) || !Number.isFinite(ttlSec) || issuedAt <= 0 || ttlSec <= 0) return null;
45
+ const expiresAt = issuedAt + ttlSec;
46
+ return { remainingSec: expiresAt - Math.floor(Date.now() / 1000), expiresAt, issuedAt, ttlSec };
47
+ }
48
+
49
+ /**
50
+ * 从一份 cookie 集合里找 sid_guard 并解析。
51
+ * @param {Record<string, string>|Array<{name:string,value:string}>} cookies
52
+ * 两种形态都吃:对象(插件自己存的 `{name: value}`)或 CDP 的数组。
53
+ * @returns {{remainingSec: number, expiresAt: number} | null}
54
+ */
55
+ export function sessionHealth(cookies) {
56
+ if (!cookies) return null;
57
+ let raw;
58
+ if (Array.isArray(cookies)) {
59
+ const hit = cookies.find((c) => c && c.name === 'sid_guard');
60
+ raw = hit?.value;
61
+ } else if (typeof cookies === 'object') {
62
+ raw = cookies.sid_guard ?? cookies.SID_GUARD;
63
+ }
64
+ const parsed = parseSidGuard(raw);
65
+ if (parsed === null) return null;
66
+ return { remainingSec: parsed.remainingSec, expiresAt: parsed.expiresAt };
67
+ }
68
+
69
+ /**
70
+ * 给诊断页用的一句话。
71
+ * @param {{remainingSec: number}|null} health sessionHealth() 的结果
72
+ * @param {{warnHours?: number}} [opts] 少于这么多小时就标黄(默认 24)
73
+ * @returns {{ok: boolean, detail: string}}
74
+ */
75
+ export function describeHealth(health, { warnHours = 24 } = {}) {
76
+ if (health === null) return { ok: true, detail: '读不到过期时间(cookie 里没有 sid_guard)' };
77
+ const { remainingSec } = health;
78
+ if (remainingSec <= 0) return { ok: false, detail: '登录已过期,需要重新扫码' };
79
+ const hours = remainingSec / 3600;
80
+ const text = `还剩 ${humanizeSeconds(remainingSec)}`;
81
+ if (hours < warnHours) return { ok: false, detail: `${text}(不足 ${warnHours} 小时,快过期了)` };
82
+ return { ok: true, detail: text };
83
+ }
package/lib/social.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { sleep } from './time-util.js';
1
2
  /**
2
3
  * social.js —— 鱼自己发动态 / 作品、回评论区的留言(v0.10,默认都关)。SocialBase 是 B站 / 抖音共用的部分,
3
4
  * 这里是 B站(走接口);抖音(走专用浏览器,尽力而为)在 douyin-social.js。
@@ -20,7 +21,6 @@ const POST_MIN_GAP_REAL_MS = 30 * 60_000;
20
21
  const STALE_MS = 24 * HOUR_MS;
21
22
  const TYPE_NAME = { 1: '视频', 11: '动态', 17: '动态' };
22
23
 
23
- const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });
24
24
  const oneLine = (s, n) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
25
25
  const errText = (e) => String(e instanceof Error ? e.message : e);
26
26
 
package/lib/time-util.js CHANGED
@@ -1,7 +1,13 @@
1
1
  /**
2
- * time-util.js —— 日期格式化的极小共享工具(零依赖)。
2
+ * time-util.js —— 时间相关的极小共享工具(零依赖)。
3
3
  */
4
4
 
5
+ /**
6
+ * 等一会儿。定时器 unref,不让这个等待挡住 Node 进程退出
7
+ *(后台轮询里 sleep 时,宿主要退出就退,别被一条 sleep 吊住)。
8
+ */
9
+ export const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });
10
+
5
11
  /** 两位补零:1 → '01'(备份文件名 / 时间戳格式化共用)。 */
6
12
  export function pad2(n) {
7
13
  return String(n).padStart(2, '0');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-fish-soul",
3
- "version": "0.15.26",
4
- "description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记、每周记忆存档与可选周记(DSH 插件,单角色 feiyu ,v0.15.14)",
3
+ "version": "0.15.28",
4
+ "description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记、每周记忆存档与可选周记(DSH 插件,单角色 feiyu ,v0.15.28)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "files": [
@@ -64,7 +64,7 @@
64
64
  }
65
65
  },
66
66
  "scripts": {
67
- "test": "node test/smoke.mjs && node test/douyin-browser.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/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",
67
+ "test": "node test/smoke.mjs && node test/douyin-browser.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/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-uid-binding.mjs && node test/image-webp-fallback.mjs && node test/douyin-emoji-fallback.mjs && node test/relay-other-platform.mjs",
68
68
  "eval": "node test/eval/persona-eval.mjs"
69
69
  }
70
70
  }