@izhimu/qq 0.2.1 → 0.2.3

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/README.md CHANGED
@@ -194,29 +194,25 @@ openclaw logs --channel qq
194
194
  ```
195
195
  openclaw-channel-qq/
196
196
  ├── src/
197
- │ ├── channel.ts # 插件主入口
197
+ │ ├── channel.ts # Main plugin definition
198
198
  │ ├── core/
199
- │ │ ├── connection.ts # WebSocket 连接管理
200
- │ │ ├── dispatch.ts # 事件分发器
201
- │ │ ├── request.ts # API 请求处理
202
- │ │ ├── runtime.ts # 运行时状态管理
203
- │ │ └── config.ts # 配置解析
199
+ │ │ ├── connection.ts # WebSocket connection manager
200
+ │ │ └── dispatch.ts # Event dispatcher
204
201
  │ ├── adapters/
205
- │ │ └── message.ts # 消息格式转换
202
+ │ │ └── message.ts # NapCat ↔ OpenClaw message conversion
203
+ │ ├── core/
204
+ │ │ └── config.ts # Configuration resolution
205
+ │ ├── onboarding.ts # Interactive setup wizard
206
206
  │ ├── types/
207
- │ │ └── index.ts # TypeScript 类型定义
208
- ├── utils/
209
- ├── index.ts # 工具函数
210
- │ │ ├── log.ts # 日志工具
211
- │ │ ├── markdown.ts # Markdown 处理
212
- │ │ └── cqcode.ts # CQ 码解析
213
- │ └── onboarding.ts # 交互式配置向导
207
+ │ │ └── channel.ts # TypeScript definitions
208
+ └── utils/
209
+ ├── channel.ts # Utility functions
214
210
  ├── docs/
215
- │ ├── napcat-websocket-api.md # NapCat API 文档
216
- │ └── plugin-development-guide.md # 插件开发指南
217
- ├── openclaw.plugin.json # 插件清单
218
- ├── package.json
219
- └── tsconfig.json
211
+ │ ├── napcat-websocket-api.md # NapCat API reference
212
+ │ └── plugin-development-guide.md
213
+ ├── channel.ts # Plugin entry point
214
+ ├── openclaw.plugin.json # Plugin manifest
215
+ └── package.json
220
216
  ```
221
217
 
222
218
  ### 核心组件
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { Logger as log, extractImageUrl, getEmojiForFaceId } from '../utils/index.js';
7
7
  import { CQCodeUtils } from '../utils/cqcode.js';
8
+ import { getMsg } from "../core/request.js";
8
9
  // =============================================================================
9
10
  // CQ Code Parsing
10
11
  // =============================================================================
