dsh-fish-soul 0.15.49 → 0.15.50

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.49**。
5
+ DSH 插件(单角色 `feiyu`)。版本 **0.15.50**。
6
6
 
7
7
  QQ 交流群:**1125962189**
8
8
 
@@ -195,7 +195,7 @@ export function isGroupConversation(msgs, { name = '', groups = [] } = {}) {
195
195
  if (Number(m.convType) === 2) return true;
196
196
  const cid = String(m.convId ?? '');
197
197
  if (cid !== '' && /^\d+:\d+:/.test(cid) && !/^0:1:/.test(cid)) return true;
198
- if (!m.fromMe) {
198
+ if (m.fromMe === false) {
199
199
  // 🔴 只认 ID,**不认昵称**:DOM 路补的媒体消息没有 senderId、只有 senderName,
200
200
  // 拿昵称当「一个人」会把「同一个人 + 他的昵称」数成两个 peer → 单聊被误判成群
201
201
  // (真机见过:会话「未云」的游标被写成 group: true,从此走群聊路径读,永远读不到)。
@@ -287,7 +287,11 @@ export function groupSender(m, members = {}) {
287
287
 
288
288
  /** 一条抖音私信 → 入站消息(与 QQ 的 normalizeMessageEvent 同形)。不该当消息的返回 null。群聊按发送者分人。 */
289
289
  export function normalizeDouyinMessage(m, { name, timeMs, messageId, group = false, members = {}, identity = '', convId = '' }) {
290
- if (!m || m.fromMe || m.system || m.unsupported) return null;
290
+ // 🔴 fromMe 可能是 null(拿不到 selfSecUid 时算不出来)。
291
+ // 对 null 是 true ⇒ 会被当成「对方发的」,于是她自己回的话
292
+ // 下一轮又被当成新消息注入回去(真机症状:她把刚发出去的话当成对方说的)。
293
+ // 未知一律按「自己的」处理:宁可不回,也不把自己的话当对方的。
294
+ if (!m || m.fromMe !== false || m.system || m.unsupported) return null;
291
295
  const media = m.media ?? {};
292
296
  const images = [];
293
297
  const links = [];
@@ -1643,7 +1647,9 @@ export class DouyinChannel {
1643
1647
  let lastMine = -1;
1644
1648
  for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].fromMe) { lastMine = i; break; }
1645
1649
  const want = typeof c.unread === 'number' && c.unread > 0 ? Math.min(INJECT_ON_FIRST_READ, c.unread) : INJECT_ON_FIRST_READ;
1646
- const eligible = msgs.map((_, i) => i).slice(lastMine + 1).filter(i => !msgs[i].fromMe && !msgs[i].system && !msgs[i].unsupported);
1650
+ // 只认**明确是对方发的**(fromMe === false)。fromMe 是 null 表示「算不出谁发的」,
1651
+ // 那种也排除掉 —— 否则她自己的话会被当成对方的新消息再注入一遍。
1652
+ const eligible = msgs.map((_, i) => i).slice(lastMine + 1).filter(i => msgs[i].fromMe === false && !msgs[i].system && !msgs[i].unsupported);
1647
1653
  truncated = eligible.length > want || (typeof c.unread === 'number' && (c.unread > want || c.unread > eligible.length));
1648
1654
  freshIndexes = eligible.slice(-want);
1649
1655
  fresh = freshIndexes.map(i => msgs[i]);
@@ -1661,7 +1667,7 @@ export class DouyinChannel {
1661
1667
  for (const m of msgs) {
1662
1668
  const id = String(m?.senderId || m?.secUid || '');
1663
1669
  const n = String(m?.senderName ?? '').trim();
1664
- if (id !== '' && n !== '' && !m.fromMe) members[id] = n;
1670
+ if (id !== '' && n !== '' && m.fromMe === false) members[id] = n;
1665
1671
  }
1666
1672
  const ids = Object.keys(members);
1667
1673
  for (const id of ids.slice(0, Math.max(0, ids.length - 200))) delete members[id];
@@ -356,7 +356,11 @@ async function resolveConvId(browser, uname) {
356
356
  /** 把接口解出来的消息整理成和 DOM 路径一致的形状 */
357
357
  function shapeResult(rawMessages, limit, via, convId = '') {
358
358
  const msgs = filterUseful(rawMessages).map((m) => ({
359
- fromMe: m.fromMe === true,
359
+ // 🔴 三态,不能压成布尔:`m.fromMe === true` 会把「未知」(null,拿不到 selfSecUid 时
360
+ // 算不出来)和「对方」(false)压成同一个值 —— 于是她刚发出去的话下一轮被当成
361
+ // 对方的新消息注入回去(真机症状:把自己的回复当成对方说的)。
362
+ // 未知保留 null,上层按「宁可当自己的」处理。
363
+ fromMe: m.fromMe === true ? true : m.fromMe === false ? false : null,
360
364
  system: m.isSystem && !m.text,
361
365
  unsupported: false,
362
366
  isShare: Boolean(m.shareAweme) || m.kind === 8,
@@ -762,7 +766,7 @@ export async function sendDM({ browser, text, uname = '', expectConvId = '' }) {
762
766
  const ok = await enterChat(browser, uname)
763
767
  if (!ok) return { ok: false, error: `进不去「${uname}」的会话` }
764
768
  await sleep(600)
765
- let cur = await currentPeer({ browser })
769
+ let cur = await currentPeer({ browser, since })
766
770
  const want = String(expectConvId ?? '').trim()
767
771
  // currentPeer 拿不到 convId(hook 缓存空)时,看刚才这次进会话触发的响应。
768
772
  // 只看 since 之后的,不看整个缓冲(缓冲里可能是别的会话的旧响应)。
@@ -981,8 +985,9 @@ async function ensureTargetChat({ browser, uname = '', expectConvId = '' }) {
981
985
  lastErr = String(e?.message || e)
982
986
  }
983
987
  if (ok) {
988
+ const since = Array.isArray(browser.events) ? browser.events.length : 0
984
989
  await sleep(600)
985
- const cur = await currentPeer({ browser })
990
+ const cur = await currentPeer({ browser, since })
986
991
  if (isTarget(cur)) return { ok: true, attempt }
987
992
  lastErr = mismatch(cur)
988
993
  }
@@ -1455,7 +1460,7 @@ export async function dmList({ browser, limit = 40 }) {
1455
1460
  *
1456
1461
  * @param {object} p { browser }
1457
1462
  */
1458
- export async function currentPeer({ browser }) {
1463
+ export async function currentPeer({ browser, since = 0 }) {
1459
1464
  const CUR_JS = String.raw`(() => {
1460
1465
  const FIND = ${pageFindHelper()}
1461
1466
  const ta = document.querySelector('textarea')
@@ -1506,7 +1511,7 @@ export async function currentPeer({ browser }) {
1506
1511
  // resolveConvId 靠消息里的 senderName 匹配,而接口消息经常不带昵称,匹配不上。
1507
1512
  // 刚 enterChat 进来时,CDP 缓冲里最近的消息响应就是当前会话的。取它的 convId 兜底。
1508
1513
  if (convId === '' && r.inChat) {
1509
- convId = await latestConvIdFromCdp(browser).catch(() => '')
1514
+ convId = await latestConvIdFromCdp(browser, since).catch(() => '')
1510
1515
  }
1511
1516
  return { ...r, convId }
1512
1517
  }
@@ -1536,8 +1541,13 @@ async function cdpHasConv(browser, target, since = 0) {
1536
1541
  return false
1537
1542
  }
1538
1543
 
1539
- async function latestConvIdFromCdp(browser) {
1540
- const bodies = await captureImBodies(browser, 0).catch(() => [])
1544
+ async function latestConvIdFromCdp(browser, since = 0) {
1545
+ // 🔴 必须限定窗口:全缓冲扫会拿到**别的会话**的响应(轮询同时在读多个会话时,
1546
+ // 缓冲里混着它们的响应,取「最近的」不一定是当前打开的)。
1547
+ // since 是 enterChat 之前的事件序号 —— 只看 enterChat 之后触发的响应,
1548
+ // 那才是当前这个会话的。没传 since(比如快路径)时**不启用**这个兜底。
1549
+ if (since <= 0) return ''
1550
+ const bodies = await captureImBodies(browser, since).catch(() => [])
1541
1551
  for (let i = bodies.length - 1; i >= 0; i--) {
1542
1552
  try {
1543
1553
  const parsed = apiParseMessages(bodies[i].body, {})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fish-soul",
3
- "version": "0.15.49",
3
+ "version": "0.15.50",
4
4
  "description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记、每周记忆存档与可选周记(DSH 插件,单角色 feiyu ,v0.15.37)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",