dsh-fish-soul 0.11.2 → 0.12.0
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 +7 -7
- package/lib/backup.js +148 -5
- package/lib/browse.js +45 -16
- package/lib/channels/bilibili.js +78 -0
- package/lib/channels/douyin.js +78 -4
- package/lib/channels/vendor/douyin-bridge/douyin-api.mjs +10 -0
- package/lib/channels/vendor/douyin-bridge/douyin-dm.mjs +19 -0
- package/lib/channels/vendor/douyin-bridge/media.mjs +2 -0
- package/lib/client.douyin.js +7 -1
- package/lib/client.js +45 -2
- package/lib/client.models.js +2 -2
- package/lib/client.persona.js +54 -1
- package/lib/client.soul.js +118 -18
- package/lib/config-platforms.js +9 -2
- package/lib/config-schema.js +8 -0
- package/lib/db.js +2 -0
- package/lib/gateway-media.js +26 -0
- package/lib/gateway-util.js +15 -2
- package/lib/gateway.js +21 -7
- package/lib/history-import.js +286 -70
- package/lib/index.js +89 -29
- package/lib/maibot-extras.js +230 -0
- package/lib/memory-bulk.js +88 -0
- package/lib/persona.js +6 -26
- package/lib/routes-douyin.js +9 -1
- package/lib/routes-soul.js +35 -0
- package/lib/routes-update.js +41 -0
- package/lib/routes-v07.js +24 -4
- package/lib/routes-worldinfo.js +59 -0
- package/lib/routes.js +31 -11
- package/lib/slang.js +48 -2
- package/lib/social.js +59 -48
- package/lib/soul-memory.js +30 -6
- package/lib/stickers.js +22 -0
- package/lib/update-check.js +198 -0
- package/lib/worldinfo.js +240 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
让一个 QQ 小号背后有"同一个人":固定人格、按人记记忆、有自己的生活节奏、会主动开口;同一个"她"同时出现在 QQ、B站、抖音。
|
|
4
4
|
|
|
5
|
-
DSH 插件(单角色 `feiyu`)。版本 **0.
|
|
5
|
+
DSH 插件(单角色 `feiyu`)。版本 **0.12.0**。
|
|
6
6
|
|
|
7
7
|
## 能力
|
|
8
8
|
|
|
9
9
|
| 面 | 内容 |
|
|
10
10
|
|---|---|
|
|
11
|
-
| 人格 | 设置页「人格」:7 个内置模板一键换上(第一个「肥鱼 · 通用」只定性格和说话方式、生活细节聊着聊着才有,后面 6 个带现成的生活细节)、可视化编辑、导入 / 导出 SillyTavern 角色卡(PNG / JSON)、撤销上次更换,换完下一轮生效;角色卡(PNG / ccv3 / chara),`{{char}}` / `{{user}}` 宏,世界书(constant /
|
|
12
|
-
| 记忆 | 按人隔离、本轮即时召回(BM25 + 向量 RRF)、艾宾浩斯衰减(重要度高不淡)、可纠正(留旧档)、出处 `evidence`、每人 ≤150 字印象、8
|
|
11
|
+
| 人格 | 设置页「人格」:7 个内置模板一键换上(第一个「肥鱼 · 通用」只定性格和说话方式、生活细节聊着聊着才有,后面 6 个带现成的生活细节)、可视化编辑、导入 / 导出 SillyTavern 角色卡(PNG / JSON)、撤销上次更换,换完下一轮生效;角色卡(PNG / ccv3 / chara),`{{char}}` / `{{user}}` 宏,世界书(constant / 关键词,可单独导入多本 SillyTavern 世界书:正则 / 整词 / 次要关键词、扫描深度、条数和字数预算);卡里的作息(`extensions.fish_soul.routine`)用来排每天的日程、「看事情的方式」(`perspective`)进人格段、「不同心情下怎么说话」(`moods`)跟着她当下的心情带上;模板按气质分、不绑职业;聊久了会「慢慢长」(性格 / 看法的变化要多次、跨天、跨聊天的证据才生效,人格页可看可删);从群聊 / 好友私聊里学梗、说话风格和「什么场合怎么说」(只学别人的话、不带人名,按学到的次数挑几条参考) |
|
|
12
|
+
| 记忆 | 按人隔离、本轮即时召回(BM25 + 向量 RRF)、艾宾浩斯衰减(重要度高不淡)、可纠正(留旧档)、出处 `evidence`、每人 ≤150 字印象、8 栏结构化印象、关系阶段演进、好感与情绪、旧聊天记录导入(断点续传,较早的聊天按时间段总结)、导入麦麦的表情包 / 表达方式 / 黑话、记忆页搜索与批量操作(删除 / 恢复 / 重要度 / 私密);**聊完自动记**(一次调用产出事实 / 事件 / 感受 + 一句话小结,去重合并);**隐私**:私聊里的敏感记忆标 `private`,群里检索和召回都不带 |
|
|
13
13
|
| 生活 | 虚拟时钟、每日作息与基调、Dream 整理(只补没写的、会标过时)、主动开口与配额 |
|
|
14
|
-
| QQ | 直连 SnowLuma(OneBot 11 正向 WS):私聊 / 群聊、群合批带署名、只听点名、戳一戳、撤回不回、可选插话、打字节奏、`/鱼
|
|
15
|
-
| B站 | 扫码登录、私信(只读 /
|
|
16
|
-
| 抖音 | 专用浏览器扫码登录、私信(只读 /
|
|
14
|
+
| QQ | 直连 SnowLuma(OneBot 11 正向 WS):私聊 / 群聊、群合批带署名、只听点名、戳一戳、撤回不回、可选插话、打字节奏、`/鱼 暂停`;**群默认拒绝**(`groupAllowList` 为空且没开 `allGroups` 时谁都不回)、**没发出去的回复排队重发**(退避重试,连上即补发,重启不丢) |
|
|
15
|
+
| B站 | 扫码登录、私信(只读 / 自动回复)、跨平台认人、空闲自己刷视频(看字幕 / 热评 / 弹幕,喜欢可点赞、想再看可收藏,后两个默认关)、自己发动态 / 回评论(默认关;可选别人评论区里 @ 她也回)、Cookie 自动续期、发图 |
|
|
16
|
+
| 抖音 | 专用浏览器扫码登录、私信(只读 / 自动回复)、群聊按人区分(被叫到才回,默认关)、canary 防风控、空闲刷视频、刷到的分享发抖音私信、自己发图文 / 回作品评论(默认关,尽力而为)、表情包复用 |
|
|
17
17
|
| 习惯 | 心情影响说话(快慢、长短、插话概率、表情包多少)、**去 AI 味**(删 markdown / AI 自称 / 套话,太长按句末截)、**每晚写日记**(私密记忆,第二天想跟谁聊)、合并转发展开、每个群的设定(气氛 / 忌讳)、**按聊天选模型**、群冷热、记忆调试器(输一句话看会想起哪几条)、戳回去 / 表情回应 |
|
|
18
18
|
| 语音 | `get_record` → STT;回复 `[语音]` → TTS 发 record(失败 / 太长发文字);听 / 说可以各配一套地址和 key(TTS 支持小米 MiMo) |
|
|
19
19
|
| 模型 | 按功能选模型、按天 × 功能 × 模型统计用量与花费、可选上网查 |
|
|
20
|
-
| 运维 | 单聊天暂停、动态页、每日自动备份(留 7
|
|
20
|
+
| 运维 | 单聊天暂停、动态页、每日自动备份(留 7 份,设置页可恢复到某一份,重启生效)、设置文件读不懂时拒绝覆盖(带 BOM 也能读)、记忆导出 / 原样导回;设置页顶部显示当前版本,**自动检查更新**(npm / GitHub,每 12 小时,只提示不安装,可关) |
|
|
21
21
|
|
|
22
22
|
## 安装
|
|
23
23
|
|
package/lib/backup.js
CHANGED
|
@@ -3,15 +3,128 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - `VACUUM INTO` 拷一份完整、一致的库(WAL 模式下也安全,不用停插件)到 $DSH_HOME/fish-soul/backups/;
|
|
5
5
|
* - 打开自动备份时每天一份(真实时间,插件启动 1 分钟后先看一眼),只留最近 keep 份;
|
|
6
|
-
* -
|
|
6
|
+
* - 恢复(设置页点「恢复到这份」):先校验备份能打开、结构对、quick_check 通过,记一个 restore-pending.json;
|
|
7
|
+
* 下次插件启动、打开 soul.db 之前换上去(运行中的库被很多模块拿着,不在跑着的时候硬换)。换之前把当前的库
|
|
8
|
+
* VACUUM INTO 另存成 backups/pre-restore-时间.db(恢复错了还能换回来,最多留 3 份),再删掉旧的 -wal / -shm。
|
|
7
9
|
*/
|
|
8
|
-
import {
|
|
10
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
9
12
|
import { join } from 'node:path';
|
|
10
13
|
import { fishSoulDir, getKv, setKv } from './db.js';
|
|
11
14
|
|
|
12
15
|
const DAY_MS = 24 * 3600_000;
|
|
13
16
|
const CHECK_MS = 3600_000;
|
|
14
17
|
const NAME_RE = /^soul-\d{8}-\d{6}\.db$/;
|
|
18
|
+
const PRE_RE = /^pre-restore-\d{8}-\d{6}\.db$/;
|
|
19
|
+
const PRE_KEEP = 3;
|
|
20
|
+
const REQUIRED_TABLES = ['memory', 'person', 'kv'];
|
|
21
|
+
|
|
22
|
+
function pendingPath() {
|
|
23
|
+
return join(fishSoulDir(), 'restore-pending.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 等着下次启动换上去的备份(没有 = null)。 */
|
|
27
|
+
export function pendingRestore() {
|
|
28
|
+
try { return JSON.parse(readFileSync(pendingPath(), 'utf8')); } catch { return null; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 带着 -wal 的库文件(恢复前另存时库打不开、只能原样拷文件 + WAL):把 WAL 合并回主文件、改回单文件。
|
|
33
|
+
* 合并不了(库坏了)就原样留着,恢复时连 WAL 一起拷。返回合并后还有没有 -wal。
|
|
34
|
+
*/
|
|
35
|
+
export function consolidateWal(path) {
|
|
36
|
+
if (!existsSync(`${path}-wal`)) return false;
|
|
37
|
+
try {
|
|
38
|
+
const db = new DatabaseSync(path);
|
|
39
|
+
try {
|
|
40
|
+
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
41
|
+
db.exec('PRAGMA journal_mode = DELETE');
|
|
42
|
+
} finally { db.close(); }
|
|
43
|
+
} catch { /* 合并不了:留着 WAL */ }
|
|
44
|
+
if (existsSync(`${path}-wal`) && statSync(`${path}-wal`).size === 0) rmSync(`${path}-wal`, { force: true });
|
|
45
|
+
rmSync(`${path}-shm`, { force: true });
|
|
46
|
+
return existsSync(`${path}-wal`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 备份文件能不能用:打得开、quick_check 通过、有记忆 / 人物 / kv 表。返回 { memories, persons }。 */
|
|
50
|
+
export function inspectBackup(path) {
|
|
51
|
+
consolidateWal(path);
|
|
52
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
53
|
+
try {
|
|
54
|
+
const check = db.prepare('PRAGMA quick_check').get();
|
|
55
|
+
const ok = Object.values(check ?? {})[0];
|
|
56
|
+
if (ok !== 'ok') throw new Error(`备份文件损坏(${String(ok).slice(0, 60)})`);
|
|
57
|
+
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
|
|
58
|
+
const missing = REQUIRED_TABLES.filter((t) => !tables.has(t));
|
|
59
|
+
if (missing.length > 0) throw new Error(`不像 soul.db 的备份(缺表 ${missing.join('、')})`);
|
|
60
|
+
return {
|
|
61
|
+
memories: db.prepare("SELECT COUNT(*) AS n FROM memory WHERE status = 'active'").get().n,
|
|
62
|
+
persons: db.prepare('SELECT COUNT(*) AS n FROM person').get().n
|
|
63
|
+
};
|
|
64
|
+
} finally {
|
|
65
|
+
db.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 插件启动时、openDatabase() 之前调:有待恢复的备份就换上去。
|
|
71
|
+
* @returns {{file: string, safety: string|null, at: number} | null}
|
|
72
|
+
*/
|
|
73
|
+
export function applyPendingRestore({ log = () => {}, nowMs = Date.now() } = {}) {
|
|
74
|
+
const pending = pendingRestore();
|
|
75
|
+
if (pending === null) return null;
|
|
76
|
+
const dir = backupDir();
|
|
77
|
+
const src = join(dir, String(pending.file ?? ''));
|
|
78
|
+
const target = join(fishSoulDir(), 'soul.db');
|
|
79
|
+
const drop = () => rmSync(pendingPath(), { force: true });
|
|
80
|
+
if (!(NAME_RE.test(pending.file) || PRE_RE.test(pending.file)) || !existsSync(src)) {
|
|
81
|
+
log('warn', `[fish-soul] 要恢复的备份 ${pending.file} 不在了,没换`);
|
|
82
|
+
drop();
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
inspectBackup(src);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
log('error', `[fish-soul] 要恢复的备份 ${pending.file} 校验失败,没换:${String(error instanceof Error ? error.message : error)}`);
|
|
89
|
+
drop();
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
// 当前的库先另存一份(VACUUM INTO 连 WAL 里没合并的也带上;库坏到打不开就原样拷文件)
|
|
93
|
+
let safety = null;
|
|
94
|
+
if (existsSync(target)) {
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
let name = `pre-restore-${stamp(nowMs)}.db`;
|
|
97
|
+
for (let i = 1; existsSync(join(dir, name)); i++) name = `pre-restore-${stamp(nowMs + i * 1000)}.db`;
|
|
98
|
+
const out = join(dir, name);
|
|
99
|
+
try {
|
|
100
|
+
const cur = new DatabaseSync(target);
|
|
101
|
+
try { cur.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`); } finally { cur.close(); }
|
|
102
|
+
} catch {
|
|
103
|
+
copyFileSync(target, out);
|
|
104
|
+
if (existsSync(`${target}-wal`)) copyFileSync(`${target}-wal`, `${out}-wal`);
|
|
105
|
+
// 最后一次 checkpoint 之后的写入都在 WAL 里:合并进另存的那份(换回去时才不会丢)
|
|
106
|
+
consolidateWal(out);
|
|
107
|
+
}
|
|
108
|
+
safety = name;
|
|
109
|
+
}
|
|
110
|
+
const tmp = `${target}.restoring`;
|
|
111
|
+
// 要恢复的那份带着 -wal(合并不了的另存):WAL 跟着一起换上,否则最后一次 checkpoint 之后的数据全丢
|
|
112
|
+
const srcWal = consolidateWal(src) ? `${src}-wal` : null;
|
|
113
|
+
copyFileSync(src, tmp);
|
|
114
|
+
if (srcWal) copyFileSync(srcWal, `${tmp}-wal`);
|
|
115
|
+
rmSync(`${target}-wal`, { force: true });
|
|
116
|
+
rmSync(`${target}-shm`, { force: true });
|
|
117
|
+
renameSync(tmp, target);
|
|
118
|
+
if (srcWal) renameSync(`${tmp}-wal`, `${target}-wal`);
|
|
119
|
+
drop();
|
|
120
|
+
// 恢复前的另存只留最近几份
|
|
121
|
+
try {
|
|
122
|
+
const pres = readdirSync(dir).filter((f) => PRE_RE.test(f)).sort().reverse();
|
|
123
|
+
for (const f of pres.slice(PRE_KEEP)) for (const x of [f, `${f}-wal`, `${f}-shm`]) rmSync(join(dir, x), { force: true });
|
|
124
|
+
} catch { /* 下次再删 */ }
|
|
125
|
+
log('info', `[fish-soul] 已从备份 ${pending.file} 恢复 soul.db${safety ? `(恢复前的库另存为 ${safety})` : ''}`);
|
|
126
|
+
return { file: pending.file, safety, at: nowMs };
|
|
127
|
+
}
|
|
15
128
|
|
|
16
129
|
export function backupDir() {
|
|
17
130
|
return join(fishSoulDir(), 'backups');
|
|
@@ -93,18 +206,48 @@ export class BackupService {
|
|
|
93
206
|
}
|
|
94
207
|
}
|
|
95
208
|
|
|
96
|
-
/**
|
|
209
|
+
/** 现有备份,新的在前(只是自动 / 手动备份;恢复前的另存见 preRestoreList)。 */
|
|
97
210
|
list() {
|
|
211
|
+
return this.files(NAME_RE);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
preRestoreList() {
|
|
215
|
+
return this.files(PRE_RE).map((f) => ({ ...f, preRestore: true }));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
files(re) {
|
|
98
219
|
const dir = backupDir();
|
|
99
220
|
if (!existsSync(dir)) return [];
|
|
100
|
-
return readdirSync(dir).filter((f) =>
|
|
221
|
+
return readdirSync(dir).filter((f) => re.test(f)).map((f) => {
|
|
101
222
|
const st = statSync(join(dir, f));
|
|
102
223
|
return { file: f, size: st.size, at: st.mtimeMs };
|
|
103
224
|
}).sort((a, b) => (a.file < b.file ? 1 : -1));
|
|
104
225
|
}
|
|
105
226
|
|
|
227
|
+
/** 选一份备份,下次启动时换上去(先校验)。 */
|
|
228
|
+
stageRestore(file) {
|
|
229
|
+
const name = String(file ?? '');
|
|
230
|
+
if (!(NAME_RE.test(name) || PRE_RE.test(name))) throw new Error('不是插件生成的备份文件');
|
|
231
|
+
const path = join(backupDir(), name);
|
|
232
|
+
if (!existsSync(path)) throw new Error('这份备份不在了');
|
|
233
|
+
const info = inspectBackup(path);
|
|
234
|
+
const pending = { file: name, at: Date.now(), ...info };
|
|
235
|
+
writeFileSync(pendingPath(), JSON.stringify(pending), 'utf8');
|
|
236
|
+
this.log('info', `[fish-soul] 已选好要恢复的备份 ${name}(${info.memories} 条记忆),重启 DSH 后换上`);
|
|
237
|
+
return pending;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
cancelRestore() {
|
|
241
|
+
const had = pendingRestore() !== null;
|
|
242
|
+
rmSync(pendingPath(), { force: true });
|
|
243
|
+
return had;
|
|
244
|
+
}
|
|
245
|
+
|
|
106
246
|
state() {
|
|
107
247
|
const cfg = this.config();
|
|
108
|
-
return {
|
|
248
|
+
return {
|
|
249
|
+
...cfg, dir: backupDir(), lastAt: Number(getKv(this.db, 'backup.lastAt', 0)) || null, list: this.list().slice(0, 10),
|
|
250
|
+
preRestore: this.preRestoreList(), pendingRestore: pendingRestore(), lastRestored: getKv(this.db, 'backup.restored', null)
|
|
251
|
+
};
|
|
109
252
|
}
|
|
110
253
|
}
|
package/lib/browse.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* 聊天时就能自然说出"我今天刷到……"。默认不点赞、不投币、不评论(点赞可在设置页打开)。
|
|
5
5
|
*/
|
|
6
6
|
import { tokenizeForBm25 } from './segment.js';
|
|
7
|
+
import { getKv, setKv } from './db.js';
|
|
7
8
|
|
|
8
9
|
const BV_RE = /BV[0-9A-Za-z]{10}/g;
|
|
9
10
|
const SHORT_RE = /https?:\/\/(?:b23\.tv|bili2233\.cn)\/[0-9A-Za-z]+/g;
|
|
@@ -80,7 +81,10 @@ export class BiliBrowser {
|
|
|
80
81
|
perDay: num(br.perDay, 3, 0, 12),
|
|
81
82
|
videosPerSession: num(br.videosPerSession, 3, 1, 6),
|
|
82
83
|
like: br.like === true,
|
|
83
|
-
share: br.share !== false
|
|
84
|
+
share: br.share !== false,
|
|
85
|
+
favorite: br.favorite === true,
|
|
86
|
+
favoritePerDay: num(br.favoritePerDay, 2, 0, 10),
|
|
87
|
+
danmaku: br.danmaku !== false
|
|
84
88
|
};
|
|
85
89
|
}
|
|
86
90
|
|
|
@@ -108,16 +112,28 @@ export class BiliBrowser {
|
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
/** 视频的"看到的内容":详情 + 字幕节选 + 热评(字幕要登录,取不到就只看简介和评论)。 */
|
|
111
|
-
async gather(bvid, { subtitleChars = 1200 } = {}) {
|
|
115
|
+
async gather(bvid, { subtitleChars = 1200, danmaku = this.cfg().danmaku } = {}) {
|
|
112
116
|
const info = await this.client.view(bvid);
|
|
113
117
|
let subtitle = '';
|
|
114
118
|
try { subtitle = await this.client.subtitleText(info.bvid, info.cid, subtitleChars); } catch { /* 没字幕 */ }
|
|
115
119
|
let comments = [];
|
|
116
120
|
try { comments = await this.client.hotComments(info.aid, 3); } catch { /* 没评论 */ }
|
|
117
|
-
|
|
121
|
+
let danmakuLines = [];
|
|
122
|
+
if (danmaku && typeof this.client.danmaku === 'function') {
|
|
123
|
+
try { danmakuLines = await this.client.danmaku(info.cid, 12); } catch { /* 没弹幕 / 解不开 */ }
|
|
124
|
+
}
|
|
125
|
+
return { ...info, subtitle, comments, danmaku: danmakuLines };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
favDayKey() {
|
|
129
|
+
return `bili.fav.${this.clock.getRoleDateParts().dateStr}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
favoritedToday() {
|
|
133
|
+
return Number(getKv(this.db, this.favDayKey(), 0)) || 0;
|
|
118
134
|
}
|
|
119
135
|
|
|
120
|
-
promptFor(v, like, share = false) {
|
|
136
|
+
promptFor(v, like, share = false, favorite = false) {
|
|
121
137
|
const p = this.persona?.() ?? { name: '鱼', brief: '' };
|
|
122
138
|
return [
|
|
123
139
|
`你是「${p.name}」。${p.brief}`.trim(),
|
|
@@ -127,9 +143,11 @@ export class BiliBrowser {
|
|
|
127
143
|
v.desc ? `简介:${oneLine(v.desc, 200)}` : '',
|
|
128
144
|
v.subtitle ? `字幕节选:${v.subtitle}` : '(这个视频没有字幕,只能看简介和评论)',
|
|
129
145
|
v.comments.length > 0 ? `热评:\n${v.comments.map((c) => `- ${c}`).join('\n')}` : '',
|
|
146
|
+
(v.danmaku ?? []).length > 0 ? `弹幕(按时间抽了几条):${v.danmaku.map((d) => oneLine(d, 30)).join(' / ')}` : '',
|
|
130
147
|
'',
|
|
131
148
|
'用第一人称、口语化写一两句你看完的感受(大概讲了什么 + 你怎么看),不超过 80 字,不要照抄标题。',
|
|
132
|
-
'完全没兴趣、划走了就只输出 [划走]。' + (like ? '特别喜欢的话在末尾加 [点赞]。' : '') + (share ? '特别有意思、很想发给朋友看的,在末尾加 [分享](大多数视频不用)。' : '')
|
|
149
|
+
'完全没兴趣、划走了就只输出 [划走]。' + (like ? '特别喜欢的话在末尾加 [点赞]。' : '') + (share ? '特别有意思、很想发给朋友看的,在末尾加 [分享](大多数视频不用)。' : '') +
|
|
150
|
+
(favorite ? '以后还想再看、值得存起来的(教程、攻略、菜谱、特别喜欢的),在末尾加 [收藏](大多数视频不用)。' : '')
|
|
133
151
|
].filter((l) => l !== '').join('\n');
|
|
134
152
|
}
|
|
135
153
|
|
|
@@ -145,14 +163,15 @@ export class BiliBrowser {
|
|
|
145
163
|
return id;
|
|
146
164
|
}
|
|
147
165
|
|
|
148
|
-
async watch(item, like, share = false) {
|
|
166
|
+
async watch(item, like, share = false, favorite = false) {
|
|
149
167
|
const v = await this.gather(item.bvid);
|
|
150
|
-
const out = await this.callLlm(this.promptFor(v, like, share));
|
|
168
|
+
const out = await this.callLlm(this.promptFor(v, like, share, favorite));
|
|
151
169
|
const raw = String(out ?? '').trim();
|
|
152
170
|
const skipped = /\[划走\]/.test(raw);
|
|
153
171
|
const liked = like && /\[点赞\]/.test(raw);
|
|
154
172
|
const wantShare = share && !skipped && /\[分享\]/.test(raw);
|
|
155
|
-
const
|
|
173
|
+
const wantFav = favorite && !skipped && /\[收藏\]/.test(raw);
|
|
174
|
+
const impression = oneLine(raw.replace(/\[(划走|点赞|分享|收藏)\]/g, ''), 120);
|
|
156
175
|
let memoryId = null;
|
|
157
176
|
if (!skipped) {
|
|
158
177
|
const who = v.owner ? `${v.owner} 的` : '';
|
|
@@ -166,13 +185,23 @@ export class BiliBrowser {
|
|
|
166
185
|
this.log('warn', `[fish-soul] 点赞失败:${String(error instanceof Error ? error.message : error)}`);
|
|
167
186
|
}
|
|
168
187
|
}
|
|
188
|
+
let favorited = false;
|
|
189
|
+
if (wantFav && this.favoritedToday() < this.cfg().favoritePerDay) {
|
|
190
|
+
try {
|
|
191
|
+
await this.client.favorite(v.aid);
|
|
192
|
+
favorited = true;
|
|
193
|
+
setKv(this.db, this.favDayKey(), this.favoritedToday() + 1);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
this.log('warn', `[fish-soul] 收藏失败:${String(error instanceof Error ? error.message : error)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
169
198
|
this.db.prepare(`
|
|
170
|
-
INSERT INTO bili_seen (bvid, aid, title, owner_name, source, impression, skipped, liked, memory_id, seen_at, seen_real_at)
|
|
171
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
199
|
+
INSERT INTO bili_seen (bvid, aid, title, owner_name, source, impression, skipped, liked, favorited, memory_id, seen_at, seen_real_at)
|
|
200
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
172
201
|
ON CONFLICT(bvid) DO NOTHING
|
|
173
202
|
`).run(v.bvid, v.aid, oneLine(v.title, 120), oneLine(v.owner, 40), item.source ?? 'recommend', impression,
|
|
174
|
-
skipped ? 1 : 0, liked ? 1 : 0, memoryId, this.clock.now(), Date.now());
|
|
175
|
-
return { bvid: v.bvid, title: v.title, owner: v.owner, impression, skipped, liked, share: wantShare && impression !== '', url: `https://www.bilibili.com/video/${v.bvid}` };
|
|
203
|
+
skipped ? 1 : 0, liked ? 1 : 0, favorited ? 1 : 0, memoryId, this.clock.now(), Date.now());
|
|
204
|
+
return { bvid: v.bvid, title: v.title, owner: v.owner, impression, skipped, liked, favorited, share: wantShare && impression !== '', url: `https://www.bilibili.com/video/${v.bvid}` };
|
|
176
205
|
}
|
|
177
206
|
|
|
178
207
|
/** 刷一轮。返回看过的视频。 */
|
|
@@ -188,7 +217,7 @@ export class BiliBrowser {
|
|
|
188
217
|
const [lo, hi] = this.watchDelayMs;
|
|
189
218
|
await sleep(lo + Math.floor(Math.random() * Math.max(0, hi - lo)));
|
|
190
219
|
}
|
|
191
|
-
try { watched.push(await this.watch(item, c.like, c.share && this.share !== null)); } catch (error) {
|
|
220
|
+
try { watched.push(await this.watch(item, c.like, c.share && this.share !== null, c.favorite && this.favoritedToday() < c.favoritePerDay)); } catch (error) {
|
|
192
221
|
this.log('warn', `[fish-soul] 看视频 ${item.bvid} 失败:${String(error instanceof Error ? error.message : error)}`);
|
|
193
222
|
}
|
|
194
223
|
}
|
|
@@ -203,9 +232,9 @@ export class BiliBrowser {
|
|
|
203
232
|
/** 设置页:最近看过的。 */
|
|
204
233
|
recent(limit = 8) {
|
|
205
234
|
return this.db.prepare(`
|
|
206
|
-
SELECT bvid, title, owner_name AS owner, impression, skipped, liked, source, seen_real_at AS seenAt
|
|
235
|
+
SELECT bvid, title, owner_name AS owner, impression, skipped, liked, favorited, source, seen_real_at AS seenAt
|
|
207
236
|
FROM bili_seen ORDER BY seen_real_at DESC LIMIT ?
|
|
208
|
-
`).all(limit).map((r) => ({ ...r, skipped: r.skipped === 1, liked: r.liked === 1 }));
|
|
237
|
+
`).all(limit).map((r) => ({ ...r, skipped: r.skipped === 1, liked: r.liked === 1, favorited: r.favorited === 1 }));
|
|
209
238
|
}
|
|
210
239
|
|
|
211
240
|
/** 瞬态块:最近一个虚拟日里看过、有感受的 2 条。 */
|
|
@@ -231,7 +260,7 @@ export class BiliBrowser {
|
|
|
231
260
|
const hit = this.described.get(bvid);
|
|
232
261
|
if (hit !== undefined && Date.now() - hit.at < DESCRIBE_TTL_MS) { notes.push(hit.text); continue; }
|
|
233
262
|
try {
|
|
234
|
-
const v = await this.gather(bvid, { subtitleChars: 300 });
|
|
263
|
+
const v = await this.gather(bvid, { subtitleChars: 300, danmaku: false });
|
|
235
264
|
const text = `【视频 ${bvid}:《${oneLine(v.title, 60)}》UP主 ${oneLine(v.owner, 20) || '未知'}` +
|
|
236
265
|
(v.desc ? `;简介:${oneLine(v.desc, 120)}` : '') +
|
|
237
266
|
(v.subtitle ? `;字幕节选:${oneLine(v.subtitle, 300)}` : '') +
|
package/lib/channels/bilibili.js
CHANGED
|
@@ -80,6 +80,16 @@ export function wbiSign(params, key, wts = Math.round(Date.now() / 1000)) {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/** 大整数(msg_key 等)先转字符串再解析。 */
|
|
83
|
+
/** 弹幕 [{t, text}] → 按时间均匀抽 n 条文字(同样的话只留一条)。 */
|
|
84
|
+
export function sampleDanmaku(items, n = 12) {
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
const uniq = [...items].sort((a, b) => a.t - b.t).filter((d) => { const k = d.text.toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; });
|
|
87
|
+
if (uniq.length <= n) return uniq.map((d) => d.text);
|
|
88
|
+
const out = [];
|
|
89
|
+
for (let i = 0; i < n; i++) out.push(uniq[Math.floor((i + 0.5) * uniq.length / n)].text);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
83
93
|
export function parseBiliJson(text) {
|
|
84
94
|
return JSON.parse(String(text).replace(/("(?:msg_key|msg_seqno|talker_id|sender_uid|receiver_id|session_ts|ack_ts|top_ts)"\s*:\s*)(\d{16,})/g, '$1"$2"'));
|
|
85
95
|
}
|
|
@@ -518,6 +528,47 @@ export class BiliClient {
|
|
|
518
528
|
await this.call('api', '/x/web-interface/archive/like', { form: { aid, like: 1, csrf: this.auth.cookies.bili_jct ?? '' } });
|
|
519
529
|
}
|
|
520
530
|
|
|
531
|
+
/** 自己的默认收藏夹 id(第一次查完记在内存里)。 */
|
|
532
|
+
async defaultFavFolder() {
|
|
533
|
+
if (!this.uid) throw new Error('B站没登录');
|
|
534
|
+
if (this.favFolder?.uid === this.uid) return this.favFolder.id;
|
|
535
|
+
const json = await this.call('api', '/x/v3/fav/folder/created/list-all', { params: { up_mid: this.uid, type: 2 } });
|
|
536
|
+
const list = json.data?.list ?? [];
|
|
537
|
+
// 默认收藏夹 attr 的二进制最低位是 0(官方没写死顺序,找不到就用第一个)
|
|
538
|
+
const pick = list.find((f) => (Number(f?.attr) & 1) === 0) ?? list[0];
|
|
539
|
+
if (!pick?.id) throw new Error('没找到收藏夹');
|
|
540
|
+
this.favFolder = { uid: this.uid, id: String(pick.id) };
|
|
541
|
+
return this.favFolder.id;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** 收藏到默认收藏夹。 */
|
|
545
|
+
async favorite(aid) {
|
|
546
|
+
const folder = await this.defaultFavFolder();
|
|
547
|
+
await this.call('api', '/x/v3/fav/resource/deal', {
|
|
548
|
+
form: { rid: aid, type: 2, add_media_ids: folder, del_media_ids: '', csrf: this.auth.cookies.bili_jct ?? '' },
|
|
549
|
+
referer: `https://www.bilibili.com/video/av${aid}`
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* 弹幕抽样(按出现时间均匀取 n 条,去重、去太短的)。接口返回 deflate 压过的 XML,fetch 一般会自己解;
|
|
555
|
+
* 解不开 / 没弹幕就返回 [],不影响看视频。
|
|
556
|
+
*/
|
|
557
|
+
async danmaku(cid, n = 12) {
|
|
558
|
+
if (!cid) return [];
|
|
559
|
+
const res = await this.raw(`https://api.bilibili.com/x/v1/dm/list.so?oid=${encodeURIComponent(cid)}`);
|
|
560
|
+
if (!res.ok) return [];
|
|
561
|
+
const xml = await res.text();
|
|
562
|
+
const items = [];
|
|
563
|
+
const re = /<d p="([\d.]+)[^"]*">([^<]*)<\/d>/g;
|
|
564
|
+
let m;
|
|
565
|
+
while ((m = re.exec(xml)) !== null && items.length < 3000) {
|
|
566
|
+
const text = m[2].replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'|'/g, "'").replace(/&/g, '&').replace(/\s+/g, ' ').trim();
|
|
567
|
+
if (text.length >= 2) items.push({ t: Number(m[1]) || 0, text: text.slice(0, 40) });
|
|
568
|
+
}
|
|
569
|
+
return sampleDanmaku(items, n);
|
|
570
|
+
}
|
|
571
|
+
|
|
521
572
|
// ───────────── 自己发动态 / 回评论(v0.10)─────────────
|
|
522
573
|
|
|
523
574
|
/** 发一条纯文字动态,返回动态 id。 */
|
|
@@ -568,6 +619,33 @@ export class BiliClient {
|
|
|
568
619
|
}).filter((r) => r.id !== '' && r.oid !== '' && r.rpid !== '' && r.type > 0);
|
|
569
620
|
}
|
|
570
621
|
|
|
622
|
+
/**
|
|
623
|
+
* "@我的"消息(新的在前),归一化成和 replyFeed 一样的结构,id 加 "at:" 前缀免得和"回复我的"撞号。
|
|
624
|
+
* 只留评论区里的 @(item.type === 'reply'):动态正文里的 @ 没有可回的那条评论。
|
|
625
|
+
*/
|
|
626
|
+
async atFeed() {
|
|
627
|
+
const json = await this.call('api', '/x/msgfeed/at', { params: { platform: 'web', build: 0, mobi_app: 'web' }, referer: 'https://message.bilibili.com/' });
|
|
628
|
+
return (json.data?.items ?? []).map((it) => {
|
|
629
|
+
const x = it?.item ?? {};
|
|
630
|
+
return {
|
|
631
|
+
id: it?.id === undefined || it?.id === null ? '' : `at:${it.id}`,
|
|
632
|
+
kind: 'at',
|
|
633
|
+
mid: String(it?.user?.mid ?? ''),
|
|
634
|
+
name: String(it?.user?.nickname ?? ''),
|
|
635
|
+
type: Number(x.business_id ?? 0),
|
|
636
|
+
oid: String(x.subject_id ?? ''),
|
|
637
|
+
rpid: String(x.source_id ?? ''),
|
|
638
|
+
root: String(x.root_id && String(x.root_id) !== '0' ? x.root_id : x.source_id ?? ''),
|
|
639
|
+
title: String(x.title ?? ''),
|
|
640
|
+
content: String(x.source_content ?? ''),
|
|
641
|
+
target: '',
|
|
642
|
+
time: Number(it?.at_time ?? 0),
|
|
643
|
+
uri: String(x.uri ?? ''),
|
|
644
|
+
isComment: String(x.type ?? '') === 'reply'
|
|
645
|
+
};
|
|
646
|
+
}).filter((r) => r.id !== '' && r.isComment && r.oid !== '' && r.rpid !== '' && r.type > 0);
|
|
647
|
+
}
|
|
648
|
+
|
|
571
649
|
/** 在评论区回复一条评论(root = 楼主评论,parent = 要回的那条)。返回新评论 id。 */
|
|
572
650
|
async replyComment({ type, oid, root, parent, message }) {
|
|
573
651
|
const csrf = this.auth.cookies.bili_jct ?? '';
|
package/lib/channels/douyin.js
CHANGED
|
@@ -104,8 +104,40 @@ export function occurrenceIndexes(prints) {
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
107
|
+
/**
|
|
108
|
+
* 这个会话是不是群聊:接口里的会话类型 = 2、会话 ID 不是单聊的 0:1:xxx:yyy 形状、
|
|
109
|
+
* 对方的消息来自两个以上不同的人,或者设置页「抖音群聊」里填了这个会话名。
|
|
110
|
+
*/
|
|
111
|
+
export function isGroupConversation(msgs, { name = '', groups = [] } = {}) {
|
|
112
|
+
if (groups.includes(String(name).trim())) return true;
|
|
113
|
+
const peers = new Set();
|
|
114
|
+
for (const m of msgs ?? []) {
|
|
115
|
+
if (!m) continue;
|
|
116
|
+
if (Number(m.convType) === 2) return true;
|
|
117
|
+
const cid = String(m.convId ?? '');
|
|
118
|
+
if (cid !== '' && /^\d+:\d+:/.test(cid) && !/^0:1:/.test(cid)) return true;
|
|
119
|
+
if (!m.fromMe) {
|
|
120
|
+
const who = String(m.senderId || m.secUid || m.senderName || '');
|
|
121
|
+
if (who !== '') peers.add(who);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return peers.size >= 2;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 群聊里这条是谁说的:有昵称用昵称(和私信里一样按名字认人,同一个人私信 / 群聊归到一起);
|
|
129
|
+
* 只有发送者 ID 就查这个群之前对上过的昵称,再不行用「群友 + ID 后四位」。
|
|
130
|
+
*/
|
|
131
|
+
export function groupSender(m, members = {}) {
|
|
132
|
+
const id = String(m?.senderId || m?.secUid || '');
|
|
133
|
+
const name = String(m?.senderName ?? '').trim() || (id !== '' ? String(members[id] ?? '').trim() : '');
|
|
134
|
+
if (name !== '') return { userId: name, nickname: name };
|
|
135
|
+
if (id !== '') return { userId: `uid:${id}`, nickname: `群友${id.slice(-4)}` };
|
|
136
|
+
return { userId: '', nickname: '' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 一条抖音私信 → 入站消息(与 QQ 的 normalizeMessageEvent 同形)。不该当消息的返回 null。群聊按发送者分人。 */
|
|
140
|
+
export function normalizeDouyinMessage(m, { name, timeMs, messageId, group = false, members = {} }) {
|
|
109
141
|
if (!m || m.fromMe || m.system || m.unsupported) return null;
|
|
110
142
|
const media = m.media ?? {};
|
|
111
143
|
const images = [];
|
|
@@ -133,6 +165,28 @@ export function normalizeDouyinMessage(m, { name, timeMs, messageId }) {
|
|
|
133
165
|
if (!/分享/.test(text)) text = `【分享视频】${text}`;
|
|
134
166
|
}
|
|
135
167
|
if (text === '' && images.length === 0) return null;
|
|
168
|
+
if (group) {
|
|
169
|
+
const who = groupSender(m, members);
|
|
170
|
+
// 认不出是谁说的(DOM 里的媒体消息、接口没带发送者):不能把整个群当成一个人,丢掉
|
|
171
|
+
if (who.userId === '') return null;
|
|
172
|
+
return {
|
|
173
|
+
channel: 'douyin',
|
|
174
|
+
chatType: 'group',
|
|
175
|
+
channelId: `douyin-group:${name}`,
|
|
176
|
+
targetId: name,
|
|
177
|
+
groupId: name,
|
|
178
|
+
userId: who.userId,
|
|
179
|
+
nickname: who.nickname,
|
|
180
|
+
subType: '',
|
|
181
|
+
messageId,
|
|
182
|
+
mentioned: false,
|
|
183
|
+
replyId: null,
|
|
184
|
+
text: text.slice(0, 1000),
|
|
185
|
+
images,
|
|
186
|
+
links,
|
|
187
|
+
timeMs
|
|
188
|
+
};
|
|
189
|
+
}
|
|
136
190
|
return {
|
|
137
191
|
channel: 'douyin',
|
|
138
192
|
chatType: 'private',
|
|
@@ -710,6 +764,12 @@ export class DouyinChannel {
|
|
|
710
764
|
for (const item of todo.slice(0, MAX_READS_PER_POLL)) await this.readConversation(item);
|
|
711
765
|
}
|
|
712
766
|
|
|
767
|
+
/** 设置页「抖音群聊」:认不出来的群可以手动填会话名。 */
|
|
768
|
+
groupNames() {
|
|
769
|
+
const g = this.getConfig?.()?.groups;
|
|
770
|
+
return Array.isArray(g) ? g.map((x) => String(x).trim()).filter(Boolean) : [];
|
|
771
|
+
}
|
|
772
|
+
|
|
713
773
|
async readConversation({ c, name, key, st, sig, unread }) {
|
|
714
774
|
const res = await this.driver.read(name);
|
|
715
775
|
if (!res?.ok) {
|
|
@@ -731,7 +791,21 @@ export class DouyinChannel {
|
|
|
731
791
|
const want = typeof c.unread === 'number' && c.unread > 0 ? Math.min(INJECT_ON_FIRST_READ, c.unread) : INJECT_ON_FIRST_READ;
|
|
732
792
|
fresh = msgs.slice(lastMine + 1).filter((m) => !m.fromMe && !m.system && !m.unsupported).slice(-want);
|
|
733
793
|
}
|
|
734
|
-
|
|
794
|
+
// 群聊:认出来一次就记住(之后读到的消息里没带会话类型也按群处理);顺便记下"发送者 ID → 昵称"
|
|
795
|
+
const groups = this.groupNames();
|
|
796
|
+
const group = st?.group === true || isGroupConversation(msgs, { name, groups });
|
|
797
|
+
const members = { ...(st?.members ?? {}) };
|
|
798
|
+
if (group) {
|
|
799
|
+
for (const m of msgs) {
|
|
800
|
+
const id = String(m?.senderId || m?.secUid || '');
|
|
801
|
+
const n = String(m?.senderName ?? '').trim();
|
|
802
|
+
if (id !== '' && n !== '' && !m.fromMe) members[id] = n;
|
|
803
|
+
}
|
|
804
|
+
const ids = Object.keys(members);
|
|
805
|
+
for (const id of ids.slice(0, Math.max(0, ids.length - 200))) delete members[id];
|
|
806
|
+
if (st?.group !== true) this.log('info', `[fish-soul] 抖音会话「${name}」是群聊,之后按发言的人分开认`);
|
|
807
|
+
}
|
|
808
|
+
setKv(this.db, key, { name, sig, window: prints.slice(-WINDOW_KEEP), readAt: Date.now(), ...(group ? { group: true, members } : {}) });
|
|
735
809
|
const fallbackTime = convTimeMs(c.time) || Date.now();
|
|
736
810
|
// 同一指纹在这次读到的列表里是第几次出现:对方连发同一句话时,没 seq 的 ID 也不会撞
|
|
737
811
|
const occ = occurrenceIndexes(prints);
|
|
@@ -739,7 +813,7 @@ export class DouyinChannel {
|
|
|
739
813
|
for (const [i, m] of fresh.entries()) {
|
|
740
814
|
const timeMs = Number(m.time) > 1e12 ? Number(m.time) : Number(m.time) > 1e9 ? Number(m.time) * 1000 : fallbackTime;
|
|
741
815
|
const messageId = m.seq ? `s${m.seq}` : `${prints[base + i]}-${timeMs}-${occ[base + i]}`;
|
|
742
|
-
const norm = normalizeDouyinMessage(m, { name, timeMs, messageId });
|
|
816
|
+
const norm = normalizeDouyinMessage(m, { name, timeMs, messageId, group, members });
|
|
743
817
|
if (norm === null) continue;
|
|
744
818
|
try { await this.onMessage(norm); } catch (error) {
|
|
745
819
|
this.log('error', `[fish-soul] 抖音私信处理失败:${errText(error)}`);
|
|
@@ -284,6 +284,14 @@ export function parseMessages(raw, opts = {}) {
|
|
|
284
284
|
const senderId = getInt(c, 7) ?? 0
|
|
285
285
|
const timeMs = getInt(c, 10) ?? 0
|
|
286
286
|
const secUid = getString(c, 14)
|
|
287
|
+
// fish-soul:#2 是会话类型(1 = 单聊,2 = 群聊);#9 的扩展属性里有时带发送者昵称
|
|
288
|
+
const convType = getInt(c, 2) ?? 0
|
|
289
|
+
let senderName = ''
|
|
290
|
+
for (const a of c.filter((x) => x.field === 9 && x.kind === 'message')) {
|
|
291
|
+
const k = getString(a.children, 1)
|
|
292
|
+
const v = getString(a.children, 2)
|
|
293
|
+
if (v && /^(s:)?(sender_?nick_?name|nick_?name|sender_?name|user_?name)$/i.test(k)) { senderName = v.trim().slice(0, 40); break }
|
|
294
|
+
}
|
|
287
295
|
|
|
288
296
|
// 正文:优先 #8 的 JSON
|
|
289
297
|
let text = ''
|
|
@@ -334,6 +342,8 @@ export function parseMessages(raw, opts = {}) {
|
|
|
334
342
|
/** 是不是我自己发的(靠 sec_uid 比对,拿不到就当未知) */
|
|
335
343
|
fromMe: selfSecUid ? secUid === selfSecUid : null,
|
|
336
344
|
convId: getString(c, 1),
|
|
345
|
+
convType,
|
|
346
|
+
senderName,
|
|
337
347
|
})
|
|
338
348
|
}
|
|
339
349
|
|
|
@@ -162,6 +162,12 @@ function shapeResult(rawMessages, limit, via) {
|
|
|
162
162
|
seq: m.seq,
|
|
163
163
|
time: m.time,
|
|
164
164
|
kind: m.kind,
|
|
165
|
+
// fish-soul:群聊要按人分开(谁说的),单聊用不上
|
|
166
|
+
senderId: m.senderId ? String(m.senderId) : '',
|
|
167
|
+
secUid: m.secUid || '',
|
|
168
|
+
senderName: m.senderName || '',
|
|
169
|
+
convId: m.convId || '',
|
|
170
|
+
convType: m.convType || 0,
|
|
165
171
|
}))
|
|
166
172
|
return {
|
|
167
173
|
ok: true,
|
|
@@ -366,6 +372,18 @@ async function readDMDom({ browser, limit = 30, uname = '', scrollBack = 0 }) {
|
|
|
366
372
|
.filter(Boolean)
|
|
367
373
|
.filter((s) => !isMeta(s))
|
|
368
374
|
|
|
375
|
+
// fish-soul:群聊里对方气泡上面有昵称(单聊没有)。认出来就单独拿出来,别混进正文
|
|
376
|
+
let senderName = ''
|
|
377
|
+
if (!fromMe) {
|
|
378
|
+
const nameEl = box.querySelector('[class*="nickname" i], [class*="nickName"], [class*="senderName" i], [class*="userName"], [data-e2e*="nickname" i]')
|
|
379
|
+
const n = nameEl ? (nameEl.innerText || nameEl.textContent || '').replace(/\u200b/g, '').trim() : ''
|
|
380
|
+
if (n && n.length <= 40 && !n.includes(String.fromCharCode(10))) {
|
|
381
|
+
senderName = n
|
|
382
|
+
const k = lines.indexOf(n)
|
|
383
|
+
if (k >= 0) lines.splice(k, 1)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
369
387
|
const text = lines.join(' ').trim()
|
|
370
388
|
|
|
371
389
|
// ── 媒体信息(图片 / 表情 / 语音 / 视频 / 位置)──────────────
|
|
@@ -410,6 +428,7 @@ async function readDMDom({ browser, limit = 30, uname = '', scrollBack = 0 }) {
|
|
|
410
428
|
type,
|
|
411
429
|
text: text.slice(0, 500),
|
|
412
430
|
media,
|
|
431
|
+
senderName,
|
|
413
432
|
// ⭐ y 坐标用于排序(DOM 顺序不可靠)
|
|
414
433
|
y: Math.round(r.top),
|
|
415
434
|
})
|
|
@@ -306,6 +306,8 @@ export function mergeMessages(api, dom) {
|
|
|
306
306
|
}
|
|
307
307
|
if (found >= 0) {
|
|
308
308
|
// 把 API 消息(带准确时间戳)放进去
|
|
309
|
+
// fish-soul:DOM 认出的群聊昵称补到接口消息上(接口里一般只有发送者 ID)
|
|
310
|
+
if (d.senderName && !apiList[found].senderName) apiList[found] = { ...apiList[found], senderName: d.senderName }
|
|
309
311
|
for (let k = ai; k <= found; k++) out.push(apiList[k])
|
|
310
312
|
ai = found + 1
|
|
311
313
|
} else {
|