koishi-plugin-msg-router 1.3.0 → 1.4.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,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')
@@ -908,6 +926,14 @@ function apply(ctx, config) {
908
926
  const logger = ctx.logger(exports.name);
909
927
  const runtimes = new Map();
910
928
  const registered = new Map();
929
+ const defaultCommandOptions = () => ({
930
+ permissions: ['authority:1'],
931
+ hidden: false,
932
+ maxUsage: 0,
933
+ minInterval: 0,
934
+ scope: 'all',
935
+ platforms: [],
936
+ });
911
937
  const normalizeCommandName = (value) => String(value ?? '')
912
938
  .trim()
913
939
  .replace(/^\/+/, '')
@@ -916,6 +942,59 @@ function apply(ctx, config) {
916
942
  const isValidCommandName = (value) => {
917
943
  return !!value && value.length <= 100 && !/[\s[\]<>]/.test(value);
918
944
  };
945
+ const normalizeCommandOptions = (raw, dynamic) => {
946
+ const minimumAuthority = Math.min(4, Math.max(0, config.minimumDynamicAuthority ?? 1));
947
+ let permissions = Array.isArray(raw?.permissions)
948
+ ? raw.permissions.map(String).map(item => item.trim()).filter(Boolean).slice(0, 20)
949
+ : [];
950
+ if (dynamic && !config.allowDynamicCommandPermissions) {
951
+ permissions = [`authority:${minimumAuthority}`];
952
+ }
953
+ else if (!permissions.length) {
954
+ permissions = [dynamic ? `authority:${minimumAuthority}` : 'authority:1'];
955
+ }
956
+ if (dynamic) {
957
+ let hasAuthorityPermission = false;
958
+ for (const permission of permissions) {
959
+ const match = /^authority:(\d+)$/.exec(permission);
960
+ if (match) {
961
+ hasAuthorityPermission = true;
962
+ if (Number(match[1]) < minimumAuthority) {
963
+ throw new Error(`permission ${permission} is below minimum authority:${minimumAuthority}`);
964
+ }
965
+ }
966
+ }
967
+ if (!hasAuthorityPermission)
968
+ permissions.push(`authority:${minimumAuthority}`);
969
+ }
970
+ const requestedScope = String(raw?.scope ?? 'all');
971
+ const scope = requestedScope === 'group' || requestedScope === 'private'
972
+ ? requestedScope
973
+ : 'all';
974
+ const platforms = Array.isArray(raw?.platforms)
975
+ ? [...new Set(raw.platforms.map((item) => String(item).trim().toLowerCase()).filter(Boolean))].slice(0, 20)
976
+ : [];
977
+ return {
978
+ permissions,
979
+ hidden: raw?.hidden === true,
980
+ maxUsage: Math.min(1_000_000, Math.max(0, Math.floor(Number(raw?.maxUsage) || 0))),
981
+ minInterval: Math.min(86_400_000, Math.max(0, Math.floor(Number(raw?.minInterval) || 0))),
982
+ scope,
983
+ platforms,
984
+ };
985
+ };
986
+ const matchesCommandOptions = (session, options) => {
987
+ if (options.platforms.length && !options.platforms.includes(String(session?.platform ?? '').toLowerCase()))
988
+ return false;
989
+ const isPrivate = session?.isDirect === true || (session?.guildId == null
990
+ && session?.channelId != null
991
+ && String(session.channelId) === String(session?.userId ?? ''));
992
+ if (options.scope === 'private' && !isPrivate)
993
+ return false;
994
+ if (options.scope === 'group' && isPrivate)
995
+ return false;
996
+ return true;
997
+ };
919
998
  const parseDeclarations = (params) => {
920
999
  const source = params?.commands;
921
1000
  if (Array.isArray(source))
@@ -925,18 +1004,45 @@ function apply(ctx, config) {
925
1004
  }
926
1005
  throw new Error('params.commands must be an array or an object map');
927
1006
  };
928
- const registerCommand = (runtime, sourceName, targetName, dynamic, description) => {
1007
+ const registerCommand = (runtime, sourceName, targetName, dynamic, description, options = defaultCommandOptions()) => {
929
1008
  const existing = registered.get(sourceName);
930
1009
  if (existing) {
931
1010
  if (existing.runtime !== runtime)
932
1011
  return 'already owned by another msg-router route';
1012
+ if (!dynamic) {
1013
+ existing.dynamic = false;
1014
+ existing.baseTarget = targetName;
1015
+ existing.baseOptions = options;
1016
+ }
933
1017
  existing.target = targetName;
1018
+ existing.options = options;
1019
+ Object.assign(existing.command.config, {
1020
+ permissions: options.permissions,
1021
+ hidden: options.hidden,
1022
+ maxUsage: options.maxUsage,
1023
+ minInterval: options.minInterval,
1024
+ });
1025
+ ctx.emit('command-updated', existing.command);
934
1026
  return;
935
1027
  }
936
1028
  if (ctx.$commander.get(sourceName))
937
1029
  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) => {
1030
+ const entry = {
1031
+ runtime,
1032
+ target: targetName,
1033
+ dynamic,
1034
+ command: null,
1035
+ options,
1036
+ baseTarget: targetName,
1037
+ baseOptions: options,
1038
+ };
1039
+ const commandCtx = ctx.intersect(session => matchesCommandOptions(session, entry.options));
1040
+ const command = commandCtx.command(`${sourceName} [content:text]`, description || `由 msg-router 动态路由到 ${targetName}`, {
1041
+ permissions: options.permissions,
1042
+ hidden: options.hidden,
1043
+ maxUsage: options.maxUsage,
1044
+ minInterval: options.minInterval,
1045
+ }).action(async ({ session }, content) => {
940
1046
  if (config.debug) {
941
1047
  logger.debug(`command trigger: source=${sourceName} target=${entry.target} content=${JSON.stringify(content ?? '')}`);
942
1048
  }
@@ -969,12 +1075,21 @@ function apply(ctx, config) {
969
1075
  rejected.push({ name: sourceName, reason: 'invalid target command' });
970
1076
  continue;
971
1077
  }
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
- });
1078
+ try {
1079
+ declarations.set(sourceName, {
1080
+ name: sourceName,
1081
+ target: targetName,
1082
+ description: raw.description == null ? undefined : String(raw.description).slice(0, 200),
1083
+ enabled: raw.enabled !== false,
1084
+ options: normalizeCommandOptions(raw, true),
1085
+ });
1086
+ }
1087
+ catch (error) {
1088
+ rejected.push({
1089
+ name: sourceName,
1090
+ reason: error instanceof Error ? error.message : String(error),
1091
+ });
1092
+ }
978
1093
  }
979
1094
  const limit = Math.max(1, config.maxDeclaredCommands || 100);
980
1095
  const resultingDynamic = new Set([...registered]
@@ -994,11 +1109,24 @@ function apply(ctx, config) {
994
1109
  const removed = [];
995
1110
  if (params?.replace !== false) {
996
1111
  for (const [sourceName, entry] of [...registered]) {
997
- if (entry.runtime !== runtime || !entry.dynamic || declarations.has(sourceName))
1112
+ if (entry.runtime !== runtime || declarations.has(sourceName))
998
1113
  continue;
999
- entry.command.dispose();
1000
- registered.delete(sourceName);
1001
- removed.push(sourceName);
1114
+ if (entry.dynamic) {
1115
+ entry.command.dispose();
1116
+ registered.delete(sourceName);
1117
+ removed.push(sourceName);
1118
+ }
1119
+ else {
1120
+ entry.target = entry.baseTarget;
1121
+ entry.options = entry.baseOptions;
1122
+ Object.assign(entry.command.config, {
1123
+ permissions: entry.options.permissions,
1124
+ hidden: entry.options.hidden,
1125
+ maxUsage: entry.options.maxUsage,
1126
+ minInterval: entry.options.minInterval,
1127
+ });
1128
+ ctx.emit('command-updated', entry.command);
1129
+ }
1002
1130
  }
1003
1131
  }
1004
1132
  const declared = [];
@@ -1011,11 +1139,19 @@ function apply(ctx, config) {
1011
1139
  removed.push(item.name);
1012
1140
  }
1013
1141
  else if (existing?.runtime === runtime) {
1014
- existing.target = item.name;
1142
+ existing.target = existing.baseTarget;
1143
+ existing.options = existing.baseOptions;
1144
+ Object.assign(existing.command.config, {
1145
+ permissions: existing.options.permissions,
1146
+ hidden: existing.options.hidden,
1147
+ maxUsage: existing.options.maxUsage,
1148
+ minInterval: existing.options.minInterval,
1149
+ });
1150
+ ctx.emit('command-updated', existing.command);
1015
1151
  }
1016
1152
  continue;
1017
1153
  }
1018
- const reason = registerCommand(runtime, item.name, item.target, true, item.description);
1154
+ const reason = registerCommand(runtime, item.name, item.target, true, item.description, item.options);
1019
1155
  if (reason) {
1020
1156
  rejected.push({ name: item.name, reason });
1021
1157
  }
@@ -1076,7 +1212,7 @@ function apply(ctx, config) {
1076
1212
  logger.warn(`skip invalid command mapping target ${JSON.stringify(mapping.target)} from route ${route.name}`);
1077
1213
  continue;
1078
1214
  }
1079
- const reason = registerCommand(runtime, sourceName, targetName, false, mapping.description);
1215
+ const reason = registerCommand(runtime, sourceName, targetName, false, mapping.description, normalizeCommandOptions(mapping, false));
1080
1216
  if (reason) {
1081
1217
  logger.warn(`skip command mapping ${sourceName} -> ${targetName} from route ${route.name}: ${reason}`);
1082
1218
  }
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.0",
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
@@ -10,6 +10,7 @@
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,6 +248,9 @@
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` 的路由也会启动并等待后端声明