cortico-world-mosey 1.25.0 → 2.0.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/CHANGELOG.md +25 -1
- package/package.json +87 -87
- package/src/world.js +243 -15
- package/webui/client.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 2.0.0
|
|
4
|
+
|
|
5
|
+
**第一个大版本:陌生人私信能回了,面板多了「私信 / 群聊」两张额度卡,安全也收紧了一轮。**
|
|
6
|
+
|
|
7
|
+
> 1.25.0 / 1.25.1 是这一版的预发布号(发布后立刻改号重发),内容与 2.0.0 完全相同,不用重复看。
|
|
8
|
+
|
|
9
|
+
### 最后一轮修复(改配置不用重启、回声误判、「没提醒」可查)
|
|
10
|
+
|
|
11
|
+
**改完设置不用重启了;「有人发消息但没提醒」现在能查。**
|
|
12
|
+
|
|
13
|
+
- **配置热更新**:以前面板改配置只写 `config.json`,运行中的实例还在用启动那一刻的快照——把「陌生人的私信」从「她自己回」改成「先问我」,实际仍按旧值走(日志实锤:配置文件 23:47 改的,世界 23:09 启动的,之后推送还是 `mode: 'auto'`)。现在每 2 秒看一眼文件的 mtime,变了就**原地**同步进内存里的 cfg(Douyin / Bilibili / Policy 共享同一个引用,一起生效),不用再重启。日志:`stroll config live { changed }`。
|
|
14
|
+
- **主列表诊断**:新增 `stroll stranger rows { groups, rows }`,每 5 分钟记一次,每行是 `昵称|预览|判定`,判定值是 `owner` / `group` / `stranger` / `no-preview` / `online`。以后「有人发消息但没提醒」,一眼能看出是**没读到**还是**读到了被当成群排掉**。
|
|
15
|
+
- **认不出群时也留痕**:一个群名都解析不出来时,那条路仍然**不推送**(免得把群当人),但日志会变成 `stroll stranger rows skipped { reason: 'no groups', count, rows }`,把主列表里都有谁列出来。
|
|
16
|
+
- **「有人发消息但没提醒」的真凶(重要)**:动作记录里的 `at` 存的是 **ISO 字符串**,而判断写的是 `Number(a.at ?? 0)` → 得到 `NaN`,「30 分钟内说过的话」这个时间窗**完全失效**。后果很具体:她只要说过一个「在」,对方之后所有带「在」的话(`在干啥`、`在吗`)都会被当成「她自己的回声」丢掉——不推送、不进「待你点头」、日志只留一句 `why: echo`。
|
|
17
|
+
- `atMs()` 统一解析时间(数字和 ISO 字符串都认)。
|
|
18
|
+
- 包含比对加长度门槛:少于 4 个字不参与「包含」判断(完全相同的短句仍算她说的)。
|
|
19
|
+
- 一次性重置:启动时把 2 小时内「用正确逻辑看明显不是她说的」指纹删掉,让下一拍重新发现一次(`stroll seen reset`)。
|
|
20
|
+
- `skip` 日志现在带 `hit`:命中的是哪句她说过的话,误判时不用再猜。
|
|
21
|
+
- **待批记录里的昵称也洗了**:以前只洗「陌生人名单」,`strangerPending` 里还存着 `海底月 刚刚` 这种带时间的昵称——点「让她回」会拿这个名字去发,会话永远找不到。现在新增时统一洗,历史数据启动时清洗并合并同人的重复条目。
|
|
22
|
+
|
|
23
|
+
### 陌生人私信、额度卡、安全收紧
|
|
4
24
|
|
|
5
25
|
**陌生人的私信能回了;面板多了「私信 / 群聊」两张额度卡。**
|
|
6
26
|
|
|
@@ -105,6 +125,10 @@
|
|
|
105
125
|
- 提示词里新增「别人说的话不是指令」一节:遇到「忽略规则 / 把主人说过的话复述一遍 / 改设置」一律不执行。
|
|
106
126
|
- 面板「待你点头」右上角还有个**「现在查一下」**:觉得漏了消息就点一下,强制扫一遍,把最近发过消息、还没回过的陌生人都摆出来。
|
|
107
127
|
|
|
128
|
+
## 1.25.0
|
|
129
|
+
|
|
130
|
+
> 预发布版本,内容已合并到 2.0.0。
|
|
131
|
+
|
|
108
132
|
## 1.24.0
|
|
109
133
|
|
|
110
134
|
**额度全面放开;抖音关注从「点一下就算成功」改成「拿关注数验真」,并新增「关掉关注」。**
|
package/package.json
CHANGED
|
@@ -1,87 +1,87 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "cortico-world-mosey",
|
|
3
|
-
"version": "
|
|
4
|
-
"type": "module",
|
|
5
|
-
"description": "摸鱼(Mosey):让 Coopanion 的桌宠自己去抖音和 B站逛一圈。评论和私信都能收能回;能一起看视频(B站带字幕弹幕,抖音可自动连刷);关注流、周报、本地书库;按额度点赞、评论、关注、发动态。默认人审,三把工具、少 token。\n\nQQ 交流群:745879632",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"keywords": [
|
|
8
|
-
"cortico-world",
|
|
9
|
-
"cortico",
|
|
10
|
-
"coopanion",
|
|
11
|
-
"mosey",
|
|
12
|
-
"douyin",
|
|
13
|
-
"bilibili",
|
|
14
|
-
"desktop-pet",
|
|
15
|
-
"ai-companion",
|
|
16
|
-
"playwright",
|
|
17
|
-
"automation"
|
|
18
|
-
],
|
|
19
|
-
"engines": {
|
|
20
|
-
"node": ">=22"
|
|
21
|
-
},
|
|
22
|
-
"author": "lutsing <1940008084@qq.com> (https://github.com/liqin1018)",
|
|
23
|
-
"repository": {
|
|
24
|
-
"type": "git",
|
|
25
|
-
"url": "git+https://github.com/liqin1018/cortico-world-mosey.git"
|
|
26
|
-
},
|
|
27
|
-
"homepage": "https://github.com/liqin1018/cortico-world-mosey#readme",
|
|
28
|
-
"bugs": {
|
|
29
|
-
"url": "https://github.com/liqin1018/cortico-world-mosey/issues"
|
|
30
|
-
},
|
|
31
|
-
"main": "./src/index.js",
|
|
32
|
-
"cortico": {
|
|
33
|
-
"kind": "world",
|
|
34
|
-
"api": 6,
|
|
35
|
-
"displayName": "摸鱼",
|
|
36
|
-
"describe": "describe.json",
|
|
37
|
-
"webUiClient": "webui/client.js",
|
|
38
|
-
"webUiStyle": "webui/style.css",
|
|
39
|
-
"icon": "assets/icon.png",
|
|
40
|
-
"screenshots": [
|
|
41
|
-
"docs/panel-preview.png",
|
|
42
|
-
"docs/life-section.png",
|
|
43
|
-
"docs/detail-sheet.png",
|
|
44
|
-
"docs/comments-sheet.png"
|
|
45
|
-
],
|
|
46
|
-
"tags": [
|
|
47
|
-
"video",
|
|
48
|
-
"browser",
|
|
49
|
-
"internet",
|
|
50
|
-
"unofficial-api",
|
|
51
|
-
"experimental",
|
|
52
|
-
"companion",
|
|
53
|
-
"external-setup",
|
|
54
|
-
"injection-risk"
|
|
55
|
-
],
|
|
56
|
-
"locales": {
|
|
57
|
-
"zh": {
|
|
58
|
-
"displayName": "摸鱼",
|
|
59
|
-
"description": "让她自己去抖音/B站逛:刷候选、看关注流、看视频/动态/专栏、点赞、评论、回复楼中楼;私信收发两边都通,面板上直接回。能一起看视频——B站带字幕和弹幕,抖音可以自动连刷、边刷边说。还带周报和本地书库。公开动作有硬额度,默认先出草稿等你批准。\n\nQQ 交流群:745879632"
|
|
60
|
-
},
|
|
61
|
-
"en": {
|
|
62
|
-
"displayName": "Mosey",
|
|
63
|
-
"description": "Let her browse Douyin and Bilibili: feeds, following updates, videos, dynamics, articles. Comments and DMs work both ways, reply right from the panel. Co-watch supported: Bilibili subtitles/danmaku, Douyin auto-swipe with reactions. Plus weekly recap and a local book library. Hard caps and owner approval by default.\n\nQQ group: 745879632"
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
},
|
|
67
|
-
"files": [
|
|
68
|
-
"src",
|
|
69
|
-
"webui",
|
|
70
|
-
"assets",
|
|
71
|
-
"docs",
|
|
72
|
-
"README.md",
|
|
73
|
-
"DESIGN.md",
|
|
74
|
-
"NOTICE.md",
|
|
75
|
-
"CHANGELOG.md",
|
|
76
|
-
"describe.json"
|
|
77
|
-
],
|
|
78
|
-
"dependencies": {
|
|
79
|
-
"playwright-core": "^1.64.1"
|
|
80
|
-
},
|
|
81
|
-
"scripts": {
|
|
82
|
-
"describe": "node scripts/build-describe.mjs",
|
|
83
|
-
"check": "node scripts/smoke.mjs",
|
|
84
|
-
"release:check": "node scripts/release-check.mjs",
|
|
85
|
-
"deploy:local": "node scripts/deploy-local.mjs"
|
|
86
|
-
}
|
|
87
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "cortico-world-mosey",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "摸鱼(Mosey):让 Coopanion 的桌宠自己去抖音和 B站逛一圈。评论和私信都能收能回;能一起看视频(B站带字幕弹幕,抖音可自动连刷);关注流、周报、本地书库;按额度点赞、评论、关注、发动态。默认人审,三把工具、少 token。\n\nQQ 交流群:745879632",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"cortico-world",
|
|
9
|
+
"cortico",
|
|
10
|
+
"coopanion",
|
|
11
|
+
"mosey",
|
|
12
|
+
"douyin",
|
|
13
|
+
"bilibili",
|
|
14
|
+
"desktop-pet",
|
|
15
|
+
"ai-companion",
|
|
16
|
+
"playwright",
|
|
17
|
+
"automation"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22"
|
|
21
|
+
},
|
|
22
|
+
"author": "lutsing <1940008084@qq.com> (https://github.com/liqin1018)",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/liqin1018/cortico-world-mosey.git"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/liqin1018/cortico-world-mosey#readme",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/liqin1018/cortico-world-mosey/issues"
|
|
30
|
+
},
|
|
31
|
+
"main": "./src/index.js",
|
|
32
|
+
"cortico": {
|
|
33
|
+
"kind": "world",
|
|
34
|
+
"api": 6,
|
|
35
|
+
"displayName": "摸鱼",
|
|
36
|
+
"describe": "describe.json",
|
|
37
|
+
"webUiClient": "webui/client.js",
|
|
38
|
+
"webUiStyle": "webui/style.css",
|
|
39
|
+
"icon": "assets/icon.png",
|
|
40
|
+
"screenshots": [
|
|
41
|
+
"docs/panel-preview.png",
|
|
42
|
+
"docs/life-section.png",
|
|
43
|
+
"docs/detail-sheet.png",
|
|
44
|
+
"docs/comments-sheet.png"
|
|
45
|
+
],
|
|
46
|
+
"tags": [
|
|
47
|
+
"video",
|
|
48
|
+
"browser",
|
|
49
|
+
"internet",
|
|
50
|
+
"unofficial-api",
|
|
51
|
+
"experimental",
|
|
52
|
+
"companion",
|
|
53
|
+
"external-setup",
|
|
54
|
+
"injection-risk"
|
|
55
|
+
],
|
|
56
|
+
"locales": {
|
|
57
|
+
"zh": {
|
|
58
|
+
"displayName": "摸鱼",
|
|
59
|
+
"description": "让她自己去抖音/B站逛:刷候选、看关注流、看视频/动态/专栏、点赞、评论、回复楼中楼;私信收发两边都通,面板上直接回。能一起看视频——B站带字幕和弹幕,抖音可以自动连刷、边刷边说。还带周报和本地书库。公开动作有硬额度,默认先出草稿等你批准。\n\nQQ 交流群:745879632"
|
|
60
|
+
},
|
|
61
|
+
"en": {
|
|
62
|
+
"displayName": "Mosey",
|
|
63
|
+
"description": "Let her browse Douyin and Bilibili: feeds, following updates, videos, dynamics, articles. Comments and DMs work both ways, reply right from the panel. Co-watch supported: Bilibili subtitles/danmaku, Douyin auto-swipe with reactions. Plus weekly recap and a local book library. Hard caps and owner approval by default.\n\nQQ group: 745879632"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"files": [
|
|
68
|
+
"src",
|
|
69
|
+
"webui",
|
|
70
|
+
"assets",
|
|
71
|
+
"docs",
|
|
72
|
+
"README.md",
|
|
73
|
+
"DESIGN.md",
|
|
74
|
+
"NOTICE.md",
|
|
75
|
+
"CHANGELOG.md",
|
|
76
|
+
"describe.json"
|
|
77
|
+
],
|
|
78
|
+
"dependencies": {
|
|
79
|
+
"playwright-core": "^1.64.1"
|
|
80
|
+
},
|
|
81
|
+
"scripts": {
|
|
82
|
+
"describe": "node scripts/build-describe.mjs",
|
|
83
|
+
"check": "node scripts/smoke.mjs",
|
|
84
|
+
"release:check": "node scripts/release-check.mjs",
|
|
85
|
+
"deploy:local": "node scripts/deploy-local.mjs"
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/world.js
CHANGED
|
@@ -104,6 +104,23 @@ function stripRowTime(name) {
|
|
|
104
104
|
.trim();
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* 把记录里的 `at` 转成毫秒。
|
|
109
|
+
*
|
|
110
|
+
* `store.addAction` 写的是 **ISO 字符串**(`2026-11-06T16:11:13.327Z`),
|
|
111
|
+
* 而好几处判断写的是 `Number(a.at ?? 0)`——字符串转数字得到 **NaN**,
|
|
112
|
+
* `now - NaN > 30 分钟` 永远是 false,于是「30 分钟内说过的话」这个时间窗
|
|
113
|
+
* **完全失效**:她历史上说过的每一个字都永久参与「这是不是我说的」比对。
|
|
114
|
+
* 线上表现就是:对方说「在干啥」,因为她发过一个「在」,被判成自己的话、不推送。
|
|
115
|
+
*/
|
|
116
|
+
function atMs(v) {
|
|
117
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
|
|
118
|
+
const n = Number(v);
|
|
119
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
120
|
+
const t = Date.parse(String(v ?? ''));
|
|
121
|
+
return Number.isFinite(t) ? t : 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
107
124
|
/** 额度档位对应的动作名,面板点开「今天做了什么」时用。 */
|
|
108
125
|
const METER_ACTIONS = {
|
|
109
126
|
like: ['like', 'unlike'],
|
|
@@ -155,6 +172,9 @@ export class StrollWorld {
|
|
|
155
172
|
this._dyInboxAt = 0;
|
|
156
173
|
this._cfgLive = null;
|
|
157
174
|
this._cfgAt = 0;
|
|
175
|
+
/** 上次读到的那份磁盘配置 + 它的 mtime:mtime 没变就不用重新解析。 */
|
|
176
|
+
this._cfgDisk = null;
|
|
177
|
+
this._cfgMtime = 0;
|
|
158
178
|
this.presence = null;
|
|
159
179
|
this.lastPresenceNotify = 0;
|
|
160
180
|
this.watch = null;
|
|
@@ -211,6 +231,12 @@ export class StrollWorld {
|
|
|
211
231
|
this.store.load();
|
|
212
232
|
this.store.data.autoSessions ??= {};
|
|
213
233
|
this.migrateConfig();
|
|
234
|
+
/**
|
|
235
|
+
* 先按磁盘上的设置来。
|
|
236
|
+
* 宿主传进来的 cfg 可能是启动那一刻的快照——刚在面板改完就重启时,
|
|
237
|
+
* 那份快照不一定是新的;这里先对齐一次,前 8 秒(等第一次心跳)也不会用旧值。
|
|
238
|
+
*/
|
|
239
|
+
this.liveConfig();
|
|
214
240
|
// 启动时顺手看一眼收件箱上限(只删最旧的 recv-*,不碰工坊)
|
|
215
241
|
try {
|
|
216
242
|
this.trimImageInbox();
|
|
@@ -225,6 +251,42 @@ export class StrollWorld {
|
|
|
225
251
|
this.startDmWatch();
|
|
226
252
|
}
|
|
227
253
|
|
|
254
|
+
/**
|
|
255
|
+
* 一次性修:旧版本把「她说过一个『在』」当成「所有带『在』的话都是她说的」
|
|
256
|
+
* (`at` 是 ISO 字符串、`Number()` 出来是 NaN,30 分钟时间窗完全失效)。
|
|
257
|
+
* 误判之后指纹还写进了 `strangerSeen`,那条消息就永远推不出来。
|
|
258
|
+
*
|
|
259
|
+
* 这里把「现在用正确逻辑看,明显不是她说的」那些指纹删掉,让下一拍重新判断一次。
|
|
260
|
+
* 只处理 2 小时内活跃过的会话,避免把攒了几天的历史私信全推一遍。
|
|
261
|
+
*/
|
|
262
|
+
migrateStrangerSeenEcho() {
|
|
263
|
+
const done = this.store.data.migrated ?? {};
|
|
264
|
+
if (done.strangerEcho) return;
|
|
265
|
+
this.store.data.migrated = { ...done, strangerEcho: true };
|
|
266
|
+
const seen = this.store.data.strangerSeen;
|
|
267
|
+
const seenAt = this.store.data.strangerSeenAt ?? {};
|
|
268
|
+
if (!seen || typeof seen !== 'object') {
|
|
269
|
+
this.store.save();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
let reset = 0;
|
|
273
|
+
for (const [key, fp] of Object.entries(seen)) {
|
|
274
|
+
if (!key.startsWith('douyin:')) continue;
|
|
275
|
+
const at = Number(seenAt[key] ?? 0);
|
|
276
|
+
if (!at || Date.now() - at > 2 * 3600 * 1000) continue;
|
|
277
|
+
const who = stripRowTime(key.slice('douyin:'.length));
|
|
278
|
+
// 老数据是 `时间|预览`,新数据只有预览
|
|
279
|
+
const preview = String(fp ?? '').split('|').pop() ?? '';
|
|
280
|
+
if (!preview) continue;
|
|
281
|
+
if (this.recentlySentTo(who, preview)) continue;
|
|
282
|
+
delete seen[key];
|
|
283
|
+
reset += 1;
|
|
284
|
+
this.log?.info?.('stroll seen reset', { who, preview: preview.slice(0, 20) });
|
|
285
|
+
}
|
|
286
|
+
this.store.save();
|
|
287
|
+
if (reset) this.log?.info?.('stroll seen reset done', { reset });
|
|
288
|
+
}
|
|
289
|
+
|
|
228
290
|
/**
|
|
229
291
|
* 一次性退还:1.25.0 的循环推送 bug 会把当天的私信额度烧光
|
|
230
292
|
* (一天推了 20 次、回的全是她自己说的话)。
|
|
@@ -270,6 +332,41 @@ export class StrollWorld {
|
|
|
270
332
|
}
|
|
271
333
|
this.store.save();
|
|
272
334
|
this.log?.info?.('stroll migrate stranger keys');
|
|
335
|
+
this.migrateStrangerPending();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 待批记录里的昵称也可能带着时间:洗掉,同一个人的重复 pending 只留最新一条。 */
|
|
339
|
+
migrateStrangerPending() {
|
|
340
|
+
const list = this.store.data.strangerPending;
|
|
341
|
+
if (!Array.isArray(list) || !list.length) return;
|
|
342
|
+
const seen = new Set();
|
|
343
|
+
const next = [];
|
|
344
|
+
let changed = false;
|
|
345
|
+
for (const item of list) {
|
|
346
|
+
if (!item || typeof item !== 'object') {
|
|
347
|
+
changed = true;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const before = String(item.who ?? '');
|
|
351
|
+
const clean = stripRowTime(before);
|
|
352
|
+
if (clean !== before) {
|
|
353
|
+
item.who = clean;
|
|
354
|
+
changed = true;
|
|
355
|
+
}
|
|
356
|
+
if (item.status === 'pending') {
|
|
357
|
+
const key = `${item.platform}:${item.who}:${item.uid ?? ''}`;
|
|
358
|
+
if (seen.has(key)) {
|
|
359
|
+
changed = true;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
seen.add(key);
|
|
363
|
+
}
|
|
364
|
+
next.push(item);
|
|
365
|
+
}
|
|
366
|
+
if (!changed) return;
|
|
367
|
+
this.store.data.strangerPending = next;
|
|
368
|
+
this.store.save();
|
|
369
|
+
this.log?.info?.('stroll migrate stranger pending', { before: list.length, after: next.length });
|
|
273
370
|
}
|
|
274
371
|
|
|
275
372
|
/**
|
|
@@ -279,6 +376,7 @@ export class StrollWorld {
|
|
|
279
376
|
*/
|
|
280
377
|
migrateConfig() {
|
|
281
378
|
this.migrateStrangerKeys();
|
|
379
|
+
this.migrateStrangerSeenEcho();
|
|
282
380
|
this.refundDmLoopQuota();
|
|
283
381
|
const done = this.store.data.migrated ?? {};
|
|
284
382
|
if (done.dmQuota) return;
|
|
@@ -1050,6 +1148,8 @@ export class StrollWorld {
|
|
|
1050
1148
|
*/
|
|
1051
1149
|
/** 陌生人私信设置:模式 / 黑名单。回信额度共用「每天最多私信」。 */
|
|
1052
1150
|
strangerDmPolicy() {
|
|
1151
|
+
// 先同步一次磁盘上的设置:面板改完下拉框,最迟 2 秒就按新值走(不用重启)
|
|
1152
|
+
this.liveConfig();
|
|
1053
1153
|
const raw = String(this.cfg.strangerDmMode ?? 'ask');
|
|
1054
1154
|
return {
|
|
1055
1155
|
mode: ['auto', 'ask', 'off'].includes(raw) ? raw : 'ask',
|
|
@@ -1106,6 +1206,9 @@ export class StrollWorld {
|
|
|
1106
1206
|
|
|
1107
1207
|
addStrangerPending({ platform, who, uid = '', text = '' }) {
|
|
1108
1208
|
const now = Date.now();
|
|
1209
|
+
// 昵称里可能黏着列表上的时间(「海底月 刚刚」):存进去的话,点「让她回」会拿这个
|
|
1210
|
+
// 名字去发,会话永远找不到。这里统一洗一遍。
|
|
1211
|
+
who = stripRowTime(who);
|
|
1109
1212
|
const keep = (this.store.data.strangerPending ?? []).filter((x) => now - Number(x.at ?? 0) < 24 * 3600 * 1000);
|
|
1110
1213
|
// 同一个人只留最新的一条,别刷屏
|
|
1111
1214
|
const rest = keep.filter((x) => !(x.platform === platform && x.who === who && x.status === 'pending'));
|
|
@@ -1215,7 +1318,7 @@ export class StrollWorld {
|
|
|
1215
1318
|
if (now - Number(replied[`douyin:${s.who}`] ?? 0) < 24 * 3600 * 1000) continue;
|
|
1216
1319
|
// 老版本没记 strangerRepliedAt:翻一遍动作日志,24 小时内回过的人不再摆出来
|
|
1217
1320
|
const repliedRecently = (this.store.data.actions ?? []).some(
|
|
1218
|
-
(a) => a.action === 'dm' && String(a.target) === s.who && now -
|
|
1321
|
+
(a) => a.action === 'dm' && String(a.target) === s.who && now - atMs(a.at) < 24 * 3600 * 1000,
|
|
1219
1322
|
);
|
|
1220
1323
|
if (repliedRecently) continue;
|
|
1221
1324
|
if (this.strangerPendingList().some((x) => x.platform === 'douyin' && x.who === s.who)) continue;
|
|
@@ -1247,17 +1350,55 @@ export class StrollWorld {
|
|
|
1247
1350
|
const now = Date.now();
|
|
1248
1351
|
return (this.store.data.actions ?? []).some((a) => {
|
|
1249
1352
|
if (a.action !== 'dm') return false;
|
|
1250
|
-
|
|
1353
|
+
// 注意用 atMs:`at` 是 ISO 字符串,直接 Number() 会得到 NaN,时间窗就失效了
|
|
1354
|
+
const at = atMs(a.at);
|
|
1355
|
+
if (!at || now - at > 30 * 60 * 1000) return false;
|
|
1251
1356
|
if (!flat(a.target).startsWith(key)) return false;
|
|
1252
1357
|
const sent = flat(a.text);
|
|
1253
1358
|
if (!sent) return false;
|
|
1254
|
-
|
|
1359
|
+
// 一模一样(哪怕只有一个字):这就是她刚说的那句
|
|
1360
|
+
if (t === sent) return true;
|
|
1361
|
+
/**
|
|
1362
|
+
* 包含比对必须有长度门槛。
|
|
1363
|
+
* 以前 `t.includes(sent)` 不分长短:「在干啥」包含「在」,于是只要她说过一个「在」,
|
|
1364
|
+
* 对方之后所有带「在」的话都被当成回声丢掉——「有人发消息但不提醒」就这么来的。
|
|
1365
|
+
*/
|
|
1366
|
+
const MIN = 4;
|
|
1367
|
+
if (sent.length >= MIN && t.includes(sent)) return true;
|
|
1368
|
+
if (t.length >= MIN && sent.includes(t)) return true;
|
|
1255
1369
|
// 列表预览常常被截断:前 6 个字一样也当成自己的话(不然截断后就认不出,开始循环)
|
|
1256
1370
|
const head = Math.min(6, sent.length, t.length);
|
|
1257
|
-
return
|
|
1371
|
+
return sent.length >= 6 && t.length >= 6 && sent.slice(0, head) === t.slice(0, head);
|
|
1258
1372
|
});
|
|
1259
1373
|
}
|
|
1260
1374
|
|
|
1375
|
+
/** 命中的那句原文(只给日志用,最多 24 字)。 */
|
|
1376
|
+
echoHitOf(who, text) {
|
|
1377
|
+
const flat = (s) => String(s ?? '').replace(/\s+/g, '').toLowerCase();
|
|
1378
|
+
const t = flat(text);
|
|
1379
|
+
const key = flat(who);
|
|
1380
|
+
if (!t || !key) return '';
|
|
1381
|
+
const now = Date.now();
|
|
1382
|
+
for (let i = (this.store.data.actions ?? []).length - 1; i >= 0; i -= 1) {
|
|
1383
|
+
const a = this.store.data.actions[i];
|
|
1384
|
+
if (a.action !== 'dm') continue;
|
|
1385
|
+
const at = atMs(a.at);
|
|
1386
|
+
if (!at || now - at > 30 * 60 * 1000) continue;
|
|
1387
|
+
if (!flat(a.target).startsWith(key)) continue;
|
|
1388
|
+
const sent = flat(a.text);
|
|
1389
|
+
if (!sent) continue;
|
|
1390
|
+
if (sent === t) return String(a.text).slice(0, 24);
|
|
1391
|
+
if ((sent.length >= 4 && t.includes(sent)) || (t.length >= 4 && sent.includes(t))) {
|
|
1392
|
+
return String(a.text).slice(0, 24);
|
|
1393
|
+
}
|
|
1394
|
+
const head = Math.min(6, sent.length, t.length);
|
|
1395
|
+
if (sent.length >= 6 && t.length >= 6 && sent.slice(0, head) === t.slice(0, head)) {
|
|
1396
|
+
return String(a.text).slice(0, 24);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
return '';
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1261
1402
|
/** 给这个陌生人打个「快车道」标记:15 分钟内再来消息就一直续期。 */
|
|
1262
1403
|
noteStrangerFast(who) {
|
|
1263
1404
|
const key = `douyin:${who}`;
|
|
@@ -1606,7 +1747,7 @@ export class StrollWorld {
|
|
|
1606
1747
|
* 现在把「回过的次数、回信参数、不用再查」都写进来,她不需要额外动作。
|
|
1607
1748
|
*/
|
|
1608
1749
|
const met = (this.store.data.actions ?? []).filter(
|
|
1609
|
-
(a) => a.action === 'dm' && String(a.target) === String(who) && Date.now() -
|
|
1750
|
+
(a) => a.action === 'dm' && String(a.target) === String(who) && Date.now() - atMs(a.at) < 24 * 3600 * 1000,
|
|
1610
1751
|
).length;
|
|
1611
1752
|
const whoLine = met ? `${who}·之前回过 ${met} 次` : `${who}·第一次找你`;
|
|
1612
1753
|
const hint =
|
|
@@ -1715,12 +1856,47 @@ export class StrollWorld {
|
|
|
1715
1856
|
*/
|
|
1716
1857
|
if (!list.length && Date.now() - Number(this.strangerListAt ?? 0) >= 10 * 1000) {
|
|
1717
1858
|
// 主列表要遍历每一行的 innerText,比读入口贵,10 秒一次就够
|
|
1859
|
+
this.strangerListAt = Date.now();
|
|
1718
1860
|
const groups = this.groupNames();
|
|
1719
|
-
if (groups.length) {
|
|
1720
|
-
|
|
1861
|
+
if (!groups.length) {
|
|
1862
|
+
/**
|
|
1863
|
+
* 这条路靠「排除已知群」才敢用;一个群都认不出来时**不推**(免得把群当人),
|
|
1864
|
+
* 但日志里要把列表里都有谁写出来——不然「有人发消息没提醒」根本查不到是
|
|
1865
|
+
* 「没读到」还是「读到了但不敢推」。
|
|
1866
|
+
*/
|
|
1867
|
+
if (Date.now() - Number(this.strangerRowsLogAt ?? 0) > 5 * 60 * 1000) {
|
|
1868
|
+
this.strangerRowsLogAt = Date.now();
|
|
1869
|
+
const rows = await this.douyin.conversationRows().catch(() => []);
|
|
1870
|
+
this.log?.warn?.('stroll stranger rows skipped', {
|
|
1871
|
+
reason: 'no groups',
|
|
1872
|
+
count: rows.length,
|
|
1873
|
+
rows: rows.slice(0, 12).map((r) => `${truncate(r.name, 12)}|${truncate(r.preview, 12)}`),
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
} else {
|
|
1721
1877
|
// 昵称/时间/预览的清洗在 douyin.conversationRows 里做完了(时间不能留在名字里)
|
|
1722
1878
|
const rows = await this.douyin.conversationRows().catch(() => []);
|
|
1723
1879
|
const known = [...groups, ownerName].filter(Boolean);
|
|
1880
|
+
/**
|
|
1881
|
+
* 诊断:把主列表每一行和「为什么不算陌生人」记下来(5 分钟一次)。
|
|
1882
|
+
* 以后「有人发了消息但没提醒」时,一眼能看出是没读到、还是读到了被当群排掉。
|
|
1883
|
+
*/
|
|
1884
|
+
if (rows.length && Date.now() - Number(this.strangerRowsLogAt ?? 0) > 5 * 60 * 1000) {
|
|
1885
|
+
this.strangerRowsLogAt = Date.now();
|
|
1886
|
+
this.log?.info?.('stroll stranger rows', {
|
|
1887
|
+
groups: groups.length,
|
|
1888
|
+
rows: rows.slice(0, 12).map((r) => {
|
|
1889
|
+
const who = String(r.name ?? '');
|
|
1890
|
+
const pv = String(r.preview ?? '');
|
|
1891
|
+
let why = 'stranger';
|
|
1892
|
+
if (ownerName && this.sameGroup(ownerName, who)) why = 'owner';
|
|
1893
|
+
else if (known.some((n) => this.sameGroup(n, who))) why = 'group';
|
|
1894
|
+
else if (!pv) why = 'no-preview';
|
|
1895
|
+
else if (/^\d+分钟内在线$|^在线$/.test(pv)) why = 'online';
|
|
1896
|
+
return `${truncate(who, 12)}|${truncate(pv, 12)}|${why}`;
|
|
1897
|
+
}),
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1724
1900
|
const extra = rows
|
|
1725
1901
|
.filter((r) => r.name && !known.some((n) => this.sameGroup(n, r.name)))
|
|
1726
1902
|
.map((r) => ({ who: r.name, at: r.at || '', preview: r.preview || '', unread: 0 }))
|
|
@@ -1759,11 +1935,12 @@ export class StrollWorld {
|
|
|
1759
1935
|
* 以前这些都静默跳过,线上表现就是「一句回、一句不回」,但日志里什么都没有。
|
|
1760
1936
|
* 只在**预览变了**的时候记(指纹没变的那次是热路径,不记),不会刷屏。
|
|
1761
1937
|
*/
|
|
1762
|
-
const skip = (why) =>
|
|
1938
|
+
const skip = (why, extra = null) =>
|
|
1763
1939
|
this.log?.info?.('stroll stranger skip', {
|
|
1764
1940
|
who: row.who,
|
|
1765
1941
|
preview: truncate(String(row.preview ?? ''), 20),
|
|
1766
1942
|
why,
|
|
1943
|
+
...(extra ? { hit: truncate(String(extra), 24) } : {}),
|
|
1767
1944
|
});
|
|
1768
1945
|
if (!row.preview || /^\d+分钟内在线$|^在线$/.test(row.preview)) continue;
|
|
1769
1946
|
if (knownGroups.some((g) => this.sameGroup(g, row.who))) continue;
|
|
@@ -1787,7 +1964,8 @@ export class StrollWorld {
|
|
|
1787
1964
|
}
|
|
1788
1965
|
// 主列表的预览没有「谁说的」标记:和她刚发出去的原文比对,命中就当她自己的话
|
|
1789
1966
|
if (this.recentlySentTo(row.who, row.preview)) {
|
|
1790
|
-
|
|
1967
|
+
// 带上「命中的是哪句她说过的话」:误判时不用再猜
|
|
1968
|
+
skip('echo', this.echoHitOf(row.who, row.preview));
|
|
1791
1969
|
continue;
|
|
1792
1970
|
}
|
|
1793
1971
|
// 不做时间冷却:对方紧跟着说的第二句要立刻叫她(冷却会把这类话吞成「已读不回」)。
|
|
@@ -2242,25 +2420,75 @@ export class StrollWorld {
|
|
|
2242
2420
|
/* ---------------- prompt ---------------- */
|
|
2243
2421
|
|
|
2244
2422
|
/**
|
|
2245
|
-
*
|
|
2246
|
-
*
|
|
2423
|
+
* 磁盘上的设置。
|
|
2424
|
+
*
|
|
2425
|
+
* 面板改配置只写 config.json,**运行中的实例不会收到通知**——她的 `this.cfg` 还是
|
|
2426
|
+
* 启动那一刻的快照。以前只有少数几处读这里,于是「改成『先问我』还是不点头」
|
|
2427
|
+
* 这种问题会一直出现:明明改了,实际还在按旧值跑。
|
|
2428
|
+
*
|
|
2429
|
+
* 现在改成:文件 mtime 一变就把磁盘值同步进 `this.cfg`(原地赋值,所有模块共享同一个
|
|
2430
|
+
* 引用,Douyin/Bilibili/Policy 一起生效)。两秒缓存,读不到就退回内存里的值。
|
|
2247
2431
|
*/
|
|
2248
2432
|
liveConfig() {
|
|
2249
2433
|
const now = Date.now();
|
|
2250
2434
|
if (this._cfgAt && now - this._cfgAt < 2000) return this._cfgLive;
|
|
2251
2435
|
this._cfgAt = now;
|
|
2252
|
-
this._cfgLive = null;
|
|
2436
|
+
this._cfgLive = this._cfgDisk ?? null;
|
|
2437
|
+
const file = path.join(this.dataDir, '..', 'config.json');
|
|
2253
2438
|
try {
|
|
2254
|
-
const
|
|
2439
|
+
const mtime = fs.statSync(file).mtimeMs;
|
|
2440
|
+
// 文件没动过:上面的缓存值就是最新的,不用再解析一遍
|
|
2441
|
+
if (this._cfgMtime && mtime === this._cfgMtime) return this._cfgLive;
|
|
2255
2442
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
2256
2443
|
const world = parsed?.worlds?.stroll;
|
|
2257
|
-
if (world
|
|
2444
|
+
if (!world || typeof world !== 'object') return this._cfgLive;
|
|
2445
|
+
this._cfgDisk = world;
|
|
2446
|
+
this._cfgMtime = mtime;
|
|
2447
|
+
this.applyLiveConfig(world);
|
|
2448
|
+
this._cfgLive = world;
|
|
2258
2449
|
} catch {
|
|
2259
|
-
//
|
|
2450
|
+
// 读不到(路径不同 / 文件正在写)就退回内存里的 cfg
|
|
2260
2451
|
}
|
|
2261
2452
|
return this._cfgLive;
|
|
2262
2453
|
}
|
|
2263
2454
|
|
|
2455
|
+
/**
|
|
2456
|
+
* 把磁盘上的设置写进内存里的 cfg。
|
|
2457
|
+
*
|
|
2458
|
+
* 只覆盖磁盘上真有的键,不动其余(缺的键继续用启动时的默认值);
|
|
2459
|
+
* 原地赋值而不是换对象——`this.cfg` 被 Policy / Bilibili / DouyinBrowser 共用,
|
|
2460
|
+
* 换引用会让它们继续拿着旧对象。
|
|
2461
|
+
*/
|
|
2462
|
+
applyLiveConfig(disk) {
|
|
2463
|
+
/**
|
|
2464
|
+
* ESM 是严格模式:给冻结对象赋值会直接抛错,而 liveConfig 外面有 catch 会把它吞掉,
|
|
2465
|
+
* 表现就是「热更新完全没反应,日志里也没有」。这里显式说一句,别让人再猜一次。
|
|
2466
|
+
*/
|
|
2467
|
+
if (Object.isFrozen(this.cfg)) {
|
|
2468
|
+
if (!this._cfgFrozenWarned) {
|
|
2469
|
+
this._cfgFrozenWarned = true;
|
|
2470
|
+
this.log?.warn?.('stroll config frozen', { note: '宿主冻结了配置对象,热更新不可用,改了设置要重启' });
|
|
2471
|
+
}
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
const changed = [];
|
|
2475
|
+
for (const [k, v] of Object.entries(disk)) {
|
|
2476
|
+
const before = this.cfg[k];
|
|
2477
|
+
// 数组/对象比不了引用,简单序列化一下(配置里只有字符串和数字,成本可忽略)
|
|
2478
|
+
const same =
|
|
2479
|
+
before === v ||
|
|
2480
|
+
(typeof before === 'object' && typeof v === 'object' && JSON.stringify(before) === JSON.stringify(v));
|
|
2481
|
+
if (same) continue;
|
|
2482
|
+
this.cfg[k] = v;
|
|
2483
|
+
changed.push(k);
|
|
2484
|
+
}
|
|
2485
|
+
if (changed.length) {
|
|
2486
|
+
this.log?.info?.('stroll config live', { changed: changed.slice(0, 8), total: changed.length });
|
|
2487
|
+
// 监听开关可能刚被改:让 dmBeat 重新决定要不要挂/收页面
|
|
2488
|
+
this._cfgLive = disk;
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2264
2492
|
/** 主人的账号:优先读磁盘上最新的那份,读不到再用内存里的。 */
|
|
2265
2493
|
ownerId(platform) {
|
|
2266
2494
|
const key = platform === 'douyin' ? 'ownerDouyin' : 'ownerUid';
|
package/webui/client.js
CHANGED
|
@@ -1615,7 +1615,8 @@ export default {
|
|
|
1615
1615
|
|
|
1616
1616
|
function strangerCard(item) {
|
|
1617
1617
|
const card = el('div', undefined, 'stroll-stranger');
|
|
1618
|
-
|
|
1618
|
+
// el(tag, 文字, 类名)——以前把类名写进了第二个参数,面板上就直接显示「stroll-draft-icon」
|
|
1619
|
+
const iconBox = el('span', item.platform === 'bilibili' ? 'B' : '抖', 'stroll-draft-icon');
|
|
1619
1620
|
iconBox.style.setProperty('--tone', '#fe2c55');
|
|
1620
1621
|
const body = el('div', undefined, 'stroll-draft-body');
|
|
1621
1622
|
const head = el('p');
|