qq-bot-roost 0.8.13 → 0.9.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 -5
- package/package.json +1 -1
- package/src/api.js +18 -5
- package/src/bot.js +231 -1
- package/src/config.js +35 -0
- package/src/gate.js +325 -0
- package/src/ledger.js +513 -0
- package/src/main.js +1389 -1364
- package/src/message-bus.js +1 -1
- package/src/message.js +3 -3
- package/src/outbound.js +65 -15
- package/src/paths.js +20 -0
- package/src/pipeline.js +10 -1
- package/src/plugin/context.js +890 -817
- package/src/plugin/loader.js +938 -929
- package/src/reach.js +364 -0
- package/src/runtime.js +8 -1
- package/src/storage/events-schema.js +71 -0
- package/src/web/qq-call.js +1 -1
- package/src/web/server.js +263 -1
- package/web/app.js +244 -0
- package/web/index.html +107 -0
package/src/web/server.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from '../updater.js';
|
|
25
25
|
// 面板改插件启停时要同步机器人配置里的过滤器,两边不能各说各的
|
|
26
26
|
import { pluginFilterOf, pluginAllowedIn } from '../bot.js';
|
|
27
|
+
import { userFacingSendError } from '../outbound.js';
|
|
27
28
|
import { runRoute } from '../plugin/routes.js';
|
|
28
29
|
import { toggleGroupPlugin } from '../plugin/groupscope.js';
|
|
29
30
|
import { suspendPluginWatch } from '../services/plugin-watch.js';
|
|
@@ -561,6 +562,13 @@ export async function startWebServer({
|
|
|
561
562
|
* 页面令牌和会话都已作废,需要立刻重载插件页而不是等下轮轮询。
|
|
562
563
|
*/
|
|
563
564
|
snap.instanceId = `${process.pid}-${PROCESS_STARTED_AT}`;
|
|
565
|
+
/**
|
|
566
|
+
* 闸状态。
|
|
567
|
+
*
|
|
568
|
+
* 放进每 2 秒推一次的 status 里,是为了面板能**立刻**画出「已暂停」横幅——
|
|
569
|
+
* 暂停是个紧急操作,等用户点进某个页签才发现「原来还暂停着」就太晚了。
|
|
570
|
+
*/
|
|
571
|
+
snap.gate = rt?.gate?.peek?.() ?? null;
|
|
564
572
|
return snap;
|
|
565
573
|
};
|
|
566
574
|
|
|
@@ -2021,6 +2029,138 @@ export async function startWebServer({
|
|
|
2021
2029
|
}
|
|
2022
2030
|
}
|
|
2023
2031
|
|
|
2032
|
+
/**
|
|
2033
|
+
* 可达性:谁能私聊、哪个群能主动推。
|
|
2034
|
+
*
|
|
2035
|
+
* 官方**没有查好友的接口**,这些状态全部来自 6 个事件
|
|
2036
|
+
* (FRIEND_ADD/DEL、C2C_MSG_REJECT/RECEIVE、GROUP_MSG_REJECT/RECEIVE)。
|
|
2037
|
+
* 接入之前就存在的关系是 `null`(未知)——**未知不等于不可达**,
|
|
2038
|
+
* 面板要把它显示成「未知」而不是「不能」。
|
|
2039
|
+
*/
|
|
2040
|
+
if (pathname === '/api/reach' && (req.method === 'GET' || req.method === 'POST')) {
|
|
2041
|
+
/**
|
|
2042
|
+
* 判定结论在服务端算,前端只负责显示。
|
|
2043
|
+
*
|
|
2044
|
+
* 判定顺序(和 `reach.canC2C` 必须一致,否则面板说的和实际发的会打架):
|
|
2045
|
+
* 无好友 → 关了推送 → 有好友 → 未知。
|
|
2046
|
+
* **`null` 是「未知」不是「不能」**,单独一个分支。
|
|
2047
|
+
*/
|
|
2048
|
+
const snapshot = async () => {
|
|
2049
|
+
const snap = (await rt?.reach?.snapshot?.()) ?? { users: {}, groups: {} };
|
|
2050
|
+
const people = rt?.listRecentPeople?.(200) ?? [];
|
|
2051
|
+
const nameOf = (id) => people.find((p) => p.openid === id)?.name || '';
|
|
2052
|
+
|
|
2053
|
+
const users = Object.entries(snap.users).map(([openid, u]) => ({
|
|
2054
|
+
openid,
|
|
2055
|
+
name: nameOf(openid),
|
|
2056
|
+
friend: u.friend ?? null,
|
|
2057
|
+
c2cPush: u.c2cPush ?? null,
|
|
2058
|
+
unionOpenid: u.unionOpenid || '',
|
|
2059
|
+
evidence: u.evidence || '',
|
|
2060
|
+
updatedAt: u.updatedAt || 0,
|
|
2061
|
+
lastFailAt: u.lastFailAt || 0,
|
|
2062
|
+
lastFailCode: u.lastFailCode || 0,
|
|
2063
|
+
lastOkAt: u.lastOkAt || 0,
|
|
2064
|
+
verdict:
|
|
2065
|
+
u.friend === 0
|
|
2066
|
+
? 'not-friend'
|
|
2067
|
+
: u.c2cPush === 0
|
|
2068
|
+
? 'push-off'
|
|
2069
|
+
: u.friend === 1
|
|
2070
|
+
? 'ok'
|
|
2071
|
+
: 'unknown',
|
|
2072
|
+
}));
|
|
2073
|
+
|
|
2074
|
+
const groups = Object.entries(snap.groups).map(([groupOpenid, g]) => ({
|
|
2075
|
+
groupOpenid,
|
|
2076
|
+
name: rt?.groupName?.(groupOpenid) || '',
|
|
2077
|
+
push: g.push ?? null,
|
|
2078
|
+
by: g.by || '',
|
|
2079
|
+
evidence: g.evidence || '',
|
|
2080
|
+
updatedAt: g.updatedAt || 0,
|
|
2081
|
+
verdict: g.push === 0 ? 'push-off' : g.push === 1 ? 'ok' : 'unknown',
|
|
2082
|
+
}));
|
|
2083
|
+
|
|
2084
|
+
return { users, groups };
|
|
2085
|
+
};
|
|
2086
|
+
|
|
2087
|
+
if (req.method === 'GET') return json(res, 200, await snapshot());
|
|
2088
|
+
|
|
2089
|
+
/**
|
|
2090
|
+
* 人工标注。
|
|
2091
|
+
*
|
|
2092
|
+
* 好友事件是增量的,接入之前的老关系永远是 `unknown`,只能人工纠正。
|
|
2093
|
+
* `friend` / `c2cPush` / `push` 三个字段的约定:
|
|
2094
|
+
* - 不传 → 这项不动
|
|
2095
|
+
* - `1`/`0` → 明确设定
|
|
2096
|
+
* - `null` → 重置为未知(**撤销标注用这个**)
|
|
2097
|
+
*/
|
|
2098
|
+
if (!rt?.reach?.noteManual) {
|
|
2099
|
+
return json(res, 503, { error: '机器人没在运行,改不了可达性状态' });
|
|
2100
|
+
}
|
|
2101
|
+
const body = await readBody(req);
|
|
2102
|
+
const triOf = (v) => (v === 1 || v === true ? 1 : v === 0 || v === false ? 0 : v === null ? null : undefined);
|
|
2103
|
+
|
|
2104
|
+
const gid = String(body?.groupOpenid ?? '').trim();
|
|
2105
|
+
if (gid) {
|
|
2106
|
+
const push = triOf(body?.push);
|
|
2107
|
+
if (push === undefined && body?.push !== undefined) {
|
|
2108
|
+
return json(res, 400, { error: 'push 只能是 1、0 或 null' });
|
|
2109
|
+
}
|
|
2110
|
+
await rt.reach.noteGroupManual({ groupOpenid: gid, push });
|
|
2111
|
+
return json(res, 200, { ok: true, ...(await snapshot()) });
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
const openid = String(body?.openid ?? '').trim();
|
|
2115
|
+
if (!openid) return json(res, 400, { error: 'openid 或 groupOpenid 不能为空' });
|
|
2116
|
+
|
|
2117
|
+
const friend = triOf(body?.friend);
|
|
2118
|
+
const c2cPush = triOf(body?.c2cPush);
|
|
2119
|
+
if (friend === undefined && body?.friend !== undefined) {
|
|
2120
|
+
return json(res, 400, { error: 'friend 只能是 1、0 或 null' });
|
|
2121
|
+
}
|
|
2122
|
+
if (c2cPush === undefined && body?.c2cPush !== undefined) {
|
|
2123
|
+
return json(res, 400, { error: 'c2cPush 只能是 1、0 或 null' });
|
|
2124
|
+
}
|
|
2125
|
+
await rt.reach.noteManual({ openid, friend, c2cPush });
|
|
2126
|
+
return json(res, 200, { ok: true, ...(await snapshot()) });
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/**
|
|
2130
|
+
* 事件账本:官方没有历史消息接口,这是唯一能翻到「刚才说了什么」的地方。
|
|
2131
|
+
*
|
|
2132
|
+
* 三条路由:
|
|
2133
|
+
* GET /api/ledger/stats 总量、时间范围、保留天数
|
|
2134
|
+
* GET /api/ledger?chatId=&limit= 某会话最近 N 条(正序)
|
|
2135
|
+
* GET /api/ledger/context?chatId=&seq= 以某条为锚点的前后邻文
|
|
2136
|
+
*
|
|
2137
|
+
* `payload`(原始报文)**一律不返回**:里面虽然有脱敏过的凭证,
|
|
2138
|
+
* 但整包给前端没有意义(前端只要正文和元信息),只会把面板变成数据外泄面。
|
|
2139
|
+
*/
|
|
2140
|
+
if (pathname === '/api/ledger/stats' && req.method === 'GET') {
|
|
2141
|
+
const st = rt?.ledger?.stats?.() ?? { available: false, total: 0 };
|
|
2142
|
+
return json(res, 200, st);
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
if (pathname === '/api/ledger/context' && req.method === 'GET') {
|
|
2146
|
+
const chatId = String(url.searchParams.get('chatId') ?? '').trim();
|
|
2147
|
+
const seq = Number(url.searchParams.get('seq') ?? 0);
|
|
2148
|
+
if (!chatId || !seq) return json(res, 400, { error: '需要 chatId 和 seq' });
|
|
2149
|
+
const before = Number(url.searchParams.get('before') ?? 2);
|
|
2150
|
+
const after = Number(url.searchParams.get('after') ?? 2);
|
|
2151
|
+
const list = rt?.ledger?.timeline?.({ chatId, anchorSeq: seq, before, after }) ?? [];
|
|
2152
|
+
return json(res, 200, { items: list, empty: list.length === 0 });
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
if (pathname === '/api/ledger' && req.method === 'GET') {
|
|
2156
|
+
const chatId = String(url.searchParams.get('chatId') ?? '').trim();
|
|
2157
|
+
if (!chatId) return json(res, 400, { error: '需要 chatId' });
|
|
2158
|
+
const limit = Number(url.searchParams.get('limit') ?? 50);
|
|
2159
|
+
const beforeSeq = Number(url.searchParams.get('beforeSeq') ?? 0) || undefined;
|
|
2160
|
+
const items = rt?.ledger?.recent?.({ chatId, limit, beforeSeq }) ?? [];
|
|
2161
|
+
return json(res, 200, { items });
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2024
2164
|
if (pathname === '/api/groups' && req.method === 'GET') {
|
|
2025
2165
|
const list = await groups.list();
|
|
2026
2166
|
/**
|
|
@@ -2204,6 +2344,95 @@ export async function startWebServer({
|
|
|
2204
2344
|
return json(res, 200, { ok: true, level: getLevel() });
|
|
2205
2345
|
}
|
|
2206
2346
|
|
|
2347
|
+
/* ---------------- 暂停闸 ---------------- */
|
|
2348
|
+
|
|
2349
|
+
/**
|
|
2350
|
+
* 闸的读与写。
|
|
2351
|
+
*
|
|
2352
|
+
* `GET /api/gate?chatId=` 一次给全:全局闸、每只鸟的闸、每个会话的闸,
|
|
2353
|
+
* 以及(给了 chatId 时)这只鸟现在能不能对它说话、为什么不能。
|
|
2354
|
+
*
|
|
2355
|
+
* 一次给全是有意的:面板要同时显示「全局暂停着」和「这只鸟也暂停着」,
|
|
2356
|
+
* 分两个接口查就会出现「一个说能、一个说不能」的错位。
|
|
2357
|
+
*/
|
|
2358
|
+
if (pathname === '/api/gate' && req.method === 'GET') {
|
|
2359
|
+
const chatId = url.searchParams.get('chatId') || '';
|
|
2360
|
+
/**
|
|
2361
|
+
* `kind` 决定要不要查可达性:私聊查好友关系 + 主动推送开关,
|
|
2362
|
+
* 群聊查群管理员的推送开关。**不传就不查**——不知道是群还是私聊
|
|
2363
|
+
* 就查不了,而「不知道」不该变成「不能」。
|
|
2364
|
+
*/
|
|
2365
|
+
const kind = url.searchParams.get('kind') || '';
|
|
2366
|
+
const bots = [];
|
|
2367
|
+
for (const r of runtimes) {
|
|
2368
|
+
const snap = await r.gate?.snapshot?.().catch(() => null);
|
|
2369
|
+
bots.push({
|
|
2370
|
+
id: r.id,
|
|
2371
|
+
name: r.name,
|
|
2372
|
+
state: r.state,
|
|
2373
|
+
paused: Boolean(snap?.bot?.paused),
|
|
2374
|
+
pausedAt: snap?.bot?.at ?? 0,
|
|
2375
|
+
pausedBy: snap?.bot?.by ?? '',
|
|
2376
|
+
reason: snap?.bot?.reason ?? '',
|
|
2377
|
+
chats: snap?.chats ?? [],
|
|
2378
|
+
silence: snap?.silence ?? [],
|
|
2379
|
+
canSpeak: chatId ? await r.canSpeak(chatId, { kind }).catch(() => null) : null,
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
const first = await rt.gate?.snapshot?.().catch(() => null);
|
|
2383
|
+
return json(res, 200, {
|
|
2384
|
+
ok: true,
|
|
2385
|
+
global: first?.global ?? { paused: false, at: 0, by: '', reason: '' },
|
|
2386
|
+
bots,
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
if (pathname === '/api/gate' && req.method === 'POST') {
|
|
2391
|
+
const body = await readBody(req);
|
|
2392
|
+
const scope = String(body.scope ?? 'bot');
|
|
2393
|
+
const paused = Boolean(body.paused);
|
|
2394
|
+
const reason = String(body.reason ?? '').slice(0, 200);
|
|
2395
|
+
const meta = { by: 'panel', reason };
|
|
2396
|
+
|
|
2397
|
+
if (scope === 'global') {
|
|
2398
|
+
if (!rt.gate) return json(res, 501, { error: '当前环境不支持暂停闸' });
|
|
2399
|
+
await rt.gate.setGlobal(paused, meta);
|
|
2400
|
+
/**
|
|
2401
|
+
* 每只鸟各有一个 gate 实例、各缓存一份全局闸。写完必须让
|
|
2402
|
+
* 其他实例重读——否则「全局暂停」要等重启才对其他鸟生效。
|
|
2403
|
+
*/
|
|
2404
|
+
for (const r of runtimes) {
|
|
2405
|
+
if (r !== rt) await r.gate?.reloadGlobal?.().catch(() => {});
|
|
2406
|
+
}
|
|
2407
|
+
return json(res, 200, { ok: true, scope, paused });
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
const target = body.botId ? runtimes.find((r) => r.id === body.botId) : rt;
|
|
2411
|
+
if (!target) return json(res, 404, { error: `没有这只鸟:${body.botId}` });
|
|
2412
|
+
if (!target.gate) return json(res, 501, { error: '当前环境不支持暂停闸' });
|
|
2413
|
+
|
|
2414
|
+
if (scope === 'bot') {
|
|
2415
|
+
await target.gate.setBot(paused, meta);
|
|
2416
|
+
return json(res, 200, { ok: true, scope, botId: target.id, paused });
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
const chatId = String(body.chatId ?? '');
|
|
2420
|
+
if (!chatId) return json(res, 400, { error: `scope=${scope} 需要 chatId` });
|
|
2421
|
+
|
|
2422
|
+
if (scope === 'chat') {
|
|
2423
|
+
await target.gate.setChat(chatId, paused, meta);
|
|
2424
|
+
return json(res, 200, { ok: true, scope, botId: target.id, chatId, paused });
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
if (scope === 'silence') {
|
|
2428
|
+
const minutes = Math.max(0, Number(body.minutes) || 0);
|
|
2429
|
+
const r2 = await target.gate.setSilence(chatId, minutes * 60_000, meta);
|
|
2430
|
+
return json(res, 200, { ok: true, scope, botId: target.id, chatId, until: r2.until });
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
return json(res, 400, { error: `不认识的 scope:${scope}(global / bot / chat / silence)` });
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2207
2436
|
// 网关操作
|
|
2208
2437
|
if (pathname === '/api/gateway/reconnect' && req.method === 'POST') {
|
|
2209
2438
|
// 已经下线时没有「重连」可言,直接当上线处理,免得点了没反应
|
|
@@ -2223,12 +2452,45 @@ export async function startWebServer({
|
|
|
2223
2452
|
}
|
|
2224
2453
|
|
|
2225
2454
|
// 主动发消息(调试用)
|
|
2455
|
+
/**
|
|
2456
|
+
* 面板手动发消息(调试用)。
|
|
2457
|
+
*
|
|
2458
|
+
* **必须走 outbound**。这条以前直连 `api.sendGroup` / `api.sendC2C`,
|
|
2459
|
+
* 绕过了每秒限速、主动消息额度记账(20/分 + 1000/天)和离线判断。
|
|
2460
|
+
* 面板随手点几下就能把额度打满,而额度账本里一条记录都没有——
|
|
2461
|
+
* **半真的账本比没有账本更糟**,因为你会相信它。
|
|
2462
|
+
*
|
|
2463
|
+
* 错误按 SendError 的 code 分开回:`quota` 用 429(等一会儿能好),
|
|
2464
|
+
* 其余用 400(重试也不会好,比如离线、被动窗口已过)。
|
|
2465
|
+
*/
|
|
2226
2466
|
if (pathname === '/api/send' && req.method === 'POST') {
|
|
2227
2467
|
const body = await readBody(req);
|
|
2228
2468
|
const { scene, target, content } = body;
|
|
2229
2469
|
if (!target || !content) return json(res, 400, { error: '需要 target 和 content' });
|
|
2230
|
-
const
|
|
2470
|
+
const kind = scene === 'c2c' ? 'c2c' : 'group';
|
|
2471
|
+
const sender = kind === 'c2c' ? api?.sendC2C : api?.sendGroup;
|
|
2231
2472
|
if (!sender) return json(res, 503, { error: '未连接' });
|
|
2473
|
+
|
|
2474
|
+
if (rt.outbound) {
|
|
2475
|
+
try {
|
|
2476
|
+
const r = await rt.outbound.send({
|
|
2477
|
+
kind,
|
|
2478
|
+
target,
|
|
2479
|
+
chatKey: `${kind}:${target}`,
|
|
2480
|
+
payload: { content },
|
|
2481
|
+
});
|
|
2482
|
+
// pending = 主动消息进了审核。既不是失败,也不是「已经送达」
|
|
2483
|
+
return json(res, 200, { ok: true, id: r?.id ?? '', pending: Boolean(r?.pending) });
|
|
2484
|
+
} catch (e) {
|
|
2485
|
+
const hint = userFacingSendError(e);
|
|
2486
|
+
if (!hint) throw e; // 真正的接口错误交给外层兜底(500 + 原始信息)
|
|
2487
|
+
// quota(等跨天)和 rate(等一分钟)都是「等会儿能好」,用 429 让调用方知道可以重试
|
|
2488
|
+
const retriable = e.code === 'quota' || e.code === 'rate';
|
|
2489
|
+
return json(res, retriable ? 429 : 400, { error: hint, code: e.code });
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// 只有自造 Bot 实例(没建调度器)才会走到这里,直发兜底
|
|
2232
2494
|
const r = await sender.call(api, target, { content });
|
|
2233
2495
|
return json(res, 200, { ok: true, id: r?.id });
|
|
2234
2496
|
}
|
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
|
|
|
@@ -690,6 +692,7 @@ function renderStatus(s) {
|
|
|
690
692
|
(s.counters.dedup ?? 0) +
|
|
691
693
|
(s.counters.limited ?? 0) +
|
|
692
694
|
(s.counters.rejected ?? 0) +
|
|
695
|
+
(s.counters.gated ?? 0) +
|
|
693
696
|
(s.counters.ignored ?? 0);
|
|
694
697
|
$('#c-err').textContent = `${s.counters.errors} / ${blocked}`;
|
|
695
698
|
|
|
@@ -711,6 +714,14 @@ function renderStatus(s) {
|
|
|
711
714
|
// 框架版本:status 里直接带,不用等用户点「检查更新」
|
|
712
715
|
if (s.version) $('#updCurrent').textContent = s.version;
|
|
713
716
|
|
|
717
|
+
/**
|
|
718
|
+
* 闸状态跟着每 2 秒的 status 一起刷。
|
|
719
|
+
*
|
|
720
|
+
* 不单独开一个轮询接口:暂停是紧急操作,如果面板要等用户点进某一页
|
|
721
|
+
* 才去查,「原来还暂停着」就会拖很久才被发现。
|
|
722
|
+
*/
|
|
723
|
+
renderGate(s.gate);
|
|
724
|
+
|
|
714
725
|
const body = $('#chatBody');
|
|
715
726
|
if (!s.chats?.length) {
|
|
716
727
|
body.innerHTML = '<tr><td colspan="4" class="empty">暂无数据</td></tr>';
|
|
@@ -934,6 +945,70 @@ $('#btnRefreshCred').addEventListener('click', async (e) => {
|
|
|
934
945
|
finally { e.target.disabled = false; }
|
|
935
946
|
});
|
|
936
947
|
|
|
948
|
+
/* ---------------- 暂停闸 ---------------- */
|
|
949
|
+
|
|
950
|
+
/** 最近一次拿到的闸状态。按钮要靠它判断「现在点下去是开还是关」 */
|
|
951
|
+
let LAST_GATE = null;
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* 画闸状态。
|
|
955
|
+
*
|
|
956
|
+
* 按钮文案跟着状态走(「暂停」/「放开」),而不是固定文案 + 一个指示灯——
|
|
957
|
+
* 指示灯那种设计,用户点之前得先抬头看灯,点错了还要再点一次。
|
|
958
|
+
*/
|
|
959
|
+
function renderGate(gate) {
|
|
960
|
+
LAST_GATE = gate ?? null;
|
|
961
|
+
const el = $('#gateState');
|
|
962
|
+
const bg = $('#btnGateGlobal');
|
|
963
|
+
const bb = $('#btnGateBot');
|
|
964
|
+
if (!el || !bg || !bb) return;
|
|
965
|
+
|
|
966
|
+
const g = gate?.global ?? {};
|
|
967
|
+
const b = gate?.bot ?? {};
|
|
968
|
+
|
|
969
|
+
bg.textContent = g.paused ? '放开全局' : '全局暂停';
|
|
970
|
+
bg.classList.toggle('primary', Boolean(g.paused));
|
|
971
|
+
bb.textContent = b.paused ? '放开这只鸟' : '暂停这只鸟';
|
|
972
|
+
bb.classList.toggle('primary', Boolean(b.paused));
|
|
973
|
+
|
|
974
|
+
const why = [];
|
|
975
|
+
if (g.paused) why.push(`全局已暂停${g.reason ? `(${g.reason})` : ''}`);
|
|
976
|
+
if (b.paused) why.push(`这只鸟已暂停${b.reason ? `(${b.reason})` : ''}`);
|
|
977
|
+
|
|
978
|
+
if (why.length) {
|
|
979
|
+
el.innerHTML = `<b style="color:var(--warn-text)">${escapeHtml(why.join(' · '))}</b>`;
|
|
980
|
+
el.title = '暂停期间消息照样进账本,恢复后能补看';
|
|
981
|
+
} else {
|
|
982
|
+
el.textContent = '运行中';
|
|
983
|
+
el.title = '';
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async function setGate(scope, paused) {
|
|
988
|
+
try {
|
|
989
|
+
await api('/api/gate', { method: 'POST', body: JSON.stringify({ scope, paused }) });
|
|
990
|
+
/**
|
|
991
|
+
* 本地先翻转,不等下一次 status 推送。
|
|
992
|
+
* status 是 2 秒一轮,点完等两秒才变,用户会以为没点上、再点一次,
|
|
993
|
+
* 结果又翻回去了——这种「点了没反应」最容易被当成 bug。
|
|
994
|
+
*/
|
|
995
|
+
if (LAST_GATE) {
|
|
996
|
+
const k = scope === 'global' ? 'global' : 'bot';
|
|
997
|
+
renderGate({ ...LAST_GATE, [k]: { ...(LAST_GATE[k] ?? {}), paused } });
|
|
998
|
+
}
|
|
999
|
+
} catch (e) {
|
|
1000
|
+
alert(`操作失败: ${e.message}`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
$('#btnGateGlobal')?.addEventListener('click', () => {
|
|
1005
|
+
setGate('global', !LAST_GATE?.global?.paused);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
$('#btnGateBot')?.addEventListener('click', () => {
|
|
1009
|
+
setGate('bot', !LAST_GATE?.bot?.paused);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
937
1012
|
$('#btnSend').addEventListener('click', async (e) => {
|
|
938
1013
|
const scene = $('#sendScene').value;
|
|
939
1014
|
const target = $('#sendTarget').value.trim();
|
|
@@ -2911,6 +2986,175 @@ function renderGroups(list) {
|
|
|
2911
2986
|
});
|
|
2912
2987
|
}
|
|
2913
2988
|
|
|
2989
|
+
/* ---------------- 可达性 ---------------- */
|
|
2990
|
+
|
|
2991
|
+
/**
|
|
2992
|
+
* 能不能主动找到人。
|
|
2993
|
+
*
|
|
2994
|
+
* 这块数据的特殊之处:**三个值不是两个**。`1` 可以、`0` 不可以、`null` 未知。
|
|
2995
|
+
*
|
|
2996
|
+
* 面板上必须把「未知」和「不可以」显示得完全不一样。如果两者都是灰色、
|
|
2997
|
+
* 或者都写「否」,用户会以为这些人真的发不出去,然后跑去手动放开——
|
|
2998
|
+
* 而实际情况只是「机器人上线之前就加的好友,事件补不回来」。
|
|
2999
|
+
*/
|
|
3000
|
+
|
|
3001
|
+
/** 依据的英文 key → 中文。事件里带参数(`friend_add:0`),所以按冒号前匹配 */
|
|
3002
|
+
const REACH_EVIDENCE = {
|
|
3003
|
+
friend_add: '加好友事件',
|
|
3004
|
+
friend_del: '删好友事件',
|
|
3005
|
+
c2c_msg_reject: '用户关了推送',
|
|
3006
|
+
c2c_msg_receive: '用户开了推送',
|
|
3007
|
+
observed_c2c: '收到过私聊',
|
|
3008
|
+
group_msg_reject: '群管理员关了推送',
|
|
3009
|
+
group_msg_receive: '群管理员开了推送',
|
|
3010
|
+
'send:no-friend': '发送失败·无好友',
|
|
3011
|
+
'send:rejected': '发送失败·被拒收',
|
|
3012
|
+
manual: '人工标注',
|
|
3013
|
+
};
|
|
3014
|
+
|
|
3015
|
+
function evidenceText(ev) {
|
|
3016
|
+
const raw = String(ev || '').trim();
|
|
3017
|
+
if (!raw) return '-';
|
|
3018
|
+
return REACH_EVIDENCE[raw.split(':')[0]] ?? raw;
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
/** 一句话结论。和后端 `reach.canC2C` 的判定顺序必须一致 */
|
|
3022
|
+
const REACH_VERDICT = {
|
|
3023
|
+
ok: { text: '能发', cls: 'on' },
|
|
3024
|
+
'not-friend': { text: '不能:没加好友', cls: 'off' },
|
|
3025
|
+
'push-off': { text: '不能:关了推送', cls: 'off' },
|
|
3026
|
+
unknown: { text: '未知,照常尝试', cls: '' },
|
|
3027
|
+
};
|
|
3028
|
+
|
|
3029
|
+
/**
|
|
3030
|
+
* 行内三态下拉。
|
|
3031
|
+
*
|
|
3032
|
+
* 用下拉而不是「标为好友 / 标为陌生人」两个按钮:后者没法表达「撤销标注」,
|
|
3033
|
+
* 而且改错了只能改到另一个错的值上。`value=""` 就是回到未知。
|
|
3034
|
+
*/
|
|
3035
|
+
function triSelect(field, v, yes = '是', no = '否') {
|
|
3036
|
+
const sel = (val, text) =>
|
|
3037
|
+
`<option value="${val}"${String(v ?? '') === val ? ' selected' : ''}>${text}</option>`;
|
|
3038
|
+
return `<select class="mini-select" data-field="${field}" title="人工标注">${
|
|
3039
|
+
sel('', '未知') + sel('1', yes) + sel('0', no)
|
|
3040
|
+
}</select>`;
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
async function loadReach() {
|
|
3044
|
+
const ub = $('#reachUserBody');
|
|
3045
|
+
const gb = $('#reachGroupBody');
|
|
3046
|
+
if (!ub || !gb) return;
|
|
3047
|
+
try {
|
|
3048
|
+
renderReach(await api('/api/reach'));
|
|
3049
|
+
} catch (e) {
|
|
3050
|
+
ub.innerHTML = `<tr><td colspan="6" class="empty">加载失败: ${escapeHtml(e.message)}</td></tr>`;
|
|
3051
|
+
gb.innerHTML = `<tr><td colspan="5" class="empty">加载失败: ${escapeHtml(e.message)}</td></tr>`;
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
function renderReach(d) {
|
|
3056
|
+
const ub = $('#reachUserBody');
|
|
3057
|
+
const gb = $('#reachGroupBody');
|
|
3058
|
+
if (!ub || !gb) return;
|
|
3059
|
+
|
|
3060
|
+
const users = d?.users ?? [];
|
|
3061
|
+
const groups = d?.groups ?? [];
|
|
3062
|
+
|
|
3063
|
+
ub.innerHTML = users.length
|
|
3064
|
+
? users
|
|
3065
|
+
.map((u) => {
|
|
3066
|
+
const v = REACH_VERDICT[u.verdict] ?? REACH_VERDICT.unknown;
|
|
3067
|
+
const extra = u.lastFailCode ? ` · 失败码 ${u.lastFailCode}` : '';
|
|
3068
|
+
return `<tr data-openid="${escapeHtml(u.openid)}">
|
|
3069
|
+
<td title="${escapeHtml(u.openid)}">${labelWithId(u.name, u.openid)}</td>
|
|
3070
|
+
<td>${triSelect('friend', u.friend)}</td>
|
|
3071
|
+
<td>${triSelect('c2cPush', u.c2cPush)}</td>
|
|
3072
|
+
<td><span class="badge ${v.cls}">${v.text}</span></td>
|
|
3073
|
+
<td title="${escapeHtml(u.evidence || '')}">${escapeHtml(evidenceText(u.evidence))}${escapeHtml(extra)}</td>
|
|
3074
|
+
<td>${u.updatedAt ? fmtAgo(u.updatedAt) : '-'}</td>
|
|
3075
|
+
</tr>`;
|
|
3076
|
+
})
|
|
3077
|
+
.join('')
|
|
3078
|
+
: '<tr><td colspan="6" class="empty">还没有记录。收到私聊消息、或被加好友之后才会出现。</td></tr>';
|
|
3079
|
+
|
|
3080
|
+
gb.innerHTML = groups.length
|
|
3081
|
+
? groups
|
|
3082
|
+
.map(
|
|
3083
|
+
(g) => `<tr data-group="${escapeHtml(g.groupOpenid)}">
|
|
3084
|
+
<td title="${escapeHtml(g.groupOpenid)}">${labelWithId(g.name, g.groupOpenid)}</td>
|
|
3085
|
+
<td>${triSelect('push', g.push, '开', '关')}</td>
|
|
3086
|
+
<td title="${escapeHtml(g.by || '')}">${g.by ? labelWithId('', g.by) : '-'}</td>
|
|
3087
|
+
<td title="${escapeHtml(g.evidence || '')}">${escapeHtml(evidenceText(g.evidence))}</td>
|
|
3088
|
+
<td>${g.updatedAt ? fmtAgo(g.updatedAt) : '-'}</td>
|
|
3089
|
+
</tr>`,
|
|
3090
|
+
)
|
|
3091
|
+
.join('')
|
|
3092
|
+
: '<tr><td colspan="5" class="empty">还没有记录。群管理员改过推送开关之后才会出现。</td></tr>';
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
/**
|
|
3096
|
+
* 行内标注:改下拉即提交。
|
|
3097
|
+
*
|
|
3098
|
+
* 委托绑在 tbody 上,因为 `renderReach` 每次都整个重建 innerHTML,
|
|
3099
|
+
* 逐行 `addEventListener` 会随着重绘丢掉。
|
|
3100
|
+
*/
|
|
3101
|
+
async function saveReachPatch(sel, body) {
|
|
3102
|
+
sel.disabled = true;
|
|
3103
|
+
try {
|
|
3104
|
+
renderReach(await api('/api/reach', { method: 'POST', body: JSON.stringify(body) }));
|
|
3105
|
+
} catch (e) {
|
|
3106
|
+
alert('标注失败: ' + e.message);
|
|
3107
|
+
sel.disabled = false;
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
$('#reachUserBody')?.addEventListener('change', (e) => {
|
|
3112
|
+
const sel = e.target.closest('select[data-field]');
|
|
3113
|
+
if (!sel) return;
|
|
3114
|
+
const openid = sel.closest('tr')?.dataset.openid;
|
|
3115
|
+
if (!openid) return;
|
|
3116
|
+
// 空字符串 = 撤销标注,要显式传 null,不能传 ''(后端会当成非法值)
|
|
3117
|
+
saveReachPatch(sel, { openid, [sel.dataset.field]: sel.value === '' ? null : Number(sel.value) });
|
|
3118
|
+
});
|
|
3119
|
+
|
|
3120
|
+
$('#reachGroupBody')?.addEventListener('change', (e) => {
|
|
3121
|
+
const sel = e.target.closest('select[data-field]');
|
|
3122
|
+
if (!sel) return;
|
|
3123
|
+
const groupOpenid = sel.closest('tr')?.dataset.group;
|
|
3124
|
+
if (!groupOpenid) return;
|
|
3125
|
+
saveReachPatch(sel, {
|
|
3126
|
+
groupOpenid,
|
|
3127
|
+
push: sel.value === '' ? null : Number(sel.value),
|
|
3128
|
+
});
|
|
3129
|
+
});
|
|
3130
|
+
|
|
3131
|
+
$('#btnReloadReach')?.addEventListener('click', loadReach);
|
|
3132
|
+
|
|
3133
|
+
/** 列表里没有的人(比如老好友、从没私聊过)手动登记一条 */
|
|
3134
|
+
$('#btnReachSave')?.addEventListener('click', async () => {
|
|
3135
|
+
const openid = $('#reachOpenidInput')?.value.trim() ?? '';
|
|
3136
|
+
if (!openid) return alert('先填 openid');
|
|
3137
|
+
const friend = $('#reachFriendSel')?.value ?? '';
|
|
3138
|
+
const c2cPush = $('#reachPushSel')?.value ?? '';
|
|
3139
|
+
if (friend === '' && c2cPush === '') return alert('好友和推送至少选一个,不然登记了也是未知');
|
|
3140
|
+
|
|
3141
|
+
const btn = $('#btnReachSave');
|
|
3142
|
+
btn.disabled = true;
|
|
3143
|
+
try {
|
|
3144
|
+
const body = { openid };
|
|
3145
|
+
if (friend !== '') body.friend = Number(friend);
|
|
3146
|
+
if (c2cPush !== '') body.c2cPush = Number(c2cPush);
|
|
3147
|
+
renderReach(await api('/api/reach', { method: 'POST', body: JSON.stringify(body) }));
|
|
3148
|
+
$('#reachOpenidInput').value = '';
|
|
3149
|
+
$('#reachFriendSel').value = '';
|
|
3150
|
+
$('#reachPushSel').value = '';
|
|
3151
|
+
} catch (e) {
|
|
3152
|
+
alert('登记失败: ' + e.message);
|
|
3153
|
+
} finally {
|
|
3154
|
+
btn.disabled = false;
|
|
3155
|
+
}
|
|
3156
|
+
});
|
|
3157
|
+
|
|
2914
3158
|
/**
|
|
2915
3159
|
* 分群插件开关。
|
|
2916
3160
|
*
|