koishi-plugin-msg-router 1.0.0 → 1.1.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.js +245 -8
- package/package.json +6 -4
- package/readme.md +35 -7
package/lib/index.js
CHANGED
|
@@ -59,6 +59,61 @@ function toOneBotId(value) {
|
|
|
59
59
|
}
|
|
60
60
|
return undefined;
|
|
61
61
|
}
|
|
62
|
+
function firstMessageId(value) {
|
|
63
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
64
|
+
if (first == null || first === '')
|
|
65
|
+
return Math.floor(Math.random() * 1000000);
|
|
66
|
+
if (typeof first === 'number')
|
|
67
|
+
return first;
|
|
68
|
+
const numeric = Number(first);
|
|
69
|
+
return Number.isFinite(numeric) ? numeric : String(first);
|
|
70
|
+
}
|
|
71
|
+
/** Extracts QQ's native Markdown payload without letting generic Satori conversion discard it. */
|
|
72
|
+
function extractQQMarkdown(value) {
|
|
73
|
+
const segments = Array.isArray(value) ? value : [value];
|
|
74
|
+
let markdownSource;
|
|
75
|
+
const remaining = [];
|
|
76
|
+
for (const item of segments) {
|
|
77
|
+
if (!item || typeof item !== 'object') {
|
|
78
|
+
remaining.push(item);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const segment = item;
|
|
82
|
+
if (segment.type === 'markdown') {
|
|
83
|
+
const source = segment.data?.markdown ?? segment.data ?? {};
|
|
84
|
+
markdownSource = typeof source === 'string' ? { content: source } : source;
|
|
85
|
+
}
|
|
86
|
+
else if (!Array.isArray(value) && segment.markdown) {
|
|
87
|
+
markdownSource = typeof segment.markdown === 'string'
|
|
88
|
+
? { content: segment.markdown }
|
|
89
|
+
: segment.markdown;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
remaining.push(item);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!markdownSource)
|
|
96
|
+
return;
|
|
97
|
+
const content = markdownSource.content == null ? undefined : String(markdownSource.content);
|
|
98
|
+
if (!content)
|
|
99
|
+
return;
|
|
100
|
+
const fallback = String(markdownSource.fallback_text
|
|
101
|
+
?? markdownSource.fallback
|
|
102
|
+
?? markdownSource.prompt
|
|
103
|
+
?? content
|
|
104
|
+
?? '');
|
|
105
|
+
return {
|
|
106
|
+
markdown: { content },
|
|
107
|
+
fallback,
|
|
108
|
+
remaining,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function resolveOutgoingMessage(params) {
|
|
112
|
+
if (params?.markdown) {
|
|
113
|
+
return { markdown: params.markdown };
|
|
114
|
+
}
|
|
115
|
+
return params?.message;
|
|
116
|
+
}
|
|
62
117
|
function toElements(value) {
|
|
63
118
|
if (value == null)
|
|
64
119
|
return [];
|
|
@@ -106,6 +161,18 @@ function toElements(value) {
|
|
|
106
161
|
if (segment.type === 'reply') {
|
|
107
162
|
return [(0, koishi_1.h)('quote', { id: segment.data?.id || '' })];
|
|
108
163
|
}
|
|
164
|
+
if (segment.type === 'markdown') {
|
|
165
|
+
const source = segment.data?.markdown ?? segment.data ?? {};
|
|
166
|
+
const fallback = source.fallback_text ?? source.fallback ?? source.prompt ?? source.content;
|
|
167
|
+
return fallback == null ? [] : [koishi_1.h.text(String(fallback))];
|
|
168
|
+
}
|
|
169
|
+
if (segment.type === 'keyboard')
|
|
170
|
+
return [];
|
|
171
|
+
if (segment.markdown !== undefined) {
|
|
172
|
+
const source = segment.markdown ?? {};
|
|
173
|
+
const fallback = source.fallback_text ?? source.fallback ?? source.prompt ?? source.content;
|
|
174
|
+
return fallback == null ? [] : [koishi_1.h.text(String(fallback))];
|
|
175
|
+
}
|
|
109
176
|
if (segment.reply !== undefined)
|
|
110
177
|
return toElements(segment.reply);
|
|
111
178
|
if (segment.message !== undefined)
|
|
@@ -203,6 +270,8 @@ class RouteRuntime {
|
|
|
203
270
|
queue = [];
|
|
204
271
|
closed = false;
|
|
205
272
|
lastPong = 0;
|
|
273
|
+
recentGroupSessions = new Map();
|
|
274
|
+
recentPrivateSessions = new Map();
|
|
206
275
|
constructor(ctx, config, route) {
|
|
207
276
|
this.ctx = ctx;
|
|
208
277
|
this.config = config;
|
|
@@ -273,6 +342,14 @@ class RouteRuntime {
|
|
|
273
342
|
this.ctx.logger(exports.name).debug(`route ${this.routeLabel} command content: ${JSON.stringify(content)}`);
|
|
274
343
|
}
|
|
275
344
|
const event = buildOneBotMessageEvent(session, commandName, content);
|
|
345
|
+
const isGroup = Boolean(session?.guildId != null
|
|
346
|
+
|| (session?.channelId != null && String(session.channelId) !== String(session?.userId ?? '')));
|
|
347
|
+
if (isGroup && session?.channelId) {
|
|
348
|
+
this.recentGroupSessions.set(String(session.channelId), session);
|
|
349
|
+
}
|
|
350
|
+
else if (session?.userId) {
|
|
351
|
+
this.recentPrivateSessions.set(String(session.userId), session);
|
|
352
|
+
}
|
|
276
353
|
this.sendEvent(event);
|
|
277
354
|
return undefined; // We do not return a reply here. Standard OB11 backends reply using send_msg API calls.
|
|
278
355
|
}
|
|
@@ -301,6 +378,58 @@ class RouteRuntime {
|
|
|
301
378
|
};
|
|
302
379
|
push().catch(e => this.ctx.logger(exports.name).warn(e));
|
|
303
380
|
}
|
|
381
|
+
async sendRoutedMessage(bot, session, targetId, isPrivate, message) {
|
|
382
|
+
const qq = extractQQMarkdown(message);
|
|
383
|
+
const isQQ = session?.platform === 'qq' || bot?.platform === 'qq';
|
|
384
|
+
const internal = bot?.internal;
|
|
385
|
+
const isQQPublicBot = typeof internal?.sendPrivateMessage === 'function';
|
|
386
|
+
const canSendNative = isQQ && typeof internal?.sendMessage === 'function'
|
|
387
|
+
&& (!isPrivate || isQQPublicBot);
|
|
388
|
+
if (!qq || !canSendNative) {
|
|
389
|
+
const elements = qq
|
|
390
|
+
? [...(qq.fallback ? [koishi_1.h.text(qq.fallback)] : []), ...toElements(qq.remaining)]
|
|
391
|
+
: toElements(message);
|
|
392
|
+
if (!elements.length)
|
|
393
|
+
return [];
|
|
394
|
+
return session
|
|
395
|
+
? await session.send(elements)
|
|
396
|
+
: isPrivate
|
|
397
|
+
? await bot.sendPrivateMessage(targetId, elements)
|
|
398
|
+
: await bot.sendMessage(targetId, elements);
|
|
399
|
+
}
|
|
400
|
+
const recentMessageId = session?.messageId
|
|
401
|
+
&& (!session.timestamp || Date.now() - session.timestamp < 5 * 60 * 1000)
|
|
402
|
+
? String(session.messageId)
|
|
403
|
+
: undefined;
|
|
404
|
+
const request = isQQPublicBot
|
|
405
|
+
? {
|
|
406
|
+
msg_type: 2,
|
|
407
|
+
markdown: qq.markdown,
|
|
408
|
+
...(recentMessageId ? { msg_id: recentMessageId } : {}),
|
|
409
|
+
}
|
|
410
|
+
: {
|
|
411
|
+
content: ' ',
|
|
412
|
+
markdown: qq.markdown,
|
|
413
|
+
...(recentMessageId ? { msg_id: recentMessageId } : {}),
|
|
414
|
+
};
|
|
415
|
+
const response = isPrivate
|
|
416
|
+
? await internal.sendPrivateMessage(targetId, request)
|
|
417
|
+
: await internal.sendMessage(targetId, request);
|
|
418
|
+
const ids = response?.id ? [response.id] : [];
|
|
419
|
+
// The Markdown segment forms one native QQ message. Preserve any other
|
|
420
|
+
// OneBot segments by sending them as a second, ordinary message.
|
|
421
|
+
const remaining = toElements(qq.remaining);
|
|
422
|
+
if (remaining.length) {
|
|
423
|
+
const extra = session
|
|
424
|
+
? await session.send(remaining)
|
|
425
|
+
: isPrivate
|
|
426
|
+
? await bot.sendPrivateMessage(targetId, remaining)
|
|
427
|
+
: await bot.sendMessage(targetId, remaining);
|
|
428
|
+
if (Array.isArray(extra))
|
|
429
|
+
ids.push(...extra);
|
|
430
|
+
}
|
|
431
|
+
return ids;
|
|
432
|
+
}
|
|
304
433
|
acquireSlot() {
|
|
305
434
|
if (this.inflight < this.route.maxConcurrency) {
|
|
306
435
|
this.inflight += 1;
|
|
@@ -464,15 +593,27 @@ class RouteRuntime {
|
|
|
464
593
|
};
|
|
465
594
|
if (data.action === 'send_private_msg' || (data.action === 'send_msg' && data.params?.message_type === 'private')) {
|
|
466
595
|
const userId = data.params?.user_id;
|
|
467
|
-
const message = data.params
|
|
596
|
+
const message = resolveOutgoingMessage(data.params);
|
|
468
597
|
if (userId == null || message == null) {
|
|
469
598
|
return sendResponse({ status: 'failed', retcode: 100, msg: 'Missing user_id or message', data: null });
|
|
470
599
|
}
|
|
471
600
|
const bot = this.ctx.bots.find(b => b.selfId === String(data.params?.self_id)) || this.ctx.bots[0];
|
|
472
|
-
|
|
601
|
+
const session = this.recentPrivateSessions.get(String(userId));
|
|
602
|
+
if (session && (!data.params?.self_id || session.bot?.selfId === String(data.params?.self_id))) {
|
|
473
603
|
try {
|
|
474
|
-
const msgIds = await
|
|
475
|
-
const msgId =
|
|
604
|
+
const msgIds = await this.sendRoutedMessage(session.bot ?? bot, session, String(userId), true, message);
|
|
605
|
+
const msgId = firstMessageId(msgIds);
|
|
606
|
+
sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
|
|
607
|
+
}
|
|
608
|
+
catch (e) {
|
|
609
|
+
this.ctx.logger(exports.name).error(`[send_private_msg] Failed to send via session to ${userId}:`, e);
|
|
610
|
+
sendResponse({ status: 'failed', retcode: 100, msg: String(e), data: null });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
else if (bot) {
|
|
614
|
+
try {
|
|
615
|
+
const msgIds = await this.sendRoutedMessage(bot, null, String(userId), true, message);
|
|
616
|
+
const msgId = firstMessageId(msgIds);
|
|
476
617
|
sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
|
|
477
618
|
}
|
|
478
619
|
catch (e) {
|
|
@@ -487,15 +628,27 @@ class RouteRuntime {
|
|
|
487
628
|
}
|
|
488
629
|
if (data.action === 'send_group_msg' || (data.action === 'send_msg' && data.params?.message_type === 'group')) {
|
|
489
630
|
const groupId = data.params?.group_id;
|
|
490
|
-
const message = data.params
|
|
631
|
+
const message = resolveOutgoingMessage(data.params);
|
|
491
632
|
if (groupId == null || message == null) {
|
|
492
633
|
return sendResponse({ status: 'failed', retcode: 100, msg: 'Missing group_id or message', data: null });
|
|
493
634
|
}
|
|
494
635
|
const bot = this.ctx.bots.find(b => b.selfId === String(data.params?.self_id)) || this.ctx.bots[0];
|
|
495
|
-
|
|
636
|
+
const session = this.recentGroupSessions.get(String(groupId));
|
|
637
|
+
if (session && (!data.params?.self_id || session.bot?.selfId === String(data.params?.self_id))) {
|
|
496
638
|
try {
|
|
497
|
-
const msgIds = await
|
|
498
|
-
const msgId =
|
|
639
|
+
const msgIds = await this.sendRoutedMessage(session.bot ?? bot, session, String(groupId), false, message);
|
|
640
|
+
const msgId = firstMessageId(msgIds);
|
|
641
|
+
sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
|
|
642
|
+
}
|
|
643
|
+
catch (e) {
|
|
644
|
+
this.ctx.logger(exports.name).error(`[send_group_msg] Failed to send via session to ${groupId}:`, e);
|
|
645
|
+
sendResponse({ status: 'failed', retcode: 100, msg: String(e), data: null });
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
else if (bot) {
|
|
649
|
+
try {
|
|
650
|
+
const msgIds = await this.sendRoutedMessage(bot, null, String(groupId), false, message);
|
|
651
|
+
const msgId = firstMessageId(msgIds);
|
|
499
652
|
sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
|
|
500
653
|
}
|
|
501
654
|
catch (e) {
|
|
@@ -508,6 +661,90 @@ class RouteRuntime {
|
|
|
508
661
|
}
|
|
509
662
|
return;
|
|
510
663
|
}
|
|
664
|
+
const bot = this.ctx.bots.find(b => b.selfId === String(data.params?.self_id)) || this.ctx.bots[0];
|
|
665
|
+
if (!bot) {
|
|
666
|
+
return sendResponse({ status: 'failed', retcode: 100, msg: 'No Koishi bot available', data: null });
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
if (data.action === 'delete_msg') {
|
|
670
|
+
const messageId = String(data.params?.message_id);
|
|
671
|
+
if (!messageId)
|
|
672
|
+
throw new Error('Missing message_id');
|
|
673
|
+
// In OneBot, we only get message_id, not channel_id. But Satori requires channelId to delete.
|
|
674
|
+
// A naive fallback if channel_id is not provided is to try to guess it, but standard OB11 doesn't provide channel_id.
|
|
675
|
+
// We will attempt deletion if channel_id is provided, or fail.
|
|
676
|
+
const channelId = String(data.params?.channel_id || data.params?.group_id || '');
|
|
677
|
+
if (!channelId)
|
|
678
|
+
throw new Error('Cannot delete message without channel_id/group_id context in Satori');
|
|
679
|
+
await bot.deleteMessage(channelId, messageId);
|
|
680
|
+
return sendResponse({ status: 'ok', retcode: 0, data: null });
|
|
681
|
+
}
|
|
682
|
+
if (data.action === 'get_login_info') {
|
|
683
|
+
return sendResponse({
|
|
684
|
+
status: 'ok',
|
|
685
|
+
retcode: 0,
|
|
686
|
+
data: {
|
|
687
|
+
user_id: toOneBotId(bot.selfId),
|
|
688
|
+
nickname: bot.user?.name || bot.user?.nick || 'bot',
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
if (data.action === 'set_group_ban') {
|
|
693
|
+
const groupId = String(data.params?.group_id);
|
|
694
|
+
const userId = String(data.params?.user_id);
|
|
695
|
+
const duration = Number(data.params?.duration) * 1000; // OB11 is seconds, Koishi is ms
|
|
696
|
+
if (!groupId || !userId)
|
|
697
|
+
throw new Error('Missing group_id or user_id');
|
|
698
|
+
await bot.muteGuildMember(groupId, userId, duration);
|
|
699
|
+
return sendResponse({ status: 'ok', retcode: 0, data: null });
|
|
700
|
+
}
|
|
701
|
+
if (data.action === 'set_group_kick') {
|
|
702
|
+
const groupId = String(data.params?.group_id);
|
|
703
|
+
const userId = String(data.params?.user_id);
|
|
704
|
+
if (!groupId || !userId)
|
|
705
|
+
throw new Error('Missing group_id or user_id');
|
|
706
|
+
await bot.kickGuildMember(groupId, userId);
|
|
707
|
+
return sendResponse({ status: 'ok', retcode: 0, data: null });
|
|
708
|
+
}
|
|
709
|
+
if (data.action === 'get_group_member_info') {
|
|
710
|
+
const groupId = String(data.params?.group_id);
|
|
711
|
+
const userId = String(data.params?.user_id);
|
|
712
|
+
if (!groupId || !userId)
|
|
713
|
+
throw new Error('Missing group_id or user_id');
|
|
714
|
+
const member = await bot.getGuildMember(groupId, userId);
|
|
715
|
+
return sendResponse({
|
|
716
|
+
status: 'ok',
|
|
717
|
+
retcode: 0,
|
|
718
|
+
data: {
|
|
719
|
+
group_id: toOneBotId(groupId),
|
|
720
|
+
user_id: toOneBotId(userId),
|
|
721
|
+
nickname: member.user?.name || member.nick || '',
|
|
722
|
+
card: member.nick || '',
|
|
723
|
+
role: member.roles?.includes('admin') || member.roles?.includes('owner') ? 'admin' : 'member'
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
if (data.action === 'get_group_info') {
|
|
728
|
+
const groupId = String(data.params?.group_id);
|
|
729
|
+
if (!groupId)
|
|
730
|
+
throw new Error('Missing group_id');
|
|
731
|
+
const guild = await bot.getGuild(groupId);
|
|
732
|
+
return sendResponse({
|
|
733
|
+
status: 'ok',
|
|
734
|
+
retcode: 0,
|
|
735
|
+
data: {
|
|
736
|
+
group_id: toOneBotId(groupId),
|
|
737
|
+
group_name: guild.name || '',
|
|
738
|
+
member_count: 0,
|
|
739
|
+
max_member_count: 0,
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
catch (e) {
|
|
745
|
+
this.ctx.logger(exports.name).warn(`[${data.action}] Native delegation failed:`, e);
|
|
746
|
+
return sendResponse({ status: 'failed', retcode: 100, msg: String(e), data: null });
|
|
747
|
+
}
|
|
511
748
|
// Unsupported action fallback
|
|
512
749
|
sendResponse({ status: 'failed', retcode: 102, msg: 'Unsupported API action', data: null });
|
|
513
750
|
return;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-msg-router",
|
|
3
|
-
"description": "OneBot v11 WebSocket
|
|
4
|
-
"version": "1.
|
|
3
|
+
"description": "Koishi 指令中转路由:仅接管指定指令,通过 OneBot v11 WebSocket 双向通信,支持 QQ Markdown",
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"typings": "lib/index.d.ts",
|
|
7
7
|
"files": [
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"plugin",
|
|
20
20
|
"onebot",
|
|
21
21
|
"onebot11",
|
|
22
|
+
"qq",
|
|
23
|
+
"markdown",
|
|
22
24
|
"websocket",
|
|
23
25
|
"router"
|
|
24
26
|
],
|
|
@@ -31,8 +33,8 @@
|
|
|
31
33
|
},
|
|
32
34
|
"koishi": {
|
|
33
35
|
"description": {
|
|
34
|
-
"zh": "
|
|
35
|
-
"en": "
|
|
36
|
+
"zh": "仅接管指定指令,通过 OneBot v11 WebSocket 与外部后端双向通信,支持正反向连接和 QQ Markdown",
|
|
37
|
+
"en": "Routes selected commands to external backends over OneBot v11 WebSocket, with forward/reverse connections and QQ Markdown"
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
}
|
package/readme.md
CHANGED
|
@@ -2,16 +2,19 @@
|
|
|
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
|
+
- **OneBot v11 兼容**:向后端推送标准消息事件,并处理常用 OneBot API 动作
|
|
13
|
+
- **双向 WebSocket**:支持插件主动连接后端,也支持插件监听并等待后端连接
|
|
14
|
+
- **多路由配置**:不同指令可以连接不同后端,分别设置地址、鉴权和连接参数
|
|
15
|
+
- **消息类型转换**:支持文本、图片、@、回复、语音、视频和表情等常用消息段
|
|
16
|
+
- **QQ Markdown**:支持 QQ 原生 Markdown 文本,其他平台自动使用普通文本降级
|
|
17
|
+
- **连接管理**:提供令牌鉴权、心跳检测、断线重连、请求超时和调试日志
|
|
15
18
|
|
|
16
19
|
## 控制台配置说明
|
|
17
20
|
|
|
@@ -156,6 +159,31 @@
|
|
|
156
159
|
|
|
157
160
|
如果 `data.text`、`data.message` 或 `data.reply` 存在,插件会直接把它返回给用户。`data.message` 支持 OneBot 风格的文本段数组。
|
|
158
161
|
|
|
162
|
+
### QQ Markdown 文本
|
|
163
|
+
|
|
164
|
+
后端通过 `send_group_msg`、`send_private_msg` 或 `send_msg` 回发消息时,可以使用 QQ 原生 Markdown 文本。插件在 QQ 官方适配器上会直接提交 `markdown.content`;在其他平台上会把同一内容作为普通文本降级发送。
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"action": "send_group_msg",
|
|
169
|
+
"params": {
|
|
170
|
+
"group_id": "群 openid",
|
|
171
|
+
"message": [
|
|
172
|
+
{
|
|
173
|
+
"type": "markdown",
|
|
174
|
+
"data": {
|
|
175
|
+
"content": "# 标题\n**加粗内容**\n[查看详情](https://example.com)",
|
|
176
|
+
"fallback_text": "标题\n加粗内容\nhttps://example.com"
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
"echo": "request-id"
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
如果后端本身直接使用 QQ 消息结构,也可以省略 `message`,直接在 `params.markdown.content` 中提供内容。`fallback_text` 可选;未提供时会直接使用 `content` 作为降级文本。
|
|
186
|
+
|
|
159
187
|
## 兼容说明
|
|
160
188
|
|
|
161
189
|
- `token` 是可选项,不填时不会附加 `Authorization` 头
|