koishi-plugin-msg-router 1.0.2 → 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 +129 -10
- 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)
|
|
@@ -311,6 +378,58 @@ class RouteRuntime {
|
|
|
311
378
|
};
|
|
312
379
|
push().catch(e => this.ctx.logger(exports.name).warn(e));
|
|
313
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
|
+
}
|
|
314
433
|
acquireSlot() {
|
|
315
434
|
if (this.inflight < this.route.maxConcurrency) {
|
|
316
435
|
this.inflight += 1;
|
|
@@ -474,7 +593,7 @@ class RouteRuntime {
|
|
|
474
593
|
};
|
|
475
594
|
if (data.action === 'send_private_msg' || (data.action === 'send_msg' && data.params?.message_type === 'private')) {
|
|
476
595
|
const userId = data.params?.user_id;
|
|
477
|
-
const message = data.params
|
|
596
|
+
const message = resolveOutgoingMessage(data.params);
|
|
478
597
|
if (userId == null || message == null) {
|
|
479
598
|
return sendResponse({ status: 'failed', retcode: 100, msg: 'Missing user_id or message', data: null });
|
|
480
599
|
}
|
|
@@ -482,8 +601,8 @@ class RouteRuntime {
|
|
|
482
601
|
const session = this.recentPrivateSessions.get(String(userId));
|
|
483
602
|
if (session && (!data.params?.self_id || session.bot?.selfId === String(data.params?.self_id))) {
|
|
484
603
|
try {
|
|
485
|
-
const msgIds = await session.
|
|
486
|
-
const msgId = (
|
|
604
|
+
const msgIds = await this.sendRoutedMessage(session.bot ?? bot, session, String(userId), true, message);
|
|
605
|
+
const msgId = firstMessageId(msgIds);
|
|
487
606
|
sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
|
|
488
607
|
}
|
|
489
608
|
catch (e) {
|
|
@@ -493,8 +612,8 @@ class RouteRuntime {
|
|
|
493
612
|
}
|
|
494
613
|
else if (bot) {
|
|
495
614
|
try {
|
|
496
|
-
const msgIds = await
|
|
497
|
-
const msgId = (
|
|
615
|
+
const msgIds = await this.sendRoutedMessage(bot, null, String(userId), true, message);
|
|
616
|
+
const msgId = firstMessageId(msgIds);
|
|
498
617
|
sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
|
|
499
618
|
}
|
|
500
619
|
catch (e) {
|
|
@@ -509,7 +628,7 @@ class RouteRuntime {
|
|
|
509
628
|
}
|
|
510
629
|
if (data.action === 'send_group_msg' || (data.action === 'send_msg' && data.params?.message_type === 'group')) {
|
|
511
630
|
const groupId = data.params?.group_id;
|
|
512
|
-
const message = data.params
|
|
631
|
+
const message = resolveOutgoingMessage(data.params);
|
|
513
632
|
if (groupId == null || message == null) {
|
|
514
633
|
return sendResponse({ status: 'failed', retcode: 100, msg: 'Missing group_id or message', data: null });
|
|
515
634
|
}
|
|
@@ -517,8 +636,8 @@ class RouteRuntime {
|
|
|
517
636
|
const session = this.recentGroupSessions.get(String(groupId));
|
|
518
637
|
if (session && (!data.params?.self_id || session.bot?.selfId === String(data.params?.self_id))) {
|
|
519
638
|
try {
|
|
520
|
-
const msgIds = await session.
|
|
521
|
-
const msgId = (
|
|
639
|
+
const msgIds = await this.sendRoutedMessage(session.bot ?? bot, session, String(groupId), false, message);
|
|
640
|
+
const msgId = firstMessageId(msgIds);
|
|
522
641
|
sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
|
|
523
642
|
}
|
|
524
643
|
catch (e) {
|
|
@@ -528,8 +647,8 @@ class RouteRuntime {
|
|
|
528
647
|
}
|
|
529
648
|
else if (bot) {
|
|
530
649
|
try {
|
|
531
|
-
const msgIds = await
|
|
532
|
-
const msgId = (
|
|
650
|
+
const msgIds = await this.sendRoutedMessage(bot, null, String(groupId), false, message);
|
|
651
|
+
const msgId = firstMessageId(msgIds);
|
|
533
652
|
sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
|
|
534
653
|
}
|
|
535
654
|
catch (e) {
|
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.0
|
|
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` 头
|