koishi-plugin-msg-router 1.1.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 +30 -0
- package/lib/index.js +355 -36
- package/package.json +5 -4
- package/readme.md +99 -1
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
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
|
+
}
|
|
12
|
+
export interface CommandMappingConfig {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
source: string;
|
|
15
|
+
target: string;
|
|
16
|
+
description: string;
|
|
17
|
+
permissions: string[];
|
|
18
|
+
hidden: boolean;
|
|
19
|
+
maxUsage: number;
|
|
20
|
+
minInterval: number;
|
|
21
|
+
scope: CommandScope;
|
|
22
|
+
platforms: string[];
|
|
23
|
+
}
|
|
3
24
|
export interface ClientRouteConfig {
|
|
4
25
|
name: string;
|
|
5
26
|
mode: 'client';
|
|
@@ -8,6 +29,7 @@ export interface ClientRouteConfig {
|
|
|
8
29
|
token: string;
|
|
9
30
|
secret: string;
|
|
10
31
|
commands: string[];
|
|
32
|
+
commandMappings: CommandMappingConfig[];
|
|
11
33
|
action: string;
|
|
12
34
|
timeout: number;
|
|
13
35
|
heartbeatInterval: number;
|
|
@@ -23,6 +45,7 @@ export interface ServerRouteConfig {
|
|
|
23
45
|
token: string;
|
|
24
46
|
secret: string;
|
|
25
47
|
commands: string[];
|
|
48
|
+
commandMappings: CommandMappingConfig[];
|
|
26
49
|
action: string;
|
|
27
50
|
timeout: number;
|
|
28
51
|
heartbeatInterval: number;
|
|
@@ -32,6 +55,13 @@ export interface ServerRouteConfig {
|
|
|
32
55
|
export type RouteConfig = ClientRouteConfig | ServerRouteConfig;
|
|
33
56
|
export interface Config {
|
|
34
57
|
debug: boolean;
|
|
58
|
+
enableQQMarkdown: boolean;
|
|
59
|
+
enableCommandDeclaration: boolean;
|
|
60
|
+
enableCommandTranslation: boolean;
|
|
61
|
+
commandDeclarationAction: string;
|
|
62
|
+
maxDeclaredCommands: number;
|
|
63
|
+
allowDynamicCommandPermissions: boolean;
|
|
64
|
+
minimumDynamicAuthority: number;
|
|
35
65
|
clientRoutes: ClientRouteConfig[];
|
|
36
66
|
serverRoutes: ServerRouteConfig[];
|
|
37
67
|
}
|
package/lib/index.js
CHANGED
|
@@ -10,11 +10,27 @@ 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
|
-
|
|
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
|
+
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;留空表示不限制'),
|
|
32
|
+
})).role('table').default([]).description('手动指令转译表。映射可以独立于 commands 使用;同名映射会覆盖本路由 commands 中的直通规则'),
|
|
33
|
+
action: koishi_1.Schema.string().default('msg_router.forward_command').description('兼容配置字段;消息事件当前按 OneBot v11 标准直接推送'),
|
|
18
34
|
timeout: koishi_1.Schema.number().default(10000).description('单次请求超时时间,单位毫秒'),
|
|
19
35
|
heartbeatInterval: koishi_1.Schema.number().default(30000).description('心跳间隔,单位毫秒'),
|
|
20
36
|
reconnectInterval: koishi_1.Schema.number().default(5000).description('断线后重连间隔,单位毫秒'),
|
|
@@ -36,16 +52,41 @@ const RouteConfig = koishi_1.Schema.union([
|
|
|
36
52
|
ServerRouteConfig,
|
|
37
53
|
]).role('table');
|
|
38
54
|
exports.Config = koishi_1.Schema.object({
|
|
39
|
-
|
|
55
|
+
enableQQMarkdown: koishi_1.Schema.boolean()
|
|
56
|
+
.default(true)
|
|
57
|
+
.description('启用 QQ 原生 Markdown 文本。后端可使用 message 中的 markdown 段,或直接声明 params.markdown.content;关闭后按普通文本降级发送'),
|
|
58
|
+
enableCommandDeclaration: koishi_1.Schema.boolean()
|
|
59
|
+
.default(false)
|
|
60
|
+
.description('允许已连接的 OneBot 后端动态声明本路由要拦截的指令。为避免后端意外接管指令,默认关闭'),
|
|
61
|
+
enableCommandTranslation: koishi_1.Schema.boolean()
|
|
62
|
+
.default(true)
|
|
63
|
+
.description('允许声明接口把一个对外指令映射为另一个后端指令;关闭后仍可动态声明,但目标指令固定为原指令'),
|
|
64
|
+
commandDeclarationAction: koishi_1.Schema.string()
|
|
65
|
+
.default('msg_router.declare_commands')
|
|
66
|
+
.description('后端声明指令所调用的 WebSocket action。请求格式:{ action, params: { commands: [{ name: "对外指令", target: "后端指令" }], replace: true }, echo }'),
|
|
67
|
+
maxDeclaredCommands: koishi_1.Schema.natural()
|
|
68
|
+
.min(1)
|
|
69
|
+
.max(1000)
|
|
70
|
+
.default(100)
|
|
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 权限时会拒绝该指令'),
|
|
80
|
+
debug: koishi_1.Schema.boolean().default(false).description('输出 WebSocket 收发内容、指令匹配和消息解析等调试日志'),
|
|
40
81
|
clientRoutes: koishi_1.Schema.array(ClientRouteConfig)
|
|
41
82
|
.role('table')
|
|
42
83
|
.default([])
|
|
43
|
-
.description('正向
|
|
84
|
+
.description('正向 WebSocket 接口:插件主动连接后端,将指定指令转换为 OneBot v11 消息事件;后端可调用 send_msg、send_group_msg、send_private_msg 等动作回发消息'),
|
|
44
85
|
serverRoutes: koishi_1.Schema.array(ServerRouteConfig)
|
|
45
86
|
.role('table')
|
|
46
87
|
.default([])
|
|
47
|
-
.description('反向
|
|
48
|
-
});
|
|
88
|
+
.description('反向 WebSocket 接口:插件在指定地址监听,等待后端连接;事件和后端动作格式与 OneBot v11 一致'),
|
|
89
|
+
}).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
90
|
function toOneBotId(value) {
|
|
50
91
|
if (value == null)
|
|
51
92
|
return undefined;
|
|
@@ -227,15 +268,16 @@ function transformElements(elements) {
|
|
|
227
268
|
}
|
|
228
269
|
return result;
|
|
229
270
|
}
|
|
230
|
-
function buildOneBotMessageEvent(session, commandName, content) {
|
|
231
|
-
const
|
|
271
|
+
function buildOneBotMessageEvent(session, commandName, content, translated = false) {
|
|
272
|
+
const routedText = (commandName + ' ' + (content ?? '')).trim();
|
|
273
|
+
const text = translated ? routedText : (session?.content || routedText);
|
|
232
274
|
const isGroup = Boolean(session?.guildId != null
|
|
233
275
|
|| (session?.channelId != null && String(session.channelId) !== String(session?.userId ?? '')));
|
|
234
276
|
const userId = toOneBotId(session?.userId);
|
|
235
277
|
const groupId = isGroup ? toOneBotId(session?.guildId ?? session?.channelId) : undefined;
|
|
236
278
|
const selfId = toOneBotId(session?.selfId ?? session?.bot?.selfId);
|
|
237
279
|
const messageId = toOneBotId(session?.messageId);
|
|
238
|
-
const elements = session?.elements || koishi_1.h.parse(text);
|
|
280
|
+
const elements = translated ? koishi_1.h.parse(text) : (session?.elements || koishi_1.h.parse(text));
|
|
239
281
|
const messageSegments = transformElements(elements);
|
|
240
282
|
return {
|
|
241
283
|
time: Math.floor(Date.now() / 1000),
|
|
@@ -259,6 +301,7 @@ class RouteRuntime {
|
|
|
259
301
|
ctx;
|
|
260
302
|
config;
|
|
261
303
|
route;
|
|
304
|
+
declareCommands;
|
|
262
305
|
socket = null;
|
|
263
306
|
server = null;
|
|
264
307
|
connecting = null;
|
|
@@ -272,10 +315,11 @@ class RouteRuntime {
|
|
|
272
315
|
lastPong = 0;
|
|
273
316
|
recentGroupSessions = new Map();
|
|
274
317
|
recentPrivateSessions = new Map();
|
|
275
|
-
constructor(ctx, config, route) {
|
|
318
|
+
constructor(ctx, config, route, declareCommands) {
|
|
276
319
|
this.ctx = ctx;
|
|
277
320
|
this.config = config;
|
|
278
321
|
this.route = route;
|
|
322
|
+
this.declareCommands = declareCommands;
|
|
279
323
|
}
|
|
280
324
|
get kind() {
|
|
281
325
|
if (this.route.mode === 'client')
|
|
@@ -289,7 +333,10 @@ class RouteRuntime {
|
|
|
289
333
|
}
|
|
290
334
|
get enabled() {
|
|
291
335
|
const commands = Array.isArray(this.route.commands) ? this.route.commands : [];
|
|
292
|
-
|
|
336
|
+
const mappings = Array.isArray(this.route.commandMappings)
|
|
337
|
+
? this.route.commandMappings.filter(item => item?.enabled !== false)
|
|
338
|
+
: [];
|
|
339
|
+
if (!this.route.enabled || (!commands.length && !mappings.length && !this.config.enableCommandDeclaration))
|
|
293
340
|
return false;
|
|
294
341
|
if (this.kind === 'client')
|
|
295
342
|
return !!('endpoint' in this.route && this.route.endpoint && this.route.endpoint.length > 0);
|
|
@@ -333,15 +380,12 @@ class RouteRuntime {
|
|
|
333
380
|
}
|
|
334
381
|
this.socket = null;
|
|
335
382
|
}
|
|
336
|
-
async forward(session, commandName, content = '') {
|
|
337
|
-
|
|
338
|
-
throw new Error(`route ${this.route.name} has no commands configured`);
|
|
339
|
-
}
|
|
340
|
-
this.ctx.logger(exports.name).info(`route ${this.routeLabel} command trigger: command=${commandName} user=${session.userId ?? 'unknown'} channel=${session.channelId ?? 'unknown'}`);
|
|
383
|
+
async forward(session, commandName, content = '', sourceCommand = commandName) {
|
|
384
|
+
this.ctx.logger(exports.name).info(`route ${this.routeLabel} command trigger: source=${sourceCommand} target=${commandName} user=${session.userId ?? 'unknown'} channel=${session.channelId ?? 'unknown'}`);
|
|
341
385
|
if (this.config.debug) {
|
|
342
386
|
this.ctx.logger(exports.name).debug(`route ${this.routeLabel} command content: ${JSON.stringify(content)}`);
|
|
343
387
|
}
|
|
344
|
-
const event = buildOneBotMessageEvent(session, commandName, content);
|
|
388
|
+
const event = buildOneBotMessageEvent(session, commandName, content, sourceCommand !== commandName);
|
|
345
389
|
const isGroup = Boolean(session?.guildId != null
|
|
346
390
|
|| (session?.channelId != null && String(session.channelId) !== String(session?.userId ?? '')));
|
|
347
391
|
if (isGroup && session?.channelId) {
|
|
@@ -379,7 +423,7 @@ class RouteRuntime {
|
|
|
379
423
|
push().catch(e => this.ctx.logger(exports.name).warn(e));
|
|
380
424
|
}
|
|
381
425
|
async sendRoutedMessage(bot, session, targetId, isPrivate, message) {
|
|
382
|
-
const qq = extractQQMarkdown(message);
|
|
426
|
+
const qq = this.config.enableQQMarkdown !== false ? extractQQMarkdown(message) : undefined;
|
|
383
427
|
const isQQ = session?.platform === 'qq' || bot?.platform === 'qq';
|
|
384
428
|
const internal = bot?.internal;
|
|
385
429
|
const isQQPublicBot = typeof internal?.sendPrivateMessage === 'function';
|
|
@@ -585,12 +629,36 @@ class RouteRuntime {
|
|
|
585
629
|
}
|
|
586
630
|
if (data.action) {
|
|
587
631
|
const sendResponse = (response) => {
|
|
588
|
-
if (data.echo)
|
|
632
|
+
if (data.echo !== undefined)
|
|
589
633
|
response.echo = data.echo;
|
|
590
634
|
if (socket.readyState === ws_1.default.OPEN) {
|
|
591
635
|
socket.send(JSON.stringify(response));
|
|
592
636
|
}
|
|
593
637
|
};
|
|
638
|
+
const declarationAction = this.config.commandDeclarationAction || 'msg_router.declare_commands';
|
|
639
|
+
if (data.action === declarationAction) {
|
|
640
|
+
if (!this.config.enableCommandDeclaration) {
|
|
641
|
+
return sendResponse({
|
|
642
|
+
status: 'failed',
|
|
643
|
+
retcode: 103,
|
|
644
|
+
msg: 'Dynamic command declaration is disabled',
|
|
645
|
+
data: null,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
const result = this.declareCommands(data.params);
|
|
650
|
+
this.ctx.logger(exports.name).info(`route ${this.routeLabel} declared=${result.declared.length} removed=${result.removed.length} rejected=${result.rejected.length}`);
|
|
651
|
+
return sendResponse({ status: 'ok', retcode: 0, data: result });
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
return sendResponse({
|
|
655
|
+
status: 'failed',
|
|
656
|
+
retcode: 100,
|
|
657
|
+
msg: error instanceof Error ? error.message : String(error),
|
|
658
|
+
data: null,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
}
|
|
594
662
|
if (data.action === 'send_private_msg' || (data.action === 'send_msg' && data.params?.message_type === 'private')) {
|
|
595
663
|
const userId = data.params?.user_id;
|
|
596
664
|
const message = resolveOutgoingMessage(data.params);
|
|
@@ -857,10 +925,246 @@ class RouteRuntime {
|
|
|
857
925
|
function apply(ctx, config) {
|
|
858
926
|
const logger = ctx.logger(exports.name);
|
|
859
927
|
const runtimes = new Map();
|
|
860
|
-
const registered = new
|
|
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
|
+
});
|
|
937
|
+
const normalizeCommandName = (value) => String(value ?? '')
|
|
938
|
+
.trim()
|
|
939
|
+
.replace(/^\/+/, '')
|
|
940
|
+
.toLowerCase()
|
|
941
|
+
.replace(/_/g, '-');
|
|
942
|
+
const isValidCommandName = (value) => {
|
|
943
|
+
return !!value && value.length <= 100 && !/[\s[\]<>]/.test(value);
|
|
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
|
+
};
|
|
998
|
+
const parseDeclarations = (params) => {
|
|
999
|
+
const source = params?.commands;
|
|
1000
|
+
if (Array.isArray(source))
|
|
1001
|
+
return source;
|
|
1002
|
+
if (source && typeof source === 'object') {
|
|
1003
|
+
return Object.entries(source).map(([command, target]) => ({ command, target }));
|
|
1004
|
+
}
|
|
1005
|
+
throw new Error('params.commands must be an array or an object map');
|
|
1006
|
+
};
|
|
1007
|
+
const registerCommand = (runtime, sourceName, targetName, dynamic, description, options = defaultCommandOptions()) => {
|
|
1008
|
+
const existing = registered.get(sourceName);
|
|
1009
|
+
if (existing) {
|
|
1010
|
+
if (existing.runtime !== runtime)
|
|
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
|
+
}
|
|
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);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (ctx.$commander.get(sourceName))
|
|
1029
|
+
return 'already registered by another Koishi plugin';
|
|
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) => {
|
|
1046
|
+
if (config.debug) {
|
|
1047
|
+
logger.debug(`command trigger: source=${sourceName} target=${entry.target} content=${JSON.stringify(content ?? '')}`);
|
|
1048
|
+
}
|
|
1049
|
+
await runtime.forward(session, entry.target, content ?? '', sourceName);
|
|
1050
|
+
return undefined;
|
|
1051
|
+
});
|
|
1052
|
+
entry.command = command;
|
|
1053
|
+
registered.set(sourceName, entry);
|
|
1054
|
+
};
|
|
1055
|
+
const declareForRoute = (runtime, params) => {
|
|
1056
|
+
const rejected = [];
|
|
1057
|
+
const declarations = new Map();
|
|
1058
|
+
for (const item of parseDeclarations(params)) {
|
|
1059
|
+
const raw = typeof item === 'string' ? { name: item } : item;
|
|
1060
|
+
if (!raw || typeof raw !== 'object') {
|
|
1061
|
+
rejected.push({ name: String(item ?? ''), reason: 'declaration must be a string or object' });
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
const declaredName = raw.name ?? raw.command ?? raw.source;
|
|
1065
|
+
const sourceName = normalizeCommandName(declaredName);
|
|
1066
|
+
if (!isValidCommandName(sourceName)) {
|
|
1067
|
+
rejected.push({ name: String(declaredName ?? ''), reason: 'invalid command name' });
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
const requestedTarget = raw.target ?? raw.target_command ?? raw.to ?? sourceName;
|
|
1071
|
+
const targetName = config.enableCommandTranslation === false
|
|
1072
|
+
? sourceName
|
|
1073
|
+
: String(requestedTarget ?? '').trim().replace(/^\/+/, '');
|
|
1074
|
+
if (!isValidCommandName(targetName)) {
|
|
1075
|
+
rejected.push({ name: sourceName, reason: 'invalid target command' });
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
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
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
const limit = Math.max(1, config.maxDeclaredCommands || 100);
|
|
1095
|
+
const resultingDynamic = new Set([...registered]
|
|
1096
|
+
.filter(([, entry]) => entry.runtime === runtime && entry.dynamic)
|
|
1097
|
+
.map(([sourceName]) => sourceName));
|
|
1098
|
+
if (params?.replace !== false)
|
|
1099
|
+
resultingDynamic.clear();
|
|
1100
|
+
for (const item of declarations.values()) {
|
|
1101
|
+
if (item.enabled)
|
|
1102
|
+
resultingDynamic.add(item.name);
|
|
1103
|
+
else
|
|
1104
|
+
resultingDynamic.delete(item.name);
|
|
1105
|
+
}
|
|
1106
|
+
if (resultingDynamic.size > limit) {
|
|
1107
|
+
throw new Error(`too many declared commands: ${resultingDynamic.size}, limit is ${limit}`);
|
|
1108
|
+
}
|
|
1109
|
+
const removed = [];
|
|
1110
|
+
if (params?.replace !== false) {
|
|
1111
|
+
for (const [sourceName, entry] of [...registered]) {
|
|
1112
|
+
if (entry.runtime !== runtime || declarations.has(sourceName))
|
|
1113
|
+
continue;
|
|
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
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
const declared = [];
|
|
1133
|
+
for (const item of declarations.values()) {
|
|
1134
|
+
const existing = registered.get(item.name);
|
|
1135
|
+
if (!item.enabled) {
|
|
1136
|
+
if (existing?.runtime === runtime && existing.dynamic) {
|
|
1137
|
+
existing.command.dispose();
|
|
1138
|
+
registered.delete(item.name);
|
|
1139
|
+
removed.push(item.name);
|
|
1140
|
+
}
|
|
1141
|
+
else if (existing?.runtime === runtime) {
|
|
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);
|
|
1151
|
+
}
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
const reason = registerCommand(runtime, item.name, item.target, true, item.description, item.options);
|
|
1155
|
+
if (reason) {
|
|
1156
|
+
rejected.push({ name: item.name, reason });
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
declared.push({ name: item.name, target: item.target });
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
return { declared, removed: [...new Set(removed)], rejected };
|
|
1163
|
+
};
|
|
861
1164
|
const routes = [...config.clientRoutes, ...config.serverRoutes];
|
|
862
1165
|
for (const route of routes) {
|
|
863
|
-
|
|
1166
|
+
let runtime;
|
|
1167
|
+
runtime = new RouteRuntime(ctx, config, route, params => declareForRoute(runtime, params));
|
|
864
1168
|
runtimes.set(route.name, runtime);
|
|
865
1169
|
if (!route.enabled)
|
|
866
1170
|
continue;
|
|
@@ -877,26 +1181,41 @@ function apply(ctx, config) {
|
|
|
877
1181
|
continue;
|
|
878
1182
|
}
|
|
879
1183
|
}
|
|
880
|
-
|
|
881
|
-
|
|
1184
|
+
const manualMappings = Array.isArray(route.commandMappings)
|
|
1185
|
+
? route.commandMappings.filter(item => item?.enabled !== false)
|
|
1186
|
+
: [];
|
|
1187
|
+
if ((!Array.isArray(route.commands) || !route.commands.length) && !manualMappings.length && !config.enableCommandDeclaration) {
|
|
1188
|
+
logger.warn(`skip route ${route.name}: no commands or command mappings configured`);
|
|
882
1189
|
continue;
|
|
883
1190
|
}
|
|
884
|
-
for (const commandName of route.commands) {
|
|
885
|
-
const normalized = commandName
|
|
1191
|
+
for (const commandName of route.commands || []) {
|
|
1192
|
+
const normalized = normalizeCommandName(commandName);
|
|
886
1193
|
if (!normalized)
|
|
887
1194
|
continue;
|
|
888
|
-
if (
|
|
889
|
-
logger.warn(`skip
|
|
1195
|
+
if (!isValidCommandName(normalized)) {
|
|
1196
|
+
logger.warn(`skip invalid command ${JSON.stringify(commandName)} from route ${route.name}`);
|
|
890
1197
|
continue;
|
|
891
1198
|
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1199
|
+
const reason = registerCommand(runtime, normalized, normalized, false);
|
|
1200
|
+
if (reason)
|
|
1201
|
+
logger.warn(`skip command ${normalized} from route ${route.name}: ${reason}`);
|
|
1202
|
+
}
|
|
1203
|
+
for (const mapping of manualMappings) {
|
|
1204
|
+
const sourceName = normalizeCommandName(mapping.source);
|
|
1205
|
+
const requestedTarget = String(mapping.target ?? '').trim().replace(/^\/+/, '');
|
|
1206
|
+
const targetName = config.enableCommandTranslation === false ? sourceName : requestedTarget;
|
|
1207
|
+
if (!isValidCommandName(sourceName)) {
|
|
1208
|
+
logger.warn(`skip invalid command mapping source ${JSON.stringify(mapping.source)} from route ${route.name}`);
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
if (!isValidCommandName(targetName)) {
|
|
1212
|
+
logger.warn(`skip invalid command mapping target ${JSON.stringify(mapping.target)} from route ${route.name}`);
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
const reason = registerCommand(runtime, sourceName, targetName, false, mapping.description, normalizeCommandOptions(mapping, false));
|
|
1216
|
+
if (reason) {
|
|
1217
|
+
logger.warn(`skip command mapping ${sourceName} -> ${targetName} from route ${route.name}: ${reason}`);
|
|
1218
|
+
}
|
|
900
1219
|
}
|
|
901
1220
|
}
|
|
902
1221
|
ctx.on('ready', () => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-msg-router",
|
|
3
|
-
"description": "Koishi
|
|
4
|
-
"version": "1.
|
|
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": [
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"onebot11",
|
|
22
22
|
"qq",
|
|
23
23
|
"markdown",
|
|
24
|
+
"command-mapping",
|
|
24
25
|
"websocket",
|
|
25
26
|
"router"
|
|
26
27
|
],
|
|
@@ -33,8 +34,8 @@
|
|
|
33
34
|
},
|
|
34
35
|
"koishi": {
|
|
35
36
|
"description": {
|
|
36
|
-
"zh": "
|
|
37
|
-
"en": "
|
|
37
|
+
"zh": "通过 OneBot 手动或动态声明并转译指定指令,可限制权限、频率、会话及平台,不影响其他指令,支持正反向 WebSocket 和 QQ Markdown",
|
|
38
|
+
"en": "Maps selected commands over OneBot with access controls, forward/reverse WebSocket, and QQ Markdown"
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
}
|
package/readme.md
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
## 主要用途
|
|
10
10
|
|
|
11
11
|
- **按需接管指令**:每条路由独立配置指令列表,只转发明确指定的指令
|
|
12
|
+
- **手动或动态转译**:可以在配置页维护指令映射,也可以由后端通过接口动态声明
|
|
13
|
+
- **Koishi 指令控制**:映射可配置权限、帮助菜单隐藏、调用频率、群聊/私聊范围和平台限制
|
|
12
14
|
- **OneBot v11 兼容**:向后端推送标准消息事件,并处理常用 OneBot API 动作
|
|
13
15
|
- **双向 WebSocket**:支持插件主动连接后端,也支持插件监听并等待后端连接
|
|
14
16
|
- **多路由配置**:不同指令可以连接不同后端,分别设置地址、鉴权和连接参数
|
|
@@ -20,7 +22,14 @@
|
|
|
20
22
|
|
|
21
23
|
### 总开关
|
|
22
24
|
|
|
23
|
-
- `
|
|
25
|
+
- `enableQQMarkdown`:是否启用 QQ 原生 Markdown 文本;关闭后 Markdown 内容按普通文本降级发送
|
|
26
|
+
- `enableCommandDeclaration`:是否允许后端通过 WebSocket 动态声明要接管的指令,默认关闭
|
|
27
|
+
- `enableCommandTranslation`:是否允许把对外指令转译为不同的后端目标指令
|
|
28
|
+
- `commandDeclarationAction`:动态声明接口的 action,默认 `msg_router.declare_commands`
|
|
29
|
+
- `maxDeclaredCommands`:每条路由最多允许动态声明的指令数量,默认 `100`
|
|
30
|
+
- `allowDynamicCommandPermissions`:是否允许后端自行声明权限规则,默认关闭
|
|
31
|
+
- `minimumDynamicAuthority`:动态指令的最低权限等级,默认 `1`;后端不得声明更低的 `authority` 权限
|
|
32
|
+
- `debug`:是否输出 WebSocket 收发、指令匹配和消息解析等调试信息
|
|
24
33
|
|
|
25
34
|
### 正向 WS 路由列表
|
|
26
35
|
|
|
@@ -33,6 +42,7 @@
|
|
|
33
42
|
- `token`:后端鉴权令牌,可不填
|
|
34
43
|
- `secret`:HMAC 签名密钥,可不填
|
|
35
44
|
- `commands`:触发这条路由的 Koishi 指令
|
|
45
|
+
- `commandMappings`:手动指令转译表,可设置对外指令、后端目标指令、说明和启用状态
|
|
36
46
|
- `action`:发送给后端的 action 名称
|
|
37
47
|
- `timeout`:单次请求等待时间,单位毫秒
|
|
38
48
|
- `heartbeatInterval`:发送 ping 的间隔,单位毫秒
|
|
@@ -51,6 +61,7 @@
|
|
|
51
61
|
- `token`:后端鉴权令牌,可不填
|
|
52
62
|
- `secret`:HMAC 签名密钥,可不填
|
|
53
63
|
- `commands`:触发这条路由的 Koishi 指令
|
|
64
|
+
- `commandMappings`:手动指令转译表,可独立于 `commands` 使用
|
|
54
65
|
- `action`:发送给后端的 action 名称
|
|
55
66
|
- `timeout`:单次请求等待时间,单位毫秒
|
|
56
67
|
- `heartbeatInterval`:发送 ping 的间隔,单位毫秒
|
|
@@ -159,10 +170,97 @@
|
|
|
159
170
|
|
|
160
171
|
如果 `data.text`、`data.message` 或 `data.reply` 存在,插件会直接把它返回给用户。`data.message` 支持 OneBot 风格的文本段数组。
|
|
161
172
|
|
|
173
|
+
### 手动配置指令转译
|
|
174
|
+
|
|
175
|
+
每条正向或反向路由都提供 `commandMappings` 表格,可以直接在 Koishi 配置页面添加映射:
|
|
176
|
+
|
|
177
|
+
| 配置项 | 说明 | 示例 |
|
|
178
|
+
| --- | --- | --- |
|
|
179
|
+
| `enabled` | 是否启用这条映射 | `true` |
|
|
180
|
+
| `source` | 用户实际执行、插件需要接管的指令 | `天气` |
|
|
181
|
+
| `target` | 转发给后端的目标指令 | `weather` |
|
|
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"]` |
|
|
189
|
+
|
|
190
|
+
配置后,用户执行 `天气 上海`,后端会收到 `weather 上海`。映射可以独立使用,不需要再把 `天气` 重复填写到 `commands`;如果两处都填写,`commandMappings` 中的目标指令优先。单条映射可以使用 `enabled` 独立关闭。
|
|
191
|
+
|
|
192
|
+
全局关闭 `enableCommandTranslation` 后,手动映射和动态声明仍会注册源指令,但不再替换指令名。`permissions` 使用 Koishi 原生权限服务;`hidden` 需要 help 插件,`maxUsage` 和 `minInterval` 需要 rate-limit 插件及数据库服务才能生效。
|
|
193
|
+
|
|
194
|
+
### 动态声明与指令转译接口
|
|
195
|
+
|
|
196
|
+
开启配置项 `enableCommandDeclaration` 后,已连接的后端可以通过 WebSocket 调用声明接口。接口 action 默认为 `msg_router.declare_commands`,也可以通过 `commandDeclarationAction` 修改。
|
|
197
|
+
|
|
198
|
+
下面的声明会让插件接管用户侧的 `天气` 和 `菜单` 指令。其中,用户执行 `天气 上海` 时,后端收到的消息内容会转译为 `weather 上海`;插件只替换指令名,后续参数保持不变。
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{
|
|
202
|
+
"action": "msg_router.declare_commands",
|
|
203
|
+
"params": {
|
|
204
|
+
"replace": true,
|
|
205
|
+
"commands": [
|
|
206
|
+
{
|
|
207
|
+
"name": "天气",
|
|
208
|
+
"target": "weather",
|
|
209
|
+
"description": "查询天气",
|
|
210
|
+
"permissions": ["authority:2"],
|
|
211
|
+
"hidden": false,
|
|
212
|
+
"maxUsage": 20,
|
|
213
|
+
"minInterval": 3000,
|
|
214
|
+
"scope": "group",
|
|
215
|
+
"platforms": ["qq"]
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"name": "菜单",
|
|
219
|
+
"target": "help"
|
|
220
|
+
}
|
|
221
|
+
]
|
|
222
|
+
},
|
|
223
|
+
"echo": "declare-1"
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
成功响应:
|
|
228
|
+
|
|
229
|
+
```json
|
|
230
|
+
{
|
|
231
|
+
"status": "ok",
|
|
232
|
+
"retcode": 0,
|
|
233
|
+
"data": {
|
|
234
|
+
"declared": [
|
|
235
|
+
{ "name": "天气", "target": "weather" },
|
|
236
|
+
{ "name": "菜单", "target": "help" }
|
|
237
|
+
],
|
|
238
|
+
"removed": [],
|
|
239
|
+
"rejected": []
|
|
240
|
+
},
|
|
241
|
+
"echo": "declare-1"
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
接口规则:
|
|
246
|
+
|
|
247
|
+
- `commands` 可以是上述数组,也可以简写成 `{ "天气": "weather", "菜单": "help" }`
|
|
248
|
+
- 字符串声明(例如 `"ping"`)表示不转译,源指令和目标指令相同
|
|
249
|
+
- `replace` 默认为 `true`,会移除该路由之前动态声明、但本次未再次声明的指令
|
|
250
|
+
- `replace: false` 表示增量更新;单项设置 `enabled: false` 可以移除动态指令
|
|
251
|
+
- 动态声明支持与手动映射相同的 `permissions`、`hidden`、`maxUsage`、`minInterval`、`scope` 和 `platforms`
|
|
252
|
+
- 默认不允许后端决定权限,动态指令统一采用 `minimumDynamicAuthority`;只有显式开启 `allowDynamicCommandPermissions` 后才读取后端提交的 `permissions`
|
|
253
|
+
- 即使允许动态权限,低于 `minimumDynamicAuthority` 的 `authority` 规则仍会被拒绝;只声明自定义权限时,插件也会自动附加最低 `authority` 规则
|
|
254
|
+
- 关闭 `enableCommandTranslation` 后仍允许动态声明,但会忽略 `target`,按原指令转发
|
|
255
|
+
- 插件不会覆盖其他 Koishi 插件已经注册的指令,冲突项会出现在 `rejected` 中
|
|
256
|
+
- 只配置动态声明、不填写静态 `commands` 的路由也会启动并等待后端声明
|
|
257
|
+
|
|
162
258
|
### QQ Markdown 文本
|
|
163
259
|
|
|
164
260
|
后端通过 `send_group_msg`、`send_private_msg` 或 `send_msg` 回发消息时,可以使用 QQ 原生 Markdown 文本。插件在 QQ 官方适配器上会直接提交 `markdown.content`;在其他平台上会把同一内容作为普通文本降级发送。
|
|
165
261
|
|
|
262
|
+
此功能可通过配置页面的 `enableQQMarkdown` 开关控制。关闭后不会调用 QQ 原生 Markdown 接口,而是把 `content` 或 `fallback_text` 作为普通消息发送。
|
|
263
|
+
|
|
166
264
|
```json
|
|
167
265
|
{
|
|
168
266
|
"action": "send_group_msg",
|