koishi-plugin-msg-router 1.1.0 → 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 +13 -0
- package/lib/index.js +219 -36
- package/package.json +5 -4
- package/readme.md +81 -1
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
|
-
|
|
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
|
-
|
|
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('正向
|
|
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('反向
|
|
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;
|
|
@@ -227,15 +250,16 @@ function transformElements(elements) {
|
|
|
227
250
|
}
|
|
228
251
|
return result;
|
|
229
252
|
}
|
|
230
|
-
function buildOneBotMessageEvent(session, commandName, content) {
|
|
231
|
-
const
|
|
253
|
+
function buildOneBotMessageEvent(session, commandName, content, translated = false) {
|
|
254
|
+
const routedText = (commandName + ' ' + (content ?? '')).trim();
|
|
255
|
+
const text = translated ? routedText : (session?.content || routedText);
|
|
232
256
|
const isGroup = Boolean(session?.guildId != null
|
|
233
257
|
|| (session?.channelId != null && String(session.channelId) !== String(session?.userId ?? '')));
|
|
234
258
|
const userId = toOneBotId(session?.userId);
|
|
235
259
|
const groupId = isGroup ? toOneBotId(session?.guildId ?? session?.channelId) : undefined;
|
|
236
260
|
const selfId = toOneBotId(session?.selfId ?? session?.bot?.selfId);
|
|
237
261
|
const messageId = toOneBotId(session?.messageId);
|
|
238
|
-
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));
|
|
239
263
|
const messageSegments = transformElements(elements);
|
|
240
264
|
return {
|
|
241
265
|
time: Math.floor(Date.now() / 1000),
|
|
@@ -259,6 +283,7 @@ class RouteRuntime {
|
|
|
259
283
|
ctx;
|
|
260
284
|
config;
|
|
261
285
|
route;
|
|
286
|
+
declareCommands;
|
|
262
287
|
socket = null;
|
|
263
288
|
server = null;
|
|
264
289
|
connecting = null;
|
|
@@ -272,10 +297,11 @@ class RouteRuntime {
|
|
|
272
297
|
lastPong = 0;
|
|
273
298
|
recentGroupSessions = new Map();
|
|
274
299
|
recentPrivateSessions = new Map();
|
|
275
|
-
constructor(ctx, config, route) {
|
|
300
|
+
constructor(ctx, config, route, declareCommands) {
|
|
276
301
|
this.ctx = ctx;
|
|
277
302
|
this.config = config;
|
|
278
303
|
this.route = route;
|
|
304
|
+
this.declareCommands = declareCommands;
|
|
279
305
|
}
|
|
280
306
|
get kind() {
|
|
281
307
|
if (this.route.mode === 'client')
|
|
@@ -289,7 +315,10 @@ class RouteRuntime {
|
|
|
289
315
|
}
|
|
290
316
|
get enabled() {
|
|
291
317
|
const commands = Array.isArray(this.route.commands) ? this.route.commands : [];
|
|
292
|
-
|
|
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))
|
|
293
322
|
return false;
|
|
294
323
|
if (this.kind === 'client')
|
|
295
324
|
return !!('endpoint' in this.route && this.route.endpoint && this.route.endpoint.length > 0);
|
|
@@ -333,15 +362,12 @@ class RouteRuntime {
|
|
|
333
362
|
}
|
|
334
363
|
this.socket = null;
|
|
335
364
|
}
|
|
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'}`);
|
|
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'}`);
|
|
341
367
|
if (this.config.debug) {
|
|
342
368
|
this.ctx.logger(exports.name).debug(`route ${this.routeLabel} command content: ${JSON.stringify(content)}`);
|
|
343
369
|
}
|
|
344
|
-
const event = buildOneBotMessageEvent(session, commandName, content);
|
|
370
|
+
const event = buildOneBotMessageEvent(session, commandName, content, sourceCommand !== commandName);
|
|
345
371
|
const isGroup = Boolean(session?.guildId != null
|
|
346
372
|
|| (session?.channelId != null && String(session.channelId) !== String(session?.userId ?? '')));
|
|
347
373
|
if (isGroup && session?.channelId) {
|
|
@@ -379,7 +405,7 @@ class RouteRuntime {
|
|
|
379
405
|
push().catch(e => this.ctx.logger(exports.name).warn(e));
|
|
380
406
|
}
|
|
381
407
|
async sendRoutedMessage(bot, session, targetId, isPrivate, message) {
|
|
382
|
-
const qq = extractQQMarkdown(message);
|
|
408
|
+
const qq = this.config.enableQQMarkdown !== false ? extractQQMarkdown(message) : undefined;
|
|
383
409
|
const isQQ = session?.platform === 'qq' || bot?.platform === 'qq';
|
|
384
410
|
const internal = bot?.internal;
|
|
385
411
|
const isQQPublicBot = typeof internal?.sendPrivateMessage === 'function';
|
|
@@ -585,12 +611,36 @@ class RouteRuntime {
|
|
|
585
611
|
}
|
|
586
612
|
if (data.action) {
|
|
587
613
|
const sendResponse = (response) => {
|
|
588
|
-
if (data.echo)
|
|
614
|
+
if (data.echo !== undefined)
|
|
589
615
|
response.echo = data.echo;
|
|
590
616
|
if (socket.readyState === ws_1.default.OPEN) {
|
|
591
617
|
socket.send(JSON.stringify(response));
|
|
592
618
|
}
|
|
593
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
|
+
}
|
|
594
644
|
if (data.action === 'send_private_msg' || (data.action === 'send_msg' && data.params?.message_type === 'private')) {
|
|
595
645
|
const userId = data.params?.user_id;
|
|
596
646
|
const message = resolveOutgoingMessage(data.params);
|
|
@@ -857,10 +907,128 @@ class RouteRuntime {
|
|
|
857
907
|
function apply(ctx, config) {
|
|
858
908
|
const logger = ctx.logger(exports.name);
|
|
859
909
|
const runtimes = new Map();
|
|
860
|
-
const registered = new
|
|
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
|
+
};
|
|
861
1028
|
const routes = [...config.clientRoutes, ...config.serverRoutes];
|
|
862
1029
|
for (const route of routes) {
|
|
863
|
-
|
|
1030
|
+
let runtime;
|
|
1031
|
+
runtime = new RouteRuntime(ctx, config, route, params => declareForRoute(runtime, params));
|
|
864
1032
|
runtimes.set(route.name, runtime);
|
|
865
1033
|
if (!route.enabled)
|
|
866
1034
|
continue;
|
|
@@ -877,26 +1045,41 @@ function apply(ctx, config) {
|
|
|
877
1045
|
continue;
|
|
878
1046
|
}
|
|
879
1047
|
}
|
|
880
|
-
|
|
881
|
-
|
|
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`);
|
|
882
1053
|
continue;
|
|
883
1054
|
}
|
|
884
|
-
for (const commandName of route.commands) {
|
|
885
|
-
const normalized = commandName
|
|
1055
|
+
for (const commandName of route.commands || []) {
|
|
1056
|
+
const normalized = normalizeCommandName(commandName);
|
|
886
1057
|
if (!normalized)
|
|
887
1058
|
continue;
|
|
888
|
-
if (
|
|
889
|
-
logger.warn(`skip
|
|
1059
|
+
if (!isValidCommandName(normalized)) {
|
|
1060
|
+
logger.warn(`skip invalid command ${JSON.stringify(commandName)} from route ${route.name}`);
|
|
890
1061
|
continue;
|
|
891
1062
|
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
+
}
|
|
900
1083
|
}
|
|
901
1084
|
}
|
|
902
1085
|
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.3.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": "Manually or dynamically maps selected commands over OneBot, with forward/reverse WebSocket and QQ Markdown"
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
}
|
package/readme.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
## 主要用途
|
|
10
10
|
|
|
11
11
|
- **按需接管指令**:每条路由独立配置指令列表,只转发明确指定的指令
|
|
12
|
+
- **手动或动态转译**:可以在配置页维护指令映射,也可以由后端通过接口动态声明
|
|
12
13
|
- **OneBot v11 兼容**:向后端推送标准消息事件,并处理常用 OneBot API 动作
|
|
13
14
|
- **双向 WebSocket**:支持插件主动连接后端,也支持插件监听并等待后端连接
|
|
14
15
|
- **多路由配置**:不同指令可以连接不同后端,分别设置地址、鉴权和连接参数
|
|
@@ -20,7 +21,12 @@
|
|
|
20
21
|
|
|
21
22
|
### 总开关
|
|
22
23
|
|
|
23
|
-
- `
|
|
24
|
+
- `enableQQMarkdown`:是否启用 QQ 原生 Markdown 文本;关闭后 Markdown 内容按普通文本降级发送
|
|
25
|
+
- `enableCommandDeclaration`:是否允许后端通过 WebSocket 动态声明要接管的指令,默认关闭
|
|
26
|
+
- `enableCommandTranslation`:是否允许把对外指令转译为不同的后端目标指令
|
|
27
|
+
- `commandDeclarationAction`:动态声明接口的 action,默认 `msg_router.declare_commands`
|
|
28
|
+
- `maxDeclaredCommands`:每条路由最多允许动态声明的指令数量,默认 `100`
|
|
29
|
+
- `debug`:是否输出 WebSocket 收发、指令匹配和消息解析等调试信息
|
|
24
30
|
|
|
25
31
|
### 正向 WS 路由列表
|
|
26
32
|
|
|
@@ -33,6 +39,7 @@
|
|
|
33
39
|
- `token`:后端鉴权令牌,可不填
|
|
34
40
|
- `secret`:HMAC 签名密钥,可不填
|
|
35
41
|
- `commands`:触发这条路由的 Koishi 指令
|
|
42
|
+
- `commandMappings`:手动指令转译表,可设置对外指令、后端目标指令、说明和启用状态
|
|
36
43
|
- `action`:发送给后端的 action 名称
|
|
37
44
|
- `timeout`:单次请求等待时间,单位毫秒
|
|
38
45
|
- `heartbeatInterval`:发送 ping 的间隔,单位毫秒
|
|
@@ -51,6 +58,7 @@
|
|
|
51
58
|
- `token`:后端鉴权令牌,可不填
|
|
52
59
|
- `secret`:HMAC 签名密钥,可不填
|
|
53
60
|
- `commands`:触发这条路由的 Koishi 指令
|
|
61
|
+
- `commandMappings`:手动指令转译表,可独立于 `commands` 使用
|
|
54
62
|
- `action`:发送给后端的 action 名称
|
|
55
63
|
- `timeout`:单次请求等待时间,单位毫秒
|
|
56
64
|
- `heartbeatInterval`:发送 ping 的间隔,单位毫秒
|
|
@@ -159,10 +167,82 @@
|
|
|
159
167
|
|
|
160
168
|
如果 `data.text`、`data.message` 或 `data.reply` 存在,插件会直接把它返回给用户。`data.message` 支持 OneBot 风格的文本段数组。
|
|
161
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
|
+
|
|
162
240
|
### QQ Markdown 文本
|
|
163
241
|
|
|
164
242
|
后端通过 `send_group_msg`、`send_private_msg` 或 `send_msg` 回发消息时,可以使用 QQ 原生 Markdown 文本。插件在 QQ 官方适配器上会直接提交 `markdown.content`;在其他平台上会把同一内容作为普通文本降级发送。
|
|
165
243
|
|
|
244
|
+
此功能可通过配置页面的 `enableQQMarkdown` 开关控制。关闭后不会调用 QQ 原生 Markdown 接口,而是把 `content` 或 `fallback_text` 作为普通消息发送。
|
|
245
|
+
|
|
166
246
|
```json
|
|
167
247
|
{
|
|
168
248
|
"action": "send_group_msg",
|