qq-bot-roost 0.8.13 → 0.8.14
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 +4 -4
- package/package.json +1 -1
- package/src/api.js +18 -5
- package/src/bot.js +70 -0
- package/src/message-bus.js +1 -1
- package/src/message.js +3 -3
- package/src/outbound.js +34 -11
- package/src/paths.js +2 -0
- package/src/plugin/context.js +8 -1
- package/src/plugin/loader.js +4 -1
- package/src/reach.js +364 -0
- package/src/web/qq-call.js +1 -1
- package/src/web/server.js +97 -0
- package/web/app.js +171 -0
- package/web/index.html +88 -0
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
<img src="https://img.shields.io/badge/Node.js-22.13%2B-339933?logo=node.js&logoColor=white" alt="Node.js 22.13+">
|
|
10
10
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
|
|
11
|
-
<img src="https://img.shields.io/badge/tests-
|
|
11
|
+
<img src="https://img.shields.io/badge/tests-700%2B%20passed-brightgreen" alt="测试 700+ 项">
|
|
12
12
|
<img src="https://img.shields.io/badge/plugins-JS%20%7C%20Python%20%7C%20Go%20%7C%20any-blue" alt="多语言插件">
|
|
13
|
-
<img src="https://img.shields.io/badge/version-0.8.
|
|
13
|
+
<img src="https://img.shields.io/badge/version-0.8.14-blueviolet" alt="版本 0.8.14">
|
|
14
14
|
<img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
|
|
15
15
|
<a href="https://github.com/tdcq9594/qq-bot-roost/actions"><img src="https://img.shields.io/github/actions/workflow/status/tdcq9594/qq-bot-roost/ci.yml?branch=master" alt="CI"></a>
|
|
16
16
|
|
|
@@ -424,8 +424,8 @@ bot/
|
|
|
424
424
|
│ ├── web/server.js 面板服务端(REST + SSE)
|
|
425
425
|
│ └── plugin/ 插件加载、配置、依赖、子进程宿主、面板页面注册
|
|
426
426
|
├── test/
|
|
427
|
-
│ ├── selftest.mjs 框架自检(
|
|
428
|
-
│ ├── e2e.mjs 端到端测试(
|
|
427
|
+
│ ├── selftest.mjs 框架自检(511 项)
|
|
428
|
+
│ ├── e2e.mjs 端到端测试(185 项,起真实服务打接口)
|
|
429
429
|
│ ├── qrlogin.mjs 扫码建机器人(8 项)
|
|
430
430
|
│ └── deps.mjs 插件依赖安装(11 项)
|
|
431
431
|
├── web/ 面板前端(原生 JS/CSS,无构建)
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -10,8 +10,11 @@ const log = createLogger('api');
|
|
|
10
10
|
export const FILE_TYPE = { IMAGE: 1, VIDEO: 2, VOICE: 3, FILE: 4 };
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* 被动回复序号的**最后一道**兜底上限。
|
|
14
|
+
*
|
|
15
|
+
* 官方口径按会话类型不同:群聊 5 次(`msg_seq` 1–5),单聊 4 次(1–4)。
|
|
16
|
+
* 这里只按 `msgId` 记,拿不到会话类型,所以取两者较大的 5 当「绝不可能超过」的硬顶。
|
|
17
|
+
* 精确拦截在 `outbound.js`(`PASSIVE_MAX_REPLIES[kind]`),正常流程走不到这里。
|
|
15
18
|
*/
|
|
16
19
|
const PASSIVE_MAX_SEQ = 5;
|
|
17
20
|
|
|
@@ -164,6 +167,16 @@ export class QQApi {
|
|
|
164
167
|
const err = new Error(`HTTP ${res.status} ${method} ${path}: ${msg}`);
|
|
165
168
|
err.status = res.status;
|
|
166
169
|
err.body = data;
|
|
170
|
+
/**
|
|
171
|
+
* QQ 的业务错误码,和 HTTP 状态码是两回事。
|
|
172
|
+
*
|
|
173
|
+
* 同样是 400,`40054004`(无好友关系)和 `40034006`(内容违规)
|
|
174
|
+
* 该做的事完全不同:前者要更新可达性状态,后者只能改文案。
|
|
175
|
+
* 上层拿不到这个码就只能瞎猜,所以在这里就拆出来。
|
|
176
|
+
*
|
|
177
|
+
* 常见值见 `docs/官方接口限制.md` 与 `src/reach.js`。
|
|
178
|
+
*/
|
|
179
|
+
err.qqCode = Number(data?.code ?? data?.err_code ?? 0) || 0;
|
|
167
180
|
throw err;
|
|
168
181
|
}
|
|
169
182
|
return data;
|
|
@@ -946,14 +959,14 @@ export class QQApi {
|
|
|
946
959
|
}
|
|
947
960
|
|
|
948
961
|
/**
|
|
949
|
-
*
|
|
950
|
-
*
|
|
962
|
+
* 被动回复序号。硬顶见 `PASSIVE_MAX_SEQ`(群聊 5 / 单聊 4,这里取 5 兜底),
|
|
963
|
+
* 超了服务端返回 40034128。正常流程由 outbound.js 按会话类型先拦。
|
|
951
964
|
*/
|
|
952
965
|
#nextSeq(msgId) {
|
|
953
966
|
const cur = this.seqMap.get(msgId) ?? 0;
|
|
954
967
|
if (cur >= PASSIVE_MAX_SEQ) {
|
|
955
968
|
throw new Error(
|
|
956
|
-
`同一条消息最多回复
|
|
969
|
+
`同一条消息最多回复 5 次(群聊)/ 4 次(单聊),这条已经回了 ${cur} 次`,
|
|
957
970
|
);
|
|
958
971
|
}
|
|
959
972
|
const next = cur + 1;
|
package/src/bot.js
CHANGED
|
@@ -16,6 +16,7 @@ import { BotMetrics } from './runtime.js';
|
|
|
16
16
|
import { claimInstance, releaseInstance } from './instance.js';
|
|
17
17
|
import { botPaths, DIRS, rel } from './paths.js';
|
|
18
18
|
import { StatsStore } from './storage/index.js';
|
|
19
|
+
import { createReach } from './reach.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* 一个机器人的完整运行时。
|
|
@@ -86,6 +87,14 @@ export class BotRuntime {
|
|
|
86
87
|
*/
|
|
87
88
|
this.groupNames = new Map();
|
|
88
89
|
this.roles = new RoleStore(this.paths.roles);
|
|
90
|
+
/**
|
|
91
|
+
* 可达性:谁能私聊、哪个群能主动推。
|
|
92
|
+
*
|
|
93
|
+
* 官方没有查好友的接口,只能靠 6 个事件维护(FRIEND_ADD/DEL、
|
|
94
|
+
* C2C_MSG_REJECT/RECEIVE、GROUP_MSG_REJECT/RECEIVE)。
|
|
95
|
+
* 插件可以通过 `bot.reach` 直接问,不用自己监听一遍。
|
|
96
|
+
*/
|
|
97
|
+
this.reach = createReach({ file: this.paths.reach });
|
|
89
98
|
this.outbound = null;
|
|
90
99
|
this.session = null;
|
|
91
100
|
this.pipeline = null;
|
|
@@ -183,6 +192,7 @@ export class BotRuntime {
|
|
|
183
192
|
outbound: this.outbound,
|
|
184
193
|
session: this.session,
|
|
185
194
|
pipeline: this.pipeline,
|
|
195
|
+
reach: this.reach,
|
|
186
196
|
isOwner: (uid) => cfg.owners.includes(uid),
|
|
187
197
|
// 插件声明了 frameworkVersion 就做兼容校验;作者本地调试可以关掉
|
|
188
198
|
ignoreCompat: this.globalCfg.plugins?.ignoreFrameworkVersion === true,
|
|
@@ -344,6 +354,51 @@ export class BotRuntime {
|
|
|
344
354
|
return this.groupNames.get(groupOpenid)?.name || undefined;
|
|
345
355
|
}
|
|
346
356
|
|
|
357
|
+
/**
|
|
358
|
+
* 用 6 个事件维护可达性。
|
|
359
|
+
*
|
|
360
|
+
* 官方**没有查好友列表的接口**(sitemap 里 `friend` 只有两个事件页),
|
|
361
|
+
* 所以「接入之前就存在的关系」永远是未知的,只能维护「接入之后的变化」。
|
|
362
|
+
* 三态语义与判定顺序见 `src/reach.js` 头注释。
|
|
363
|
+
*/
|
|
364
|
+
async #noteReach(eventName, d) {
|
|
365
|
+
const openid = d?.openid ?? '';
|
|
366
|
+
switch (eventName) {
|
|
367
|
+
case 'FRIEND_ADD':
|
|
368
|
+
await this.reach.noteFriendAdd({
|
|
369
|
+
openid,
|
|
370
|
+
unionOpenid: d?.author?.union_openid ?? '',
|
|
371
|
+
scene: d?.scene ?? 0,
|
|
372
|
+
});
|
|
373
|
+
break;
|
|
374
|
+
case 'FRIEND_DEL':
|
|
375
|
+
await this.reach.noteFriendDel({ openid });
|
|
376
|
+
break;
|
|
377
|
+
case 'C2C_MSG_REJECT':
|
|
378
|
+
await this.reach.noteC2cPush({ openid, on: false });
|
|
379
|
+
break;
|
|
380
|
+
case 'C2C_MSG_RECEIVE':
|
|
381
|
+
await this.reach.noteC2cPush({ openid, on: true });
|
|
382
|
+
break;
|
|
383
|
+
case 'GROUP_MSG_REJECT':
|
|
384
|
+
await this.reach.noteGroupPush({
|
|
385
|
+
groupOpenid: d?.group_openid,
|
|
386
|
+
on: false,
|
|
387
|
+
by: d?.op_member_openid,
|
|
388
|
+
});
|
|
389
|
+
break;
|
|
390
|
+
case 'GROUP_MSG_RECEIVE':
|
|
391
|
+
await this.reach.noteGroupPush({
|
|
392
|
+
groupOpenid: d?.group_openid,
|
|
393
|
+
on: true,
|
|
394
|
+
by: d?.op_member_openid,
|
|
395
|
+
});
|
|
396
|
+
break;
|
|
397
|
+
default:
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
347
402
|
/**
|
|
348
403
|
* 补一个群的名字(给面板调的公开入口,内部走带缓存的那份)。
|
|
349
404
|
* 不会抛异常,调用方不用 try/catch。
|
|
@@ -415,6 +470,7 @@ export class BotRuntime {
|
|
|
415
470
|
await this.outbound?.flush().catch(() => {});
|
|
416
471
|
await this.groups?.flush().catch(() => {});
|
|
417
472
|
await this.roles?.flush().catch(() => {});
|
|
473
|
+
await this.reach?.flush().catch(() => {});
|
|
418
474
|
this.stats?.close();
|
|
419
475
|
if (!this.mock) await releaseInstance(this.cfg.appId).catch(() => {});
|
|
420
476
|
this.state = 'stopped';
|
|
@@ -456,6 +512,12 @@ export class BotRuntime {
|
|
|
456
512
|
await groups.onRemoved(d?.group_openid);
|
|
457
513
|
}
|
|
458
514
|
|
|
515
|
+
// 可达性:官方没有查询接口,只能靠这 6 个事件维护(见 src/reach.js)
|
|
516
|
+
// 放在插件监听器之前,插件读到的就是最新状态
|
|
517
|
+
await this.#noteReach(eventName, d).catch((e) => {
|
|
518
|
+
this.log.warn(`可达性更新失败: ${e.message}`);
|
|
519
|
+
});
|
|
520
|
+
|
|
459
521
|
const meta = EVENTS[eventName] ?? null;
|
|
460
522
|
for (const l of registry.listeners.get(eventName) ?? []) {
|
|
461
523
|
// 事件里的群:有 group_openid 就按群开关过滤,没有(如私聊事件)就放行
|
|
@@ -478,6 +540,14 @@ export class BotRuntime {
|
|
|
478
540
|
|
|
479
541
|
/* ---- 消息事件 ---- */
|
|
480
542
|
const msg = buildMessage(eventName, d, { api: this.api, cfg, outbound, session });
|
|
543
|
+
|
|
544
|
+
// 收到私聊 = 好友关系存在的强证据。不置 c2cPush:他能发不等于他允许机器人主动推
|
|
545
|
+
if (msg.kind === 'c2c' && msg.userId) {
|
|
546
|
+
this.reach
|
|
547
|
+
.noteC2cObserved({ openid: msg.userId, unionOpenid: d?.author?.union_openid })
|
|
548
|
+
.catch(() => {});
|
|
549
|
+
}
|
|
550
|
+
|
|
481
551
|
/**
|
|
482
552
|
* 先播出去,再做业务处理。
|
|
483
553
|
*
|
package/src/message-bus.js
CHANGED
|
@@ -104,7 +104,7 @@ export function serializeMessage(botId, msg) {
|
|
|
104
104
|
mentioned: Boolean(msg.mentioned),
|
|
105
105
|
addressed: Boolean(msg.addressed),
|
|
106
106
|
isCommand: Boolean(msg.isCommand),
|
|
107
|
-
/**
|
|
107
|
+
/** 被动回复用(群聊 5 分钟 / 5 次,单聊 60 分钟 / 4 次) */
|
|
108
108
|
msgId: msg.id,
|
|
109
109
|
/** 引用回复用(message_reference.message_id) */
|
|
110
110
|
msgIdx: msg.msgIdx,
|
package/src/message.js
CHANGED
|
@@ -362,7 +362,7 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
|
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
// 底层发送:group 走群接口,c2c 走单聊接口。
|
|
365
|
-
// 一律经过 outbound
|
|
365
|
+
// 一律经过 outbound,由它处理被动窗口(群聊 5 分钟 / 单聊 60 分钟)、每秒限速、主动消息额度。
|
|
366
366
|
const send = (opts, passive) => {
|
|
367
367
|
if (kind !== 'group' && kind !== 'c2c') return Promise.resolve(null);
|
|
368
368
|
const body = passive ? { msgId: msg.id, ...opts } : { ...opts };
|
|
@@ -410,7 +410,7 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
|
|
|
410
410
|
const MAX_CHUNK = 1500; // 单条上限,超了按行切成多条
|
|
411
411
|
|
|
412
412
|
/**
|
|
413
|
-
* 被动回复(带 msg_id
|
|
413
|
+
* 被动回复(带 msg_id,群聊 5 分钟内、单聊 60 分钟内有效)。
|
|
414
414
|
* **不会立刻发出**,而是攒到本次处理结束,由 flushReplies() 合并成一条。
|
|
415
415
|
* 想立刻发用 msg.replyNow()。
|
|
416
416
|
*/
|
|
@@ -446,7 +446,7 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
|
|
|
446
446
|
* 引用回复:以「引用某条消息」的样式发出去(官方 `message_reference`)。
|
|
447
447
|
*
|
|
448
448
|
* 跟 `reply()` 不是一回事:
|
|
449
|
-
* - `reply()` 是**被动回复**,靠 msg_id
|
|
449
|
+
* - `reply()` 是**被动回复**,靠 msg_id,群聊 5 分钟 / 5 次,单聊 60 分钟 / 4 次
|
|
450
450
|
* - 这个是**引用**,靠 `REFIDX`,视觉上带"回复某某"的引用块
|
|
451
451
|
*
|
|
452
452
|
* 默认引用当前这条消息(用它的 `msg_idx`)。想引用别的传 `refIdx`。
|
package/src/outbound.js
CHANGED
|
@@ -20,15 +20,36 @@ const log = createLogger('outbound');
|
|
|
20
20
|
* 插件只管调 msg.reply(),剩下的交给这里。
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
/**
|
|
24
|
+
* 被动回复窗口。**两个接口不一样,别再用一个值。**
|
|
25
|
+
*
|
|
26
|
+
* 群聊 `/v2/groups/{group_openid}/messages`:5 分钟、最多 5 次
|
|
27
|
+
* 单聊 `/v2/users/{user_openid}/messages`:**60 分钟、最多 4 次**
|
|
28
|
+
*
|
|
29
|
+
* 以前两者共用「5 分钟 / 5 次」(群聊的口径),单聊的后果是:
|
|
30
|
+
* · 5 分钟后就降级成主动消息,白吃「每个好友 1000 条/天」的额度,
|
|
31
|
+
* 而被动回复本来不占主动额度;
|
|
32
|
+
* · 第 5 次回复会被服务端拒(40034128)。
|
|
33
|
+
*
|
|
34
|
+
* 各留 15 秒给网络和服务端时钟差。
|
|
35
|
+
*/
|
|
36
|
+
const PASSIVE_WINDOW_MS = {
|
|
37
|
+
group: 5 * 60 * 1000,
|
|
38
|
+
c2c: 60 * 60 * 1000,
|
|
39
|
+
};
|
|
40
|
+
const PASSIVE_SAFE_MS = {
|
|
41
|
+
group: PASSIVE_WINDOW_MS.group - 15_000,
|
|
42
|
+
c2c: PASSIVE_WINDOW_MS.c2c - 15_000,
|
|
43
|
+
};
|
|
26
44
|
|
|
27
45
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
46
|
+
* 同一条消息最多回复次数:群聊 5、单聊 4。
|
|
47
|
+
* 超过会返回 40034128「被动回复时间或次数超限」。
|
|
30
48
|
*/
|
|
31
|
-
const PASSIVE_MAX_REPLIES =
|
|
49
|
+
const PASSIVE_MAX_REPLIES = {
|
|
50
|
+
group: 5,
|
|
51
|
+
c2c: 4,
|
|
52
|
+
};
|
|
32
53
|
|
|
33
54
|
/** 主动消息进入审核的返回码,属于正常结果 */
|
|
34
55
|
const CODE_AUDIT = 304023;
|
|
@@ -245,11 +266,12 @@ export class Outbound {
|
|
|
245
266
|
// 1) 被动窗口检查:快过期就别发请求了,直接按主动消息处理
|
|
246
267
|
if (wantPassive) {
|
|
247
268
|
const age = receivedAt ? Date.now() - receivedAt : 0;
|
|
248
|
-
|
|
269
|
+
const safeMs = PASSIVE_SAFE_MS[kind];
|
|
270
|
+
if (age > safeMs) {
|
|
249
271
|
if (!this.downgrade) {
|
|
250
272
|
this.stats.expired += 1;
|
|
251
273
|
throw new SendError(
|
|
252
|
-
`这条消息已经过去 ${Math.round(age / 1000)} 秒,超过 QQ 的
|
|
274
|
+
`这条消息已经过去 ${Math.round(age / 1000)} 秒,超过 QQ 的 ${Math.round(safeMs / 1000)} 秒被动回复窗口,回复发不出去了`,
|
|
253
275
|
'expired',
|
|
254
276
|
);
|
|
255
277
|
}
|
|
@@ -267,17 +289,18 @@ export class Outbound {
|
|
|
267
289
|
// 2) 排队 + 限速 + 额度扣减 + 实际发送
|
|
268
290
|
// 额度的「查」和「扣」都放在队列里,避免两个并发推送同时通过检查后超额
|
|
269
291
|
return this.#enqueue(chatKey, async () => {
|
|
270
|
-
// 2a)
|
|
292
|
+
// 2a) 被动回复次数上限:群聊 5 次、单聊 4 次
|
|
271
293
|
// 同一条消息被多个插件连着回、或用户一句话触发多轮时很容易撞上,
|
|
272
294
|
// 本地先拦,省得服务端返回 40034128 再兜底
|
|
273
295
|
let passiveNow = wantPassive && !active;
|
|
274
296
|
if (passiveNow) {
|
|
297
|
+
const maxReplies = PASSIVE_MAX_REPLIES[kind];
|
|
275
298
|
const used = this.passiveCount.get(body.msgId) ?? 0;
|
|
276
|
-
if (used >=
|
|
299
|
+
if (used >= maxReplies) {
|
|
277
300
|
if (!this.downgrade) {
|
|
278
301
|
this.stats.expired += 1;
|
|
279
302
|
throw new SendError(
|
|
280
|
-
`同一条消息最多回复 ${
|
|
303
|
+
`同一条消息最多回复 ${maxReplies} 次(QQ 官方限制),这条已经回满了。\n` +
|
|
281
304
|
`等用户再说一句,就能继续用被动回复。`,
|
|
282
305
|
'expired',
|
|
283
306
|
);
|
package/src/paths.js
CHANGED
|
@@ -113,6 +113,8 @@ export function botPaths(id) {
|
|
|
113
113
|
groups: path.join(base, 'groups.json'),
|
|
114
114
|
roles: path.join(base, 'roles.json'),
|
|
115
115
|
quota: path.join(base, 'quota.json'),
|
|
116
|
+
/** 可达性(好友关系 / 主动推送开关),官方无查询接口,靠事件维护 */
|
|
117
|
+
reach: path.join(base, 'reach.json'),
|
|
116
118
|
/** 统计库(SQLite,存消息计数与成员活跃,不存正文) */
|
|
117
119
|
stats: path.join(base, 'stats.db'),
|
|
118
120
|
pluginState: path.join(base, 'plugins.json'),
|
package/src/plugin/context.js
CHANGED
|
@@ -302,7 +302,7 @@ export class PluginStorage {
|
|
|
302
302
|
/**
|
|
303
303
|
* 插件上下文:插件能用的全部能力都在这里,不暴露内部实现。
|
|
304
304
|
*/
|
|
305
|
-
export function createContext({ meta, paths, registry, api, cfg, config, configStore, groups, roles, levels, outbound, session, pipeline, isOwner, pages = null, routes = null, state = null }) {
|
|
305
|
+
export function createContext({ meta, paths, registry, api, cfg, config, configStore, groups, roles, levels, outbound, session, pipeline, reach, isOwner, pages = null, routes = null, state = null }) {
|
|
306
306
|
const id = meta.id;
|
|
307
307
|
// 日志 tag 带机器人 id,多机器人下同名插件的日志不会串
|
|
308
308
|
const logger = createLogger(`plugin:${paths?.id ?? '-'}:${id}`);
|
|
@@ -343,6 +343,13 @@ export function createContext({ meta, paths, registry, api, cfg, config, configS
|
|
|
343
343
|
state: state ?? {},
|
|
344
344
|
api,
|
|
345
345
|
logger,
|
|
346
|
+
/**
|
|
347
|
+
* 可达性:`ctx.reach.canC2C(openid)` / `ctx.reach.canGroupPush(groupOpenid)`。
|
|
348
|
+
*
|
|
349
|
+
* 返回 `{ verdict: 'yes'|'no'|'unknown', reason? }`。
|
|
350
|
+
* **`unknown` 不能当 `no` 用**——未知是「不知道」,不是「不行」。
|
|
351
|
+
*/
|
|
352
|
+
reach,
|
|
346
353
|
storage: new PluginStorage(paths.pluginData(id)),
|
|
347
354
|
|
|
348
355
|
/**
|
package/src/plugin/loader.js
CHANGED
|
@@ -27,7 +27,7 @@ const log = createLogger('plugins');
|
|
|
27
27
|
* 启用状态存在 data/bots/<机器人id>/plugins.json,删掉文件夹即等于卸载。
|
|
28
28
|
*/
|
|
29
29
|
export class PluginManager {
|
|
30
|
-
constructor({ pluginsDir, paths, registry, api, cfg, goBin, groups, roles, outbound, session, pipeline, isOwner, ignoreCompat }) {
|
|
30
|
+
constructor({ pluginsDir, paths, registry, api, cfg, goBin, groups, roles, outbound, session, pipeline, isOwner, reach, ignoreCompat }) {
|
|
31
31
|
this.dir = pluginsDir;
|
|
32
32
|
// 该机器人的全套路径(配置/数据/日志),多机器人下互不干扰
|
|
33
33
|
this.paths = paths;
|
|
@@ -41,6 +41,8 @@ export class PluginManager {
|
|
|
41
41
|
this.outbound = outbound;
|
|
42
42
|
this.session = session;
|
|
43
43
|
this.pipeline = pipeline;
|
|
44
|
+
/** 可达性(能不能私聊/能不能往这个群主动推),见 src/reach.js */
|
|
45
|
+
this.reach = reach;
|
|
44
46
|
this.isOwner = isOwner;
|
|
45
47
|
/** 跳过插件声明的 frameworkVersion 校验(作者本地调试用) */
|
|
46
48
|
this.ignoreCompat = Boolean(ignoreCompat);
|
|
@@ -407,6 +409,7 @@ export class PluginManager {
|
|
|
407
409
|
outbound: this.outbound,
|
|
408
410
|
session: this.session,
|
|
409
411
|
pipeline: this.pipeline,
|
|
412
|
+
reach: this.reach,
|
|
410
413
|
isOwner: this.isOwner,
|
|
411
414
|
pages: this.pages,
|
|
412
415
|
routes: this.routes,
|
package/src/reach.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 可达性:这个人能不能私聊、这个群能不能主动推。
|
|
3
|
+
* ==============================================
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要单独一个模块:官方**没有查好友列表的接口**(sitemap 里 `friend`
|
|
6
|
+
* 只有两个事件页,没有任何 API)。但官方会把变化**推**过来,所以状态能维护,
|
|
7
|
+
* 只是「接入之前就已经存在的关系」永远是未知的。
|
|
8
|
+
*
|
|
9
|
+
* ── 两层开关,不是一层 ──
|
|
10
|
+
*
|
|
11
|
+
* 很多人以为「加了好友就能发私聊」。官方文档里 `C2C_MSG_REJECT` 的原话是:
|
|
12
|
+
*
|
|
13
|
+
* > 用户在机器人资料卡手动关闭「主动消息」推送时触发。
|
|
14
|
+
* > **关闭后机器人无法向该用户发送主动消息。**
|
|
15
|
+
*
|
|
16
|
+
* 也就是说:**有好友 ≠ 能主动发**。用户可以在资料卡里单独关掉推送。
|
|
17
|
+
* 所以这里必须分两列存,判定时先看好友、再看推送开关。
|
|
18
|
+
*
|
|
19
|
+
* 群聊同理,`GROUP_MSG_REJECT` / `GROUP_MSG_RECEIVE` 是**群的属性**
|
|
20
|
+
* (事件体只有 `group_openid` + `op_member_openid`,没有目标用户),
|
|
21
|
+
* 所以群维度单独一张表,不挂在人身上。
|
|
22
|
+
*
|
|
23
|
+
* ── 三个值,不是两个 ──
|
|
24
|
+
*
|
|
25
|
+
* `1` 已知可以、`0` 已知不可以、`null` 未知。
|
|
26
|
+
*
|
|
27
|
+
* **`null` 绝不能当 `0` 用**:群里聊过的人很可能早就加了好友,只是还没私聊过。
|
|
28
|
+
* 把未知当不可,会让主动搭话永远开不了口;把未知当可,最多发失败一次
|
|
29
|
+
* ——两害相权,未知应该放行,失败再记下来。
|
|
30
|
+
*
|
|
31
|
+
* ── 存 JSON 不存 SQLite ──
|
|
32
|
+
*
|
|
33
|
+
* 一个机器人几百个好友,几十 KB,读多写少。框架里已有 `quota.json`
|
|
34
|
+
* 的先例(`outbound.js` 的 QuotaLedger),同一套「内存缓存 + 延迟落盘」,
|
|
35
|
+
* 不引迁移、能手改、出问题能直接删。
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import fs from 'node:fs/promises';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { createLogger } from './logger.js';
|
|
41
|
+
|
|
42
|
+
const log = createLogger('reach');
|
|
43
|
+
|
|
44
|
+
/** 落盘延迟。好友变动是低频事件,攒一下再写,避免连续事件写多次盘 */
|
|
45
|
+
const SAVE_DELAY_MS = 500;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 用户档案条数上限。正常部署远远到不了,纯粹防「事件风暴把文件写爆」。
|
|
49
|
+
* 超了按 `updatedAt` 丢最旧的。
|
|
50
|
+
*/
|
|
51
|
+
const MAX_USERS = 5000;
|
|
52
|
+
|
|
53
|
+
/** 值归一:只认 1 / 0 / null,别的一律当未知 */
|
|
54
|
+
function tri(v) {
|
|
55
|
+
if (v === 1 || v === true) return 1;
|
|
56
|
+
if (v === 0 || v === false) return 0;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} p
|
|
62
|
+
* @param {string} p.file reach.json 的路径(每鸟一份)
|
|
63
|
+
*/
|
|
64
|
+
export function createReach({ file }) {
|
|
65
|
+
/** @type {{users: Record<string, object>, groups: Record<string, object>}|null} */
|
|
66
|
+
let data = null;
|
|
67
|
+
let dirty = false;
|
|
68
|
+
let timer = null;
|
|
69
|
+
|
|
70
|
+
async function load() {
|
|
71
|
+
if (data) return data;
|
|
72
|
+
try {
|
|
73
|
+
const raw = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
74
|
+
const users = raw?.users && typeof raw.users === 'object' ? raw.users : {};
|
|
75
|
+
// 手改文件写错类型时不至于让判定崩掉,一律归一成 1 / 0 / null
|
|
76
|
+
for (const u of Object.values(users)) {
|
|
77
|
+
if (!u || typeof u !== 'object') continue;
|
|
78
|
+
u.friend = tri(u.friend);
|
|
79
|
+
u.c2cPush = tri(u.c2cPush);
|
|
80
|
+
}
|
|
81
|
+
const groups = raw?.groups && typeof raw.groups === 'object' ? raw.groups : {};
|
|
82
|
+
for (const g of Object.values(groups)) {
|
|
83
|
+
if (!g || typeof g !== 'object') continue;
|
|
84
|
+
g.push = tri(g.push);
|
|
85
|
+
}
|
|
86
|
+
data = { users, groups };
|
|
87
|
+
} catch {
|
|
88
|
+
data = { users: {}, groups: {} };
|
|
89
|
+
}
|
|
90
|
+
return data;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function scheduleSave() {
|
|
94
|
+
dirty = true;
|
|
95
|
+
if (timer) return;
|
|
96
|
+
timer = setTimeout(() => {
|
|
97
|
+
timer = null;
|
|
98
|
+
flush().catch((e) => log.warn('可达性写盘失败:', e.message));
|
|
99
|
+
}, SAVE_DELAY_MS);
|
|
100
|
+
timer.unref?.();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function flush() {
|
|
104
|
+
if (timer) {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
timer = null;
|
|
107
|
+
}
|
|
108
|
+
if (!dirty || !data) return;
|
|
109
|
+
dirty = false;
|
|
110
|
+
try {
|
|
111
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
112
|
+
await fs.writeFile(file, JSON.stringify(data, null, 2), 'utf8');
|
|
113
|
+
} catch (e) {
|
|
114
|
+
log.warn('可达性写盘失败:', e.message);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 取或建一个用户档案 */
|
|
119
|
+
function slot(users, openid) {
|
|
120
|
+
const id = String(openid ?? '').trim();
|
|
121
|
+
if (!id) return null;
|
|
122
|
+
users[id] ??= { friend: null, c2cPush: null, unionOpenid: '', evidence: '', updatedAt: 0 };
|
|
123
|
+
return users[id];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 超出上限时按 updatedAt 丢最旧的 */
|
|
127
|
+
function trim(users) {
|
|
128
|
+
const ids = Object.keys(users);
|
|
129
|
+
if (ids.length <= MAX_USERS) return;
|
|
130
|
+
ids
|
|
131
|
+
.sort((a, b) => (users[a].updatedAt ?? 0) - (users[b].updatedAt ?? 0))
|
|
132
|
+
.slice(0, ids.length - MAX_USERS)
|
|
133
|
+
.forEach((id) => delete users[id]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
/**
|
|
138
|
+
* `FRIEND_ADD`:用户加了机器人。
|
|
139
|
+
*
|
|
140
|
+
* 这是**唯一能拿到 `union_openid` 的地方**(消息事件里也可能有,但经常是空串)。
|
|
141
|
+
* 顺手存下来,将来跨机器人关联人物时用得上。
|
|
142
|
+
*/
|
|
143
|
+
async noteFriendAdd({ openid, unionOpenid = '', scene = 0 } = {}) {
|
|
144
|
+
const d = await load();
|
|
145
|
+
const u = slot(d.users, openid);
|
|
146
|
+
if (!u) return false;
|
|
147
|
+
u.friend = 1;
|
|
148
|
+
// 空串不覆盖已有的值——消息事件里 union_openid 常常是空的
|
|
149
|
+
if (unionOpenid) u.unionOpenid = String(unionOpenid);
|
|
150
|
+
u.evidence = `friend_add:${scene}`;
|
|
151
|
+
u.updatedAt = Date.now();
|
|
152
|
+
trim(d.users);
|
|
153
|
+
scheduleSave();
|
|
154
|
+
return true;
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/** `FRIEND_DEL`:用户删了机器人。这时私聊一定发不出去 */
|
|
158
|
+
async noteFriendDel({ openid } = {}) {
|
|
159
|
+
const d = await load();
|
|
160
|
+
const u = slot(d.users, openid);
|
|
161
|
+
if (!u) return false;
|
|
162
|
+
u.friend = 0;
|
|
163
|
+
u.evidence = 'friend_del';
|
|
164
|
+
u.updatedAt = Date.now();
|
|
165
|
+
scheduleSave();
|
|
166
|
+
return true;
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* `C2C_MSG_REJECT` / `C2C_MSG_RECEIVE`:用户在资料卡里关/开了主动推送。
|
|
171
|
+
*
|
|
172
|
+
* 注意这是**独立于好友关系**的一层:好友还在,但推送被关了,照样发不出去。
|
|
173
|
+
*/
|
|
174
|
+
async noteC2cPush({ openid, on } = {}) {
|
|
175
|
+
const d = await load();
|
|
176
|
+
const u = slot(d.users, openid);
|
|
177
|
+
if (!u) return false;
|
|
178
|
+
u.c2cPush = on ? 1 : 0;
|
|
179
|
+
u.evidence = on ? 'c2c_msg_receive' : 'c2c_msg_reject';
|
|
180
|
+
u.updatedAt = Date.now();
|
|
181
|
+
scheduleSave();
|
|
182
|
+
return true;
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `GROUP_MSG_REJECT` / `GROUP_MSG_RECEIVE`:群管理员关/开了机器人通知。
|
|
187
|
+
*
|
|
188
|
+
* 存群维度,不存人——事件体里只有 `group_openid` 和操作人,
|
|
189
|
+
* 没有「这个开关对谁生效」的概念,它就是对整个群生效。
|
|
190
|
+
*/
|
|
191
|
+
async noteGroupPush({ groupOpenid, on, by = '' } = {}) {
|
|
192
|
+
const gid = String(groupOpenid ?? '').trim();
|
|
193
|
+
if (!gid) return false;
|
|
194
|
+
const d = await load();
|
|
195
|
+
d.groups[gid] = {
|
|
196
|
+
push: on ? 1 : 0,
|
|
197
|
+
by: String(by ?? ''),
|
|
198
|
+
evidence: on ? 'group_msg_receive' : 'group_msg_reject',
|
|
199
|
+
updatedAt: Date.now(),
|
|
200
|
+
};
|
|
201
|
+
scheduleSave();
|
|
202
|
+
return true;
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 收到这个人的单聊消息 —— 最强证据:他能发,说明好友一定在。
|
|
207
|
+
*
|
|
208
|
+
* 同时把 `c2cPush` 留空而不是置 1:他主动发消息不代表他允许机器人主动推。
|
|
209
|
+
*/
|
|
210
|
+
async noteC2cObserved({ openid, unionOpenid = '' } = {}) {
|
|
211
|
+
const d = await load();
|
|
212
|
+
const u = slot(d.users, openid);
|
|
213
|
+
if (!u) return false;
|
|
214
|
+
u.friend = 1;
|
|
215
|
+
if (unionOpenid) u.unionOpenid = String(unionOpenid);
|
|
216
|
+
u.evidence = 'observed_c2c';
|
|
217
|
+
u.updatedAt = Date.now();
|
|
218
|
+
scheduleSave();
|
|
219
|
+
return true;
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 主动发送失败了 —— 按 QQ 业务错误码更新状态。
|
|
224
|
+
*
|
|
225
|
+
* 这些码是官方文档里明写的(`/v2/users/{user_openid}/messages` 错误码表),
|
|
226
|
+
* 所以能**精确判定**,不用再靠人猜:
|
|
227
|
+
*
|
|
228
|
+
* 40054004 无好友关系 → is_friend = 0
|
|
229
|
+
* 40054006 验证好友关系失败 → 不改状态(官方建议「重试」)
|
|
230
|
+
* 40054013 用户拒收消息 → c2c_push = 0(对应 C2C_MSG_REJECT)
|
|
231
|
+
*
|
|
232
|
+
* 其它码(频控 40034100、内容违规 40034006、审核……)**一律不改状态**:
|
|
233
|
+
* 它们跟「这个人能不能收到」无关,改了就等于一次抖动永久封掉一个人。
|
|
234
|
+
*
|
|
235
|
+
* @param {object} p
|
|
236
|
+
* @param {string} p.openid
|
|
237
|
+
* @param {number} p.qqCode QQ 业务错误码(不是 HTTP 状态码)
|
|
238
|
+
*/
|
|
239
|
+
async noteSendError({ openid, qqCode } = {}) {
|
|
240
|
+
const d = await load();
|
|
241
|
+
const u = slot(d.users, openid);
|
|
242
|
+
if (!u) return false;
|
|
243
|
+
const code = Number(qqCode) || 0;
|
|
244
|
+
u.lastFailAt = Date.now();
|
|
245
|
+
u.lastFailCode = code;
|
|
246
|
+
|
|
247
|
+
if (code === 40054004) {
|
|
248
|
+
u.friend = 0;
|
|
249
|
+
u.evidence = 'send:no-friend';
|
|
250
|
+
} else if (code === 40054013) {
|
|
251
|
+
u.c2cPush = 0;
|
|
252
|
+
u.evidence = 'send:rejected';
|
|
253
|
+
}
|
|
254
|
+
// 40054006 只记时间:官方自己都说「请重试」
|
|
255
|
+
u.updatedAt = Date.now();
|
|
256
|
+
scheduleSave();
|
|
257
|
+
return true;
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
/** 主动发送成功了 —— 反证「可达」 */
|
|
261
|
+
async noteSendOk({ openid } = {}) {
|
|
262
|
+
const d = await load();
|
|
263
|
+
const u = slot(d.users, openid);
|
|
264
|
+
if (!u) return false;
|
|
265
|
+
u.friend = 1;
|
|
266
|
+
u.lastOkAt = Date.now();
|
|
267
|
+
scheduleSave();
|
|
268
|
+
return true;
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 人工标注 —— 面板上手动纠正状态。
|
|
273
|
+
*
|
|
274
|
+
* 为什么必须有这个口子:好友关系的事件是**增量**的,接入之前就存在的关系
|
|
275
|
+
* 永远拿不到事件,状态会一直停在 `null`。线上真出问题时,运维只能靠
|
|
276
|
+
* 「手动发一条试试」来试,而失败的证据又常常和网络/限流混在一起分不清。
|
|
277
|
+
* 与其让人去改 JSON,不如给一个能校验的入口。
|
|
278
|
+
*
|
|
279
|
+
* `friend` / `c2cPush` 传 `undefined` 表示「这项不动」,传 `null` 表示「重置为未知」。
|
|
280
|
+
* 两者语义不同,所以不能用 `??` 或真值判断。
|
|
281
|
+
*
|
|
282
|
+
* @param {object} p
|
|
283
|
+
* @param {string} p.openid
|
|
284
|
+
* @param {1|0|null} [p.friend]
|
|
285
|
+
* @param {1|0|null} [p.c2cPush]
|
|
286
|
+
*/
|
|
287
|
+
async noteManual({ openid, friend, c2cPush } = {}) {
|
|
288
|
+
const d = await load();
|
|
289
|
+
const u = slot(d.users, openid);
|
|
290
|
+
if (!u) return false;
|
|
291
|
+
if (friend !== undefined) u.friend = tri(friend);
|
|
292
|
+
if (c2cPush !== undefined) u.c2cPush = tri(c2cPush);
|
|
293
|
+
u.evidence = 'manual';
|
|
294
|
+
u.updatedAt = Date.now();
|
|
295
|
+
trim(d.users);
|
|
296
|
+
scheduleSave();
|
|
297
|
+
return true;
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 群维度的人工标注。`push` 传 `undefined` 不动、`null` 重置为未知。
|
|
302
|
+
*
|
|
303
|
+
* @param {object} p
|
|
304
|
+
* @param {string} p.groupOpenid
|
|
305
|
+
* @param {1|0|null} [p.push]
|
|
306
|
+
*/
|
|
307
|
+
async noteGroupManual({ groupOpenid, push } = {}) {
|
|
308
|
+
const gid = String(groupOpenid ?? '').trim();
|
|
309
|
+
if (!gid) return false;
|
|
310
|
+
const d = await load();
|
|
311
|
+
const g = d.groups[gid] ?? { push: null, by: '', evidence: '', updatedAt: 0 };
|
|
312
|
+
if (push !== undefined) g.push = tri(push);
|
|
313
|
+
g.evidence = 'manual';
|
|
314
|
+
g.updatedAt = Date.now();
|
|
315
|
+
d.groups[gid] = g;
|
|
316
|
+
scheduleSave();
|
|
317
|
+
return true;
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 能不能给这个人发私聊。
|
|
322
|
+
*
|
|
323
|
+
* @returns {{verdict:'yes'|'no'|'unknown', reason?:string}}
|
|
324
|
+
*/
|
|
325
|
+
async canC2C(openid) {
|
|
326
|
+
const d = await load();
|
|
327
|
+
const u = d.users[String(openid ?? '').trim()];
|
|
328
|
+
if (!u) return { verdict: 'unknown' };
|
|
329
|
+
if (u.friend === 0) return { verdict: 'no', reason: 'reach:not-friend' };
|
|
330
|
+
if (u.c2cPush === 0) return { verdict: 'no', reason: 'reach:push-off' };
|
|
331
|
+
if (u.friend === 1) return { verdict: 'yes' };
|
|
332
|
+
return { verdict: 'unknown' };
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 能不能往这个群主动推。
|
|
337
|
+
*
|
|
338
|
+
* @returns {{verdict:'yes'|'no'|'unknown', reason?:string}}
|
|
339
|
+
*/
|
|
340
|
+
async canGroupPush(groupOpenid) {
|
|
341
|
+
const d = await load();
|
|
342
|
+
const g = d.groups[String(groupOpenid ?? '').trim()];
|
|
343
|
+
if (!g) return { verdict: 'unknown' };
|
|
344
|
+
if (g.push === 0) return { verdict: 'no', reason: 'reach:group-push-off' };
|
|
345
|
+
return { verdict: 'yes' };
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
/** 单个人档案,面板用 */
|
|
349
|
+
async get(openid) {
|
|
350
|
+
const d = await load();
|
|
351
|
+
return d.users[String(openid ?? '').trim()] ?? null;
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
/** 面板用:全量快照(只读副本) */
|
|
355
|
+
async snapshot() {
|
|
356
|
+
const d = await load();
|
|
357
|
+
return { users: { ...d.users }, groups: { ...d.groups } };
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
flush,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export { tri };
|
package/src/web/qq-call.js
CHANGED
|
@@ -29,7 +29,7 @@ const RISK_BY_API = (() => {
|
|
|
29
29
|
|
|
30
30
|
/** 官方错误码 → 人话。AI 拿到的是这句,不是一串数字 */
|
|
31
31
|
const ERR_HINTS = {
|
|
32
|
-
40034128: '
|
|
32
|
+
40034128: '被动回复超限(群聊 5 分钟 / 5 次,单聊 60 分钟 / 4 次)。等用户再说一句,或改用主动消息',
|
|
33
33
|
40034100: '被官方限流了,稍后再试',
|
|
34
34
|
40034008: '消息内容不合法(markdown 和 content 不能同时给)',
|
|
35
35
|
304023: '消息已进入审核(这不是失败,会正常发出)',
|
package/src/web/server.js
CHANGED
|
@@ -2021,6 +2021,103 @@ export async function startWebServer({
|
|
|
2021
2021
|
}
|
|
2022
2022
|
}
|
|
2023
2023
|
|
|
2024
|
+
/**
|
|
2025
|
+
* 可达性:谁能私聊、哪个群能主动推。
|
|
2026
|
+
*
|
|
2027
|
+
* 官方**没有查好友的接口**,这些状态全部来自 6 个事件
|
|
2028
|
+
* (FRIEND_ADD/DEL、C2C_MSG_REJECT/RECEIVE、GROUP_MSG_REJECT/RECEIVE)。
|
|
2029
|
+
* 接入之前就存在的关系是 `null`(未知)——**未知不等于不可达**,
|
|
2030
|
+
* 面板要把它显示成「未知」而不是「不能」。
|
|
2031
|
+
*/
|
|
2032
|
+
if (pathname === '/api/reach' && (req.method === 'GET' || req.method === 'POST')) {
|
|
2033
|
+
/**
|
|
2034
|
+
* 判定结论在服务端算,前端只负责显示。
|
|
2035
|
+
*
|
|
2036
|
+
* 判定顺序(和 `reach.canC2C` 必须一致,否则面板说的和实际发的会打架):
|
|
2037
|
+
* 无好友 → 关了推送 → 有好友 → 未知。
|
|
2038
|
+
* **`null` 是「未知」不是「不能」**,单独一个分支。
|
|
2039
|
+
*/
|
|
2040
|
+
const snapshot = async () => {
|
|
2041
|
+
const snap = (await rt?.reach?.snapshot?.()) ?? { users: {}, groups: {} };
|
|
2042
|
+
const people = rt?.listRecentPeople?.(200) ?? [];
|
|
2043
|
+
const nameOf = (id) => people.find((p) => p.openid === id)?.name || '';
|
|
2044
|
+
|
|
2045
|
+
const users = Object.entries(snap.users).map(([openid, u]) => ({
|
|
2046
|
+
openid,
|
|
2047
|
+
name: nameOf(openid),
|
|
2048
|
+
friend: u.friend ?? null,
|
|
2049
|
+
c2cPush: u.c2cPush ?? null,
|
|
2050
|
+
unionOpenid: u.unionOpenid || '',
|
|
2051
|
+
evidence: u.evidence || '',
|
|
2052
|
+
updatedAt: u.updatedAt || 0,
|
|
2053
|
+
lastFailAt: u.lastFailAt || 0,
|
|
2054
|
+
lastFailCode: u.lastFailCode || 0,
|
|
2055
|
+
lastOkAt: u.lastOkAt || 0,
|
|
2056
|
+
verdict:
|
|
2057
|
+
u.friend === 0
|
|
2058
|
+
? 'not-friend'
|
|
2059
|
+
: u.c2cPush === 0
|
|
2060
|
+
? 'push-off'
|
|
2061
|
+
: u.friend === 1
|
|
2062
|
+
? 'ok'
|
|
2063
|
+
: 'unknown',
|
|
2064
|
+
}));
|
|
2065
|
+
|
|
2066
|
+
const groups = Object.entries(snap.groups).map(([groupOpenid, g]) => ({
|
|
2067
|
+
groupOpenid,
|
|
2068
|
+
name: rt?.groupName?.(groupOpenid) || '',
|
|
2069
|
+
push: g.push ?? null,
|
|
2070
|
+
by: g.by || '',
|
|
2071
|
+
evidence: g.evidence || '',
|
|
2072
|
+
updatedAt: g.updatedAt || 0,
|
|
2073
|
+
verdict: g.push === 0 ? 'push-off' : g.push === 1 ? 'ok' : 'unknown',
|
|
2074
|
+
}));
|
|
2075
|
+
|
|
2076
|
+
return { users, groups };
|
|
2077
|
+
};
|
|
2078
|
+
|
|
2079
|
+
if (req.method === 'GET') return json(res, 200, await snapshot());
|
|
2080
|
+
|
|
2081
|
+
/**
|
|
2082
|
+
* 人工标注。
|
|
2083
|
+
*
|
|
2084
|
+
* 好友事件是增量的,接入之前的老关系永远是 `unknown`,只能人工纠正。
|
|
2085
|
+
* `friend` / `c2cPush` / `push` 三个字段的约定:
|
|
2086
|
+
* - 不传 → 这项不动
|
|
2087
|
+
* - `1`/`0` → 明确设定
|
|
2088
|
+
* - `null` → 重置为未知(**撤销标注用这个**)
|
|
2089
|
+
*/
|
|
2090
|
+
if (!rt?.reach?.noteManual) {
|
|
2091
|
+
return json(res, 503, { error: '机器人没在运行,改不了可达性状态' });
|
|
2092
|
+
}
|
|
2093
|
+
const body = await readBody(req);
|
|
2094
|
+
const triOf = (v) => (v === 1 || v === true ? 1 : v === 0 || v === false ? 0 : v === null ? null : undefined);
|
|
2095
|
+
|
|
2096
|
+
const gid = String(body?.groupOpenid ?? '').trim();
|
|
2097
|
+
if (gid) {
|
|
2098
|
+
const push = triOf(body?.push);
|
|
2099
|
+
if (push === undefined && body?.push !== undefined) {
|
|
2100
|
+
return json(res, 400, { error: 'push 只能是 1、0 或 null' });
|
|
2101
|
+
}
|
|
2102
|
+
await rt.reach.noteGroupManual({ groupOpenid: gid, push });
|
|
2103
|
+
return json(res, 200, { ok: true, ...(await snapshot()) });
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
const openid = String(body?.openid ?? '').trim();
|
|
2107
|
+
if (!openid) return json(res, 400, { error: 'openid 或 groupOpenid 不能为空' });
|
|
2108
|
+
|
|
2109
|
+
const friend = triOf(body?.friend);
|
|
2110
|
+
const c2cPush = triOf(body?.c2cPush);
|
|
2111
|
+
if (friend === undefined && body?.friend !== undefined) {
|
|
2112
|
+
return json(res, 400, { error: 'friend 只能是 1、0 或 null' });
|
|
2113
|
+
}
|
|
2114
|
+
if (c2cPush === undefined && body?.c2cPush !== undefined) {
|
|
2115
|
+
return json(res, 400, { error: 'c2cPush 只能是 1、0 或 null' });
|
|
2116
|
+
}
|
|
2117
|
+
await rt.reach.noteManual({ openid, friend, c2cPush });
|
|
2118
|
+
return json(res, 200, { ok: true, ...(await snapshot()) });
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2024
2121
|
if (pathname === '/api/groups' && req.method === 'GET') {
|
|
2025
2122
|
const list = await groups.list();
|
|
2026
2123
|
/**
|
package/web/app.js
CHANGED
|
@@ -232,6 +232,7 @@ async function selectBot(id) {
|
|
|
232
232
|
if (tab === 'config') loadConfig();
|
|
233
233
|
if (tab === 'plugins') loadPlugins();
|
|
234
234
|
if (tab === 'events') loadEvents();
|
|
235
|
+
if (tab === 'reach') loadReach();
|
|
235
236
|
if (tab === 'groups') loadGroups();
|
|
236
237
|
if (tab === 'roles') { loadRoles(); loadPeople(); }
|
|
237
238
|
if (tab === 'stats') loadStats();
|
|
@@ -597,6 +598,7 @@ function activateTab(name) {
|
|
|
597
598
|
}
|
|
598
599
|
if (name === 'config') loadConfig();
|
|
599
600
|
if (name === 'groups') loadGroups();
|
|
601
|
+
if (name === 'reach') loadReach();
|
|
600
602
|
if (name === 'roles') { loadRoles(); loadPeople(); }
|
|
601
603
|
}
|
|
602
604
|
|
|
@@ -2911,6 +2913,175 @@ function renderGroups(list) {
|
|
|
2911
2913
|
});
|
|
2912
2914
|
}
|
|
2913
2915
|
|
|
2916
|
+
/* ---------------- 可达性 ---------------- */
|
|
2917
|
+
|
|
2918
|
+
/**
|
|
2919
|
+
* 能不能主动找到人。
|
|
2920
|
+
*
|
|
2921
|
+
* 这块数据的特殊之处:**三个值不是两个**。`1` 可以、`0` 不可以、`null` 未知。
|
|
2922
|
+
*
|
|
2923
|
+
* 面板上必须把「未知」和「不可以」显示得完全不一样。如果两者都是灰色、
|
|
2924
|
+
* 或者都写「否」,用户会以为这些人真的发不出去,然后跑去手动放开——
|
|
2925
|
+
* 而实际情况只是「机器人上线之前就加的好友,事件补不回来」。
|
|
2926
|
+
*/
|
|
2927
|
+
|
|
2928
|
+
/** 依据的英文 key → 中文。事件里带参数(`friend_add:0`),所以按冒号前匹配 */
|
|
2929
|
+
const REACH_EVIDENCE = {
|
|
2930
|
+
friend_add: '加好友事件',
|
|
2931
|
+
friend_del: '删好友事件',
|
|
2932
|
+
c2c_msg_reject: '用户关了推送',
|
|
2933
|
+
c2c_msg_receive: '用户开了推送',
|
|
2934
|
+
observed_c2c: '收到过私聊',
|
|
2935
|
+
group_msg_reject: '群管理员关了推送',
|
|
2936
|
+
group_msg_receive: '群管理员开了推送',
|
|
2937
|
+
'send:no-friend': '发送失败·无好友',
|
|
2938
|
+
'send:rejected': '发送失败·被拒收',
|
|
2939
|
+
manual: '人工标注',
|
|
2940
|
+
};
|
|
2941
|
+
|
|
2942
|
+
function evidenceText(ev) {
|
|
2943
|
+
const raw = String(ev || '').trim();
|
|
2944
|
+
if (!raw) return '-';
|
|
2945
|
+
return REACH_EVIDENCE[raw.split(':')[0]] ?? raw;
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
/** 一句话结论。和后端 `reach.canC2C` 的判定顺序必须一致 */
|
|
2949
|
+
const REACH_VERDICT = {
|
|
2950
|
+
ok: { text: '能发', cls: 'on' },
|
|
2951
|
+
'not-friend': { text: '不能:没加好友', cls: 'off' },
|
|
2952
|
+
'push-off': { text: '不能:关了推送', cls: 'off' },
|
|
2953
|
+
unknown: { text: '未知,照常尝试', cls: '' },
|
|
2954
|
+
};
|
|
2955
|
+
|
|
2956
|
+
/**
|
|
2957
|
+
* 行内三态下拉。
|
|
2958
|
+
*
|
|
2959
|
+
* 用下拉而不是「标为好友 / 标为陌生人」两个按钮:后者没法表达「撤销标注」,
|
|
2960
|
+
* 而且改错了只能改到另一个错的值上。`value=""` 就是回到未知。
|
|
2961
|
+
*/
|
|
2962
|
+
function triSelect(field, v, yes = '是', no = '否') {
|
|
2963
|
+
const sel = (val, text) =>
|
|
2964
|
+
`<option value="${val}"${String(v ?? '') === val ? ' selected' : ''}>${text}</option>`;
|
|
2965
|
+
return `<select class="mini-select" data-field="${field}" title="人工标注">${
|
|
2966
|
+
sel('', '未知') + sel('1', yes) + sel('0', no)
|
|
2967
|
+
}</select>`;
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
async function loadReach() {
|
|
2971
|
+
const ub = $('#reachUserBody');
|
|
2972
|
+
const gb = $('#reachGroupBody');
|
|
2973
|
+
if (!ub || !gb) return;
|
|
2974
|
+
try {
|
|
2975
|
+
renderReach(await api('/api/reach'));
|
|
2976
|
+
} catch (e) {
|
|
2977
|
+
ub.innerHTML = `<tr><td colspan="6" class="empty">加载失败: ${escapeHtml(e.message)}</td></tr>`;
|
|
2978
|
+
gb.innerHTML = `<tr><td colspan="5" class="empty">加载失败: ${escapeHtml(e.message)}</td></tr>`;
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
function renderReach(d) {
|
|
2983
|
+
const ub = $('#reachUserBody');
|
|
2984
|
+
const gb = $('#reachGroupBody');
|
|
2985
|
+
if (!ub || !gb) return;
|
|
2986
|
+
|
|
2987
|
+
const users = d?.users ?? [];
|
|
2988
|
+
const groups = d?.groups ?? [];
|
|
2989
|
+
|
|
2990
|
+
ub.innerHTML = users.length
|
|
2991
|
+
? users
|
|
2992
|
+
.map((u) => {
|
|
2993
|
+
const v = REACH_VERDICT[u.verdict] ?? REACH_VERDICT.unknown;
|
|
2994
|
+
const extra = u.lastFailCode ? ` · 失败码 ${u.lastFailCode}` : '';
|
|
2995
|
+
return `<tr data-openid="${escapeHtml(u.openid)}">
|
|
2996
|
+
<td title="${escapeHtml(u.openid)}">${labelWithId(u.name, u.openid)}</td>
|
|
2997
|
+
<td>${triSelect('friend', u.friend)}</td>
|
|
2998
|
+
<td>${triSelect('c2cPush', u.c2cPush)}</td>
|
|
2999
|
+
<td><span class="badge ${v.cls}">${v.text}</span></td>
|
|
3000
|
+
<td title="${escapeHtml(u.evidence || '')}">${escapeHtml(evidenceText(u.evidence))}${escapeHtml(extra)}</td>
|
|
3001
|
+
<td>${u.updatedAt ? fmtAgo(u.updatedAt) : '-'}</td>
|
|
3002
|
+
</tr>`;
|
|
3003
|
+
})
|
|
3004
|
+
.join('')
|
|
3005
|
+
: '<tr><td colspan="6" class="empty">还没有记录。收到私聊消息、或被加好友之后才会出现。</td></tr>';
|
|
3006
|
+
|
|
3007
|
+
gb.innerHTML = groups.length
|
|
3008
|
+
? groups
|
|
3009
|
+
.map(
|
|
3010
|
+
(g) => `<tr data-group="${escapeHtml(g.groupOpenid)}">
|
|
3011
|
+
<td title="${escapeHtml(g.groupOpenid)}">${labelWithId(g.name, g.groupOpenid)}</td>
|
|
3012
|
+
<td>${triSelect('push', g.push, '开', '关')}</td>
|
|
3013
|
+
<td title="${escapeHtml(g.by || '')}">${g.by ? labelWithId('', g.by) : '-'}</td>
|
|
3014
|
+
<td title="${escapeHtml(g.evidence || '')}">${escapeHtml(evidenceText(g.evidence))}</td>
|
|
3015
|
+
<td>${g.updatedAt ? fmtAgo(g.updatedAt) : '-'}</td>
|
|
3016
|
+
</tr>`,
|
|
3017
|
+
)
|
|
3018
|
+
.join('')
|
|
3019
|
+
: '<tr><td colspan="5" class="empty">还没有记录。群管理员改过推送开关之后才会出现。</td></tr>';
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
/**
|
|
3023
|
+
* 行内标注:改下拉即提交。
|
|
3024
|
+
*
|
|
3025
|
+
* 委托绑在 tbody 上,因为 `renderReach` 每次都整个重建 innerHTML,
|
|
3026
|
+
* 逐行 `addEventListener` 会随着重绘丢掉。
|
|
3027
|
+
*/
|
|
3028
|
+
async function saveReachPatch(sel, body) {
|
|
3029
|
+
sel.disabled = true;
|
|
3030
|
+
try {
|
|
3031
|
+
renderReach(await api('/api/reach', { method: 'POST', body: JSON.stringify(body) }));
|
|
3032
|
+
} catch (e) {
|
|
3033
|
+
alert('标注失败: ' + e.message);
|
|
3034
|
+
sel.disabled = false;
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
$('#reachUserBody')?.addEventListener('change', (e) => {
|
|
3039
|
+
const sel = e.target.closest('select[data-field]');
|
|
3040
|
+
if (!sel) return;
|
|
3041
|
+
const openid = sel.closest('tr')?.dataset.openid;
|
|
3042
|
+
if (!openid) return;
|
|
3043
|
+
// 空字符串 = 撤销标注,要显式传 null,不能传 ''(后端会当成非法值)
|
|
3044
|
+
saveReachPatch(sel, { openid, [sel.dataset.field]: sel.value === '' ? null : Number(sel.value) });
|
|
3045
|
+
});
|
|
3046
|
+
|
|
3047
|
+
$('#reachGroupBody')?.addEventListener('change', (e) => {
|
|
3048
|
+
const sel = e.target.closest('select[data-field]');
|
|
3049
|
+
if (!sel) return;
|
|
3050
|
+
const groupOpenid = sel.closest('tr')?.dataset.group;
|
|
3051
|
+
if (!groupOpenid) return;
|
|
3052
|
+
saveReachPatch(sel, {
|
|
3053
|
+
groupOpenid,
|
|
3054
|
+
push: sel.value === '' ? null : Number(sel.value),
|
|
3055
|
+
});
|
|
3056
|
+
});
|
|
3057
|
+
|
|
3058
|
+
$('#btnReloadReach')?.addEventListener('click', loadReach);
|
|
3059
|
+
|
|
3060
|
+
/** 列表里没有的人(比如老好友、从没私聊过)手动登记一条 */
|
|
3061
|
+
$('#btnReachSave')?.addEventListener('click', async () => {
|
|
3062
|
+
const openid = $('#reachOpenidInput')?.value.trim() ?? '';
|
|
3063
|
+
if (!openid) return alert('先填 openid');
|
|
3064
|
+
const friend = $('#reachFriendSel')?.value ?? '';
|
|
3065
|
+
const c2cPush = $('#reachPushSel')?.value ?? '';
|
|
3066
|
+
if (friend === '' && c2cPush === '') return alert('好友和推送至少选一个,不然登记了也是未知');
|
|
3067
|
+
|
|
3068
|
+
const btn = $('#btnReachSave');
|
|
3069
|
+
btn.disabled = true;
|
|
3070
|
+
try {
|
|
3071
|
+
const body = { openid };
|
|
3072
|
+
if (friend !== '') body.friend = Number(friend);
|
|
3073
|
+
if (c2cPush !== '') body.c2cPush = Number(c2cPush);
|
|
3074
|
+
renderReach(await api('/api/reach', { method: 'POST', body: JSON.stringify(body) }));
|
|
3075
|
+
$('#reachOpenidInput').value = '';
|
|
3076
|
+
$('#reachFriendSel').value = '';
|
|
3077
|
+
$('#reachPushSel').value = '';
|
|
3078
|
+
} catch (e) {
|
|
3079
|
+
alert('登记失败: ' + e.message);
|
|
3080
|
+
} finally {
|
|
3081
|
+
btn.disabled = false;
|
|
3082
|
+
}
|
|
3083
|
+
});
|
|
3084
|
+
|
|
2914
3085
|
/**
|
|
2915
3086
|
* 分群插件开关。
|
|
2916
3087
|
*
|
package/web/index.html
CHANGED
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
<div class="tab-menu" hidden>
|
|
57
57
|
<button class="tab-item" data-tab="stats">统计</button>
|
|
58
58
|
<button class="tab-item" data-tab="events">事件</button>
|
|
59
|
+
<button class="tab-item" data-tab="reach">可达性</button>
|
|
59
60
|
<button class="tab-item" data-tab="logs">日志</button>
|
|
60
61
|
</div>
|
|
61
62
|
</div>
|
|
@@ -494,6 +495,93 @@
|
|
|
494
495
|
</div>
|
|
495
496
|
</section>
|
|
496
497
|
|
|
498
|
+
<!-- 可达性 -->
|
|
499
|
+
<section id="tab-reach" class="panel">
|
|
500
|
+
<div class="box">
|
|
501
|
+
<div class="box-hd">
|
|
502
|
+
<h3>能不能主动找到人</h3>
|
|
503
|
+
<span class="hint">两道门:先得是好友,还得开着主动推送</span>
|
|
504
|
+
</div>
|
|
505
|
+
<ul class="tips">
|
|
506
|
+
<li><b>好友</b>是用户把机器人加为好友;<b>主动推送</b>是用户在机器人资料卡里单独开的开关——
|
|
507
|
+
<b>有好友也可能被关掉</b>,关了就发不出去</li>
|
|
508
|
+
<li>官方<b>没有查询好友的接口</b>,这里的值全部来自事件推送。机器人启动之前就存在的老关系拿不到事件,
|
|
509
|
+
会一直是「未知」</li>
|
|
510
|
+
<li><b>未知不等于不可达</b>——未知会照常尝试发送,失败了才记下来。把未知当成「不能」,
|
|
511
|
+
所有老用户就都被静音了</li>
|
|
512
|
+
</ul>
|
|
513
|
+
</div>
|
|
514
|
+
|
|
515
|
+
<div class="box">
|
|
516
|
+
<div class="box-hd">
|
|
517
|
+
<h3>单聊</h3>
|
|
518
|
+
<div class="btn-row" style="margin:0">
|
|
519
|
+
<button class="btn small" id="btnReloadReach">刷新</button>
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
<div class="table-wrap">
|
|
523
|
+
<table>
|
|
524
|
+
<thead>
|
|
525
|
+
<tr>
|
|
526
|
+
<th>用户</th>
|
|
527
|
+
<th>好友</th>
|
|
528
|
+
<th>主动推送</th>
|
|
529
|
+
<th>结论</th>
|
|
530
|
+
<th>依据</th>
|
|
531
|
+
<th>更新</th>
|
|
532
|
+
</tr>
|
|
533
|
+
</thead>
|
|
534
|
+
<tbody id="reachUserBody">
|
|
535
|
+
<tr><td colspan="6" class="empty">还没有记录。收到私聊消息、或被加好友之后才会出现。</td></tr>
|
|
536
|
+
</tbody>
|
|
537
|
+
</table>
|
|
538
|
+
</div>
|
|
539
|
+
<div class="hint" style="margin-top:8px">
|
|
540
|
+
每行的两个下拉框就是<b>人工标注</b>,改完立刻生效并覆盖事件推断的值;选「未知」等于撤销标注。
|
|
541
|
+
</div>
|
|
542
|
+
<div class="form-inline" style="margin-top:12px">
|
|
543
|
+
<input id="reachOpenidInput" placeholder="openid(列表里没有的人可以直接登记)">
|
|
544
|
+
<select id="reachFriendSel" class="mini-select">
|
|
545
|
+
<option value="">好友:未知</option>
|
|
546
|
+
<option value="1">好友:是</option>
|
|
547
|
+
<option value="0">好友:否</option>
|
|
548
|
+
</select>
|
|
549
|
+
<select id="reachPushSel" class="mini-select">
|
|
550
|
+
<option value="">推送:未知</option>
|
|
551
|
+
<option value="1">推送:开</option>
|
|
552
|
+
<option value="0">推送:关</option>
|
|
553
|
+
</select>
|
|
554
|
+
<button class="btn primary" id="btnReachSave">登记</button>
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
|
|
558
|
+
<div class="box">
|
|
559
|
+
<div class="box-hd">
|
|
560
|
+
<h3>群主动推送</h3>
|
|
561
|
+
<span class="hint">群管理员可以整个群关掉机器人的主动消息</span>
|
|
562
|
+
</div>
|
|
563
|
+
<div class="table-wrap">
|
|
564
|
+
<table>
|
|
565
|
+
<thead>
|
|
566
|
+
<tr>
|
|
567
|
+
<th>群</th>
|
|
568
|
+
<th>主动推送</th>
|
|
569
|
+
<th>操作人</th>
|
|
570
|
+
<th>依据</th>
|
|
571
|
+
<th>更新</th>
|
|
572
|
+
</tr>
|
|
573
|
+
</thead>
|
|
574
|
+
<tbody id="reachGroupBody">
|
|
575
|
+
<tr><td colspan="5" class="empty">还没有记录。群管理员改过推送开关之后才会出现。</td></tr>
|
|
576
|
+
</tbody>
|
|
577
|
+
</table>
|
|
578
|
+
</div>
|
|
579
|
+
<div class="hint" style="margin-top:8px">
|
|
580
|
+
没记录过的群按「未知」处理,照常尝试推送。
|
|
581
|
+
</div>
|
|
582
|
+
</div>
|
|
583
|
+
</section>
|
|
584
|
+
|
|
497
585
|
<!-- 群组 -->
|
|
498
586
|
<section id="tab-groups" class="panel">
|
|
499
587
|
<div class="box">
|