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.
Files changed (3) hide show
  1. package/lib/index.js +245 -8
  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)
@@ -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?.message;
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
- if (bot) {
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 bot.sendPrivateMessage(String(userId), toElements(message));
475
- const msgId = msgIds && msgIds.length > 0 ? (Number(msgIds[0]) || Math.floor(Math.random() * 1000000)) : Math.floor(Math.random() * 1000000);
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?.message;
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
- if (bot) {
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 bot.sendMessage(String(groupId), toElements(message));
498
- const msgId = msgIds && msgIds.length > 0 ? (Number(msgIds[0]) || Math.floor(Math.random() * 1000000)) : Math.floor(Math.random() * 1000000);
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.0.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": "标准 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` 头