koishi-plugin-msg-router 1.0.2 → 1.3.0

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
@@ -1,5 +1,11 @@
1
1
  import { Context, Schema } from 'koishi';
2
2
  export declare const name = "msg-router";
3
+ export interface CommandMappingConfig {
4
+ enabled: boolean;
5
+ source: string;
6
+ target: string;
7
+ description: string;
8
+ }
3
9
  export interface ClientRouteConfig {
4
10
  name: string;
5
11
  mode: 'client';
@@ -8,6 +14,7 @@ export interface ClientRouteConfig {
8
14
  token: string;
9
15
  secret: string;
10
16
  commands: string[];
17
+ commandMappings: CommandMappingConfig[];
11
18
  action: string;
12
19
  timeout: number;
13
20
  heartbeatInterval: number;
@@ -23,6 +30,7 @@ export interface ServerRouteConfig {
23
30
  token: string;
24
31
  secret: string;
25
32
  commands: string[];
33
+ commandMappings: CommandMappingConfig[];
26
34
  action: string;
27
35
  timeout: number;
28
36
  heartbeatInterval: number;
@@ -32,6 +40,11 @@ export interface ServerRouteConfig {
32
40
  export type RouteConfig = ClientRouteConfig | ServerRouteConfig;
33
41
  export interface Config {
34
42
  debug: boolean;
43
+ enableQQMarkdown: boolean;
44
+ enableCommandDeclaration: boolean;
45
+ enableCommandTranslation: boolean;
46
+ commandDeclarationAction: string;
47
+ maxDeclaredCommands: number;
35
48
  clientRoutes: ClientRouteConfig[];
36
49
  serverRoutes: ServerRouteConfig[];
37
50
  }
package/lib/index.js CHANGED
@@ -10,11 +10,17 @@ const koishi_1 = require("koishi");
10
10
  exports.name = 'msg-router';
11
11
  const CommonRouteConfig = {
12
12
  name: koishi_1.Schema.string().default('后端路由').description('这条路由的显示名称'),
13
- enabled: koishi_1.Schema.boolean().default(true).description('是否启用这条路由'),
13
+ enabled: koishi_1.Schema.boolean().default(true).description('是否启用这条路由;关闭后不会注册或拦截本路由配置的指令'),
14
14
  token: koishi_1.Schema.string().role('secret').default('').description('后端鉴权令牌,可留空'),
15
15
  secret: koishi_1.Schema.string().role('secret').default('').description('HMAC 签名密钥,可留空'),
16
- commands: koishi_1.Schema.array(koishi_1.Schema.string()).default([]).description('触发这条路由的命令列表'),
17
- action: koishi_1.Schema.string().default('msg_router.forward_command').description('发送给后端的 action 名称'),
16
+ commands: koishi_1.Schema.array(koishi_1.Schema.string()).default([]).description('本路由需要接管的 Koishi 指令;未填写的其他指令不受插件影响'),
17
+ commandMappings: koishi_1.Schema.array(koishi_1.Schema.object({
18
+ enabled: koishi_1.Schema.boolean().default(true).description('是否启用这条手动映射'),
19
+ source: koishi_1.Schema.string().required().description('用户侧需要接管的指令名,例如:天气'),
20
+ target: koishi_1.Schema.string().required().description('发送给后端的目标指令名,例如:weather'),
21
+ description: koishi_1.Schema.string().default('').description('显示在 Koishi 指令系统中的说明,可留空'),
22
+ })).role('table').default([]).description('手动指令转译表。映射可以独立于 commands 使用;同名映射会覆盖本路由 commands 中的直通规则'),
23
+ action: koishi_1.Schema.string().default('msg_router.forward_command').description('兼容配置字段;消息事件当前按 OneBot v11 标准直接推送'),
18
24
  timeout: koishi_1.Schema.number().default(10000).description('单次请求超时时间,单位毫秒'),
19
25
  heartbeatInterval: koishi_1.Schema.number().default(30000).description('心跳间隔,单位毫秒'),
20
26
  reconnectInterval: koishi_1.Schema.number().default(5000).description('断线后重连间隔,单位毫秒'),
@@ -36,16 +42,33 @@ const RouteConfig = koishi_1.Schema.union([
36
42
  ServerRouteConfig,
37
43
  ]).role('table');
38
44
  exports.Config = koishi_1.Schema.object({
39
- debug: koishi_1.Schema.boolean().default(false).description('是否输出调试日志'),
45
+ enableQQMarkdown: koishi_1.Schema.boolean()
46
+ .default(true)
47
+ .description('启用 QQ 原生 Markdown 文本。后端可使用 message 中的 markdown 段,或直接声明 params.markdown.content;关闭后按普通文本降级发送'),
48
+ enableCommandDeclaration: koishi_1.Schema.boolean()
49
+ .default(false)
50
+ .description('允许已连接的 OneBot 后端动态声明本路由要拦截的指令。为避免后端意外接管指令,默认关闭'),
51
+ enableCommandTranslation: koishi_1.Schema.boolean()
52
+ .default(true)
53
+ .description('允许声明接口把一个对外指令映射为另一个后端指令;关闭后仍可动态声明,但目标指令固定为原指令'),
54
+ commandDeclarationAction: koishi_1.Schema.string()
55
+ .default('msg_router.declare_commands')
56
+ .description('后端声明指令所调用的 WebSocket action。请求格式:{ action, params: { commands: [{ name: "对外指令", target: "后端指令" }], replace: true }, echo }'),
57
+ maxDeclaredCommands: koishi_1.Schema.natural()
58
+ .min(1)
59
+ .max(1000)
60
+ .default(100)
61
+ .description('每条路由最多允许后端动态声明的指令数量'),
62
+ debug: koishi_1.Schema.boolean().default(false).description('输出 WebSocket 收发内容、指令匹配和消息解析等调试日志'),
40
63
  clientRoutes: koishi_1.Schema.array(ClientRouteConfig)
41
64
  .role('table')
42
65
  .default([])
43
- .description('正向 WS 路由列表,插件主动连接后端'),
66
+ .description('正向 WebSocket 接口:插件主动连接后端,将指定指令转换为 OneBot v11 消息事件;后端可调用 send_msg、send_group_msg、send_private_msg 等动作回发消息'),
44
67
  serverRoutes: koishi_1.Schema.array(ServerRouteConfig)
45
68
  .role('table')
46
69
  .default([])
47
- .description('反向 WS 路由列表,插件监听等待后端连接'),
48
- });
70
+ .description('反向 WebSocket 接口:插件在指定地址监听,等待后端连接;事件和后端动作格式与 OneBot v11 一致'),
71
+ }).description('仅接管路由中配置或由后端声明的指令,不覆盖其他 Koishi 指令。每条路由可通过 commandMappings 手动设置“对外指令 → 后端指令”,也可调用 msg_router.declare_commands 动态声明。后端回发接口:send_msg、send_group_msg、send_private_msg;管理接口:delete_msg、get_login_info、set_group_ban、set_group_kick、get_group_member_info、get_group_info。');
49
72
  function toOneBotId(value) {
50
73
  if (value == null)
51
74
  return undefined;
@@ -59,6 +82,61 @@ function toOneBotId(value) {
59
82
  }
60
83
  return undefined;
61
84
  }
85
+ function firstMessageId(value) {
86
+ const first = Array.isArray(value) ? value[0] : value;
87
+ if (first == null || first === '')
88
+ return Math.floor(Math.random() * 1000000);
89
+ if (typeof first === 'number')
90
+ return first;
91
+ const numeric = Number(first);
92
+ return Number.isFinite(numeric) ? numeric : String(first);
93
+ }
94
+ /** Extracts QQ's native Markdown payload without letting generic Satori conversion discard it. */
95
+ function extractQQMarkdown(value) {
96
+ const segments = Array.isArray(value) ? value : [value];
97
+ let markdownSource;
98
+ const remaining = [];
99
+ for (const item of segments) {
100
+ if (!item || typeof item !== 'object') {
101
+ remaining.push(item);
102
+ continue;
103
+ }
104
+ const segment = item;
105
+ if (segment.type === 'markdown') {
106
+ const source = segment.data?.markdown ?? segment.data ?? {};
107
+ markdownSource = typeof source === 'string' ? { content: source } : source;
108
+ }
109
+ else if (!Array.isArray(value) && segment.markdown) {
110
+ markdownSource = typeof segment.markdown === 'string'
111
+ ? { content: segment.markdown }
112
+ : segment.markdown;
113
+ }
114
+ else {
115
+ remaining.push(item);
116
+ }
117
+ }
118
+ if (!markdownSource)
119
+ return;
120
+ const content = markdownSource.content == null ? undefined : String(markdownSource.content);
121
+ if (!content)
122
+ return;
123
+ const fallback = String(markdownSource.fallback_text
124
+ ?? markdownSource.fallback
125
+ ?? markdownSource.prompt
126
+ ?? content
127
+ ?? '');
128
+ return {
129
+ markdown: { content },
130
+ fallback,
131
+ remaining,
132
+ };
133
+ }
134
+ function resolveOutgoingMessage(params) {
135
+ if (params?.markdown) {
136
+ return { markdown: params.markdown };
137
+ }
138
+ return params?.message;
139
+ }
62
140
  function toElements(value) {
63
141
  if (value == null)
64
142
  return [];
@@ -106,6 +184,18 @@ function toElements(value) {
106
184
  if (segment.type === 'reply') {
107
185
  return [(0, koishi_1.h)('quote', { id: segment.data?.id || '' })];
108
186
  }
187
+ if (segment.type === 'markdown') {
188
+ const source = segment.data?.markdown ?? segment.data ?? {};
189
+ const fallback = source.fallback_text ?? source.fallback ?? source.prompt ?? source.content;
190
+ return fallback == null ? [] : [koishi_1.h.text(String(fallback))];
191
+ }
192
+ if (segment.type === 'keyboard')
193
+ return [];
194
+ if (segment.markdown !== undefined) {
195
+ const source = segment.markdown ?? {};
196
+ const fallback = source.fallback_text ?? source.fallback ?? source.prompt ?? source.content;
197
+ return fallback == null ? [] : [koishi_1.h.text(String(fallback))];
198
+ }
109
199
  if (segment.reply !== undefined)
110
200
  return toElements(segment.reply);
111
201
  if (segment.message !== undefined)
@@ -160,15 +250,16 @@ function transformElements(elements) {
160
250
  }
161
251
  return result;
162
252
  }
163
- function buildOneBotMessageEvent(session, commandName, content) {
164
- const text = session?.content || (commandName + ' ' + (content ?? '')).trim();
253
+ function buildOneBotMessageEvent(session, commandName, content, translated = false) {
254
+ const routedText = (commandName + ' ' + (content ?? '')).trim();
255
+ const text = translated ? routedText : (session?.content || routedText);
165
256
  const isGroup = Boolean(session?.guildId != null
166
257
  || (session?.channelId != null && String(session.channelId) !== String(session?.userId ?? '')));
167
258
  const userId = toOneBotId(session?.userId);
168
259
  const groupId = isGroup ? toOneBotId(session?.guildId ?? session?.channelId) : undefined;
169
260
  const selfId = toOneBotId(session?.selfId ?? session?.bot?.selfId);
170
261
  const messageId = toOneBotId(session?.messageId);
171
- const elements = session?.elements || koishi_1.h.parse(text);
262
+ const elements = translated ? koishi_1.h.parse(text) : (session?.elements || koishi_1.h.parse(text));
172
263
  const messageSegments = transformElements(elements);
173
264
  return {
174
265
  time: Math.floor(Date.now() / 1000),
@@ -192,6 +283,7 @@ class RouteRuntime {
192
283
  ctx;
193
284
  config;
194
285
  route;
286
+ declareCommands;
195
287
  socket = null;
196
288
  server = null;
197
289
  connecting = null;
@@ -205,10 +297,11 @@ class RouteRuntime {
205
297
  lastPong = 0;
206
298
  recentGroupSessions = new Map();
207
299
  recentPrivateSessions = new Map();
208
- constructor(ctx, config, route) {
300
+ constructor(ctx, config, route, declareCommands) {
209
301
  this.ctx = ctx;
210
302
  this.config = config;
211
303
  this.route = route;
304
+ this.declareCommands = declareCommands;
212
305
  }
213
306
  get kind() {
214
307
  if (this.route.mode === 'client')
@@ -222,7 +315,10 @@ class RouteRuntime {
222
315
  }
223
316
  get enabled() {
224
317
  const commands = Array.isArray(this.route.commands) ? this.route.commands : [];
225
- if (!this.route.enabled || !commands.length)
318
+ const mappings = Array.isArray(this.route.commandMappings)
319
+ ? this.route.commandMappings.filter(item => item?.enabled !== false)
320
+ : [];
321
+ if (!this.route.enabled || (!commands.length && !mappings.length && !this.config.enableCommandDeclaration))
226
322
  return false;
227
323
  if (this.kind === 'client')
228
324
  return !!('endpoint' in this.route && this.route.endpoint && this.route.endpoint.length > 0);
@@ -266,15 +362,12 @@ class RouteRuntime {
266
362
  }
267
363
  this.socket = null;
268
364
  }
269
- async forward(session, commandName, content = '') {
270
- if (!Array.isArray(this.route.commands)) {
271
- throw new Error(`route ${this.route.name} has no commands configured`);
272
- }
273
- this.ctx.logger(exports.name).info(`route ${this.routeLabel} command trigger: command=${commandName} user=${session.userId ?? 'unknown'} channel=${session.channelId ?? 'unknown'}`);
365
+ async forward(session, commandName, content = '', sourceCommand = commandName) {
366
+ this.ctx.logger(exports.name).info(`route ${this.routeLabel} command trigger: source=${sourceCommand} target=${commandName} user=${session.userId ?? 'unknown'} channel=${session.channelId ?? 'unknown'}`);
274
367
  if (this.config.debug) {
275
368
  this.ctx.logger(exports.name).debug(`route ${this.routeLabel} command content: ${JSON.stringify(content)}`);
276
369
  }
277
- const event = buildOneBotMessageEvent(session, commandName, content);
370
+ const event = buildOneBotMessageEvent(session, commandName, content, sourceCommand !== commandName);
278
371
  const isGroup = Boolean(session?.guildId != null
279
372
  || (session?.channelId != null && String(session.channelId) !== String(session?.userId ?? '')));
280
373
  if (isGroup && session?.channelId) {
@@ -311,6 +404,58 @@ class RouteRuntime {
311
404
  };
312
405
  push().catch(e => this.ctx.logger(exports.name).warn(e));
313
406
  }
407
+ async sendRoutedMessage(bot, session, targetId, isPrivate, message) {
408
+ const qq = this.config.enableQQMarkdown !== false ? extractQQMarkdown(message) : undefined;
409
+ const isQQ = session?.platform === 'qq' || bot?.platform === 'qq';
410
+ const internal = bot?.internal;
411
+ const isQQPublicBot = typeof internal?.sendPrivateMessage === 'function';
412
+ const canSendNative = isQQ && typeof internal?.sendMessage === 'function'
413
+ && (!isPrivate || isQQPublicBot);
414
+ if (!qq || !canSendNative) {
415
+ const elements = qq
416
+ ? [...(qq.fallback ? [koishi_1.h.text(qq.fallback)] : []), ...toElements(qq.remaining)]
417
+ : toElements(message);
418
+ if (!elements.length)
419
+ return [];
420
+ return session
421
+ ? await session.send(elements)
422
+ : isPrivate
423
+ ? await bot.sendPrivateMessage(targetId, elements)
424
+ : await bot.sendMessage(targetId, elements);
425
+ }
426
+ const recentMessageId = session?.messageId
427
+ && (!session.timestamp || Date.now() - session.timestamp < 5 * 60 * 1000)
428
+ ? String(session.messageId)
429
+ : undefined;
430
+ const request = isQQPublicBot
431
+ ? {
432
+ msg_type: 2,
433
+ markdown: qq.markdown,
434
+ ...(recentMessageId ? { msg_id: recentMessageId } : {}),
435
+ }
436
+ : {
437
+ content: ' ',
438
+ markdown: qq.markdown,
439
+ ...(recentMessageId ? { msg_id: recentMessageId } : {}),
440
+ };
441
+ const response = isPrivate
442
+ ? await internal.sendPrivateMessage(targetId, request)
443
+ : await internal.sendMessage(targetId, request);
444
+ const ids = response?.id ? [response.id] : [];
445
+ // The Markdown segment forms one native QQ message. Preserve any other
446
+ // OneBot segments by sending them as a second, ordinary message.
447
+ const remaining = toElements(qq.remaining);
448
+ if (remaining.length) {
449
+ const extra = session
450
+ ? await session.send(remaining)
451
+ : isPrivate
452
+ ? await bot.sendPrivateMessage(targetId, remaining)
453
+ : await bot.sendMessage(targetId, remaining);
454
+ if (Array.isArray(extra))
455
+ ids.push(...extra);
456
+ }
457
+ return ids;
458
+ }
314
459
  acquireSlot() {
315
460
  if (this.inflight < this.route.maxConcurrency) {
316
461
  this.inflight += 1;
@@ -466,15 +611,39 @@ class RouteRuntime {
466
611
  }
467
612
  if (data.action) {
468
613
  const sendResponse = (response) => {
469
- if (data.echo)
614
+ if (data.echo !== undefined)
470
615
  response.echo = data.echo;
471
616
  if (socket.readyState === ws_1.default.OPEN) {
472
617
  socket.send(JSON.stringify(response));
473
618
  }
474
619
  };
620
+ const declarationAction = this.config.commandDeclarationAction || 'msg_router.declare_commands';
621
+ if (data.action === declarationAction) {
622
+ if (!this.config.enableCommandDeclaration) {
623
+ return sendResponse({
624
+ status: 'failed',
625
+ retcode: 103,
626
+ msg: 'Dynamic command declaration is disabled',
627
+ data: null,
628
+ });
629
+ }
630
+ try {
631
+ const result = this.declareCommands(data.params);
632
+ this.ctx.logger(exports.name).info(`route ${this.routeLabel} declared=${result.declared.length} removed=${result.removed.length} rejected=${result.rejected.length}`);
633
+ return sendResponse({ status: 'ok', retcode: 0, data: result });
634
+ }
635
+ catch (error) {
636
+ return sendResponse({
637
+ status: 'failed',
638
+ retcode: 100,
639
+ msg: error instanceof Error ? error.message : String(error),
640
+ data: null,
641
+ });
642
+ }
643
+ }
475
644
  if (data.action === 'send_private_msg' || (data.action === 'send_msg' && data.params?.message_type === 'private')) {
476
645
  const userId = data.params?.user_id;
477
- const message = data.params?.message;
646
+ const message = resolveOutgoingMessage(data.params);
478
647
  if (userId == null || message == null) {
479
648
  return sendResponse({ status: 'failed', retcode: 100, msg: 'Missing user_id or message', data: null });
480
649
  }
@@ -482,8 +651,8 @@ class RouteRuntime {
482
651
  const session = this.recentPrivateSessions.get(String(userId));
483
652
  if (session && (!data.params?.self_id || session.bot?.selfId === String(data.params?.self_id))) {
484
653
  try {
485
- const msgIds = await session.send(toElements(message));
486
- const msgId = (Array.isArray(msgIds) && msgIds.length > 0) ? (Number(msgIds[0]) || Math.floor(Math.random() * 1000000)) : (typeof msgIds === 'string' ? msgIds : Math.floor(Math.random() * 1000000));
654
+ const msgIds = await this.sendRoutedMessage(session.bot ?? bot, session, String(userId), true, message);
655
+ const msgId = firstMessageId(msgIds);
487
656
  sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
488
657
  }
489
658
  catch (e) {
@@ -493,8 +662,8 @@ class RouteRuntime {
493
662
  }
494
663
  else if (bot) {
495
664
  try {
496
- const msgIds = await bot.sendPrivateMessage(String(userId), toElements(message));
497
- const msgId = (Array.isArray(msgIds) && msgIds.length > 0) ? (Number(msgIds[0]) || Math.floor(Math.random() * 1000000)) : (typeof msgIds === 'string' ? msgIds : Math.floor(Math.random() * 1000000));
665
+ const msgIds = await this.sendRoutedMessage(bot, null, String(userId), true, message);
666
+ const msgId = firstMessageId(msgIds);
498
667
  sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
499
668
  }
500
669
  catch (e) {
@@ -509,7 +678,7 @@ class RouteRuntime {
509
678
  }
510
679
  if (data.action === 'send_group_msg' || (data.action === 'send_msg' && data.params?.message_type === 'group')) {
511
680
  const groupId = data.params?.group_id;
512
- const message = data.params?.message;
681
+ const message = resolveOutgoingMessage(data.params);
513
682
  if (groupId == null || message == null) {
514
683
  return sendResponse({ status: 'failed', retcode: 100, msg: 'Missing group_id or message', data: null });
515
684
  }
@@ -517,8 +686,8 @@ class RouteRuntime {
517
686
  const session = this.recentGroupSessions.get(String(groupId));
518
687
  if (session && (!data.params?.self_id || session.bot?.selfId === String(data.params?.self_id))) {
519
688
  try {
520
- const msgIds = await session.send(toElements(message));
521
- const msgId = (Array.isArray(msgIds) && msgIds.length > 0) ? (Number(msgIds[0]) || Math.floor(Math.random() * 1000000)) : (typeof msgIds === 'string' ? msgIds : Math.floor(Math.random() * 1000000));
689
+ const msgIds = await this.sendRoutedMessage(session.bot ?? bot, session, String(groupId), false, message);
690
+ const msgId = firstMessageId(msgIds);
522
691
  sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
523
692
  }
524
693
  catch (e) {
@@ -528,8 +697,8 @@ class RouteRuntime {
528
697
  }
529
698
  else if (bot) {
530
699
  try {
531
- const msgIds = await bot.sendMessage(String(groupId), toElements(message));
532
- const msgId = (Array.isArray(msgIds) && msgIds.length > 0) ? (Number(msgIds[0]) || Math.floor(Math.random() * 1000000)) : (typeof msgIds === 'string' ? msgIds : Math.floor(Math.random() * 1000000));
700
+ const msgIds = await this.sendRoutedMessage(bot, null, String(groupId), false, message);
701
+ const msgId = firstMessageId(msgIds);
533
702
  sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
534
703
  }
535
704
  catch (e) {
@@ -738,10 +907,128 @@ class RouteRuntime {
738
907
  function apply(ctx, config) {
739
908
  const logger = ctx.logger(exports.name);
740
909
  const runtimes = new Map();
741
- const registered = new Set();
910
+ const registered = new Map();
911
+ const normalizeCommandName = (value) => String(value ?? '')
912
+ .trim()
913
+ .replace(/^\/+/, '')
914
+ .toLowerCase()
915
+ .replace(/_/g, '-');
916
+ const isValidCommandName = (value) => {
917
+ return !!value && value.length <= 100 && !/[\s[\]<>]/.test(value);
918
+ };
919
+ const parseDeclarations = (params) => {
920
+ const source = params?.commands;
921
+ if (Array.isArray(source))
922
+ return source;
923
+ if (source && typeof source === 'object') {
924
+ return Object.entries(source).map(([command, target]) => ({ command, target }));
925
+ }
926
+ throw new Error('params.commands must be an array or an object map');
927
+ };
928
+ const registerCommand = (runtime, sourceName, targetName, dynamic, description) => {
929
+ const existing = registered.get(sourceName);
930
+ if (existing) {
931
+ if (existing.runtime !== runtime)
932
+ return 'already owned by another msg-router route';
933
+ existing.target = targetName;
934
+ return;
935
+ }
936
+ if (ctx.$commander.get(sourceName))
937
+ return 'already registered by another Koishi plugin';
938
+ const entry = { runtime, target: targetName, dynamic, command: null };
939
+ const command = ctx.command(`${sourceName} [content:text]`, description || `由 msg-router 动态路由到 ${targetName}`).action(async ({ session }, content) => {
940
+ if (config.debug) {
941
+ logger.debug(`command trigger: source=${sourceName} target=${entry.target} content=${JSON.stringify(content ?? '')}`);
942
+ }
943
+ await runtime.forward(session, entry.target, content ?? '', sourceName);
944
+ return undefined;
945
+ });
946
+ entry.command = command;
947
+ registered.set(sourceName, entry);
948
+ };
949
+ const declareForRoute = (runtime, params) => {
950
+ const rejected = [];
951
+ const declarations = new Map();
952
+ for (const item of parseDeclarations(params)) {
953
+ const raw = typeof item === 'string' ? { name: item } : item;
954
+ if (!raw || typeof raw !== 'object') {
955
+ rejected.push({ name: String(item ?? ''), reason: 'declaration must be a string or object' });
956
+ continue;
957
+ }
958
+ const declaredName = raw.name ?? raw.command ?? raw.source;
959
+ const sourceName = normalizeCommandName(declaredName);
960
+ if (!isValidCommandName(sourceName)) {
961
+ rejected.push({ name: String(declaredName ?? ''), reason: 'invalid command name' });
962
+ continue;
963
+ }
964
+ const requestedTarget = raw.target ?? raw.target_command ?? raw.to ?? sourceName;
965
+ const targetName = config.enableCommandTranslation === false
966
+ ? sourceName
967
+ : String(requestedTarget ?? '').trim().replace(/^\/+/, '');
968
+ if (!isValidCommandName(targetName)) {
969
+ rejected.push({ name: sourceName, reason: 'invalid target command' });
970
+ continue;
971
+ }
972
+ declarations.set(sourceName, {
973
+ name: sourceName,
974
+ target: targetName,
975
+ description: raw.description == null ? undefined : String(raw.description).slice(0, 200),
976
+ enabled: raw.enabled !== false,
977
+ });
978
+ }
979
+ const limit = Math.max(1, config.maxDeclaredCommands || 100);
980
+ const resultingDynamic = new Set([...registered]
981
+ .filter(([, entry]) => entry.runtime === runtime && entry.dynamic)
982
+ .map(([sourceName]) => sourceName));
983
+ if (params?.replace !== false)
984
+ resultingDynamic.clear();
985
+ for (const item of declarations.values()) {
986
+ if (item.enabled)
987
+ resultingDynamic.add(item.name);
988
+ else
989
+ resultingDynamic.delete(item.name);
990
+ }
991
+ if (resultingDynamic.size > limit) {
992
+ throw new Error(`too many declared commands: ${resultingDynamic.size}, limit is ${limit}`);
993
+ }
994
+ const removed = [];
995
+ if (params?.replace !== false) {
996
+ for (const [sourceName, entry] of [...registered]) {
997
+ if (entry.runtime !== runtime || !entry.dynamic || declarations.has(sourceName))
998
+ continue;
999
+ entry.command.dispose();
1000
+ registered.delete(sourceName);
1001
+ removed.push(sourceName);
1002
+ }
1003
+ }
1004
+ const declared = [];
1005
+ for (const item of declarations.values()) {
1006
+ const existing = registered.get(item.name);
1007
+ if (!item.enabled) {
1008
+ if (existing?.runtime === runtime && existing.dynamic) {
1009
+ existing.command.dispose();
1010
+ registered.delete(item.name);
1011
+ removed.push(item.name);
1012
+ }
1013
+ else if (existing?.runtime === runtime) {
1014
+ existing.target = item.name;
1015
+ }
1016
+ continue;
1017
+ }
1018
+ const reason = registerCommand(runtime, item.name, item.target, true, item.description);
1019
+ if (reason) {
1020
+ rejected.push({ name: item.name, reason });
1021
+ }
1022
+ else {
1023
+ declared.push({ name: item.name, target: item.target });
1024
+ }
1025
+ }
1026
+ return { declared, removed: [...new Set(removed)], rejected };
1027
+ };
742
1028
  const routes = [...config.clientRoutes, ...config.serverRoutes];
743
1029
  for (const route of routes) {
744
- const runtime = new RouteRuntime(ctx, config, route);
1030
+ let runtime;
1031
+ runtime = new RouteRuntime(ctx, config, route, params => declareForRoute(runtime, params));
745
1032
  runtimes.set(route.name, runtime);
746
1033
  if (!route.enabled)
747
1034
  continue;
@@ -758,26 +1045,41 @@ function apply(ctx, config) {
758
1045
  continue;
759
1046
  }
760
1047
  }
761
- if (!Array.isArray(route.commands) || !route.commands.length) {
762
- logger.warn(`skip route ${route.name}: no commands configured`);
1048
+ const manualMappings = Array.isArray(route.commandMappings)
1049
+ ? route.commandMappings.filter(item => item?.enabled !== false)
1050
+ : [];
1051
+ if ((!Array.isArray(route.commands) || !route.commands.length) && !manualMappings.length && !config.enableCommandDeclaration) {
1052
+ logger.warn(`skip route ${route.name}: no commands or command mappings configured`);
763
1053
  continue;
764
1054
  }
765
- for (const commandName of route.commands) {
766
- const normalized = commandName.trim();
1055
+ for (const commandName of route.commands || []) {
1056
+ const normalized = normalizeCommandName(commandName);
767
1057
  if (!normalized)
768
1058
  continue;
769
- if (registered.has(normalized)) {
770
- logger.warn(`skip duplicated command ${normalized} from route ${route.name}`);
1059
+ if (!isValidCommandName(normalized)) {
1060
+ logger.warn(`skip invalid command ${JSON.stringify(commandName)} from route ${route.name}`);
771
1061
  continue;
772
1062
  }
773
- registered.add(normalized);
774
- ctx.command(`${normalized} [content:text]`).action(async ({ session }, content) => {
775
- if (config.debug) {
776
- logger.debug(`command trigger: ${normalized} route=${route.name} user=${session?.userId ?? 'unknown'} channel=${session?.channelId ?? 'unknown'} content=${JSON.stringify(content ?? '')}`);
777
- }
778
- await runtime.forward(session, normalized, content ?? '');
779
- return undefined;
780
- });
1063
+ const reason = registerCommand(runtime, normalized, normalized, false);
1064
+ if (reason)
1065
+ logger.warn(`skip command ${normalized} from route ${route.name}: ${reason}`);
1066
+ }
1067
+ for (const mapping of manualMappings) {
1068
+ const sourceName = normalizeCommandName(mapping.source);
1069
+ const requestedTarget = String(mapping.target ?? '').trim().replace(/^\/+/, '');
1070
+ const targetName = config.enableCommandTranslation === false ? sourceName : requestedTarget;
1071
+ if (!isValidCommandName(sourceName)) {
1072
+ logger.warn(`skip invalid command mapping source ${JSON.stringify(mapping.source)} from route ${route.name}`);
1073
+ continue;
1074
+ }
1075
+ if (!isValidCommandName(targetName)) {
1076
+ logger.warn(`skip invalid command mapping target ${JSON.stringify(mapping.target)} from route ${route.name}`);
1077
+ continue;
1078
+ }
1079
+ const reason = registerCommand(runtime, sourceName, targetName, false, mapping.description);
1080
+ if (reason) {
1081
+ logger.warn(`skip command mapping ${sourceName} -> ${targetName} from route ${route.name}: ${reason}`);
1082
+ }
781
1083
  }
782
1084
  }
783
1085
  ctx.on('ready', () => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-msg-router",
3
- "description": "OneBot v11 WebSocket 消息转发插件",
4
- "version": "1.0.2",
3
+ "description": "Koishi OneBot 指令路由:支持手动/动态指令转译、双向 WebSocket 与 QQ Markdown",
4
+ "version": "1.3.0",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
@@ -19,6 +19,9 @@
19
19
  "plugin",
20
20
  "onebot",
21
21
  "onebot11",
22
+ "qq",
23
+ "markdown",
24
+ "command-mapping",
22
25
  "websocket",
23
26
  "router"
24
27
  ],
@@ -31,8 +34,8 @@
31
34
  },
32
35
  "koishi": {
33
36
  "description": {
34
- "zh": "标准 OneBot v11 WebSocket 消息路由与通信中间件",
35
- "en": "Standard OneBot v11 WebSocket message routing and communication middleware"
37
+ "zh": "通过 OneBot 手动或动态声明并转译指定指令,不影响其他指令,支持正反向 WebSocket 和 QQ Markdown",
38
+ "en": "Manually or dynamically maps selected commands over OneBot, with forward/reverse WebSocket and QQ Markdown"
36
39
  }
37
40
  }
38
41
  }
package/readme.md CHANGED
@@ -2,22 +2,31 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/koishi-plugin-msg-router?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-msg-router)
4
4
 
5
- 这是一个用于 Koishi 的消息转发插件。它把你配置好的指令转发到外部 OneBot v11 WebSocket 后端,再把后端返回内容回传给用户。
5
+ 一个面向 Koishi OneBot v11 指令中转路由插件。插件仅接管配置中指定的指令,将会话转换为 OneBot v11 消息事件并通过 WebSocket 交给外部后端处理;未配置的指令保持 Koishi 原有处理流程,不受影响。
6
+
7
+ 后端可以通过标准 OneBot API 动作向群聊或私聊回发文本、图片、语音等消息。针对 QQ 官方适配器,插件还支持原生 Markdown 文本,并在不支持 Markdown 的平台上自动降级为普通文本。
6
8
 
7
9
  ## 主要用途
8
10
 
9
- - 把某些 Koishi 指令交给外部后端程序处理
10
- - 支持正向 WS 和反向 WS 两种模式
11
- - 后端可通过 WebSocket 常驻连接接收请求
12
- - 支持令牌鉴权,`token` 可留空
13
- - 支持可选的 HMAC 签名
14
- - 支持断线重连、心跳保活、超时控制、并发控制
11
+ - **按需接管指令**:每条路由独立配置指令列表,只转发明确指定的指令
12
+ - **手动或动态转译**:可以在配置页维护指令映射,也可以由后端通过接口动态声明
13
+ - **OneBot v11 兼容**:向后端推送标准消息事件,并处理常用 OneBot API 动作
14
+ - **双向 WebSocket**:支持插件主动连接后端,也支持插件监听并等待后端连接
15
+ - **多路由配置**:不同指令可以连接不同后端,分别设置地址、鉴权和连接参数
16
+ - **消息类型转换**:支持文本、图片、@、回复、语音、视频和表情等常用消息段
17
+ - **QQ Markdown**:支持 QQ 原生 Markdown 文本,其他平台自动使用普通文本降级
18
+ - **连接管理**:提供令牌鉴权、心跳检测、断线重连、请求超时和调试日志
15
19
 
16
20
  ## 控制台配置说明
17
21
 
18
22
  ### 总开关
19
23
 
20
- - `debug`:是否输出调试信息
24
+ - `enableQQMarkdown`:是否启用 QQ 原生 Markdown 文本;关闭后 Markdown 内容按普通文本降级发送
25
+ - `enableCommandDeclaration`:是否允许后端通过 WebSocket 动态声明要接管的指令,默认关闭
26
+ - `enableCommandTranslation`:是否允许把对外指令转译为不同的后端目标指令
27
+ - `commandDeclarationAction`:动态声明接口的 action,默认 `msg_router.declare_commands`
28
+ - `maxDeclaredCommands`:每条路由最多允许动态声明的指令数量,默认 `100`
29
+ - `debug`:是否输出 WebSocket 收发、指令匹配和消息解析等调试信息
21
30
 
22
31
  ### 正向 WS 路由列表
23
32
 
@@ -30,6 +39,7 @@
30
39
  - `token`:后端鉴权令牌,可不填
31
40
  - `secret`:HMAC 签名密钥,可不填
32
41
  - `commands`:触发这条路由的 Koishi 指令
42
+ - `commandMappings`:手动指令转译表,可设置对外指令、后端目标指令、说明和启用状态
33
43
  - `action`:发送给后端的 action 名称
34
44
  - `timeout`:单次请求等待时间,单位毫秒
35
45
  - `heartbeatInterval`:发送 ping 的间隔,单位毫秒
@@ -48,6 +58,7 @@
48
58
  - `token`:后端鉴权令牌,可不填
49
59
  - `secret`:HMAC 签名密钥,可不填
50
60
  - `commands`:触发这条路由的 Koishi 指令
61
+ - `commandMappings`:手动指令转译表,可独立于 `commands` 使用
51
62
  - `action`:发送给后端的 action 名称
52
63
  - `timeout`:单次请求等待时间,单位毫秒
53
64
  - `heartbeatInterval`:发送 ping 的间隔,单位毫秒
@@ -156,6 +167,103 @@
156
167
 
157
168
  如果 `data.text`、`data.message` 或 `data.reply` 存在,插件会直接把它返回给用户。`data.message` 支持 OneBot 风格的文本段数组。
158
169
 
170
+ ### 手动配置指令转译
171
+
172
+ 每条正向或反向路由都提供 `commandMappings` 表格,可以直接在 Koishi 配置页面添加映射:
173
+
174
+ | 配置项 | 说明 | 示例 |
175
+ | --- | --- | --- |
176
+ | `enabled` | 是否启用这条映射 | `true` |
177
+ | `source` | 用户实际执行、插件需要接管的指令 | `天气` |
178
+ | `target` | 转发给后端的目标指令 | `weather` |
179
+ | `description` | Koishi 指令系统中显示的说明 | `查询天气` |
180
+
181
+ 配置后,用户执行 `天气 上海`,后端会收到 `weather 上海`。映射可以独立使用,不需要再把 `天气` 重复填写到 `commands`;如果两处都填写,`commandMappings` 中的目标指令优先。单条映射可以使用 `enabled` 独立关闭。
182
+
183
+ 全局关闭 `enableCommandTranslation` 后,手动映射和动态声明仍会注册源指令,但不再替换指令名。
184
+
185
+ ### 动态声明与指令转译接口
186
+
187
+ 开启配置项 `enableCommandDeclaration` 后,已连接的后端可以通过 WebSocket 调用声明接口。接口 action 默认为 `msg_router.declare_commands`,也可以通过 `commandDeclarationAction` 修改。
188
+
189
+ 下面的声明会让插件接管用户侧的 `天气` 和 `菜单` 指令。其中,用户执行 `天气 上海` 时,后端收到的消息内容会转译为 `weather 上海`;插件只替换指令名,后续参数保持不变。
190
+
191
+ ```json
192
+ {
193
+ "action": "msg_router.declare_commands",
194
+ "params": {
195
+ "replace": true,
196
+ "commands": [
197
+ {
198
+ "name": "天气",
199
+ "target": "weather",
200
+ "description": "查询天气"
201
+ },
202
+ {
203
+ "name": "菜单",
204
+ "target": "help"
205
+ }
206
+ ]
207
+ },
208
+ "echo": "declare-1"
209
+ }
210
+ ```
211
+
212
+ 成功响应:
213
+
214
+ ```json
215
+ {
216
+ "status": "ok",
217
+ "retcode": 0,
218
+ "data": {
219
+ "declared": [
220
+ { "name": "天气", "target": "weather" },
221
+ { "name": "菜单", "target": "help" }
222
+ ],
223
+ "removed": [],
224
+ "rejected": []
225
+ },
226
+ "echo": "declare-1"
227
+ }
228
+ ```
229
+
230
+ 接口规则:
231
+
232
+ - `commands` 可以是上述数组,也可以简写成 `{ "天气": "weather", "菜单": "help" }`
233
+ - 字符串声明(例如 `"ping"`)表示不转译,源指令和目标指令相同
234
+ - `replace` 默认为 `true`,会移除该路由之前动态声明、但本次未再次声明的指令
235
+ - `replace: false` 表示增量更新;单项设置 `enabled: false` 可以移除动态指令
236
+ - 关闭 `enableCommandTranslation` 后仍允许动态声明,但会忽略 `target`,按原指令转发
237
+ - 插件不会覆盖其他 Koishi 插件已经注册的指令,冲突项会出现在 `rejected` 中
238
+ - 只配置动态声明、不填写静态 `commands` 的路由也会启动并等待后端声明
239
+
240
+ ### QQ Markdown 文本
241
+
242
+ 后端通过 `send_group_msg`、`send_private_msg` 或 `send_msg` 回发消息时,可以使用 QQ 原生 Markdown 文本。插件在 QQ 官方适配器上会直接提交 `markdown.content`;在其他平台上会把同一内容作为普通文本降级发送。
243
+
244
+ 此功能可通过配置页面的 `enableQQMarkdown` 开关控制。关闭后不会调用 QQ 原生 Markdown 接口,而是把 `content` 或 `fallback_text` 作为普通消息发送。
245
+
246
+ ```json
247
+ {
248
+ "action": "send_group_msg",
249
+ "params": {
250
+ "group_id": "群 openid",
251
+ "message": [
252
+ {
253
+ "type": "markdown",
254
+ "data": {
255
+ "content": "# 标题\n**加粗内容**\n[查看详情](https://example.com)",
256
+ "fallback_text": "标题\n加粗内容\nhttps://example.com"
257
+ }
258
+ }
259
+ ]
260
+ },
261
+ "echo": "request-id"
262
+ }
263
+ ```
264
+
265
+ 如果后端本身直接使用 QQ 消息结构,也可以省略 `message`,直接在 `params.markdown.content` 中提供内容。`fallback_text` 可选;未提供时会直接使用 `content` 作为降级文本。
266
+
159
267
  ## 兼容说明
160
268
 
161
269
  - `token` 是可选项,不填时不会附加 `Authorization` 头