qq-bot-roost 1.0.6 → 1.0.8
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 -3
- package/package.json +57 -57
- package/src/api.js +18 -2
- package/src/bot.js +20 -1
- package/src/instance.js +159 -1
- package/src/message.js +94 -2
- package/src/outbound.js +7 -0
- package/src/plugin/testkit.js +12 -0
- package/src/web/server.js +77 -2
- package/web/app.js +77 -0
- package/web/index.html +25 -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-813%20passed-brightgreen" alt="测试 813 项">
|
|
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-1.0.
|
|
13
|
+
<img src="https://img.shields.io/badge/version-1.0.8-blueviolet" alt="版本 1.0.8">
|
|
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
|
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
- **多轮对话**:`ctx.prompt()` 问一句等回答,文字游戏的地基
|
|
69
69
|
- **流式回复**:`msg.replyStream()` 做 AI 打字机效果,自动维护分片序号;群聊自动降级成普通消息
|
|
70
70
|
- **模板消息**:`msg.replyCard()` 一次带上 markdown 正文 + 按钮 + ark 模板,不会拆成三条碎片
|
|
71
|
+
- **图文卡片**:`msg.replyTuwen({title, description, url, picUrl})` 发官方 2026-10-28 新增的 `msg_type=8` 卡片,字段长度和 https 在发之前就校验
|
|
71
72
|
- **素材缓存**:同一个图片 url 两天内只上传一次,省富媒体频控和流量
|
|
72
73
|
- **中间件管道**:去重、白名单、限流、命令冷却都可插拔
|
|
73
74
|
|
|
@@ -275,7 +276,7 @@ docker compose up -d
|
|
|
275
276
|
| 页签 | 能做什么 |
|
|
276
277
|
|------|----------|
|
|
277
278
|
| 概览 | 连接状态、运行时长、凭证有效期、内存、收发统计、活跃会话 Top20、重连 / 刷新凭证、调试发消息 |
|
|
278
|
-
| 配置 | 凭证、intents 勾选、命令前缀、白名单、限流、去重窗口、消息日志、日志级别、面板端口 /
|
|
279
|
+
| 配置 | 凭证、intents 勾选、命令前缀、白名单、限流、去重窗口、消息日志、日志级别、面板端口 / 密钥;底部**实例锁**:哪个 AppID 被哪个进程占着、残留记录一键清 |
|
|
279
280
|
| 插件 | 列表、启用 / 禁用 / 重载、加载错误提示 |
|
|
280
281
|
| 事件 | **47 个官方事件的订阅状态与接收计数**,一眼看出哪个事件没收到 |
|
|
281
282
|
| 群组 | 全局主人增删、群档案(邀请人 / 群主 / 状态)、手动登记群 |
|
package/package.json
CHANGED
|
@@ -1,57 +1,57 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "qq-bot-roost",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
|
|
6
|
-
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
-
"repository": {
|
|
8
|
-
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/tdcq9594/qq-bot-roost.git"
|
|
10
|
-
},
|
|
11
|
-
"bugs": {
|
|
12
|
-
"url": "https://github.com/tdcq9594/qq-bot-roost/issues"
|
|
13
|
-
},
|
|
14
|
-
"homepage": "https://github.com/tdcq9594/qq-bot-roost#readme",
|
|
15
|
-
"keywords": [
|
|
16
|
-
"qq-bot",
|
|
17
|
-
"qqbot",
|
|
18
|
-
"bot-framework",
|
|
19
|
-
"qq-official-bot",
|
|
20
|
-
"roost",
|
|
21
|
-
"multi-bot",
|
|
22
|
-
"plugin",
|
|
23
|
-
"zero-dependency"
|
|
24
|
-
],
|
|
25
|
-
"engines": {
|
|
26
|
-
"node": ">=22.13"
|
|
27
|
-
},
|
|
28
|
-
"scripts": {
|
|
29
|
-
"start": "node src/main.js",
|
|
30
|
-
"mock": "node src/main.js --mock",
|
|
31
|
-
"check": "node src/main.js --check",
|
|
32
|
-
"test": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs && node --test test/group-name.mjs && node --test test/identity.mjs && node --test test/identity-commands.mjs && node --test test/mail.mjs && node --test test/ledger-outbound.mjs && node --test test/api-record.mjs",
|
|
33
|
-
"test:e2e": "node test/e2e.mjs",
|
|
34
|
-
"test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs && node --test test/group-name.mjs && node --test test/identity.mjs && node --test test/identity-commands.mjs && node --test test/mail.mjs && node --test test/ledger-outbound.mjs && node --test test/api-record.mjs && node test/e2e.mjs && node test/live-driver.mjs",
|
|
35
|
-
"check:secrets": "node scripts/check-secrets.mjs",
|
|
36
|
-
"create": "node scripts/create-plugin.mjs",
|
|
37
|
-
"test:plugin": "node scripts/test-plugin.mjs",
|
|
38
|
-
"live": "node scripts/live-test.mjs",
|
|
39
|
-
"market:index": "node scripts/build-market-index.mjs",
|
|
40
|
-
"sums": "node scripts/build-sums.mjs",
|
|
41
|
-
"sums:check": "node scripts/build-sums.mjs --check",
|
|
42
|
-
"sums:verify": "node scripts/build-sums.mjs --verify",
|
|
43
|
-
"test:coverage": "node --experimental-test-coverage --test-coverage-lines=95 --test-coverage-branches=80 --test-coverage-include=src/trust.js --test-coverage-include=src/plugin/manifest.js --test-coverage-include=src/trace.js --test test/trust.mjs test/manifest.mjs test/trace.mjs"
|
|
44
|
-
},
|
|
45
|
-
"author": {
|
|
46
|
-
"name": "倦飞之鸟"
|
|
47
|
-
},
|
|
48
|
-
"files": [
|
|
49
|
-
"src",
|
|
50
|
-
"web",
|
|
51
|
-
"sdk",
|
|
52
|
-
"types",
|
|
53
|
-
"config/*.example.json",
|
|
54
|
-
"README.md",
|
|
55
|
-
"LICENSE"
|
|
56
|
-
]
|
|
57
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "qq-bot-roost",
|
|
3
|
+
"version": "1.0.8",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/tdcq9594/qq-bot-roost.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/tdcq9594/qq-bot-roost/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/tdcq9594/qq-bot-roost#readme",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"qq-bot",
|
|
17
|
+
"qqbot",
|
|
18
|
+
"bot-framework",
|
|
19
|
+
"qq-official-bot",
|
|
20
|
+
"roost",
|
|
21
|
+
"multi-bot",
|
|
22
|
+
"plugin",
|
|
23
|
+
"zero-dependency"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.13"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"start": "node src/main.js",
|
|
30
|
+
"mock": "node src/main.js --mock",
|
|
31
|
+
"check": "node src/main.js --check",
|
|
32
|
+
"test": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs && node --test test/group-name.mjs && node --test test/identity.mjs && node --test test/identity-commands.mjs && node --test test/mail.mjs && node --test test/ledger-outbound.mjs && node --test test/api-record.mjs",
|
|
33
|
+
"test:e2e": "node test/e2e.mjs",
|
|
34
|
+
"test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs && node --test test/group-name.mjs && node --test test/identity.mjs && node --test test/identity-commands.mjs && node --test test/mail.mjs && node --test test/ledger-outbound.mjs && node --test test/api-record.mjs && node test/e2e.mjs && node test/live-driver.mjs",
|
|
35
|
+
"check:secrets": "node scripts/check-secrets.mjs",
|
|
36
|
+
"create": "node scripts/create-plugin.mjs",
|
|
37
|
+
"test:plugin": "node scripts/test-plugin.mjs",
|
|
38
|
+
"live": "node scripts/live-test.mjs",
|
|
39
|
+
"market:index": "node scripts/build-market-index.mjs",
|
|
40
|
+
"sums": "node scripts/build-sums.mjs",
|
|
41
|
+
"sums:check": "node scripts/build-sums.mjs --check",
|
|
42
|
+
"sums:verify": "node scripts/build-sums.mjs --verify",
|
|
43
|
+
"test:coverage": "node --experimental-test-coverage --test-coverage-lines=95 --test-coverage-branches=80 --test-coverage-include=src/trust.js --test-coverage-include=src/plugin/manifest.js --test-coverage-include=src/trace.js --test test/trust.mjs test/manifest.mjs test/trace.mjs"
|
|
44
|
+
},
|
|
45
|
+
"author": {
|
|
46
|
+
"name": "倦飞之鸟"
|
|
47
|
+
},
|
|
48
|
+
"files": [
|
|
49
|
+
"src",
|
|
50
|
+
"web",
|
|
51
|
+
"sdk",
|
|
52
|
+
"types",
|
|
53
|
+
"config/*.example.json",
|
|
54
|
+
"README.md",
|
|
55
|
+
"LICENSE"
|
|
56
|
+
]
|
|
57
|
+
}
|
package/src/api.js
CHANGED
|
@@ -58,7 +58,7 @@ const PASSIVE_MAX_SEQ = 5;
|
|
|
58
58
|
* 构造发送报文。抽成纯函数是为了能单测——`#payload` 是私有方法,测试摸不到。
|
|
59
59
|
*
|
|
60
60
|
* @param {object} opts 驼峰字段:content / msgType / msgId / msgSeq / eventId /
|
|
61
|
-
* markdown / keyboard / media / ark / messageReference
|
|
61
|
+
* markdown / keyboard / media / card / ark / messageReference
|
|
62
62
|
* @param {(msgId:string)=>number} nextSeq 取下一个 msg_seq
|
|
63
63
|
* @returns {object} 官方要的 snake_case 报文
|
|
64
64
|
*/
|
|
@@ -72,6 +72,7 @@ export function buildSendPayload(opts = {}, nextSeq = () => 1) {
|
|
|
72
72
|
markdown,
|
|
73
73
|
keyboard,
|
|
74
74
|
media,
|
|
75
|
+
card,
|
|
75
76
|
ark,
|
|
76
77
|
messageReference,
|
|
77
78
|
} = opts;
|
|
@@ -100,6 +101,21 @@ export function buildSendPayload(opts = {}, nextSeq = () => 1) {
|
|
|
100
101
|
payload.ark = ark;
|
|
101
102
|
payload.msg_type = 3;
|
|
102
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* 图文卡片(`msg_type=8`,官方 2026-10-28 新增,群聊/单聊都支持)。
|
|
106
|
+
*
|
|
107
|
+
* 放在 ark 之后是有意的:`msg_type` 是**单值**,一条消息只能有一种富文本。
|
|
108
|
+
* 同时给 markdown 和 card 时以 card 为准(最后写的赢),调用方本该只给一个。
|
|
109
|
+
*
|
|
110
|
+
* `content` 置空照抄 markdown 那条路的做法——官方示例里 `msg_type=8`
|
|
111
|
+
* 根本没有 content 字段,而 `#payload` 总会带一个 `content: ''`,
|
|
112
|
+
* 留着空串跟「类型与内容不匹配」(22006)只差一步。
|
|
113
|
+
*/
|
|
114
|
+
if (card) {
|
|
115
|
+
payload.card = card;
|
|
116
|
+
payload.msg_type = 8;
|
|
117
|
+
payload.content = '';
|
|
118
|
+
}
|
|
103
119
|
/**
|
|
104
120
|
* 引用回复。官方是独立字段,不是 msg_id 那套被动回复:
|
|
105
121
|
* message_id 取 `REFIDX_xxx`——别人发的消息从事件的
|
|
@@ -234,7 +250,7 @@ export class QQApi {
|
|
|
234
250
|
/**
|
|
235
251
|
* 发送群消息
|
|
236
252
|
* @param {string} groupOpenid
|
|
237
|
-
* @param {object} opts content / msgType / msgId / eventId / markdown / keyboard / media / ark / messageReference
|
|
253
|
+
* @param {object} opts content / msgType / msgId / eventId / markdown / keyboard / media / card / ark / messageReference
|
|
238
254
|
*/
|
|
239
255
|
async sendGroup(groupOpenid, opts = {}) {
|
|
240
256
|
const r = await this.#request('POST', `/v2/groups/${enc(groupOpenid)}/messages`, this.#payload(opts));
|
package/src/bot.js
CHANGED
|
@@ -379,7 +379,19 @@ export class BotRuntime {
|
|
|
379
379
|
const claimed = await claimInstance({ appId: cfg.appId });
|
|
380
380
|
if (!claimed.ok) {
|
|
381
381
|
this.state = 'error';
|
|
382
|
-
|
|
382
|
+
/**
|
|
383
|
+
* 文案得能指导下一步。
|
|
384
|
+
*
|
|
385
|
+
* 以前只有「AppID xxx 已有实例在运行(PID 2816)」——用户既不知道 2816
|
|
386
|
+
* 是谁,也没有任何地方能查。而他遇到的往往是「那个进程早就不在了,
|
|
387
|
+
* 只是记录没删掉」,这时候要做的事跟「真的有另一个进程在跑」完全不同。
|
|
388
|
+
*/
|
|
389
|
+
const cpid = Number(claimed.conflict?.pid) || 0;
|
|
390
|
+
const who = cpid === process.pid ? '本进程里的另一个机器人配置' : `PID ${cpid}`;
|
|
391
|
+
this.lastError =
|
|
392
|
+
`AppID ${cfg.appId} 已被${who}占用。` +
|
|
393
|
+
'如果那个进程已经关了,去「设置 → 实例锁」清掉这条记录再上线;' +
|
|
394
|
+
'如果它确实还在跑,先把另一个停掉';
|
|
383
395
|
this.log.error(this.lastError);
|
|
384
396
|
return this.state;
|
|
385
397
|
}
|
|
@@ -401,6 +413,13 @@ export class BotRuntime {
|
|
|
401
413
|
this.state = 'error';
|
|
402
414
|
this.lastError = e.message;
|
|
403
415
|
this.log.error(`连接失败: ${e.message}`);
|
|
416
|
+
/**
|
|
417
|
+
* 连接失败就把认领还回去。
|
|
418
|
+
*
|
|
419
|
+
* 不还的话会留下一条「我这个 pid 还占着这个 AppID」的记录——而实际上
|
|
420
|
+
* 我根本没连上网关。那个 pid 之后被系统复用,就变成一条永远清不掉的脏记录。
|
|
421
|
+
*/
|
|
422
|
+
if (!this.mock) await releaseInstance(cfg.appId).catch(() => {});
|
|
404
423
|
}
|
|
405
424
|
return this.state;
|
|
406
425
|
}
|
package/src/instance.js
CHANGED
|
@@ -83,6 +83,15 @@ function pidLooksLikeNode(pid) {
|
|
|
83
83
|
/** pid 是否还活着(且确实是 node 进程)。EPERM 说明进程存在但没权限,也算活着 */
|
|
84
84
|
function isAlive(pid) {
|
|
85
85
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
86
|
+
/**
|
|
87
|
+
* 问「我自己还活着吗」恒为真,不用查进程名。
|
|
88
|
+
*
|
|
89
|
+
* 「进程名是不是 node」那一步是为了识别 **PID 复用**——系统把回收的 pid 分给了
|
|
90
|
+
* 别的程序。可这件事对自己根本不适用:pid 就是我的,那个进程当然是我。
|
|
91
|
+
* 而多查这一步反而会出错:CI 的 ubuntu + Node 24 上,面板「实例锁」把
|
|
92
|
+
* 本进程自己那条显示成「已退出」(自检里 635/636 就挂在这一条)。
|
|
93
|
+
*/
|
|
94
|
+
if (pid === process.pid) return true;
|
|
86
95
|
try {
|
|
87
96
|
process.kill(pid, 0);
|
|
88
97
|
} catch (e) {
|
|
@@ -92,6 +101,91 @@ function isAlive(pid) {
|
|
|
92
101
|
return pidLooksLikeNode(pid);
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
/**
|
|
105
|
+
* 这个进程的命令行。
|
|
106
|
+
*
|
|
107
|
+
* 拿不到(工具不在、没权限、进程刚好退出)返回 null —— 调用方要区分
|
|
108
|
+
* 「问不到」和「问到了但不匹配」,这两种的处理方向完全相反。
|
|
109
|
+
*/
|
|
110
|
+
function commandLineOf(pid) {
|
|
111
|
+
if (process.platform === 'win32') {
|
|
112
|
+
/**
|
|
113
|
+
* 先用 wmic:开一个进程约 40ms,比 PowerShell 快一个数量级。
|
|
114
|
+
* Win11 24H2 起 wmic 已被移除(本机实测 ENOENT),所以要留后路。
|
|
115
|
+
*/
|
|
116
|
+
try {
|
|
117
|
+
const out = execFileSync(
|
|
118
|
+
'wmic',
|
|
119
|
+
['process', 'where', `ProcessId=${pid}`, 'get', 'CommandLine', '/value'],
|
|
120
|
+
{ encoding: 'utf8', timeout: 3000, windowsHide: true },
|
|
121
|
+
);
|
|
122
|
+
const m = /CommandLine=(.+)/.exec(out);
|
|
123
|
+
if (m) return m[1].trim() || null;
|
|
124
|
+
} catch {}
|
|
125
|
+
/**
|
|
126
|
+
* 退回 PowerShell。慢(起一个进程 300–600ms),所以**只在冲突路径上调用**:
|
|
127
|
+
* 正常启动、正常注销、面板刷新都不会走到这里。
|
|
128
|
+
*/
|
|
129
|
+
try {
|
|
130
|
+
const out = execFileSync(
|
|
131
|
+
'powershell',
|
|
132
|
+
[
|
|
133
|
+
'-NoProfile',
|
|
134
|
+
'-NonInteractive',
|
|
135
|
+
'-Command',
|
|
136
|
+
`(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`,
|
|
137
|
+
],
|
|
138
|
+
{ encoding: 'utf8', timeout: 8000, windowsHide: true },
|
|
139
|
+
);
|
|
140
|
+
return out.trim() || null;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
if (process.platform === 'linux') {
|
|
147
|
+
// 内核直接给,不用 fork;参数之间是 \0
|
|
148
|
+
return readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim() || null;
|
|
149
|
+
}
|
|
150
|
+
return (
|
|
151
|
+
execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8', timeout: 3000 }).trim() ||
|
|
152
|
+
null
|
|
153
|
+
);
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 本框架进程的特征。安装目录各不相同,所以只认「包名」和「入口文件名」 */
|
|
160
|
+
const ROOST_HINT = /(qq-bot-roost|src[\\/]main\.js)/i;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* pid 是否还活着(且确实是本框架的进程)。
|
|
164
|
+
*
|
|
165
|
+
* 三层,一层比一层贵:
|
|
166
|
+
* ① `isAlive` —— pid 存在 + 进程名是 node(便宜,正常路径都走这个)
|
|
167
|
+
* ② 命令行里有没有本框架的特征(慢,**只在冲突时**才查)
|
|
168
|
+
*
|
|
169
|
+
* 第 ② 层是为 PID 复用准备的:Windows 会把回收的 PID 分给别的程序。
|
|
170
|
+
* 踩过的坑——被强杀的机器人留下 pid=15008,后来 15008 被
|
|
171
|
+
* SearchProtocolHost.exe 占用(进程名不是 node,① 就挡住了)。但如果占用者
|
|
172
|
+
* 是**另一个 node 程序**(你的构建脚本、别的 bot),① 看不出来,于是
|
|
173
|
+
* 「机器人还在跑」——启动被拒、记录删不掉、界面上显示一个你从没见过的 PID。
|
|
174
|
+
*
|
|
175
|
+
* 方向仍然是「拿不准就往活着倒」:误判成死 → 同 AppID 双开 → 4902 互踢,
|
|
176
|
+
* 比「多让用户点一次清理」严重得多。
|
|
177
|
+
*
|
|
178
|
+
* @returns {Promise<boolean>}
|
|
179
|
+
*/
|
|
180
|
+
async function isOurRoost(pid) {
|
|
181
|
+
if (!isAlive(pid)) return false;
|
|
182
|
+
const cmd = commandLineOf(pid);
|
|
183
|
+
if (cmd === null) return true; // 问不到 → 当成我们的
|
|
184
|
+
if (ROOST_HINT.test(cmd)) return true;
|
|
185
|
+
// 命令行里跑着某个 .js,却不含本框架特征 → 是别的 node 程序,占的是复用的 pid
|
|
186
|
+
return !/\.[cm]?js\b/i.test(cmd);
|
|
187
|
+
}
|
|
188
|
+
|
|
95
189
|
async function readAll() {
|
|
96
190
|
try {
|
|
97
191
|
const raw = JSON.parse(await fs.readFile(FILE, 'utf8'));
|
|
@@ -156,7 +250,18 @@ export async function claimInstance({ appId, force = false }) {
|
|
|
156
250
|
|
|
157
251
|
const existing = data[key];
|
|
158
252
|
if (existing && existing.pid !== process.pid && !force) {
|
|
159
|
-
|
|
253
|
+
/**
|
|
254
|
+
* 冲突时**再核一次**:那个 pid 现在还是不是本框架。
|
|
255
|
+
*
|
|
256
|
+
* `prune()` 走的是便宜的判断(pid 在 + 进程名是 node),挡不住
|
|
257
|
+
* 「pid 被复用给另一个 node 程序」——那种情况用户会看到一个从没见过的
|
|
258
|
+
* PID 拦着自己,而记录删不掉。这里多花一次命令行查询,
|
|
259
|
+
* **只在这条冲突路径上花**,正常启动一次都不会走到。
|
|
260
|
+
*/
|
|
261
|
+
if (await isOurRoost(Number(existing.pid))) {
|
|
262
|
+
return { ok: false, conflict: { pid: existing.pid, at: existing.at } };
|
|
263
|
+
}
|
|
264
|
+
// 命令行显示是别的程序占了复用的 pid → 当死记录,继续往下认领
|
|
160
265
|
}
|
|
161
266
|
|
|
162
267
|
inProcessClaims.add(key);
|
|
@@ -179,7 +284,60 @@ export async function listInstances() {
|
|
|
179
284
|
return prune(await readAll());
|
|
180
285
|
}
|
|
181
286
|
|
|
287
|
+
/**
|
|
288
|
+
* 面板用:把文件里的记录**原样**列出来,并标出每条还活着没。
|
|
289
|
+
*
|
|
290
|
+
* 和 `listInstances()` 的区别:那个是互斥判定用的,只返回活的;这个连死记录
|
|
291
|
+
* 一起返回——用户要清的恰恰是死记录,看不到就没法清。
|
|
292
|
+
*/
|
|
293
|
+
export async function listInstancesAll() {
|
|
294
|
+
const data = await readAll();
|
|
295
|
+
return Object.entries(data)
|
|
296
|
+
.filter(([, rec]) => rec && typeof rec === 'object')
|
|
297
|
+
.map(([appId, rec]) => {
|
|
298
|
+
const pid = Number(rec.pid) || 0;
|
|
299
|
+
return {
|
|
300
|
+
appId,
|
|
301
|
+
pid,
|
|
302
|
+
at: Number(rec.at) || 0,
|
|
303
|
+
mock: Boolean(rec.mock),
|
|
304
|
+
alive: isAlive(pid),
|
|
305
|
+
/** 是不是当前这个面板进程自己在用的(那条不能清) */
|
|
306
|
+
self: pid === process.pid,
|
|
307
|
+
};
|
|
308
|
+
})
|
|
309
|
+
.sort((a, b) => b.at - a.at);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 强制清掉一条实例记录(面板「实例锁」里手动清理用)。
|
|
314
|
+
*
|
|
315
|
+
* 不检查 pid 还活着没——用户点它,本来就是冲着「进程明明没了却还拦着我」来的。
|
|
316
|
+
* 但**本进程自己那条不许清**:那是正在生效的互斥,删掉等于放开同进程双开,
|
|
317
|
+
* 两个机器人用同一个 AppID 连网关会 4902 互踢。
|
|
318
|
+
*
|
|
319
|
+
* @returns {Promise<{ok:boolean, message?:string, pid?:number}>}
|
|
320
|
+
*/
|
|
321
|
+
export async function clearInstance(appId) {
|
|
322
|
+
const key = String(appId ?? '').trim();
|
|
323
|
+
if (!key) return { ok: false, message: '要指定 AppID' };
|
|
324
|
+
const data = await readAll();
|
|
325
|
+
const rec = data[key];
|
|
326
|
+
if (!rec) return { ok: false, message: `没有 ${key} 的记录` };
|
|
327
|
+
if (Number(rec.pid) === process.pid) {
|
|
328
|
+
return { ok: false, message: '这条是本进程自己在用的,不能清——先把那个机器人下线' };
|
|
329
|
+
}
|
|
330
|
+
delete data[key];
|
|
331
|
+
await writeAll(data);
|
|
332
|
+
return { ok: true, pid: Number(rec.pid) || 0 };
|
|
333
|
+
}
|
|
334
|
+
|
|
182
335
|
/** 测试用:直接看文件内容(不剪枝) */
|
|
183
336
|
export async function _rawRead() {
|
|
184
337
|
return readAll();
|
|
185
338
|
}
|
|
339
|
+
|
|
340
|
+
/** 测试用:看某个 pid 的命令行拿不拿得到(拿不到返回 null) */
|
|
341
|
+
export function _commandLineOf(pid) {
|
|
342
|
+
return commandLineOf(pid);
|
|
343
|
+
}
|
package/src/message.js
CHANGED
|
@@ -42,6 +42,34 @@ function stripMention(content) {
|
|
|
42
42
|
.trim();
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 图文卡片(`msg_type=8`)的官方硬约束,发之前先查一遍。
|
|
47
|
+
*
|
|
48
|
+
* 为什么要自己查:官方对参数不合规只回一个 `304061 消息内容无效`,
|
|
49
|
+
* **不说是哪个字段**。四个字段里有一个超长或写成了 http,日志里就只有
|
|
50
|
+
* 这么一句话,排查全靠逐个试。在这里提前拦下,日志能直接写
|
|
51
|
+
* 「description 要 1–200 字(当前 231)」。
|
|
52
|
+
*
|
|
53
|
+
* 长度按字符算(`.length`):中文一个字算一位,跟官方「1–50 字符」对齐。
|
|
54
|
+
* emoji 会占 2 位——这只会让判断偏严,不会放过超长的内容。
|
|
55
|
+
*
|
|
56
|
+
* @returns {string} 不合格的原因;空串表示通过
|
|
57
|
+
*/
|
|
58
|
+
function tuwenProblem({ title, description, url, picUrl } = {}) {
|
|
59
|
+
const len = (v) => String(v ?? '').length;
|
|
60
|
+
if (!len(title)) return 'title 不能为空';
|
|
61
|
+
if (len(title) > 50) return `title 要 1–50 字(当前 ${len(title)})`;
|
|
62
|
+
if (!len(description)) return 'description 不能为空';
|
|
63
|
+
if (len(description) > 200) return `description 要 1–200 字(当前 ${len(description)})`;
|
|
64
|
+
for (const [name, v] of [['url', url], ['picUrl', picUrl]]) {
|
|
65
|
+
if (!len(v)) return `${name} 不能为空`;
|
|
66
|
+
/** 官方:必须是 https;http 会被拒,而且报的同样是 304061 */
|
|
67
|
+
if (!/^https:\/\//i.test(String(v))) return `${name} 必须是 https 开头`;
|
|
68
|
+
if (len(v) > 1024) return `${name} 超过 1024 字符(当前 ${len(v)})`;
|
|
69
|
+
}
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
|
|
45
73
|
/**
|
|
46
74
|
* 从正文里捞 @ 的 openid。
|
|
47
75
|
*
|
|
@@ -726,6 +754,12 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session, traceI
|
|
|
726
754
|
*
|
|
727
755
|
* ark 和 markdown 同时给时以 markdown 为准(官方不支持一条消息两种富文本)。
|
|
728
756
|
*
|
|
757
|
+
* **ark 这条路现在基本用不上**:官方「消息类型」页把结构化卡片在单聊和
|
|
758
|
+
* 群聊都标成「发 ❌ / 收 ✅」,也就是只能收不能发(频道曾是唯一支持发送的
|
|
759
|
+
* 场景)。真实插件里也没有人调它——ark 在本项目主要是**接收侧**在用
|
|
760
|
+
* (解析别人发来的卡片)。参数保留是为了老代码不报错,新代码要发卡片
|
|
761
|
+
* 请用 `replyTuwen()`(`msg_type=8`,2026-10-28 官方新出的图文卡片)。
|
|
762
|
+
*
|
|
729
763
|
* @param {{markdown?:object, keyboard?:object, ark?:object}} card
|
|
730
764
|
*/
|
|
731
765
|
msg.replyCard = async (card = {}) => {
|
|
@@ -748,13 +782,71 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session, traceI
|
|
|
748
782
|
});
|
|
749
783
|
};
|
|
750
784
|
|
|
785
|
+
/**
|
|
786
|
+
* 发**图文卡片**(`msg_type=8`,官方 2026-10-28 新增,群聊 / 单聊都支持)。
|
|
787
|
+
*
|
|
788
|
+
* 一张可点的卡片:标题 + 一句描述 + 跳转链接 + 封面图。四个字段都是必填。
|
|
789
|
+
*
|
|
790
|
+
* await msg.replyTuwen({
|
|
791
|
+
* title: 'iPhone 17 Pro 正式发布',
|
|
792
|
+
* description: '全新 A20 芯片 / 钛金属机身 / 支持卫星通信',
|
|
793
|
+
* url: 'https://news.qq.com/rain/a/20261014A05XYZ00',
|
|
794
|
+
* picUrl: 'https://inews.gtimg.com/newsapp_bt/0/xxx/1000.jpg',
|
|
795
|
+
* });
|
|
796
|
+
*
|
|
797
|
+
* **和 `replyCard()` 不是一回事**,虽然名字都带 card:那个发的是
|
|
798
|
+
* markdown + 按钮 + ark 的组合,ark 属于旧版结构化模板,官方现在
|
|
799
|
+
* 群聊 / 单聊都标「发 ❌」;这个发的是卡片消息类型本身,是新东西。
|
|
800
|
+
* 另起一个方法而不是塞进 `replyCard`,是因为两者的「同时给了怎么办」
|
|
801
|
+
* 逻辑不一样——那边要丢一个,这边没有并列的字段。
|
|
802
|
+
*
|
|
803
|
+
* 没有任何降级:卡片渲染不出来就是失败,不像 markdown 能退成纯文本
|
|
804
|
+
* (退成纯文本等于把图和链接都丢掉,发出去反而更奇怪)。
|
|
805
|
+
*
|
|
806
|
+
* @param {{title:string, description:string, url:string, picUrl:string}} card
|
|
807
|
+
* @returns {Promise<object|null>} 发送结果;参数不合格时是 null
|
|
808
|
+
*/
|
|
809
|
+
msg.replyTuwen = async (card = {}) => {
|
|
810
|
+
const bad = tuwenProblem(card);
|
|
811
|
+
if (bad) {
|
|
812
|
+
log.error(`图文卡片没发:${bad}`);
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
await msg.flushReplies();
|
|
816
|
+
return send({
|
|
817
|
+
/**
|
|
818
|
+
* `msgType` 显式带上,和 `replyImage` 传 7 是同一个理由:真正决定
|
|
819
|
+
* `msg_type` 的是 `buildSendPayload` 里 `card` 那一支,但中间还有
|
|
820
|
+
* 出站记账要读这个字段,不传它账本里这条消息就是「未知类型」。
|
|
821
|
+
*/
|
|
822
|
+
msgType: 8,
|
|
823
|
+
card: {
|
|
824
|
+
type: 'tuwen',
|
|
825
|
+
content: {
|
|
826
|
+
title: String(card.title),
|
|
827
|
+
description: String(card.description),
|
|
828
|
+
url: String(card.url),
|
|
829
|
+
pic_url: String(card.picUrl),
|
|
830
|
+
},
|
|
831
|
+
},
|
|
832
|
+
}, true).catch((e) => {
|
|
833
|
+
log.error('发送图文卡片失败:', e.message);
|
|
834
|
+
return null;
|
|
835
|
+
});
|
|
836
|
+
};
|
|
837
|
+
|
|
751
838
|
/**
|
|
752
839
|
* 发富媒体:上传后以 msg_type=7 发送。
|
|
753
840
|
*
|
|
754
841
|
* 富媒体在官方接口里只能是独立消息(msg_type=7),但这条消息自带
|
|
755
842
|
* `content` 文本字段:传了 `text` 就和图走**同一条**,不用先发一句话
|
|
756
|
-
*
|
|
757
|
-
*
|
|
843
|
+
* 再发一张图(那是两条消息,群里看着是两截)。
|
|
844
|
+
*
|
|
845
|
+
* 不传 text 时**不带 content 字段**。官方对 `msg_type=7` 的 content
|
|
846
|
+
* 没有语义要求(只有 `msg_type=0` 时它才是全文),客户端拿到
|
|
847
|
+
* 「富媒体 + 空正文」就是一张干净的图。以前这里塞了个空格占位,
|
|
848
|
+
* 理由是「官方不接受空 content」——那说的是传 `''`,跟不传不是一回事,
|
|
849
|
+
* 结果客户端按图文混排渲染,图外面多出一圈文字区(v1.0.6 修掉)。
|
|
758
850
|
*
|
|
759
851
|
* 发送前会先把攒着的文本 flush 掉,免得「缓冲中的回复」被这条
|
|
760
852
|
* 富媒体消息挤成顺序错乱的两条。
|
package/src/outbound.js
CHANGED
|
@@ -219,6 +219,7 @@ class QuotaLedger {
|
|
|
219
219
|
* · 纯文本 `{ content }`
|
|
220
220
|
* · markdown `{ markdown: { content } }` ← ai-chat 默认就走这条
|
|
221
221
|
* · 卡片 `{ markdown, keyboard, ark }`
|
|
222
|
+
* · 图文卡片 `{ msgType: 8, card: { type:'tuwen', content:{ title … } } }`
|
|
222
223
|
* · 富媒体 `{ msgType: 7, media }`,**不带 content**(7 里 content 没有语义,
|
|
223
224
|
* 填了客户端会当正文,渲染出一块空文字区)
|
|
224
225
|
*
|
|
@@ -247,6 +248,12 @@ export function digestTextOf(payload) {
|
|
|
247
248
|
if (p.media) return mediaName ?? '[媒体]';
|
|
248
249
|
if (mediaName) return mediaName;
|
|
249
250
|
if (p.ark) return '[卡片]';
|
|
251
|
+
/**
|
|
252
|
+
* 图文卡片(`msg_type=8`)带上标题。只写 `[卡片]` 的话,一天发十条卡片
|
|
253
|
+
* 就是十条一模一样的记录,账本里根本分不出哪条是哪条。
|
|
254
|
+
*/
|
|
255
|
+
const cardTitle = p.card?.content?.title;
|
|
256
|
+
if (p.card) return cardTitle ? `[图文卡片] ${cardTitle}` : '[图文卡片]';
|
|
250
257
|
return '';
|
|
251
258
|
}
|
|
252
259
|
|
package/src/plugin/testkit.js
CHANGED
|
@@ -241,6 +241,8 @@ export function makeMsg({
|
|
|
241
241
|
const replies = [];
|
|
242
242
|
/** markdown 消息单独记一份,好断言「走的是富文本还是纯文本」 */
|
|
243
243
|
const markdowns = [];
|
|
244
|
+
/** 图文卡片同理:结构化留一份,插件测试能断言 title/url 有没有传对 */
|
|
245
|
+
const tuwens = [];
|
|
244
246
|
const prefix = '/';
|
|
245
247
|
const isCommand = content.startsWith(prefix);
|
|
246
248
|
const body = isCommand ? content.slice(prefix.length).trim() : content;
|
|
@@ -268,6 +270,7 @@ export function makeMsg({
|
|
|
268
270
|
replied: false,
|
|
269
271
|
replies,
|
|
270
272
|
markdowns,
|
|
273
|
+
tuwens,
|
|
271
274
|
|
|
272
275
|
async reply(text) {
|
|
273
276
|
msg.replied = true;
|
|
@@ -300,6 +303,15 @@ export function makeMsg({
|
|
|
300
303
|
replies.push(card?.markdown?.content ?? '[card]');
|
|
301
304
|
return { id: 'x' };
|
|
302
305
|
},
|
|
306
|
+
async replyTuwen(card) {
|
|
307
|
+
msg.replied = true;
|
|
308
|
+
// 和真身一样先校验:插件测试里传错字段也该像线上那样「发不出去」
|
|
309
|
+
const bad = !card?.title || !card?.description || !card?.url || !card?.picUrl;
|
|
310
|
+
if (bad) return null;
|
|
311
|
+
tuwens.push({ ...card });
|
|
312
|
+
replies.push(`[tuwen] ${card.title} :: ${card.url}`);
|
|
313
|
+
return { id: 'x' };
|
|
314
|
+
},
|
|
303
315
|
async replyImage(src, opts = {}) {
|
|
304
316
|
msg.replied = true;
|
|
305
317
|
// opts.text 走的是 msg_type=7 的 content(图文同一条消息)。
|
package/src/web/server.js
CHANGED
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
// 面板改插件启停时要同步机器人配置里的过滤器,两边不能各说各的
|
|
27
27
|
import { pluginFilterOf, pluginAllowedIn } from '../bot.js';
|
|
28
28
|
import { CONFLICT, INVALID } from '../identity.js';
|
|
29
|
+
// 实例锁:面板要能看见「哪个 AppID 被谁占着」并清掉残留记录
|
|
30
|
+
import { listInstancesAll, clearInstance, releaseInstance } from '../instance.js';
|
|
29
31
|
import { SMTP_DEFAULTS } from '../mail/defaults.js';
|
|
30
32
|
import { extractAddress } from '../mail/smtp.js';
|
|
31
33
|
import { userFacingSendError } from '../outbound.js';
|
|
@@ -1352,6 +1354,28 @@ export async function startWebServer({
|
|
|
1352
1354
|
if ((await listBotIds()).includes(id)) {
|
|
1353
1355
|
return json(res, 409, { error: `机器人 ${id} 已存在` });
|
|
1354
1356
|
}
|
|
1357
|
+
/**
|
|
1358
|
+
* AppID 也必须唯一。
|
|
1359
|
+
*
|
|
1360
|
+
* 以前只查 id:面板里能建出两只鸟填同一个 AppID,第二只点上线必报
|
|
1361
|
+
* 「AppID xxx 已有实例在运行(PID yyy)」——而那个 yyy 就是面板自己。
|
|
1362
|
+
* 用户看到这句话完全想不到问题出在「有两个配置填了同一个 AppID」上。
|
|
1363
|
+
*/
|
|
1364
|
+
const appIdWanted = String(body.appId ?? '').trim();
|
|
1365
|
+
if (appIdWanted) {
|
|
1366
|
+
for (const other of await listBotIds()) {
|
|
1367
|
+
if (other === id) continue;
|
|
1368
|
+
let oc = null;
|
|
1369
|
+
try {
|
|
1370
|
+
oc = JSON.parse(await fs.readFile(botPaths(other).config, 'utf8'));
|
|
1371
|
+
} catch {}
|
|
1372
|
+
if (String(oc?.appId ?? '').trim() === appIdWanted) {
|
|
1373
|
+
return json(res, 409, {
|
|
1374
|
+
error: `AppID ${appIdWanted} 已经被机器人 ${other} 用了。一个 AppID 只能配一只鸟——先改掉那边,或者用另一个 AppID`,
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1355
1379
|
try {
|
|
1356
1380
|
const created = await onBotAdd(id, {
|
|
1357
1381
|
name: String(body.name ?? '').trim() || id,
|
|
@@ -1458,6 +1482,8 @@ export async function startWebServer({
|
|
|
1458
1482
|
if (pathname === '/api/config' && req.method === 'PUT') {
|
|
1459
1483
|
const body = await readBody(req);
|
|
1460
1484
|
const before = JSON.stringify({ appId: cfg.appId, intents: cfg.intents });
|
|
1485
|
+
/** 改 AppID 之后要释放旧记录、认领新记录,所以先把旧值存下来 */
|
|
1486
|
+
const oldAppId = String(cfg.appId ?? '');
|
|
1461
1487
|
const oldSecret = cfg.appSecret;
|
|
1462
1488
|
|
|
1463
1489
|
if (typeof body.appId === 'string') cfg.appId = body.appId.trim();
|
|
@@ -1598,8 +1624,27 @@ export async function startWebServer({
|
|
|
1598
1624
|
tokens.token = '';
|
|
1599
1625
|
tokens.expiresAt = 0;
|
|
1600
1626
|
}
|
|
1601
|
-
|
|
1602
|
-
|
|
1627
|
+
/**
|
|
1628
|
+
* 换 AppID 不能只 `gateway.restart()`。
|
|
1629
|
+
*
|
|
1630
|
+
* 那条路径只重连 WebSocket,**不经过 goOnline,也就永远不会 claim
|
|
1631
|
+
* 新 AppID** —— 于是换完之后互斥对新值完全失效:两个进程用同一个新
|
|
1632
|
+
* AppID 双开也不会被拦,直接 4902 互踢。反过来旧 AppID 的记录还挂着
|
|
1633
|
+
* 自己的 pid,想换回去又会被它拦住。
|
|
1634
|
+
*
|
|
1635
|
+
* 所以走「下线 → 上线」:`goOffline` 释放(此时 cfg.appId 已经是新值,
|
|
1636
|
+
* 老 key 要单独清),`goOnline` 认领新的。
|
|
1637
|
+
*/
|
|
1638
|
+
if (oldAppId && oldAppId !== cfg.appId) {
|
|
1639
|
+
await releaseInstance(oldAppId).catch(() => {});
|
|
1640
|
+
if (rt.state === 'online') {
|
|
1641
|
+
await rt.goOffline?.().catch(() => {});
|
|
1642
|
+
await rt.goOnline?.().catch(() => {});
|
|
1643
|
+
}
|
|
1644
|
+
} else {
|
|
1645
|
+
log.info('连接参数变化,正在重连网关...');
|
|
1646
|
+
rt.gateway?.restart?.().catch(() => {});
|
|
1647
|
+
}
|
|
1603
1648
|
}
|
|
1604
1649
|
|
|
1605
1650
|
// 填完 AppID / AppSecret 顺手把「默认机器人」换成官方昵称
|
|
@@ -2909,6 +2954,36 @@ export async function startWebServer({
|
|
|
2909
2954
|
return json(res, 200, { ok: true });
|
|
2910
2955
|
}
|
|
2911
2956
|
|
|
2957
|
+
/**
|
|
2958
|
+
* 实例锁:谁占着哪个 AppID。
|
|
2959
|
+
*
|
|
2960
|
+
* 这张表以前**只写不读**——`listInstances()` 定义了但全仓零引用。
|
|
2961
|
+
* 于是用户看到「AppID xxx 已有实例在运行(PID 2816)」时,既不知道 2816
|
|
2962
|
+
* 是谁,也没有任何地方能清掉它,唯一出路是自己去删 `data/instances.json`,
|
|
2963
|
+
* 而大多数人根本不知道有这个文件。
|
|
2964
|
+
*/
|
|
2965
|
+
if (pathname === '/api/instances' && req.method === 'GET') {
|
|
2966
|
+
return json(res, 200, {
|
|
2967
|
+
instances: await listInstancesAll(),
|
|
2968
|
+
/** 当前面板进程的 pid:前端用它标出「这条是你正在用的」 */
|
|
2969
|
+
pid: process.pid,
|
|
2970
|
+
/** 文件路径:接口也清不掉时,至少告诉用户手动去哪删 */
|
|
2971
|
+
file: path.join(DIRS.data, 'instances.json'),
|
|
2972
|
+
});
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
/**
|
|
2976
|
+
* 清掉一条残留记录。
|
|
2977
|
+
*
|
|
2978
|
+
* 只删「不是本进程在用」的那条——本进程的记录是正在生效的互斥,
|
|
2979
|
+
* 删了就等于放开同进程双开(两个机器人同一 AppID → 4902 互踢)。
|
|
2980
|
+
*/
|
|
2981
|
+
if (pathname === '/api/instances/clear' && req.method === 'POST') {
|
|
2982
|
+
const body = await readBody(req);
|
|
2983
|
+
const r = await clearInstance(String(body?.appId ?? ''));
|
|
2984
|
+
return json(res, r.ok ? 200 : 400, r);
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2912
2987
|
// 日志
|
|
2913
2988
|
if (pathname === '/api/logs' && req.method === 'GET') {
|
|
2914
2989
|
const limit = Math.min(500, Number(url.searchParams.get('limit')) || 200);
|
package/web/app.js
CHANGED
|
@@ -1326,8 +1326,85 @@ async function loadConfig() {
|
|
|
1326
1326
|
} catch (e) {
|
|
1327
1327
|
$('#cfgMsg').textContent = '读取配置失败: ' + e.message;
|
|
1328
1328
|
}
|
|
1329
|
+
refreshInstances();
|
|
1329
1330
|
}
|
|
1330
1331
|
|
|
1332
|
+
/* ---------------- 实例锁 ---------------- */
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* 拉实例表并渲染。
|
|
1336
|
+
*
|
|
1337
|
+
* 为什么要有这块 UI:以前 `listInstances()` 定义了但**全仓零引用**。用户看到
|
|
1338
|
+
* 「AppID xxx 已有实例在运行(PID 2816)」时,既不知道 2816 是谁、也不知道
|
|
1339
|
+
* 上哪查,唯一出路是自己去删 `data/instances.json`——而大多数人不知道有这个文件。
|
|
1340
|
+
*/
|
|
1341
|
+
async function refreshInstances() {
|
|
1342
|
+
const body = $('#instancesBody');
|
|
1343
|
+
if (!body) return;
|
|
1344
|
+
let data;
|
|
1345
|
+
try {
|
|
1346
|
+
data = await api('/api/instances');
|
|
1347
|
+
} catch (e) {
|
|
1348
|
+
body.innerHTML = `<tr><td colspan="5" class="empty">读不到实例表:${escapeHtml(e.message)}</td></tr>`;
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
const list = Array.isArray(data?.instances) ? data.instances : [];
|
|
1352
|
+
if (!list.length) {
|
|
1353
|
+
body.innerHTML = '<tr><td colspan="5" class="empty">没有实例记录</td></tr>';
|
|
1354
|
+
} else {
|
|
1355
|
+
body.innerHTML = list
|
|
1356
|
+
.map((it) => {
|
|
1357
|
+
// 用内联色值:面板没有现成的状态类,硬造新 class 还得改 style.css
|
|
1358
|
+
const state = it.alive
|
|
1359
|
+
? '<span style="color:var(--ok)">在跑</span>'
|
|
1360
|
+
: '<span style="color:var(--err)">已退出</span>';
|
|
1361
|
+
const self = it.self ? ' <span class="hint">本进程</span>' : '';
|
|
1362
|
+
const when = it.at ? new Date(it.at).toLocaleString('zh-CN') : '-';
|
|
1363
|
+
// 本进程那条是正在生效的互斥,清掉等于放开同进程双开
|
|
1364
|
+
const action = it.self
|
|
1365
|
+
? '<span class="hint">—</span>'
|
|
1366
|
+
: `<button class="btn small" data-clear="${escapeHtml(it.appId)}">清理</button>`;
|
|
1367
|
+
return `<tr>
|
|
1368
|
+
<td class="mono">${escapeHtml(it.appId)}</td>
|
|
1369
|
+
<td class="mono">${it.pid || '-'}</td>
|
|
1370
|
+
<td>${state}${self}</td>
|
|
1371
|
+
<td>${escapeHtml(when)}</td>
|
|
1372
|
+
<td>${action}</td>
|
|
1373
|
+
</tr>`;
|
|
1374
|
+
})
|
|
1375
|
+
.join('');
|
|
1376
|
+
}
|
|
1377
|
+
const foot = $('#instancesFoot');
|
|
1378
|
+
if (foot) {
|
|
1379
|
+
foot.textContent = data?.file
|
|
1380
|
+
? `记录文件:${data.file}${list.some((x) => !x.alive) ? '(「已退出」的记录可以直接清)' : ''}`
|
|
1381
|
+
: '';
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
$('#btnRefreshInstances')?.addEventListener('click', () => refreshInstances());
|
|
1386
|
+
|
|
1387
|
+
// 清理按钮走事件委托:行是 innerHTML 拼的,逐行绑事件会在每次刷新后失效
|
|
1388
|
+
document.addEventListener('click', async (e) => {
|
|
1389
|
+
const btn = e.target?.closest?.('[data-clear]');
|
|
1390
|
+
if (!btn) return;
|
|
1391
|
+
const appId = btn.dataset.clear;
|
|
1392
|
+
const ok = await confirmDialog({
|
|
1393
|
+
title: '清掉这条实例记录?',
|
|
1394
|
+
message: `AppID <b>${escapeHtml(appId)}</b> 的占用记录会被删除。<br><br>
|
|
1395
|
+
只有确认那个进程<b>已经不在跑了</b>才能清。如果它其实还活着,清掉之后两个进程会用同一个 AppID 连网关,互相踢下线。`,
|
|
1396
|
+
okText: '清掉',
|
|
1397
|
+
danger: true,
|
|
1398
|
+
});
|
|
1399
|
+
if (!ok) return;
|
|
1400
|
+
try {
|
|
1401
|
+
await api('/api/instances/clear', { method: 'POST', body: JSON.stringify({ appId }) });
|
|
1402
|
+
refreshInstances();
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
alert('清理失败:' + err.message);
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1331
1408
|
$('#cfgForm').addEventListener('submit', async (e) => {
|
|
1332
1409
|
e.preventDefault();
|
|
1333
1410
|
const f = e.target;
|
package/web/index.html
CHANGED
|
@@ -573,6 +573,31 @@
|
|
|
573
573
|
<button type="submit" class="btn primary">保存配置</button>
|
|
574
574
|
</div>
|
|
575
575
|
</form>
|
|
576
|
+
|
|
577
|
+
<!--
|
|
578
|
+
实例锁:同一个 AppID 只允许一个进程连网关。
|
|
579
|
+
平时不用看,出现「已有实例在运行」时才来这里。
|
|
580
|
+
放在 form 外面:这里的按钮不能触发「保存配置」。
|
|
581
|
+
-->
|
|
582
|
+
<div class="box">
|
|
583
|
+
<div class="box-hd">
|
|
584
|
+
<h3>实例锁</h3>
|
|
585
|
+
<div class="plugin-tools">
|
|
586
|
+
<button class="btn small" type="button" id="btnRefreshInstances">刷新</button>
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
<div class="hint">
|
|
590
|
+
每个机器人被哪个进程(PID)占着。机器人已经退出了却还提示「已有实例在运行」时,
|
|
591
|
+
把那条「已退出」的记录清掉就行——不需要自己去翻文件。
|
|
592
|
+
</div>
|
|
593
|
+
<div class="table-wrap">
|
|
594
|
+
<table>
|
|
595
|
+
<thead><tr><th>AppID</th><th>PID</th><th>状态</th><th>登记时间</th><th></th></tr></thead>
|
|
596
|
+
<tbody id="instancesBody"><tr><td colspan="5" class="empty">读取中…</td></tr></tbody>
|
|
597
|
+
</table>
|
|
598
|
+
</div>
|
|
599
|
+
<div class="hint" id="instancesFoot"></div>
|
|
600
|
+
</div>
|
|
576
601
|
</section>
|
|
577
602
|
|
|
578
603
|
<!-- 统计 -->
|