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 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
- heartbeatTimer = null;
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
- if (this.heartbeatTimer) {
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
- if (this.socket && this.socket.readyState === ws_1.default.OPEN) {
376
- this.socket.close(1000, 'plugin disposed');
377
- }
378
- else if (this.socket) {
379
- this.socket.terminate();
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
- const socket = this.socket;
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
- socket.send(JSON.stringify(event), (error) => {
418
- if (error) {
419
- this.ctx.logger(exports.name).warn(`route ${this.routeLabel} push event failed: ${error.message}`);
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.socket && this.socket !== socket) {
583
- this.socket.terminate();
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 = 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.socket?.readyState === ws_1.default.OPEN)
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
- this.lastPong = Date.now();
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.handleDisconnect();
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
- startHeartbeat() {
846
- if (this.heartbeatTimer)
847
- clearInterval(this.heartbeatTimer);
848
- this.heartbeatTimer = setInterval(() => {
849
- const socket = this.socket;
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
- handleDisconnect() {
861
- this.socket = null;
862
- if (this.heartbeatTimer) {
863
- clearInterval(this.heartbeatTimer);
864
- this.heartbeatTimer = null;
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
- this.rejectServerReadyWaiter(new Error(`route ${this.route.name} disconnected`));
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 && this.kind === 'client') {
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-msg-router",
3
3
  "description": "Koishi OneBot 指令路由:支持手动/动态转译、权限与频率控制、双向 WebSocket 和 QQ Markdown",
4
- "version": "1.4.0",
4
+ "version": "1.4.3",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
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 文本,并在不支持 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
  ]