koishi-plugin-msg-router 1.4.0 → 1.4.3
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/lib/index.d.ts +2 -0
- package/lib/index.js +87 -58
- package/package.json +1 -1
- package/readme.md +17 -8
package/lib/index.d.ts
CHANGED
|
@@ -35,6 +35,7 @@ export interface ClientRouteConfig {
|
|
|
35
35
|
heartbeatInterval: number;
|
|
36
36
|
reconnectInterval: number;
|
|
37
37
|
maxConcurrency: number;
|
|
38
|
+
maxConnections: number;
|
|
38
39
|
}
|
|
39
40
|
export interface ServerRouteConfig {
|
|
40
41
|
name: string;
|
|
@@ -51,6 +52,7 @@ export interface ServerRouteConfig {
|
|
|
51
52
|
heartbeatInterval: number;
|
|
52
53
|
reconnectInterval: number;
|
|
53
54
|
maxConcurrency: number;
|
|
55
|
+
maxConnections: number;
|
|
54
56
|
}
|
|
55
57
|
export type RouteConfig = ClientRouteConfig | ServerRouteConfig;
|
|
56
58
|
export interface Config {
|
package/lib/index.js
CHANGED
|
@@ -35,6 +35,7 @@ const CommonRouteConfig = {
|
|
|
35
35
|
heartbeatInterval: koishi_1.Schema.number().default(30000).description('心跳间隔,单位毫秒'),
|
|
36
36
|
reconnectInterval: koishi_1.Schema.number().default(5000).description('断线后重连间隔,单位毫秒'),
|
|
37
37
|
maxConcurrency: koishi_1.Schema.number().min(1).default(16).description('这条路由同时处理的最大并发数'),
|
|
38
|
+
maxConnections: koishi_1.Schema.natural().min(1).default(16).description('server 模式下同时允许连接的后端数量上限;同一事件会广播给全部已连接后端'),
|
|
38
39
|
};
|
|
39
40
|
const ClientRouteConfig = koishi_1.Schema.object({
|
|
40
41
|
...CommonRouteConfig,
|
|
@@ -146,6 +147,9 @@ function extractQQMarkdown(value) {
|
|
|
146
147
|
return {
|
|
147
148
|
markdown: { content },
|
|
148
149
|
fallback,
|
|
150
|
+
keyboard: markdownSource.keyboard && typeof markdownSource.keyboard === 'object'
|
|
151
|
+
? markdownSource.keyboard
|
|
152
|
+
: undefined,
|
|
149
153
|
remaining,
|
|
150
154
|
};
|
|
151
155
|
}
|
|
@@ -304,15 +308,15 @@ class RouteRuntime {
|
|
|
304
308
|
declareCommands;
|
|
305
309
|
socket = null;
|
|
306
310
|
server = null;
|
|
311
|
+
serverSockets = new Set();
|
|
307
312
|
connecting = null;
|
|
308
313
|
reconnectTimer = null;
|
|
309
|
-
|
|
314
|
+
heartbeats = new Map();
|
|
310
315
|
serverReadyWaiter = null;
|
|
311
316
|
pending = new Map();
|
|
312
317
|
inflight = 0;
|
|
313
318
|
queue = [];
|
|
314
319
|
closed = false;
|
|
315
|
-
lastPong = 0;
|
|
316
320
|
recentGroupSessions = new Map();
|
|
317
321
|
recentPrivateSessions = new Map();
|
|
318
322
|
constructor(ctx, config, route, declareCommands) {
|
|
@@ -331,6 +335,9 @@ class RouteRuntime {
|
|
|
331
335
|
get routeLabel() {
|
|
332
336
|
return `${this.route.name}(${this.kind})`;
|
|
333
337
|
}
|
|
338
|
+
get maxConnections() {
|
|
339
|
+
return Math.max(1, Number(this.route.maxConnections) || 16);
|
|
340
|
+
}
|
|
334
341
|
get enabled() {
|
|
335
342
|
const commands = Array.isArray(this.route.commands) ? this.route.commands : [];
|
|
336
343
|
const mappings = Array.isArray(this.route.commandMappings)
|
|
@@ -362,22 +369,20 @@ class RouteRuntime {
|
|
|
362
369
|
clearTimeout(this.reconnectTimer);
|
|
363
370
|
this.reconnectTimer = null;
|
|
364
371
|
}
|
|
365
|
-
|
|
366
|
-
clearInterval(this.heartbeatTimer);
|
|
367
|
-
this.heartbeatTimer = null;
|
|
368
|
-
}
|
|
372
|
+
this.stopAllHeartbeats();
|
|
369
373
|
if (this.server) {
|
|
370
374
|
this.server.close();
|
|
371
375
|
this.server = null;
|
|
372
376
|
}
|
|
373
377
|
this.rejectServerReadyWaiter(new Error(`route ${this.route.name} closed`));
|
|
374
378
|
this.rejectAll(new Error(`route ${this.route.name} closed`));
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
379
|
+
for (const socket of this.getOpenSockets()) {
|
|
380
|
+
if (socket.readyState === ws_1.default.OPEN)
|
|
381
|
+
socket.close(1000, 'plugin disposed');
|
|
382
|
+
else
|
|
383
|
+
socket.terminate();
|
|
380
384
|
}
|
|
385
|
+
this.serverSockets.clear();
|
|
381
386
|
this.socket = null;
|
|
382
387
|
}
|
|
383
388
|
async forward(session, commandName, content = '', sourceCommand = commandName) {
|
|
@@ -401,24 +406,24 @@ class RouteRuntime {
|
|
|
401
406
|
if (this.closed)
|
|
402
407
|
return;
|
|
403
408
|
const push = async () => {
|
|
404
|
-
if (this.kind === 'server')
|
|
409
|
+
if (this.kind === 'server')
|
|
405
410
|
await this.waitForServerSocket();
|
|
406
|
-
|
|
407
|
-
else {
|
|
411
|
+
else
|
|
408
412
|
await this.connect();
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
if (!socket || socket.readyState !== ws_1.default.OPEN) {
|
|
413
|
+
const sockets = this.getOpenSockets();
|
|
414
|
+
if (!sockets.length) {
|
|
412
415
|
throw new Error(`route ${this.route.name} is not connected`);
|
|
413
416
|
}
|
|
414
417
|
if (this.config.debug) {
|
|
415
418
|
this.ctx.logger(exports.name).debug(`route ${this.routeLabel} push event => ${JSON.stringify(event)}`);
|
|
416
419
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
420
|
+
const payload = JSON.stringify(event);
|
|
421
|
+
for (const socket of sockets) {
|
|
422
|
+
socket.send(payload, (error) => {
|
|
423
|
+
if (error)
|
|
424
|
+
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} push event failed: ${error.message}`);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
422
427
|
};
|
|
423
428
|
push().catch(e => this.ctx.logger(exports.name).warn(e));
|
|
424
429
|
}
|
|
@@ -449,11 +454,13 @@ class RouteRuntime {
|
|
|
449
454
|
? {
|
|
450
455
|
msg_type: 2,
|
|
451
456
|
markdown: qq.markdown,
|
|
457
|
+
...(qq.keyboard ? { keyboard: qq.keyboard } : {}),
|
|
452
458
|
...(recentMessageId ? { msg_id: recentMessageId } : {}),
|
|
453
459
|
}
|
|
454
460
|
: {
|
|
455
461
|
content: ' ',
|
|
456
462
|
markdown: qq.markdown,
|
|
463
|
+
...(qq.keyboard ? { keyboard: qq.keyboard } : {}),
|
|
457
464
|
...(recentMessageId ? { msg_id: recentMessageId } : {}),
|
|
458
465
|
};
|
|
459
466
|
const response = isPrivate
|
|
@@ -524,9 +531,8 @@ class RouteRuntime {
|
|
|
524
531
|
});
|
|
525
532
|
socket.once('open', () => {
|
|
526
533
|
this.socket = socket;
|
|
527
|
-
this.lastPong = Date.now();
|
|
528
534
|
this.bindSocket(socket);
|
|
529
|
-
this.startHeartbeat();
|
|
535
|
+
this.startHeartbeat(socket);
|
|
530
536
|
this.ctx.logger(exports.name).info(`route ${this.routeLabel} connected`);
|
|
531
537
|
// Push standard OB11 lifecycle meta_event
|
|
532
538
|
const selfId = toOneBotId(this.ctx.bots[0]?.selfId) || 0;
|
|
@@ -579,13 +585,14 @@ class RouteRuntime {
|
|
|
579
585
|
return;
|
|
580
586
|
}
|
|
581
587
|
}
|
|
582
|
-
if (this.
|
|
583
|
-
this.
|
|
588
|
+
if (this.serverSockets.size >= this.maxConnections) {
|
|
589
|
+
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} rejected connection from ${remote}: connection limit (${this.maxConnections}) reached`);
|
|
590
|
+
socket.close(1013, 'connection limit reached');
|
|
591
|
+
return;
|
|
584
592
|
}
|
|
585
|
-
this.socket
|
|
586
|
-
this.lastPong = Date.now();
|
|
593
|
+
this.serverSockets.add(socket);
|
|
587
594
|
this.bindSocket(socket);
|
|
588
|
-
this.startHeartbeat();
|
|
595
|
+
this.startHeartbeat(socket);
|
|
589
596
|
this.resolveServerReadyWaiter();
|
|
590
597
|
// Push standard OB11 lifecycle meta_event
|
|
591
598
|
const selfId = toOneBotId(this.ctx.bots[0]?.selfId) || 0;
|
|
@@ -599,11 +606,6 @@ class RouteRuntime {
|
|
|
599
606
|
if (socket.readyState === ws_1.default.OPEN) {
|
|
600
607
|
socket.send(JSON.stringify(connectEvent));
|
|
601
608
|
}
|
|
602
|
-
socket.on('close', () => {
|
|
603
|
-
if (this.socket === socket) {
|
|
604
|
-
this.handleDisconnect();
|
|
605
|
-
}
|
|
606
|
-
});
|
|
607
609
|
});
|
|
608
610
|
this.server.on('listening', () => {
|
|
609
611
|
this.ctx.logger(exports.name).info(`route ${this.routeLabel} listening on ws://${listenHost}:${listenPort}`);
|
|
@@ -613,7 +615,7 @@ class RouteRuntime {
|
|
|
613
615
|
});
|
|
614
616
|
}
|
|
615
617
|
async waitForServerSocket() {
|
|
616
|
-
if (this.
|
|
618
|
+
if (this.getOpenSockets().length)
|
|
617
619
|
return;
|
|
618
620
|
const waiter = this.getServerReadyWaiter();
|
|
619
621
|
await waiter.promise;
|
|
@@ -819,12 +821,14 @@ class RouteRuntime {
|
|
|
819
821
|
}
|
|
820
822
|
});
|
|
821
823
|
socket.on('pong', () => {
|
|
822
|
-
|
|
824
|
+
const heartbeat = this.heartbeats.get(socket);
|
|
825
|
+
if (heartbeat)
|
|
826
|
+
heartbeat.lastPong = Date.now();
|
|
823
827
|
});
|
|
824
828
|
socket.on('close', (code, reason) => {
|
|
825
829
|
const text = Buffer.isBuffer(reason) ? reason.toString() : String(reason ?? '');
|
|
826
830
|
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} socket closed: code=${code} reason=${text || 'none'}`);
|
|
827
|
-
this.
|
|
831
|
+
this.handleSocketDisconnect(socket);
|
|
828
832
|
});
|
|
829
833
|
socket.on('error', (error) => {
|
|
830
834
|
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} websocket error: ${error.message}`);
|
|
@@ -842,30 +846,55 @@ class RouteRuntime {
|
|
|
842
846
|
return null;
|
|
843
847
|
}
|
|
844
848
|
}
|
|
845
|
-
|
|
846
|
-
if (this.
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
if (!socket || socket.readyState !== ws_1.default.OPEN)
|
|
851
|
-
return;
|
|
852
|
-
const elapsed = Date.now() - this.lastPong;
|
|
853
|
-
if (elapsed > this.route.heartbeatInterval * 2) {
|
|
854
|
-
socket.terminate();
|
|
855
|
-
return;
|
|
856
|
-
}
|
|
857
|
-
socket.ping();
|
|
858
|
-
}, this.route.heartbeatInterval);
|
|
849
|
+
getOpenSockets() {
|
|
850
|
+
if (this.kind === 'client') {
|
|
851
|
+
return this.socket?.readyState === ws_1.default.OPEN ? [this.socket] : [];
|
|
852
|
+
}
|
|
853
|
+
return [...this.serverSockets].filter(socket => socket.readyState === ws_1.default.OPEN);
|
|
859
854
|
}
|
|
860
|
-
|
|
861
|
-
this.socket
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
855
|
+
startHeartbeat(socket) {
|
|
856
|
+
this.stopHeartbeat(socket);
|
|
857
|
+
const heartbeat = {
|
|
858
|
+
lastPong: Date.now(),
|
|
859
|
+
timer: setInterval(() => {
|
|
860
|
+
if (socket.readyState !== ws_1.default.OPEN)
|
|
861
|
+
return;
|
|
862
|
+
const elapsed = Date.now() - heartbeat.lastPong;
|
|
863
|
+
if (elapsed > this.route.heartbeatInterval * 2) {
|
|
864
|
+
socket.terminate();
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
socket.ping();
|
|
868
|
+
}, this.route.heartbeatInterval),
|
|
869
|
+
};
|
|
870
|
+
this.heartbeats.set(socket, heartbeat);
|
|
871
|
+
}
|
|
872
|
+
stopHeartbeat(socket) {
|
|
873
|
+
const heartbeat = this.heartbeats.get(socket);
|
|
874
|
+
if (!heartbeat)
|
|
875
|
+
return;
|
|
876
|
+
clearInterval(heartbeat.timer);
|
|
877
|
+
this.heartbeats.delete(socket);
|
|
878
|
+
}
|
|
879
|
+
stopAllHeartbeats() {
|
|
880
|
+
for (const { timer } of this.heartbeats.values())
|
|
881
|
+
clearInterval(timer);
|
|
882
|
+
this.heartbeats.clear();
|
|
883
|
+
}
|
|
884
|
+
handleSocketDisconnect(socket) {
|
|
885
|
+
this.stopHeartbeat(socket);
|
|
886
|
+
if (this.kind === 'server') {
|
|
887
|
+
this.serverSockets.delete(socket);
|
|
888
|
+
if (!this.serverSockets.size) {
|
|
889
|
+
this.rejectServerReadyWaiter(new Error(`route ${this.route.name} disconnected`));
|
|
890
|
+
}
|
|
891
|
+
return;
|
|
865
892
|
}
|
|
866
|
-
|
|
893
|
+
if (this.socket !== socket)
|
|
894
|
+
return;
|
|
895
|
+
this.socket = null;
|
|
867
896
|
this.rejectAll(new Error(`route ${this.route.name} disconnected`));
|
|
868
|
-
if (!this.closed
|
|
897
|
+
if (!this.closed) {
|
|
869
898
|
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} scheduling reconnect in ${this.route.reconnectInterval}ms`);
|
|
870
899
|
this.scheduleReconnect();
|
|
871
900
|
}
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
一个面向 Koishi 的 OneBot v11 指令中转路由插件。插件仅接管配置中指定的指令,将会话转换为 OneBot v11 消息事件并通过 WebSocket 交给外部后端处理;未配置的指令保持 Koishi 原有处理流程,不受影响。
|
|
6
6
|
|
|
7
|
-
后端可以通过标准 OneBot API 动作向群聊或私聊回发文本、图片、语音等消息。针对 QQ 官方适配器,插件还支持原生 Markdown
|
|
7
|
+
后端可以通过标准 OneBot API 动作向群聊或私聊回发文本、图片、语音等消息。针对 QQ 官方适配器,插件还支持原生 Markdown 文本和 Markdown 键盘,并在不支持 Markdown 的平台上自动降级为普通文本。
|
|
8
8
|
|
|
9
9
|
## 主要用途
|
|
10
10
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
- **手动或动态转译**:可以在配置页维护指令映射,也可以由后端通过接口动态声明
|
|
13
13
|
- **Koishi 指令控制**:映射可配置权限、帮助菜单隐藏、调用频率、群聊/私聊范围和平台限制
|
|
14
14
|
- **OneBot v11 兼容**:向后端推送标准消息事件,并处理常用 OneBot API 动作
|
|
15
|
-
- **双向 WebSocket
|
|
15
|
+
- **双向 WebSocket**:支持插件主动连接后端,也支持插件监听并同时维持多个后端连接
|
|
16
16
|
- **多路由配置**:不同指令可以连接不同后端,分别设置地址、鉴权和连接参数
|
|
17
17
|
- **消息类型转换**:支持文本、图片、@、回复、语音、视频和表情等常用消息段
|
|
18
18
|
- **QQ Markdown**:支持 QQ 原生 Markdown 文本,其他平台自动使用普通文本降级
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
- `heartbeatInterval`:发送 ping 的间隔,单位毫秒
|
|
49
49
|
- `reconnectInterval`:断线后的重连间隔,单位毫秒
|
|
50
50
|
- `maxConcurrency`:同时允许的请求数量
|
|
51
|
+
- `maxConnections`:仅 server 模式有效,同时允许接入的后端连接数量上限,默认 `16`
|
|
51
52
|
|
|
52
53
|
### 反向 WS 路由列表
|
|
53
54
|
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
- `heartbeatInterval`:发送 ping 的间隔,单位毫秒
|
|
68
69
|
- `reconnectInterval`:断线后的重连间隔,单位毫秒
|
|
69
70
|
- `maxConcurrency`:同时允许的请求数量
|
|
71
|
+
- `maxConnections`:同时允许接入的后端连接数量上限,默认 `16`;每个指令事件会广播给全部已连接后端
|
|
70
72
|
|
|
71
73
|
## 推荐配置示例
|
|
72
74
|
|
|
@@ -86,7 +88,8 @@
|
|
|
86
88
|
"timeout": 10000,
|
|
87
89
|
"heartbeatInterval": 30000,
|
|
88
90
|
"reconnectInterval": 5000,
|
|
89
|
-
"maxConcurrency": 16
|
|
91
|
+
"maxConcurrency": 16,
|
|
92
|
+
"maxConnections": 16
|
|
90
93
|
}
|
|
91
94
|
],
|
|
92
95
|
"serverRoutes": [
|
|
@@ -103,7 +106,8 @@
|
|
|
103
106
|
"timeout": 10000,
|
|
104
107
|
"heartbeatInterval": 30000,
|
|
105
108
|
"reconnectInterval": 5000,
|
|
106
|
-
"maxConcurrency": 16
|
|
109
|
+
"maxConcurrency": 16,
|
|
110
|
+
"maxConnections": 16
|
|
107
111
|
}
|
|
108
112
|
]
|
|
109
113
|
}
|
|
@@ -113,7 +117,7 @@
|
|
|
113
117
|
|
|
114
118
|
- `client` 模式:插件主动连接后端
|
|
115
119
|
- `server` 模式:插件自己监听,等待后端连接
|
|
116
|
-
- `server`
|
|
120
|
+
- `server` 模式下可以同时连接多个后端;每个命令事件会广播给所有在线连接。若还没有任何后端连上来,命令会等待到超时为止
|
|
117
121
|
|
|
118
122
|
## 协议约定
|
|
119
123
|
|
|
@@ -255,9 +259,9 @@
|
|
|
255
259
|
- 插件不会覆盖其他 Koishi 插件已经注册的指令,冲突项会出现在 `rejected` 中
|
|
256
260
|
- 只配置动态声明、不填写静态 `commands` 的路由也会启动并等待后端声明
|
|
257
261
|
|
|
258
|
-
### QQ Markdown
|
|
262
|
+
### QQ Markdown 与键盘
|
|
259
263
|
|
|
260
|
-
后端通过 `send_group_msg`、`send_private_msg` 或 `send_msg` 回发消息时,可以使用 QQ 原生 Markdown 文本。插件在 QQ 官方适配器上会直接提交 `markdown.content`;在其他平台上会把同一内容作为普通文本降级发送。
|
|
264
|
+
后端通过 `send_group_msg`、`send_private_msg` 或 `send_msg` 回发消息时,可以使用 QQ 原生 Markdown 文本。插件在 QQ 官方适配器上会直接提交 `markdown.content`,并透传 `keyboard`;在其他平台上会把同一内容作为普通文本降级发送。
|
|
261
265
|
|
|
262
266
|
此功能可通过配置页面的 `enableQQMarkdown` 开关控制。关闭后不会调用 QQ 原生 Markdown 接口,而是把 `content` 或 `fallback_text` 作为普通消息发送。
|
|
263
267
|
|
|
@@ -271,7 +275,12 @@
|
|
|
271
275
|
"type": "markdown",
|
|
272
276
|
"data": {
|
|
273
277
|
"content": "# 标题\n**加粗内容**\n[查看详情](https://example.com)",
|
|
274
|
-
"fallback_text": "标题\n加粗内容\nhttps://example.com"
|
|
278
|
+
"fallback_text": "标题\n加粗内容\nhttps://example.com",
|
|
279
|
+
"keyboard": {
|
|
280
|
+
"content": {
|
|
281
|
+
"rows": []
|
|
282
|
+
}
|
|
283
|
+
}
|
|
275
284
|
}
|
|
276
285
|
}
|
|
277
286
|
]
|