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