koishi-plugin-msg-router 1.3.0 → 1.4.2

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,10 +1,25 @@
1
1
  import { Context, Schema } from 'koishi';
2
2
  export declare const name = "msg-router";
3
+ export type CommandScope = 'all' | 'group' | 'private';
4
+ export interface CommandAccessConfig {
5
+ permissions: string[];
6
+ hidden: boolean;
7
+ maxUsage: number;
8
+ minInterval: number;
9
+ scope: CommandScope;
10
+ platforms: string[];
11
+ }
3
12
  export interface CommandMappingConfig {
4
13
  enabled: boolean;
5
14
  source: string;
6
15
  target: string;
7
16
  description: string;
17
+ permissions: string[];
18
+ hidden: boolean;
19
+ maxUsage: number;
20
+ minInterval: number;
21
+ scope: CommandScope;
22
+ platforms: string[];
8
23
  }
9
24
  export interface ClientRouteConfig {
10
25
  name: string;
@@ -45,6 +60,8 @@ export interface Config {
45
60
  enableCommandTranslation: boolean;
46
61
  commandDeclarationAction: string;
47
62
  maxDeclaredCommands: number;
63
+ allowDynamicCommandPermissions: boolean;
64
+ minimumDynamicAuthority: number;
48
65
  clientRoutes: ClientRouteConfig[];
49
66
  serverRoutes: ServerRouteConfig[];
50
67
  }
package/lib/index.js CHANGED
@@ -19,6 +19,16 @@ const CommonRouteConfig = {
19
19
  source: koishi_1.Schema.string().required().description('用户侧需要接管的指令名,例如:天气'),
20
20
  target: koishi_1.Schema.string().required().description('发送给后端的目标指令名,例如:weather'),
21
21
  description: koishi_1.Schema.string().default('').description('显示在 Koishi 指令系统中的说明,可留空'),
22
+ permissions: koishi_1.Schema.array(koishi_1.Schema.string()).role('perms').default(['authority:1']).description('Koishi 原生权限规则,例如 authority:3'),
23
+ hidden: koishi_1.Schema.boolean().default(false).description('是否在帮助菜单中隐藏,需要启用 help 插件'),
24
+ maxUsage: koishi_1.Schema.natural().default(0).description('每名用户每天最多调用次数,0 表示不限;需要 rate-limit 和数据库'),
25
+ minInterval: koishi_1.Schema.natural().default(0).description('同一用户连续调用的最小间隔,单位毫秒;0 表示不限'),
26
+ scope: koishi_1.Schema.union([
27
+ koishi_1.Schema.const('all').description('群聊和私聊'),
28
+ koishi_1.Schema.const('group').description('仅群聊'),
29
+ koishi_1.Schema.const('private').description('仅私聊'),
30
+ ]).default('all').description('指令可用的会话范围'),
31
+ platforms: koishi_1.Schema.array(koishi_1.Schema.string()).default([]).description('允许使用的平台列表,例如 qq;留空表示不限制'),
22
32
  })).role('table').default([]).description('手动指令转译表。映射可以独立于 commands 使用;同名映射会覆盖本路由 commands 中的直通规则'),
23
33
  action: koishi_1.Schema.string().default('msg_router.forward_command').description('兼容配置字段;消息事件当前按 OneBot v11 标准直接推送'),
24
34
  timeout: koishi_1.Schema.number().default(10000).description('单次请求超时时间,单位毫秒'),
