qq-bot-roost 1.0.5 → 1.0.7
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 -8
- package/package.json +1 -1
- package/src/api.js +25 -3
- package/src/bot.js +20 -1
- package/src/instance.js +150 -1
- package/src/message.js +107 -5
- package/src/outbound.js +9 -1
- 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.7-blueviolet" alt="版本 1.0.7">
|
|
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
|
| 群组 | 全局主人增删、群档案(邀请人 / 群主 / 状态)、手动登记群 |
|
|
@@ -474,14 +475,9 @@ openid 完全不同。共享 `roles.json` 等于所有权限记录全部对不
|
|
|
474
475
|
| [官方接口限制](docs/官方接口限制.md) | 官方限制清单、免@粘性原理、实现要点对照 |
|
|
475
476
|
| [常见问题](docs/常见问题.md) | 收不到消息、发不出去、面板打不开、数据在哪 |
|
|
476
477
|
| [真机联调](docs/真机联调.md) | 用小号(OneBot v11)在真群里跟机器人对话,测 intents / 被动回复 / msg_seq |
|
|
477
|
-
| [对标 ElainaBot](docs/对标ElainaBot.md) | 与主流 Python 框架的逐项对比、差距与取舍 |
|
|
478
|
-
| [事件与接口清单](docs/事件与接口清单.md) | 官方 47 个事件、47 个接口的覆盖审计(含频道生态缺口) |
|
|
479
|
-
| [官方接口核查](docs/官方接口核查与AI工具化方案.md) | 逐页核对官方文档的结果:哪些接口真没有、官方能力怎么给 AI 用 |
|
|
480
|
-
| [DSH-Aurabot 适配 v2](docs/DSH-Aurabot适配方案-v2.md) | 按 v0.7.2 的真实能力重写的接入方案:通道设计、模块去留、分期 |
|
|
481
478
|
| [架构说明](docs/架构说明.md) | 进程模型、数据隔离、事件流、插件加载、面板多机器人实现 |
|
|
482
479
|
| [DSH 接入指南](docs/DSH接入.md) | 装进 DeepSeek Harness,用 AI 管机器人:安装、配置、验证、排查 |
|
|
483
480
|
| [AI 宿主接入](docs/AI宿主接入.md) | 外部 AI 驱动 Roost:SSE 消息下行、通用能力上行、能力清单 |
|
|
484
|
-
| [同类项目调研](docs/GitHub同类项目调研.md) | 与 AstrBot / NoneBot2 / Koishi / ZeroBot 的对比 |
|
|
485
481
|
|
|
486
482
|
### 相关仓库
|
|
487
483
|
|
package/package.json
CHANGED
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));
|
|
@@ -547,9 +563,15 @@ export class QQApi {
|
|
|
547
563
|
async sendMedia(scene, target, { fileType, url, fileData, msgId, messageReference }) {
|
|
548
564
|
const up = await this.uploadMedia(scene, target, { fileType, url, fileData });
|
|
549
565
|
if (!up?.file_info) throw new Error('上传成功但未返回 file_info');
|
|
566
|
+
/**
|
|
567
|
+
* **不要 content。**
|
|
568
|
+
*
|
|
569
|
+
* 官方字段表:`msg_type=7` 时生效的是 `media`,`content` 只在
|
|
570
|
+
* `msg_type=0` 时是全文。以前这里填了一个空格占位,客户端会把
|
|
571
|
+
* 它当成正文,渲染出一块空的文字区(图被挤到一侧)。
|
|
572
|
+
*/
|
|
550
573
|
const opts = {
|
|
551
574
|
msgType: 7,
|
|
552
|
-
content: ' ',
|
|
553
575
|
media: { file_info: up.file_info },
|
|
554
576
|
msgId,
|
|
555
577
|
messageReference,
|
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
|
@@ -92,6 +92,91 @@ function isAlive(pid) {
|
|
|
92
92
|
return pidLooksLikeNode(pid);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* 这个进程的命令行。
|
|
97
|
+
*
|
|
98
|
+
* 拿不到(工具不在、没权限、进程刚好退出)返回 null —— 调用方要区分
|
|
99
|
+
* 「问不到」和「问到了但不匹配」,这两种的处理方向完全相反。
|
|
100
|
+
*/
|
|
101
|
+
function commandLineOf(pid) {
|
|
102
|
+
if (process.platform === 'win32') {
|
|
103
|
+
/**
|
|
104
|
+
* 先用 wmic:开一个进程约 40ms,比 PowerShell 快一个数量级。
|
|
105
|
+
* Win11 24H2 起 wmic 已被移除(本机实测 ENOENT),所以要留后路。
|
|
106
|
+
*/
|
|
107
|
+
try {
|
|
108
|
+
const out = execFileSync(
|
|
109
|
+
'wmic',
|
|
110
|
+
['process', 'where', `ProcessId=${pid}`, 'get', 'CommandLine', '/value'],
|
|
111
|
+
{ encoding: 'utf8', timeout: 3000, windowsHide: true },
|
|
112
|
+
);
|
|
113
|
+
const m = /CommandLine=(.+)/.exec(out);
|
|
114
|
+
if (m) return m[1].trim() || null;
|
|
115
|
+
} catch {}
|
|
116
|
+
/**
|
|
117
|
+
* 退回 PowerShell。慢(起一个进程 300–600ms),所以**只在冲突路径上调用**:
|
|
118
|
+
* 正常启动、正常注销、面板刷新都不会走到这里。
|
|
119
|
+
*/
|
|
120
|
+
try {
|
|
121
|
+
const out = execFileSync(
|
|
122
|
+
'powershell',
|
|
123
|
+
[
|
|
124
|
+
'-NoProfile',
|
|
125
|
+
'-NonInteractive',
|
|
126
|
+
'-Command',
|
|
127
|
+
`(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`,
|
|
128
|
+
],
|
|
129
|
+
{ encoding: 'utf8', timeout: 8000, windowsHide: true },
|
|
130
|
+
);
|
|
131
|
+
return out.trim() || null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
if (process.platform === 'linux') {
|
|
138
|
+
// 内核直接给,不用 fork;参数之间是 \0
|
|
139
|
+
return readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim() || null;
|
|
140
|
+
}
|
|
141
|
+
return (
|
|
142
|
+
execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8', timeout: 3000 }).trim() ||
|
|
143
|
+
null
|
|
144
|
+
);
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 本框架进程的特征。安装目录各不相同,所以只认「包名」和「入口文件名」 */
|
|
151
|
+
const ROOST_HINT = /(qq-bot-roost|src[\\/]main\.js)/i;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* pid 是否还活着(且确实是本框架的进程)。
|
|
155
|
+
*
|
|
156
|
+
* 三层,一层比一层贵:
|
|
157
|
+
* ① `isAlive` —— pid 存在 + 进程名是 node(便宜,正常路径都走这个)
|
|
158
|
+
* ② 命令行里有没有本框架的特征(慢,**只在冲突时**才查)
|
|
159
|
+
*
|
|
160
|
+
* 第 ② 层是为 PID 复用准备的:Windows 会把回收的 PID 分给别的程序。
|
|
161
|
+
* 踩过的坑——被强杀的机器人留下 pid=15008,后来 15008 被
|
|
162
|
+
* SearchProtocolHost.exe 占用(进程名不是 node,① 就挡住了)。但如果占用者
|
|
163
|
+
* 是**另一个 node 程序**(你的构建脚本、别的 bot),① 看不出来,于是
|
|
164
|
+
* 「机器人还在跑」——启动被拒、记录删不掉、界面上显示一个你从没见过的 PID。
|
|
165
|
+
*
|
|
166
|
+
* 方向仍然是「拿不准就往活着倒」:误判成死 → 同 AppID 双开 → 4902 互踢,
|
|
167
|
+
* 比「多让用户点一次清理」严重得多。
|
|
168
|
+
*
|
|
169
|
+
* @returns {Promise<boolean>}
|
|
170
|
+
*/
|
|
171
|
+
async function isOurRoost(pid) {
|
|
172
|
+
if (!isAlive(pid)) return false;
|
|
173
|
+
const cmd = commandLineOf(pid);
|
|
174
|
+
if (cmd === null) return true; // 问不到 → 当成我们的
|
|
175
|
+
if (ROOST_HINT.test(cmd)) return true;
|
|
176
|
+
// 命令行里跑着某个 .js,却不含本框架特征 → 是别的 node 程序,占的是复用的 pid
|
|
177
|
+
return !/\.[cm]?js\b/i.test(cmd);
|
|
178
|
+
}
|
|
179
|
+
|
|
95
180
|
async function readAll() {
|
|
96
181
|
try {
|
|
97
182
|
const raw = JSON.parse(await fs.readFile(FILE, 'utf8'));
|
|
@@ -156,7 +241,18 @@ export async function claimInstance({ appId, force = false }) {
|
|
|
156
241
|
|
|
157
242
|
const existing = data[key];
|
|
158
243
|
if (existing && existing.pid !== process.pid && !force) {
|
|
159
|
-
|
|
244
|
+
/**
|
|
245
|
+
* 冲突时**再核一次**:那个 pid 现在还是不是本框架。
|
|
246
|
+
*
|
|
247
|
+
* `prune()` 走的是便宜的判断(pid 在 + 进程名是 node),挡不住
|
|
248
|
+
* 「pid 被复用给另一个 node 程序」——那种情况用户会看到一个从没见过的
|
|
249
|
+
* PID 拦着自己,而记录删不掉。这里多花一次命令行查询,
|
|
250
|
+
* **只在这条冲突路径上花**,正常启动一次都不会走到。
|
|
251
|
+
*/
|
|
252
|
+
if (await isOurRoost(Number(existing.pid))) {
|
|
253
|
+
return { ok: false, conflict: { pid: existing.pid, at: existing.at } };
|
|
254
|
+
}
|
|
255
|
+
// 命令行显示是别的程序占了复用的 pid → 当死记录,继续往下认领
|
|
160
256
|
}
|
|
161
257
|
|
|
162
258
|
inProcessClaims.add(key);
|
|
@@ -179,7 +275,60 @@ export async function listInstances() {
|
|
|
179
275
|
return prune(await readAll());
|
|
180
276
|
}
|
|
181
277
|
|
|
278
|
+
/**
|
|
279
|
+
* 面板用:把文件里的记录**原样**列出来,并标出每条还活着没。
|
|
280
|
+
*
|
|
281
|
+
* 和 `listInstances()` 的区别:那个是互斥判定用的,只返回活的;这个连死记录
|
|
282
|
+
* 一起返回——用户要清的恰恰是死记录,看不到就没法清。
|
|
283
|
+
*/
|
|
284
|
+
export async function listInstancesAll() {
|
|
285
|
+
const data = await readAll();
|
|
286
|
+
return Object.entries(data)
|
|
287
|
+
.filter(([, rec]) => rec && typeof rec === 'object')
|
|
288
|
+
.map(([appId, rec]) => {
|
|
289
|
+
const pid = Number(rec.pid) || 0;
|
|
290
|
+
return {
|
|
291
|
+
appId,
|
|
292
|
+
pid,
|
|
293
|
+
at: Number(rec.at) || 0,
|
|
294
|
+
mock: Boolean(rec.mock),
|
|
295
|
+
alive: isAlive(pid),
|
|
296
|
+
/** 是不是当前这个面板进程自己在用的(那条不能清) */
|
|
297
|
+
self: pid === process.pid,
|
|
298
|
+
};
|
|
299
|
+
})
|
|
300
|
+
.sort((a, b) => b.at - a.at);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 强制清掉一条实例记录(面板「实例锁」里手动清理用)。
|
|
305
|
+
*
|
|
306
|
+
* 不检查 pid 还活着没——用户点它,本来就是冲着「进程明明没了却还拦着我」来的。
|
|
307
|
+
* 但**本进程自己那条不许清**:那是正在生效的互斥,删掉等于放开同进程双开,
|
|
308
|
+
* 两个机器人用同一个 AppID 连网关会 4902 互踢。
|
|
309
|
+
*
|
|
310
|
+
* @returns {Promise<{ok:boolean, message?:string, pid?:number}>}
|
|
311
|
+
*/
|
|
312
|
+
export async function clearInstance(appId) {
|
|
313
|
+
const key = String(appId ?? '').trim();
|
|
314
|
+
if (!key) return { ok: false, message: '要指定 AppID' };
|
|
315
|
+
const data = await readAll();
|
|
316
|
+
const rec = data[key];
|
|
317
|
+
if (!rec) return { ok: false, message: `没有 ${key} 的记录` };
|
|
318
|
+
if (Number(rec.pid) === process.pid) {
|
|
319
|
+
return { ok: false, message: '这条是本进程自己在用的,不能清——先把那个机器人下线' };
|
|
320
|
+
}
|
|
321
|
+
delete data[key];
|
|
322
|
+
await writeAll(data);
|
|
323
|
+
return { ok: true, pid: Number(rec.pid) || 0 };
|
|
324
|
+
}
|
|
325
|
+
|
|
182
326
|
/** 测试用:直接看文件内容(不剪枝) */
|
|
183
327
|
export async function _rawRead() {
|
|
184
328
|
return readAll();
|
|
185
329
|
}
|
|
330
|
+
|
|
331
|
+
/** 测试用:看某个 pid 的命令行拿不拿得到(拿不到返回 null) */
|
|
332
|
+
export function _commandLineOf(pid) {
|
|
333
|
+
return commandLineOf(pid);
|
|
334
|
+
}
|
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
|
* 富媒体消息挤成顺序错乱的两条。
|
|
@@ -821,9 +913,18 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session, traceI
|
|
|
821
913
|
}
|
|
822
914
|
if (!up?.file_info) throw new Error('未返回 file_info');
|
|
823
915
|
|
|
824
|
-
|
|
916
|
+
/**
|
|
917
|
+
* **没话要说就别带 content。**
|
|
918
|
+
*
|
|
919
|
+
* 官方字段表里 `msg_type=7` 是「富媒体(media)」,`content` 只在
|
|
920
|
+
* `msg_type=0` 时才是全文,在 7 里没有语义。以前这里塞一个空格占位,
|
|
921
|
+
* 客户端收到「富媒体 + 非空 content」就按图文排版——图被挤进卡片一侧,
|
|
922
|
+
* 群里看到的是「一张图 + 一块空文字框」,而不是一张干净的图。
|
|
923
|
+
*/
|
|
825
924
|
return await send({
|
|
826
|
-
msgType: 7,
|
|
925
|
+
msgType: 7,
|
|
926
|
+
...(text ? { content: String(text) } : {}),
|
|
927
|
+
media: { file_info: up.file_info },
|
|
827
928
|
/** 有效类型(可能因超过软限被降级成 FILE),账本占位按它来 */
|
|
828
929
|
fileType: effectiveType,
|
|
829
930
|
}, true);
|
|
@@ -865,8 +966,9 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session, traceI
|
|
|
865
966
|
reuse: false, // base64 本来就不进缓存
|
|
866
967
|
});
|
|
867
968
|
if (!up?.file_info) throw new Error('未返回 file_info');
|
|
969
|
+
/** 同上:不带 content。buffer 分支没有「附一句话」的入口,所以永远不该有 */
|
|
868
970
|
return await send({
|
|
869
|
-
msgType: 7,
|
|
971
|
+
msgType: 7, media: { file_info: up.file_info }, fileType,
|
|
870
972
|
}, true);
|
|
871
973
|
} catch (e) {
|
|
872
974
|
log.error('发送图片 Buffer 失败:', e.message);
|
package/src/outbound.js
CHANGED
|
@@ -219,7 +219,9 @@ class QuotaLedger {
|
|
|
219
219
|
* · 纯文本 `{ content }`
|
|
220
220
|
* · markdown `{ markdown: { content } }` ← ai-chat 默认就走这条
|
|
221
221
|
* · 卡片 `{ markdown, keyboard, ark }`
|
|
222
|
-
* ·
|
|
222
|
+
* · 图文卡片 `{ msgType: 8, card: { type:'tuwen', content:{ title … } } }`
|
|
223
|
+
* · 富媒体 `{ msgType: 7, media }`,**不带 content**(7 里 content 没有语义,
|
|
224
|
+
* 填了客户端会当正文,渲染出一块空文字区)
|
|
223
225
|
*
|
|
224
226
|
* 漏掉的后果不是发不出去(账本的状态机不看摘要),而是摘要列全变成
|
|
225
227
|
* `…(0)#da39a3ee`——所有记录长得一模一样,「人工核对」这条路等于堵死,
|
|
@@ -246,6 +248,12 @@ export function digestTextOf(payload) {
|
|
|
246
248
|
if (p.media) return mediaName ?? '[媒体]';
|
|
247
249
|
if (mediaName) return mediaName;
|
|
248
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}` : '[图文卡片]';
|
|
249
257
|
return '';
|
|
250
258
|
}
|
|
251
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
|
<!-- 统计 -->
|