@@ -64,7 +65,7 @@ function parseJsonSegment(segment) {
64
65
  // =============================================================================
65
66
  // NapCat -> OpenClaw Adapters (Inbound)
66
67
  // =============================================================================
67
- function napCatToOpenClaw(segment) {
68
+ async function napCatToOpenClaw(segment) {
68
69
  const data = segment.data;
69
70
  switch (segment.type) {
70
71
  case 'text':
@@ -80,7 +81,19 @@ function napCatToOpenClaw(segment) {
80
81
  return url ? { type: 'image', url, summary: data.summary } : null;
81
82
  }
82
83
  case 'reply':
83
- return { type: 'reply', messageId: String(data.id || '') };
84
+ const response = await getMsg({
85
+ message_id: Number(data.id),
86
+ });
87
+ if (response.data?.message == undefined) {
88
+ return null;
89
+ }
90
+ return {
91
+ type: 'reply',
92
+ messageId: String(data.id),
93
+ message: response.data.raw_message,
94
+ senderId: String(response.data.sender.user_id),
95
+ sender: response.data.sender.nickname
96
+ };
84
97
  case 'face':
85
98
  return { type: 'text', text: getEmojiForFaceId(String(data.id || '')) };
86
99
  case 'record':
@@ -143,7 +156,7 @@ export async function napCatToOpenClawMessage(segments) {
143
156
  const normalized = normalizeMessage(segments);
144
157
  const content = [];
145
158
  for (const segment of normalized) {
146
- const result = napCatToOpenClaw(segment);
159
+ const result = await napCatToOpenClaw(segment);
147
160
  if (result) {
148
161
  content.push(result);
149
162
  }
@@ -2,6 +2,6 @@
2
2
  * QQ NapCat Plugin for OpenClaw
3
3
  * Main plugin entry point
4
4
  */
5
- import type { ChannelPlugin } from "openclaw/plugin-sdk";
5
+ import { ChannelPlugin } from "openclaw/plugin-sdk";
6
6
  import type { QQConfig } from "./types/index.js";
7
7
  export declare const qqPlugin: ChannelPlugin<QQConfig>;
@@ -2,8 +2,9 @@
2
2
  * QQ NapCat Plugin for OpenClaw
3
3
  * Main plugin entry point
4
4
  */
5
+ import { setAccountEnabledInConfigSection, deleteAccountFromConfigSection } from "openclaw/plugin-sdk";
5
6
  import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
6
- import { messageIdToString, getFileType, getFileName, Logger as log } from "./utils/index.js";
7
+ import { messageIdToString, markdownToText, getFileType, getFileName, Logger as log } from "./utils/index.js";
7
8
  import { setContext, setContextStatus, clearContext, setConnection, getConnection, clearConnection } from "./core/runtime.js";
8
9
  import { ConnectionManager } from "./core/connection.js";
9
10
  import { openClawToNapCatMessage } from "./adapters/message.js";
@@ -18,6 +19,7 @@ export const qqPlugin = {
18
19
  selectionLabel: "QQ",
19
20
  docsPath: "/channels/qq",
20
21
  blurb: "通过 NapCat WebSocket 连接 QQ 机器人",
22
+ quickstartAllowFrom: true,
21
23
  },
22
24
  capabilities: {
23
25
  chatTypes: ["direct", "group"],
@@ -33,6 +35,18 @@ export const qqPlugin = {
33
35
  resolveAccount: (cfg) => resolveQQAccount({ cfg }),
34
36
  isEnabled: (account) => Boolean(account?.enabled),
35
37
  isConfigured: (account) => Boolean(account?.wsUrl),
38
+ setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({
39
+ cfg,
40
+ sectionKey: "qq",
41
+ accountId,
42
+ enabled,
43
+ allowTopLevel: true,
44
+ }),
45
+ deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfigSection({
46
+ cfg,
47
+ sectionKey: "qq",
48
+ accountId,
49
+ }),
36
50
  },
37
51
  configSchema: buildChannelConfigSchema(QQConfigSchema),
38
52
  messaging: {
@@ -125,7 +139,7 @@ async function outboundSend(ctx) {
125
139
  const chatId = id || to;
126
140
  const content = [];
127
141
  if (text) {
128
- content.push({ type: "text", text });
142
+ content.push({ type: "text", text: markdownToText(text) });
129
143
  }
130
144
  if (mediaUrl) {
131
145
  switch (getFileType(mediaUrl)) {
@@ -3,7 +3,7 @@
3
3
  * Handles routing and dispatching incoming messages to the AI
4
4
  */
5
5
  import { getRuntime, getContext } from './runtime.js';
6
- import { getMsg, getFile, sendMsg, setInputStatus } from './request.js';
6
+ import { getFile, sendMsg, setInputStatus } from './request.js';
7
7
  import { napCatToOpenClawMessage } from '../adapters/message.js';
8
8
  import { Logger as log, markdownToText } from '../utils/index.js';
9
9
  import { CHANNEL_ID } from "./config.js";
@@ -14,19 +14,23 @@ import { CHANNEL_ID } from "./config.js";
14
14
  */
15
15
  async function contentToPlainText(content) {
16
16
  return content
17
- .filter(c => c.type !== 'reply' && c.type !== 'image' && c.type !== 'audio' && c.type !== 'file')
17
+ .filter(c => c.type !== 'image' && c.type !== 'audio' && c.type !== 'file')
18
18
  .map((c) => {
19
19
  switch (c.type) {
20
20
  case 'text':
21
- return `[消息]\n${c.text}`;
21
+ return `${c.text}`;
22
22
  case 'at':
23
23
  return c.isAll ? '@全体成员' : `@${c.userId}`;
24
24
  case 'json':
25
25
  return `[JSON]\n\`\`\`json\n${c.data}\n\`\`\``;
26
+ case 'reply':
27
+ let replyContent = `${c.sender}(${c.senderId}):\n${c.message}`;
28
+ replyContent = replyContent.split('\n').map(line => `> ${line}`).join('\n');
29
+ return `[回复]\n${replyContent}\n`;
26
30
  default:
27
31
  return '';
28
32
  }
29
- }).join('');
33
+ }).join('\n');
30
34
  }
31
35
  async function contextToMedia(content) {
32
36
  const hasMedia = content.some(c => c.type === 'image' || c.type === 'audio' || c.type === 'file');
@@ -63,30 +67,6 @@ async function contextToMedia(content) {
63
67
  }
64
68
  return;
65
69
  }
66
- // TODO弃用
67
- async function contextToReply(content) {
68
- const hasReply = content.some(c => c.type === 'reply');
69
- if (!hasReply) {
70
- return;
71
- }
72
- const reply = content.find(c => c.type === 'reply');
73
- if (!reply) {
74
- return;
75
- }
76
- const response = await getMsg({
77
- message_id: Number(reply.messageId),
78
- });
79
- if (response.data?.message == undefined) {
80
- return;
81
- }
82
- const replyMessage = await napCatToOpenClawMessage(response.data?.message);
83
- const text = await contentToPlainText(replyMessage);
84
- return {
85
- id: reply.messageId,
86
- content: text,
87
- sender: String(response.data?.sender.user_id)
88
- };
89
- }
90
70
  async function sendText(isGroup, chatId, text) {
91
71
  const cleanText = text.replace(/NO_REPLY\s*$/, '');
92
72
  const messageSegments = [{ type: 'text', data: { text: markdownToText(cleanText) } }];
@@ -107,7 +87,7 @@ async function sendText(isGroup, chatId, text) {
107
87
  * Dispatch an incoming message to the AI for processing
108
88
  */
109
89
  export async function dispatchMessage(params) {
110
- const { chatType, chatId, senderId, senderName, messageId, content, media, reply, timestamp } = params;
90
+ const { chatType, chatId, senderId, senderName, messageId, content, media, timestamp } = params;
111
91
  const runtime = getRuntime();
112
92
  if (!runtime) {
113
93
  log.warn('dispatch', `Plugin runtime not available`);
@@ -120,20 +100,20 @@ export async function dispatchMessage(params) {
120
100
  }
121
101
  const isGroup = chatType === 'group';
122
102
  const peerId = isGroup ? `group:${chatId}` : senderId;
123
- const fullContent = `${content}\n\nFrom QQ(${senderId}) - Nickname: ${senderName}`;
124
103
  const route = runtime.channel.routing.resolveAgentRoute({
125
104
  cfg: context.cfg,
126
105
  channel: CHANNEL_ID,
127
106
  peer: {
128
- kind: isGroup ? 'group' : 'dm',
107
+ kind: 'group',
129
108
  id: peerId,
130
109
  },
131
110
  });
111
+ log.debug('dispatch', `Resolved route: ${JSON.stringify(route)}`);
132
112
  const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(context.cfg);
133
113
  const body = runtime.channel.reply.formatInboundEnvelope({
134
114
  channel: CHANNEL_ID,
135
115
  from: senderName || senderId,
136
- body: fullContent,
116
+ body: content,
137
117
  timestamp,
138
118
  chatType: isGroup ? 'group' : 'direct',
139
119
  sender: {
@@ -142,12 +122,13 @@ export async function dispatchMessage(params) {
142
122
  },
143
123
  envelope: envelopeOptions,
144
124
  });
145
- const fromAddress = isGroup ? `qq:group:${chatId}` : `qq:private:${senderId}`;
146
- const toAddress = `qq:${route.accountId}`;
125
+ log.debug('dispatch', `Inbound envelope: ${body}`);
126
+ const fromAddress = isGroup ? `qq:group:${chatId}` : `qq:${senderId}`;
127
+ const toAddress = `qq:${chatId}`;
147
128
  const ctxPayload = runtime.channel.reply.finalizeInboundContext({
148
129
  Body: body,
149
- RawBody: fullContent,
150
- CommandBody: fullContent,
130
+ RawBody: content,
131
+ CommandBody: content,
151
132
  From: fromAddress,
152
133
  To: toAddress,
153
134
  SessionKey: route.sessionKey,
@@ -159,10 +140,6 @@ export async function dispatchMessage(params) {
159
140
  Surface: CHANNEL_ID,
160
141
  MessageSid: messageId,
161
142
  Timestamp: timestamp,
162
- ReplyToId: reply?.id,
163
- ReplyToBody: reply?.content,
164
- ReplyToSender: reply?.sender,
165
- ReplyToIsQuote: !!reply,
166
143
  MediaType: media?.type,
167
144
  MediaPath: media?.path,
168
145
  MediaUrl: media?.url,
@@ -224,8 +201,7 @@ export async function handleGroupMessage(event) {
224
201
  const content = await napCatToOpenClawMessage(event.message);
225
202
  const plainText = await contentToPlainText(content);
226
203
  const media = await contextToMedia(content);
227
- const reply = await contextToReply(content);
228
- log.info('dispatch', `Group message from ${event.sender?.nickname || event.sender?.card || event.user_id}: ${plainText}, media: ${media != undefined}, reply: ${reply != undefined}`);
204
+ log.info('dispatch', `Group message from ${event.sender?.nickname || event.sender?.card || event.user_id}: ${plainText}, media: ${media != undefined}`);
229
205
  await dispatchMessage({
230
206
  chatType: 'group',
231
207
  chatId: String(event.group_id),
@@ -234,7 +210,6 @@ export async function handleGroupMessage(event) {
234
210
  messageId: String(event.message_id),
235
211
  content: plainText,
236
212
  media,
237
- reply,
238
213
  timestamp: event.time * 1000,
239
214
  });
240
215
  }
@@ -245,8 +220,7 @@ export async function handlePrivateMessage(event) {
245
220
  const content = await napCatToOpenClawMessage(event.message);
246
221
  const plainText = await contentToPlainText(content);
247
222
  const media = await contextToMedia(content);
248
- const reply = await contextToReply(content);
249
- log.info('dispatch', `Private message from ${event.sender?.nickname || event.user_id}: ${plainText}, media: ${media != undefined}, reply: ${reply != undefined}`);
223
+ log.info('dispatch', `Private message from ${event.sender?.nickname || event.user_id}: ${plainText}, media: ${media != undefined}`);
250
224
  await dispatchMessage({
251
225
  chatType: 'direct',
252
226
  chatId: String(event.user_id),
@@ -255,7 +229,6 @@ export async function handlePrivateMessage(event) {
255
229
  messageId: String(event.message_id),
256
230
  content: plainText,
257
231
  media,
258
- reply,
259
232
  timestamp: event.time * 1000,
260
233
  });
261
234
  }
@@ -120,6 +120,9 @@ export interface OpenClawImageContent {
120
120
  export interface OpenClawReplyContent {
121
121
  type: 'reply';
122
122
  messageId: string;
123
+ message?: string;
124
+ senderId?: string;
125
+ sender?: string;
123
126
  }
124
127
  export interface OpenClawAudioContent {
125
128
  type: 'audio';
@@ -245,11 +248,6 @@ export interface DispatchMessageMedia {
245
248
  path?: string;
246
249
  url?: string;
247
250
  }
248
- export interface DispatchMessageReply {
249
- id?: string;
250
- content?: string;
251
- sender?: string;
252
- }
253
251
  export interface DispatchMessageParams {
254
252
  chatType: 'direct' | 'group';
255
253
  chatId: string;
@@ -258,6 +256,5 @@ export interface DispatchMessageParams {
258
256
  messageId: string;
259
257
  content: string;
260
258
  media?: DispatchMessageMedia;
261
- reply?: DispatchMessageReply;
262
259
  timestamp: number;
263
260
  }
@@ -3,19 +3,22 @@ export declare class MarkdownToText {
3
3
  private maskPrefix;
4
4
  private maskCounter;
5
5
  /**
6
- * 主入口:将 Markdown 转换为纯文本(移动端优化版)
6
+ * 主入口:将 Markdown 转换为纯文本
7
7
  */
8
8
  convert(markdown: string): string;
9
9
  /**
10
- * 保护代码块
10
+ * 保护代码块 (```)
11
+ * 生成 Key 格式:%%MD-MASK-BLOCK-0
11
12
  */
12
13
  private maskCodeBlocks;
13
14
  /**
14
- * 保护行内代码
15
+ * 保护行内代码 (`)
16
+ * 生成 Key 格式:%%MD-MASK-INLINE-0
15
17
  */
16
18
  private maskInlineCode;
17
19
  /**
18
20
  * 还原掩码内容
21
+ * 必须匹配连字符,支持 %%MD-MASK-BLOCK-1 格式
19
22
  */
20
23
  private unmaskContent;
21
24
  /**
@@ -1,9 +1,11 @@
1
1
  export class MarkdownToText {
2
+ // 1. 存储池
2
3
  codeBlockStore = new Map();
4
+ // 2. 关键修复:使用连字符 "-" 而非下划线,避免被斜体正则(Text)误伤
3
5
  maskPrefix = '%%MD-MASK-';
4
6
  maskCounter = 0;
5
7
  /**
6
- * 主入口:将 Markdown 转换为纯文本(移动端优化版)
8
+ * 主入口:将 Markdown 转换为纯文本
7
9
  */
8
10
  convert(markdown) {
9
11
  if (!markdown)
@@ -14,107 +16,106 @@ export class MarkdownToText {
14
16
  let text = markdown;
15
17
  // ============================================================
16
18
  // 阶段 1: 保护性预处理 (Protect)
19
+ // 必须最先执行,将代码块抽离,防止内部字符被后续逻辑误处理
17
20
  // ============================================================
18
21
  text = this.maskCodeBlocks(text);
19
22
  text = this.maskInlineCode(text);
20
23
  // ============================================================
21
24
  // 阶段 2: 优先处理特殊标签 (Priority Tags)
25
+ // 必须在清理 HTML 之前处理,防止 <http://...> 被当成 HTML 标签误删
22
26
  // ============================================================
23
- // 2.1 图片 -> [图片] url(保留 URL,放在同一行)
24
- text = text.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (_match, alt, url) => {
25
- const displayText = alt ? `[图片: ${alt}]` : '[图片]';
26
- return `${displayText} ${url}`;
27
+ // 2.1 图片 -> [图片: Alt]
28
+ text = text.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (_match, alt) => {
29
+ return `[图片: ${alt || 'Image'}]`;
27
30
  });
28
31
  // 2.2 自动链接 <http://...> -> http://...
32
+ // 注意:这一步非常重要,Markdown 的自动链接语法和 HTML 标签很像
29
33
  text = text.replace(/<((?:https?|ftp|email|mailto):[^>]+)>/g, '$1');
30
- // 2.3 普通链接 [Text](url) -> Text: url(冒号分隔,更清晰)
31
- text = text.replace(/\[([^\]]+)]\(([^)]+)\)/g, (_match, linkText, url) => {
32
- // 如果链接文本就是 URL,只显示一次
33
- if (linkText === url || linkText.trim() === url.trim()) {
34
- return url;
35
- }
36
- return `${linkText}: ${url}`;
37
- });
34
+ // 2.3 普通链接 [Text](url) -> Text (url)
35
+ text = text.replace(/\[([^\]]+)]\(([^)]+)\)/g, '$1 ($2)');
38
36
  // ============================================================
39
37
  // 阶段 3: 结构化转换 & 清理 (Structure & Clean)
40
38
  // ============================================================
41
39
  // 3.1 预处理换行和分割线标签
42
40
  text = text.replace(/<br\s*\/?>/gi, '\n');
43
- text = text.replace(/<hr\s*\/?>/gi, '\n---\n');
44
- // 3.2 安全清理 HTML 标签
41
+ text = text.replace(/<hr\s*\/?>/gi, '\n──────────\n');
42
+ // 3.2 安全清理 HTML 标签 (Smart Strip)
43
+ // A. 移除 <script> 和 <style> 及其内容
45
44
  text = text.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, '');
45
+ // B. 移除 HTML 注释 (修复了之前的语法错误)
46
46
  text = text.replace(/<!--[\s\S]*?-->/g, '');
47
+ // C. 智能移除 HTML 标签
48
+ // 逻辑:匹配 < 后紧跟字母的模式,保留 "a < b" 或 "1 < 5" 这种数学公式
47
49
  text = text.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, '');
48
- // 3.3 标题 -> 简洁样式
49
- text = text.replace(/^#\s+(.*)$/gm, '\n【$1】\n');
50
- text = text.replace(/^##\s+(.*)$/gm, '\n$1\n');
51
- text = text.replace(/^(#{3,6})\s+(.*)$/gm, '\n $2\n');
52
- // 3.4 Markdown 分割线
53
- text = text.replace(/^(-\s*?|\*\s*?|_\s*?){3,}\s*$/gm, '---');
54
- // 3.5 引用
55
- text = text.replace(/^(>+)\s?(.*)$/gm, (_match, arrows, content) => {
56
- const level = arrows.length;
57
- return level > 1 ? ` ${content}` : `${content}`;
58
- });
50
+ // 3.3 标题 (Headers) -> 视觉醒目文本
51
+ text = text.replace(/^#\s+(.*)$/gm, '\n$1\n══════════\n');
52
+ text = text.replace(/^##\s+(.*)$/gm, '\n$1\n──────────\n');
53
+ text = text.replace(/^(#{3,6})\s+(.*)$/gm, '\n $2 】\n');
54
+ // 3.4 Markdown 分割线 (---, ***)
55
+ text = text.replace(/^(-\s*?|\*\s*?|_\s*?){3,}\s*$/gm, '──────────');
56
+ // 3.5 引用 (Blockquotes)
57
+ text = text.replace(/^(>+)\s?(.*)$/gm, (_match, _arrows, content) => `▎ ${content}`);
59
58
  // 3.6 任务列表 & 无序列表
60
- text = text.replace(/^(\s*)-\s\[x]\s/gim, '$1 ');
61
- text = text.replace(/^(\s*)-\s\[\s]\s/gim, '$1 ');
62
- text = text.replace(/^(\s*)[-*+]\s+(.*)$/gm, '$1· $2');
63
- // 3.7 表格
64
- text = text.replace(/^\s*\|?[\s\-:|]+\|?\s*$/gm, '');
59
+ text = text.replace(/^(\s*)-\s\[x]\s/gim, '$1 '); // 完成的任务
60
+ text = text.replace(/^(\s*)-\s\[\s]\s/gim, '$1 '); // 未完成的任务
61
+ text = text.replace(/^(\s*)[-*+]\s+(.*)$/gm, '$1 $2'); // 列表项变圆点
62
+ // 3.7 表格 (Tables) -> 空格分隔
63
+ text = text.replace(/^\s*\|?[\s\-:|]+\|?\s*$/gm, ''); // 移除 |---|---| 分隔行
65
64
  text = text.replace(/^\|(.*)\|$/gm, (_match, content) => {
66
- return content.split('|').map((s) => s.trim()).join(' | ');
65
+ return content.split('|').map((s) => s.trim()).join(' ');
67
66
  });
68
67
  // ============================================================
69
68
  // 阶段 4: 行内格式 (Inline Formatting)
70
69
  // ============================================================
71
- // 4.1 粗体 -> 保留文字
72
- text = text.replace(/(\*\*|__)([\s\S]*?)\1/g, '$2');
73
- // 4.2 斜体 -> 保留文字
70
+ // 4.1 粗体 (**Text**) -> “Text”
71
+ text = text.replace(/(\*\*|__)([\s\S]*?)\1/g, '“$2');
72
+ // 4.2 斜体 (*Text*) -> Text
73
+ // 此时 Mask Key 是 "MD-MASK",不包含下划线或星号,所以不会被这里误伤
74
74
  text = text.replace(/([*_])([\s\S]*?)\1/g, '$2');
75
- // 4.3 删除线 -> 保留文字
75
+ // 4.3 删除线 (~~Text~~) -> Text
76
76
  text = text.replace(/~~([\s\S]*?)~~/g, '$1');
77
77
  // ============================================================
78
78
  // 阶段 5: 还原与收尾 (Restore & Finalize)
79
79
  // ============================================================
80
80
  // 5.1 还原代码块
81
81
  text = this.unmaskContent(text);
82
- // 5.2 解码 HTML 实体
82
+ // 5.2 解码 HTML 实体 (&amp; -> &)
83
83
  text = this.decodeHtmlEntities(text);
84
- // 5.3 最终排版优化
85
- text = text.replace(/\n{3,}/g, '\n\n');
86
- text = text.replace(/[ \t]+/g, ' ');
87
- text = text.replace(/^\s+|\s+$/gm, '');
88
- text = text.trim();
84
+ // 5.3 最终排版优化:合并多余换行
85
+ text = text.replace(/\n{3,}/g, '\n\n').trim();
89
86
  return text;
90
87
  }
91
88
  /**
92
- * 保护代码块
89
+ * 保护代码块 (```)
90
+ * 生成 Key 格式:%%MD-MASK-BLOCK-0
93
91
  */
94
92
  maskCodeBlocks(text) {
95
- const codeBlockRegex = /(`{3,}|~{3,})(\w*)\n?([\s\S]*?)\n?\1/g;
93
+ const codeBlockRegex = /(`{3,}|~{3,})(\w*)\n([\s\S]*?)\1/g;
96
94
  return text.replace(codeBlockRegex, (_match, _fence, lang, code) => {
97
95
  const key = `${this.maskPrefix}BLOCK-${this.maskCounter++}`;
98
- const langTag = lang ? `[${lang}]\n` : '';
99
- const formatted = `\n${langTag}${code.trim()}\n`;
96
+ const langTag = lang ? ` [${lang}]` : '';
97
+ const formatted = `\n───code───${langTag}\n${code.replace(/^\n+|\n+$/g, '')}\n──────────\n`;
100
98
  this.codeBlockStore.set(key, formatted);
101
99
  return key;
102
100
  });
103
101
  }
104
102
  /**
105
- * 保护行内代码
103
+ * 保护行内代码 (`)
104
+ * 生成 Key 格式:%%MD-MASK-INLINE-0
106
105
  */
107
106
  maskInlineCode(text) {
108
107
  return text.replace(/`([^`]+)`/g, (_match, code) => {
109
108
  const key = `${this.maskPrefix}INLINE-${this.maskCounter++}`;
110
- this.codeBlockStore.set(key, `'${code}'`);
109
+ this.codeBlockStore.set(key, ` ‘${code}`);
111
110
  return key;
112
111
  });
113
112
  }
114
113
  /**
115
114
  * 还原掩码内容
115
+ * 必须匹配连字符,支持 %%MD-MASK-BLOCK-1 格式
116
116
  */
117
117
  unmaskContent(text) {
118
+ // 这里的正则 [\w-]+ 允许匹配字母、数字、下划线和连字符
118
119
  const maskRegex = new RegExp(`${this.maskPrefix}[\\w-]+`, 'g');
119
120
  return text.replace(maskRegex, (key) => {
120
121
  return this.codeBlockStore.get(key) || '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izhimu/qq",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "A QQ channel plugin for OpenClaw using NapCat WebSocket",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",