koishi-plugin-msg-router 1.4.2 → 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,
@@ -307,15 +308,15 @@ class RouteRuntime {
307
308
  declareCommands;
308
309
  socket = null;
309
310
  server = null;
311
+ serverSockets = new Set();
310
312
  connecting = null;
311
313
  reconnectTimer = null;
312
- heartbeatTimer = null;
314
+ heartbeats = new Map();
313
315
  serverReadyWaiter = null;
314
316
  pending = new Map();
315
317
  inflight = 0;
316
318
  queue = [];
317
319
  closed = false;
318
- lastPong = 0;
319
320
  recentGroupSessions = new Map();
320
321
  recentPrivateSessions = new Map();
321
322
  constructor(ctx, config, route, declareCommands) {
@@ -334,6 +335,9 @@ class RouteRuntime {
334
335
  get routeLabel() {
335
336
  return `${this.route.name}(${this.kind})`;
336
337
  }
338
+ get maxConnections() {
339
+ return Math.max(1, Number(this.route.maxConnections) || 16);
340
+ }
337
341
  get enabled() {
338
342
  const commands = Array.isArray(this.route.commands) ? this.route.commands : [];
339
343
  const mappings = Array.isArray(this.route.commandMappings)
@@ -365,22 +369,20 @@ class RouteRuntime {
365
369
  clearTimeout(this.reconnectTimer);
366
370
  this.reconnectTimer = null;
367
371
  }
368
- if (this.heartbeatTimer) {
369
- clearInterval(this.heartbeatTimer);
370
- this.heartbeatTimer = null;
371
- }
372
+ this.stopAllHeartbeats();
372
373
  if (this.server) {
373
374
  this.server.close();
374
375
  this.server = null;
375
376
  }
376
377
  this.rejectServerReadyWaiter(new Error(`route ${this.route.name} closed`));
377
378
  this.rejectAll(new Error(`route ${this.route.name} closed`));
378
- if (this.socket && this.socket.readyState === ws_1.default.OPEN) {
379
- this.socket.close(1000, 'plugin disposed');
380
- }
381
- else if (this.socket) {
382
- 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();
383
384
  }
385
+ this.serverSockets.clear();
384
386
  this.socket = null;
385
387
  }
386
388
  async forward(session, commandName, content = '', sourceCommand = commandName) {
@@ -404,24 +406,24 @@ class RouteRuntime {
404
406
  if (this.closed)
405
407
  return;
406
408
  const push = async () => {
407
- if (this.kind === 'server') {
409
+ if (this.kind === 'server')
408
410
  await this.waitForServerSocket();
409
- }
410
- else {
411
+ else
411
412
  await this.connect();
412
- }
413
- const socket = this.socket;
414
- if (!socket || socket.readyState !== ws_1.default.OPEN) {
413
+ const sockets = this.getOpenSockets();
414
+ if (!sockets.length) {
415
415
  throw new Error(`route ${this.route.name} is not connected`);
416
416
  }
417
417
  if (this.config.debug) {
418
418
  this.ctx.logger(exports.name).debug(`route ${this.routeLabel} push event => ${JSON.stringify(event)}`);
419
419
  }
420
- socket.send(JSON.stringify(event), (error) => {
421
- if (error) {
422
- this.ctx.logger(exports.name).warn(`route ${this.routeLabel} push event failed: ${error.message}`);
423
- }
424
- });
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
+ }
425
427
  };
426
428
  push().catch(e => this.ctx.logger(exports.name).warn(e));
427
429
  }
@@ -529,9 +531,8 @@ class RouteRuntime {
529
531
  });
530
532
  socket.once('open', () => {
531
533
  this.socket = socket;
532
- this.lastPong = Date.now();
533
534
  this.bindSocket(socket);
534
- this.startHeartbeat();
535
+ this.startHeartbeat(socket);
535
536
  this.ctx.logger(exports.name).info(`route ${this.routeLabel} connected`);
536
537
  // Push standard OB11 lifecycle meta_event
537
538
  const selfId = toOneBotId(this.ctx.bots[0]?.selfId) || 0;
@@ -584,13 +585,14 @@ class RouteRuntime {
584
585
  return;
585
586
  }
586
587
  }
587
- if (this.socket && this.socket !== socket) {
588
- 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;
589
592
  }
590
- this.socket = socket;
591
- this.lastPong = Date.now();
593
+ this.serverSockets.add(socket);
592
594
  this.bindSocket(socket);
593
- this.startHeartbeat();
595
+ this.startHeartbeat(socket);
594
596
  this.resolveServerReadyWaiter();
595
597
  // Push standard OB11 lifecycle meta_event
596
598
  const selfId = toOneBotId(this.ctx.bots[0]?.selfId) || 0;
@@ -604,11 +606,6 @@ class RouteRuntime {
604
606
  if (socket.readyState === ws_1.default.OPEN) {
605
607
  socket.send(JSON.stringify(connectEvent));
606
608
  }
607
- socket.on('close', () => {
608
- if (this.socket === socket) {
609
- this.handleDisconnect();
610
- }
611
- });
612
609
  });
613
610
  this.server.on('listening', () => {
614
611
  this.ctx.logger(exports.name).info(`route ${this.routeLabel} listening on ws://${listenHost}:${listenPort}`);
@@ -618,7 +615,7 @@ class RouteRuntime {
618
615
  });
619
616
  }
620
617
  async waitForServerSocket() {
621
- if (this.socket?.readyState === ws_1.default.OPEN)
618
+ if (this.getOpenSockets().length)
622
619
  return;
623
620
  const waiter = this.getServerReadyWaiter();
624
621
  await waiter.promise;
@@ -824,12 +821,14 @@ class RouteRuntime {
824
821
  }
825
822
  });
826
823
  socket.on('pong', () => {
827
- this.lastPong = Date.now();
824
+ const heartbeat = this.heartbeats.get(socket);
825
+ if (heartbeat)
826
+ heartbeat.lastPong = Date.now();
828
827
  });
829
828
  socket.on('close', (code, reason) => {
830
829
  const text = Buffer.isBuffer(reason) ? reason.toString() : String(reason ?? '');
831
830
  this.ctx.logger(exports.name).warn(`route ${this.routeLabel} socket closed: code=${code} reason=${text || 'none'}`);
832
- this.handleDisconnect();
831
+ this.handleSocketDisconnect(socket);
833
832
  });
834
833
  socket.on('error', (error) => {
835
834
  this.ctx.logger(exports.name).warn(`route ${this.routeLabel} websocket error: ${error.message}`);
@@ -847,30 +846,55 @@ class RouteRuntime {
847
846
  return null;
848
847
  }
849
848
  }
850
- startHeartbeat() {
851
- if (this.heartbeatTimer)
852
- clearInterval(this.heartbeatTimer);
853
- this.heartbeatTimer = setInterval(() => {
854
- const socket = this.socket;
855
- if (!socket || socket.readyState !== ws_1.default.OPEN)
856
- return;
857
- const elapsed = Date.now() - this.lastPong;
858
- if (elapsed > this.route.heartbeatInterval * 2) {
859
- socket.terminate();
860
- return;
861
- }
862
- socket.ping();
863
- }, 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);
864
854
  }
865
- handleDisconnect() {
866
- this.socket = null;
867
- if (this.heartbeatTimer) {
868
- clearInterval(this.heartbeatTimer);
869
- 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;
870
892
  }
871
- this.rejectServerReadyWaiter(new Error(`route ${this.route.name} disconnected`));
893
+ if (this.socket !== socket)
894
+ return;
895
+ this.socket = null;
872
896
  this.rejectAll(new Error(`route ${this.route.name} disconnected`));
873
- if (!this.closed && this.kind === 'client') {
897
+ if (!this.closed) {
874
898
  this.ctx.logger(exports.name).warn(`route ${this.routeLabel} scheduling reconnect in ${this.route.reconnectInterval}ms`);
875
899
  this.scheduleReconnect();
876
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.2",
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
@@ -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