@@ -59,6 +69,14 @@ exports.Config = koishi_1.Schema.object({
59
69
  .max(1000)
60
70
  .default(100)
61
71
  .description('每条路由最多允许后端动态声明的指令数量'),
72
+ allowDynamicCommandPermissions: koishi_1.Schema.boolean()
73
+ .default(false)
74
+ .description('允许后端声明 permissions 权限规则。关闭时动态指令统一使用 minimumDynamicAuthority,避免后端意外开放敏感指令'),
75
+ minimumDynamicAuthority: koishi_1.Schema.natural()
76
+ .min(0)
77
+ .max(4)
78
+ .default(1)
79
+ .description('动态指令允许的最低权限等级;后端声明低于此等级的 authority 权限时会拒绝该指令'),
62
80
  debug: koishi_1.Schema.boolean().default(false).description('输出 WebSocket 收发内容、指令匹配和消息解析等调试日志'),
63
81
  clientRoutes: koishi_1.Schema.array(ClientRouteConfig)
64
82
  .role('table')
@@ -128,6 +146,9 @@ function extractQQMarkdown(value) {
128
146
  return {
129
147
  markdown: { content },
130
148
  fallback,
149
+ keyboard: markdownSource.keyboard && typeof markdownSource.keyboard === 'object'
150
+ ? markdownSource.keyboard
151
+ : undefined,
131
152
  remaining,
132
153
  };
133
154
  }
@@ -431,11 +452,13 @@ class RouteRuntime {
431
452
  ? {
432
453
  msg_type: 2,
433
454
  markdown: qq.markdown,
455
+ ...(qq.keyboard ? { keyboard: qq.keyboard } : {}),
434
456
  ...(recentMessageId ? { msg_id: recentMessageId } : {}),
435
457
  }
436
458
  : {
437
459
  content: ' ',
438
460
  markdown: qq.markdown,
461
+ ...(qq.keyboard ? { keyboard: qq.keyboard } : {}),
439
462
  ...(recentMessageId ? { msg_id: recentMessageId } : {}),
440
463
  };
441
464
  const response = isPrivate
@@ -908,6 +931,14 @@ function apply(ctx, config) {
908
931
  const logger = ctx.logger(exports.name);
909
932
  const runtimes = new Map();
910
933
  const registered = new Map();
934
+ const defaultCommandOptions = () => ({
935
+ permissions: ['authority:1'],
936
+ hidden: false,
937
+ maxUsage: 0,
938
+ minInterval: 0,
939
+ scope: 'all',
940
+ platforms: [],
941
+ });
911
942
  const normalizeCommandName = (value) => String(value ?? '')
912
943
  .trim()
913
944
  .replace(/^\/+/, '')
@@ -916,6 +947,59 @@ function apply(ctx, config) {
916
947
  const isValidCommandName = (value) => {
917
948
  return !!value && value.length <= 100 && !/[\s[\]<>]/.test(value);
918
949
  };
950
+ const normalizeCommandOptions = (raw, dynamic) => {
951
+ const minimumAuthority = Math.min(4, Math.max(0, config.minimumDynamicAuthority ?? 1));
952
+ let permissions = Array.isArray(raw?.permissions)
953
+ ? raw.permissions.map(String).map(item => item.trim()).filter(Boolean).slice(0, 20)
954
+ : [];
955
+ if (dynamic && !config.allowDynamicCommandPermissions) {
956
+ permissions = [`authority:${minimumAuthority}`];
957
+ }
958
+ else if (!permissions.length) {
959
+ permissions = [dynamic ? `authority:${minimumAuthority}` : 'authority:1'];
960
+ }
961
+ if (dynamic) {
962
+ let hasAuthorityPermission = false;
963
+ for (const permission of permissions) {
964
+ const match = /^authority:(\d+)$/.exec(permission);
965
+ if (match) {
966
+ hasAuthorityPermission = true;
967
+ if (Number(match[1]) < minimumAuthority) {
968
+ throw new Error(`permission ${permission} is below minimum authority:${minimumAuthority}`);
969
+ }
970
+ }
971
+ }
972
+ if (!hasAuthorityPermission)
973
+ permissions.push(`authority:${minimumAuthority}`);
974
+ }
975
+ const requestedScope = String(raw?.scope ?? 'all');
976
+ const scope = requestedScope === 'group' || requestedScope === 'private'
977
+ ? requestedScope
978
+ : 'all';
979
+ const platforms = Array.isArray(raw?.platforms)
980
+ ? [...new Set(raw.platforms.map((item) => String(item).trim().toLowerCase()).filter(Boolean))].slice(0, 20)
981
+ : [];
982
+ return {
983
+ permissions,
984
+ hidden: raw?.hidden === true,
985
+ maxUsage: Math.min(1_000_000, Math.max(0, Math.floor(Number(raw?.maxUsage) || 0))),
986
+ minInterval: Math.min(86_400_000, Math.max(0, Math.floor(Number(raw?.minInterval) || 0))),
987
+ scope,
988
+ platforms,
989
+ };
990
+ };
991
+ const matchesCommandOptions = (session, options) => {
992
+ if (options.platforms.length && !options.platforms.includes(String(session?.platform ?? '').toLowerCase()))
993
+ return false;
994
+ const isPrivate = session?.isDirect === true || (session?.guildId == null
995
+ && session?.channelId != null
996
+ && String(session.channelId) === String(session?.userId ?? ''));
997
+ if (options.scope === 'private' && !isPrivate)
998
+ return false;
999
+ if (options.scope === 'group' && isPrivate)
1000
+ return false;
1001
+ return true;
1002
+ };
919
1003
  const parseDeclarations = (params) => {
920
1004
  const source = params?.commands;
921
1005
  if (Array.isArray(source))
@@ -925,18 +1009,45 @@ function apply(ctx, config) {
925
1009
  }
926
1010
  throw new Error('params.commands must be an array or an object map');
927
1011
  };
928
- const registerCommand = (runtime, sourceName, targetName, dynamic, description) => {
1012
+ const registerCommand = (runtime, sourceName, targetName, dynamic, description, options = defaultCommandOptions()) => {
929
1013
  const existing = registered.get(sourceName);
930
1014
  if (existing) {
931
1015
  if (existing.runtime !== runtime)
932
1016
  return 'already owned by another msg-router route';
1017
+ if (!dynamic) {
1018
+ existing.dynamic = false;
1019
+ existing.baseTarget = targetName;
1020
+ existing.baseOptions = options;
1021
+ }
933
1022
  existing.target = targetName;
1023
+ existing.options = options;
1024
+ Object.assign(existing.command.config, {
1025
+ permissions: options.permissions,
1026
+ hidden: options.hidden,
1027
+ maxUsage: options.maxUsage,
1028
+ minInterval: options.minInterval,
1029
+ });
1030
+ ctx.emit('command-updated', existing.command);
934
1031
  return;
935
1032
  }
936
1033
  if (ctx.$commander.get(sourceName))
937
1034
  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) => {
1035
+ const entry = {
1036
+ runtime,
1037
+ target: targetName,
1038
+ dynamic,
1039
+ command: null,
1040
+ options,
1041
+ baseTarget: targetName,
1042
+ baseOptions: options,
1043
+ };
1044
+ const commandCtx = ctx.intersect(session => matchesCommandOptions(session, entry.options));
1045
+ const command = commandCtx.command(`${sourceName} [content:text]`, description || `由 msg-router 动态路由到 ${targetName}`, {
1046
+ permissions: options.permissions,
1047
+ hidden: options.hidden,
1048
+ maxUsage: options.maxUsage,
1049
+ minInterval: options.minInterval,
1050
+ }).action(async ({ session }, content) => {
940
1051
  if (config.debug) {
941
1052
  logger.debug(`command trigger: source=${sourceName} target=${entry.target} content=${JSON.stringify(content ?? '')}`);
942
1053
  }
@@ -969,12 +1080,21 @@ function apply(ctx, config) {
969
1080
  rejected.push({ name: sourceName, reason: 'invalid target command' });
970
1081
  continue;
971
1082
  }
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
- });
1083
+ try {
1084
+ declarations.set(sourceName, {
1085
+ name: sourceName,
1086
+ target: targetName,
1087
+ description: raw.description == null ? undefined : String(raw.description).slice(0, 200),
1088
+ enabled: raw.enabled !== false,
1089
+ options: normalizeCommandOptions(raw, true),
1090
+ });
1091
+ }
1092
+ catch (error) {
1093
+ rejected.push({
1094
+ name: sourceName,
1095
+ reason: error instanceof Error ? error.message : String(error),
1096
+ });
1097
+ }
978
1098
  }
979
1099
  const limit = Math.max(1, config.maxDeclaredCommands || 100);
980
1100
  const resultingDynamic = new Set([...registered]
@@ -994,11 +1114,24 @@ function apply(ctx, config) {
994
1114
  const removed = [];
995
1115
  if (params?.replace !== false) {
996
1116
  for (const [sourceName, entry] of [...registered]) {
997
- if (entry.runtime !== runtime || !entry.dynamic || declarations.has(sourceName))
1117
+ if (entry.runtime !== runtime || declarations.has(sourceName))
998
1118
  continue;
999
- entry.command.dispose();
1000
- registered.delete(sourceName);
1001
- removed.push(sourceName);
1119
+ if (entry.dynamic) {
1120
+ entry.command.dispose();
1121
+ registered.delete(sourceName);
1122
+ removed.push(sourceName);
1123
+ }
1124
+ else {
1125
+ entry.target = entry.baseTarget;
1126
+ entry.options = entry.baseOptions;
1127
+ Object.assign(entry.command.config, {
1128
+ permissions: entry.options.permissions,
1129
+ hidden: entry.options.hidden,
1130
+ maxUsage: entry.options.maxUsage,
1131
+ minInterval: entry.options.minInterval,
1132
+ });
1133
+ ctx.emit('command-updated', entry.command);
1134
+ }
1002
1135
  }
1003
1136
  }
1004
1137
  const declared = [];
@@ -1011,11 +1144,19 @@ function apply(ctx, config) {
1011
1144
  removed.push(item.name);
1012
1145
  }
1013
1146
  else if (existing?.runtime === runtime) {
1014
- existing.target = item.name;
1147
+ existing.target = existing.baseTarget;
1148
+ existing.options = existing.baseOptions;
1149
+ Object.assign(existing.command.config, {
1150
+ permissions: existing.options.permissions,
1151
+ hidden: existing.options.hidden,
1152
+ maxUsage: existing.options.maxUsage,
1153
+ minInterval: existing.options.minInterval,
1154
+ });
1155
+ ctx.emit('command-updated', existing.command);
1015
1156
  }
1016
1157
  continue;
1017
1158
  }
1018
- const reason = registerCommand(runtime, item.name, item.target, true, item.description);
1159
+ const reason = registerCommand(runtime, item.name, item.target, true, item.description, item.options);
1019
1160
  if (reason) {
1020
1161
  rejected.push({ name: item.name, reason });
1021
1162
  }
@@ -1076,7 +1217,7 @@ function apply(ctx, config) {
1076
1217
  logger.warn(`skip invalid command mapping target ${JSON.stringify(mapping.target)} from route ${route.name}`);
1077
1218
  continue;
1078
1219
  }
1079
- const reason = registerCommand(runtime, sourceName, targetName, false, mapping.description);
1220
+ const reason = registerCommand(runtime, sourceName, targetName, false, mapping.description, normalizeCommandOptions(mapping, false));
1080
1221
  if (reason) {
1081
1222
  logger.warn(`skip command mapping ${sourceName} -> ${targetName} from route ${route.name}: ${reason}`);
1082
1223
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-msg-router",
3
- "description": "Koishi OneBot 指令路由:支持手动/动态指令转译、双向 WebSocket QQ Markdown",
4
- "version": "1.3.0",
3
+ "description": "Koishi OneBot 指令路由:支持手动/动态转译、权限与频率控制、双向 WebSocket QQ Markdown",
4
+ "version": "1.4.2",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
@@ -34,8 +34,8 @@
34
34
  },
35
35
  "koishi": {
36
36
  "description": {
37
- "zh": "通过 OneBot 手动或动态声明并转译指定指令,不影响其他指令,支持正反向 WebSocket 和 QQ Markdown",
38
- "en": "Manually or dynamically maps selected commands over OneBot, with forward/reverse WebSocket and QQ Markdown"
37
+ "zh": "通过 OneBot 手动或动态声明并转译指定指令,可限制权限、频率、会话及平台,不影响其他指令,支持正反向 WebSocket 和 QQ Markdown",
38
+ "en": "Maps selected commands over OneBot with access controls, forward/reverse WebSocket, and QQ Markdown"
39
39
  }
40
40
  }
41
41
  }
package/readme.md CHANGED
@@ -4,12 +4,13 @@
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
 
11
11
  - **按需接管指令**:每条路由独立配置指令列表,只转发明确指定的指令
12
12
  - **手动或动态转译**:可以在配置页维护指令映射,也可以由后端通过接口动态声明
13
+ - **Koishi 指令控制**:映射可配置权限、帮助菜单隐藏、调用频率、群聊/私聊范围和平台限制
13
14
  - **OneBot v11 兼容**:向后端推送标准消息事件,并处理常用 OneBot API 动作
14
15
  - **双向 WebSocket**:支持插件主动连接后端,也支持插件监听并等待后端连接
15
16
  - **多路由配置**:不同指令可以连接不同后端,分别设置地址、鉴权和连接参数
@@ -26,6 +27,8 @@
26
27
  - `enableCommandTranslation`:是否允许把对外指令转译为不同的后端目标指令
27
28
  - `commandDeclarationAction`:动态声明接口的 action,默认 `msg_router.declare_commands`
28
29
  - `maxDeclaredCommands`:每条路由最多允许动态声明的指令数量,默认 `100`
30
+ - `allowDynamicCommandPermissions`:是否允许后端自行声明权限规则,默认关闭
31
+ - `minimumDynamicAuthority`:动态指令的最低权限等级,默认 `1`;后端不得声明更低的 `authority` 权限
29
32
  - `debug`:是否输出 WebSocket 收发、指令匹配和消息解析等调试信息
30
33
 
31
34
  ### 正向 WS 路由列表
@@ -177,10 +180,16 @@
177
180
  | `source` | 用户实际执行、插件需要接管的指令 | `天气` |
178
181
  | `target` | 转发给后端的目标指令 | `weather` |
179
182
  | `description` | Koishi 指令系统中显示的说明 | `查询天气` |
183
+ | `permissions` | Koishi 权限规则 | `["authority:2"]` |
184
+ | `hidden` | 是否从帮助菜单隐藏 | `false` |
185
+ | `maxUsage` | 每名用户每天最多调用次数,`0` 不限制 | `20` |
186
+ | `minInterval` | 同一用户连续调用的最小间隔(毫秒),`0` 不限制 | `3000` |
187
+ | `scope` | `all`、`group` 或 `private` | `group` |
188
+ | `platforms` | 允许的平台;空数组不限制 | `["qq"]` |
180
189
 
181
190
  配置后,用户执行 `天气 上海`,后端会收到 `weather 上海`。映射可以独立使用,不需要再把 `天气` 重复填写到 `commands`;如果两处都填写,`commandMappings` 中的目标指令优先。单条映射可以使用 `enabled` 独立关闭。
182
191
 
183
- 全局关闭 `enableCommandTranslation` 后,手动映射和动态声明仍会注册源指令,但不再替换指令名。
192
+ 全局关闭 `enableCommandTranslation` 后,手动映射和动态声明仍会注册源指令,但不再替换指令名。`permissions` 使用 Koishi 原生权限服务;`hidden` 需要 help 插件,`maxUsage` 和 `minInterval` 需要 rate-limit 插件及数据库服务才能生效。
184
193
 
185
194
  ### 动态声明与指令转译接口
186
195
 
@@ -197,7 +206,13 @@
197
206
  {
198
207
  "name": "天气",
199
208
  "target": "weather",
200
- "description": "查询天气"
209
+ "description": "查询天气",
210
+ "permissions": ["authority:2"],
211
+ "hidden": false,
212
+ "maxUsage": 20,
213
+ "minInterval": 3000,
214
+ "scope": "group",
215
+ "platforms": ["qq"]
201
216
  },
202
217
  {
203
218
  "name": "菜单",
@@ -233,13 +248,16 @@
233
248
  - 字符串声明(例如 `"ping"`)表示不转译,源指令和目标指令相同
234
249
  - `replace` 默认为 `true`,会移除该路由之前动态声明、但本次未再次声明的指令
235
250
  - `replace: false` 表示增量更新;单项设置 `enabled: false` 可以移除动态指令
251
+ - 动态声明支持与手动映射相同的 `permissions`、`hidden`、`maxUsage`、`minInterval`、`scope` 和 `platforms`
252
+ - 默认不允许后端决定权限,动态指令统一采用 `minimumDynamicAuthority`;只有显式开启 `allowDynamicCommandPermissions` 后才读取后端提交的 `permissions`
253
+ - 即使允许动态权限,低于 `minimumDynamicAuthority` 的 `authority` 规则仍会被拒绝;只声明自定义权限时,插件也会自动附加最低 `authority` 规则
236
254
  - 关闭 `enableCommandTranslation` 后仍允许动态声明,但会忽略 `target`,按原指令转发
237
255
  - 插件不会覆盖其他 Koishi 插件已经注册的指令,冲突项会出现在 `rejected` 中
238
256
  - 只配置动态声明、不填写静态 `commands` 的路由也会启动并等待后端声明
239
257
 
240
- ### QQ Markdown 文本
258
+ ### QQ Markdown 与键盘
241
259
 
242
- 后端通过 `send_group_msg`、`send_private_msg` 或 `send_msg` 回发消息时,可以使用 QQ 原生 Markdown 文本。插件在 QQ 官方适配器上会直接提交 `markdown.content`;在其他平台上会把同一内容作为普通文本降级发送。
260
+ 后端通过 `send_group_msg`、`send_private_msg` 或 `send_msg` 回发消息时,可以使用 QQ 原生 Markdown 文本。插件在 QQ 官方适配器上会直接提交 `markdown.content`,并透传 `keyboard`;在其他平台上会把同一内容作为普通文本降级发送。
243
261
 
244
262
  此功能可通过配置页面的 `enableQQMarkdown` 开关控制。关闭后不会调用 QQ 原生 Markdown 接口,而是把 `content` 或 `fallback_text` 作为普通消息发送。
245
263
 
@@ -253,7 +271,12 @@
253
271
  "type": "markdown",
254
272
  "data": {
255
273
  "content": "# 标题\n**加粗内容**\n[查看详情](https://example.com)",
256
- "fallback_text": "标题\n加粗内容\nhttps://example.com"
274
+ "fallback_text": "标题\n加粗内容\nhttps://example.com",
275
+ "keyboard": {
276
+ "content": {
277
+ "rows": []
278
+ }
279
+ }
257
280
  }
258
281
  }
259
282
  ]