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.
Files changed (3) hide show
  1. package/lib/index.js +129 -10
  2. package/package.json +6 -4
  3. 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?.message;
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.send(toElements(message));
486
- const msgId = (Array.isArray(msgIds) && msgIds.length > 0) ? (Number(msgIds[0]) || Math.floor(Math.random() * 1000000)) : (typeof msgIds === 'string' ? msgIds : Math.floor(Math.random() * 1000000));
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 bot.sendPrivateMessage(String(userId), toElements(message));
497
- const msgId = (Array.isArray(msgIds) && msgIds.length > 0) ? (Number(msgIds[0]) || Math.floor(Math.random() * 1000000)) : (typeof msgIds === 'string' ? msgIds : Math.floor(Math.random() * 1000000));
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?.message;
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.send(toElements(message));
521
- const msgId = (Array.isArray(msgIds) && msgIds.length > 0) ? (Number(msgIds[0]) || Math.floor(Math.random() * 1000000)) : (typeof msgIds === 'string' ? msgIds : Math.floor(Math.random() * 1000000));
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 bot.sendMessage(String(groupId), toElements(message));
532
- const msgId = (Array.isArray(msgIds) && msgIds.length > 0) ? (Number(msgIds[0]) || Math.floor(Math.random() * 1000000)) : (typeof msgIds === 'string' ? msgIds : Math.floor(Math.random() * 1000000));
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.2",
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": "标准 OneBot v11 WebSocket 消息路由与通信中间件",
35
- "en": "Standard OneBot v11 WebSocket message routing and communication middleware"
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
  [![npm](https://img.shields.io/npm/v/koishi-plugin-msg-router?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-msg-router)
4
4
 
5
- 这是一个用于 Koishi 的消息转发插件。它把你配置好的指令转发到外部 OneBot v11 WebSocket 后端,再把后端返回内容回传给用户。
5
+ 一个面向 Koishi OneBot v11 指令中转路由插件。插件仅接管配置中指定的指令,将会话转换为 OneBot v11 消息事件并通过 WebSocket 交给外部后端处理;未配置的指令保持 Koishi 原有处理流程,不受影响。
6
+
7
+ 后端可以通过标准 OneBot API 动作向群聊或私聊回发文本、图片、语音等消息。针对 QQ 官方适配器,插件还支持原生 Markdown 文本,并在不支持 Markdown 的平台上自动降级为普通文本。
6
8
 
7
9
  ## 主要用途
8
10
 
9
- - 把某些 Koishi 指令交给外部后端程序处理
10
- - 支持正向 WS 和反向 WS 两种模式
11
- - 后端可通过 WebSocket 常驻连接接收请求
12
- - 支持令牌鉴权,`token` 可留空
13
- - 支持可选的 HMAC 签名
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` 头