@izhimu/qq 0.2.0 → 0.2.2
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/LICENSE +21 -21
- package/README.md +384 -388
- package/dist/src/adapters/message.js +16 -3
- package/dist/src/channel.d.ts +1 -1
- package/dist/src/channel.js +14 -0
- package/dist/src/core/dispatch.js +19 -46
- package/dist/src/types/index.d.ts +3 -6
- package/dist/src/utils/markdown.d.ts +6 -6
- package/dist/src/utils/markdown.js +72 -66
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/src/channel.d.ts
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
* QQ NapCat Plugin for OpenClaw
|
|
3
3
|
* Main plugin entry point
|
|
4
4
|
*/
|
|
5
|
-
import
|
|
5
|
+
import { ChannelPlugin } from "openclaw/plugin-sdk";
|
|
6
6
|
import type { QQConfig } from "./types/index.js";
|
|
7
7
|
export declare const qqPlugin: ChannelPlugin<QQConfig>;
|
package/dist/src/channel.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
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
7
|
import { messageIdToString, getFileType, getFileName, Logger as log } from "./utils/index.js";
|
|
7
8
|
import { setContext, setContextStatus, clearContext, setConnection, getConnection, clearConnection } from "./core/runtime.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: {
|
|
@@ -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 {
|
|
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 !== '
|
|
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
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
-
|
|
146
|
-
const
|
|
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:
|
|
150
|
-
CommandBody:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -4,21 +4,21 @@ export declare class MarkdownToText {
|
|
|
4
4
|
private maskCounter;
|
|
5
5
|
/**
|
|
6
6
|
* 主入口:将 Markdown 转换为纯文本
|
|
7
|
-
* @param markdown 原始 Markdown 字符串
|
|
8
7
|
*/
|
|
9
8
|
convert(markdown: string): string;
|
|
10
9
|
/**
|
|
11
|
-
* 保护代码块
|
|
12
|
-
*
|
|
10
|
+
* 保护代码块 (```)
|
|
11
|
+
* 生成 Key 格式:%%MD-MASK-BLOCK-0
|
|
13
12
|
*/
|
|
14
13
|
private maskCodeBlocks;
|
|
15
14
|
/**
|
|
16
|
-
* 保护行内代码
|
|
17
|
-
*
|
|
15
|
+
* 保护行内代码 (`)
|
|
16
|
+
* 生成 Key 格式:%%MD-MASK-INLINE-0
|
|
18
17
|
*/
|
|
19
18
|
private maskInlineCode;
|
|
20
19
|
/**
|
|
21
|
-
*
|
|
20
|
+
* 还原掩码内容
|
|
21
|
+
* 必须匹配连字符,支持 %%MD-MASK-BLOCK-1 格式
|
|
22
22
|
*/
|
|
23
23
|
private unmaskContent;
|
|
24
24
|
/**
|
|
@@ -1,116 +1,122 @@
|
|
|
1
1
|
export class MarkdownToText {
|
|
2
|
-
//
|
|
2
|
+
// 1. 存储池
|
|
3
3
|
codeBlockStore = new Map();
|
|
4
|
-
|
|
4
|
+
// 2. 关键修复:使用连字符 "-" 而非下划线,避免被斜体正则(Text)误伤
|
|
5
|
+
maskPrefix = '%%MD-MASK-';
|
|
5
6
|
maskCounter = 0;
|
|
6
7
|
/**
|
|
7
8
|
* 主入口:将 Markdown 转换为纯文本
|
|
8
|
-
* @param markdown 原始 Markdown 字符串
|
|
9
9
|
*/
|
|
10
10
|
convert(markdown) {
|
|
11
11
|
if (!markdown)
|
|
12
12
|
return '';
|
|
13
|
-
//
|
|
13
|
+
// 初始化
|
|
14
14
|
this.codeBlockStore.clear();
|
|
15
15
|
this.maskCounter = 0;
|
|
16
16
|
let text = markdown;
|
|
17
|
-
//
|
|
18
|
-
//
|
|
17
|
+
// ============================================================
|
|
18
|
+
// 阶段 1: 保护性预处理 (Protect)
|
|
19
|
+
// 必须最先执行,将代码块抽离,防止内部字符被后续逻辑误处理
|
|
20
|
+
// ============================================================
|
|
19
21
|
text = this.maskCodeBlocks(text);
|
|
20
22
|
text = this.maskInlineCode(text);
|
|
21
|
-
//
|
|
22
|
-
// 2
|
|
23
|
+
// ============================================================
|
|
24
|
+
// 阶段 2: 优先处理特殊标签 (Priority Tags)
|
|
25
|
+
// 必须在清理 HTML 之前处理,防止 <http://...> 被当成 HTML 标签误删
|
|
26
|
+
// ============================================================
|
|
27
|
+
// 2.1 图片 -> [图片: Alt]
|
|
28
|
+
text = text.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (_match, alt) => {
|
|
29
|
+
return `[图片: ${alt || 'Image'}]`;
|
|
30
|
+
});
|
|
31
|
+
// 2.2 自动链接 <http://...> -> http://...
|
|
32
|
+
// 注意:这一步非常重要,Markdown 的自动链接语法和 HTML 标签很像
|
|
33
|
+
text = text.replace(/<((?:https?|ftp|email|mailto):[^>]+)>/g, '$1');
|
|
34
|
+
// 2.3 普通链接 [Text](url) -> Text (url)
|
|
35
|
+
text = text.replace(/\[([^\]]+)]\(([^)]+)\)/g, '$1 ($2)');
|
|
36
|
+
// ============================================================
|
|
37
|
+
// 阶段 3: 结构化转换 & 清理 (Structure & Clean)
|
|
38
|
+
// ============================================================
|
|
39
|
+
// 3.1 预处理换行和分割线标签
|
|
23
40
|
text = text.replace(/<br\s*\/?>/gi, '\n');
|
|
24
|
-
text = text.replace(/<hr\s*\/?>/gi, '\n
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
text = text.replace(
|
|
41
|
+
text = text.replace(/<hr\s*\/?>/gi, '\n──────────\n');
|
|
42
|
+
// 3.2 安全清理 HTML 标签 (Smart Strip)
|
|
43
|
+
// A. 移除 <script> 和 <style> 及其内容
|
|
44
|
+
text = text.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, '');
|
|
45
|
+
// B. 移除 HTML 注释 (修复了之前的语法错误)
|
|
46
|
+
text = text.replace(/<!--[\s\S]*?-->/g, '');
|
|
47
|
+
// C. 智能移除 HTML 标签
|
|
48
|
+
// 逻辑:匹配 < 后紧跟字母的模式,保留 "a < b" 或 "1 < 5" 这种数学公式
|
|
49
|
+
text = text.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, '');
|
|
50
|
+
// 3.3 标题 (Headers) -> 视觉醒目文本
|
|
51
|
+
text = text.replace(/^#\s+(.*)$/gm, '\n$1\n══════════\n');
|
|
52
|
+
text = text.replace(/^##\s+(.*)$/gm, '\n$1\n──────────\n');
|
|
30
53
|
text = text.replace(/^(#{3,6})\s+(.*)$/gm, '\n【 $2 】\n');
|
|
31
|
-
//
|
|
32
|
-
text = text.replace(/^(-\s*?|\*\s*?|_\s*?){3,}\s*$/gm, '
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
text = text.replace(
|
|
41
|
-
// 2.6 无序与有序列表 (Lists)
|
|
42
|
-
// 保留 $1 (缩进空格),将 -/*/+ 替换为 •
|
|
43
|
-
text = text.replace(/^(\s*)[-*+]\s+(.*)$/gm, '$1• $2');
|
|
44
|
-
// 有序列表 1. 2. 通常不需要改动,保留原样即可
|
|
45
|
-
// 2.7 表格 (Tables)
|
|
46
|
-
// 移除对齐行 |---|---|
|
|
47
|
-
text = text.replace(/^\s*\|?[\s\-:|]+\|?\s*$/gm, '');
|
|
48
|
-
// 将 | Cell | Cell | 转换为空格分隔,尽量保持一行
|
|
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}`);
|
|
58
|
+
// 3.6 任务列表 & 无序列表
|
|
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, ''); // 移除 |---|---| 分隔行
|
|
49
64
|
text = text.replace(/^\|(.*)\|$/gm, (_match, content) => {
|
|
50
|
-
// 移除首尾管道符,中间管道符变为空格
|
|
51
65
|
return content.split('|').map((s) => s.trim()).join(' ');
|
|
52
66
|
});
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
67
|
+
// ============================================================
|
|
68
|
+
// 阶段 4: 行内格式 (Inline Formatting)
|
|
69
|
+
// ============================================================
|
|
70
|
+
// 4.1 粗体 (**Text**) -> “Text”
|
|
56
71
|
text = text.replace(/(\*\*|__)([\s\S]*?)\1/g, '“$2”');
|
|
57
|
-
//
|
|
58
|
-
//
|
|
72
|
+
// 4.2 斜体 (*Text*) -> Text
|
|
73
|
+
// 此时 Mask Key 是 "MD-MASK",不包含下划线或星号,所以不会被这里误伤
|
|
59
74
|
text = text.replace(/([*_])([\s\S]*?)\1/g, '$2');
|
|
60
|
-
//
|
|
75
|
+
// 4.3 删除线 (~~Text~~) -> Text
|
|
61
76
|
text = text.replace(/~~([\s\S]*?)~~/g, '$1');
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// 3.5 链接 (Links) -> Text (URL)
|
|
67
|
-
// 排除锚点链接或空链接
|
|
68
|
-
text = text.replace(/\[([^\]]+)]\(([^)]+)\)/g, '$1 ($2)');
|
|
69
|
-
// 处理自动链接 <http://example.com>
|
|
70
|
-
text = text.replace(/<((?:https?|ftp|email):[^>]+)>/g, '$1');
|
|
71
|
-
// --- 阶段 4: 还原与收尾 (Restore & Finalize) ---
|
|
72
|
-
// 4.1 还原代码块
|
|
77
|
+
// ============================================================
|
|
78
|
+
// 阶段 5: 还原与收尾 (Restore & Finalize)
|
|
79
|
+
// ============================================================
|
|
80
|
+
// 5.1 还原代码块
|
|
73
81
|
text = this.unmaskContent(text);
|
|
74
|
-
//
|
|
82
|
+
// 5.2 解码 HTML 实体 (& -> &)
|
|
75
83
|
text = this.decodeHtmlEntities(text);
|
|
76
|
-
//
|
|
77
|
-
// 移除段首段尾多余空白,将连续3个以上换行压缩为2个(段落间距)
|
|
84
|
+
// 5.3 最终排版优化:合并多余换行
|
|
78
85
|
text = text.replace(/\n{3,}/g, '\n\n').trim();
|
|
79
86
|
return text;
|
|
80
87
|
}
|
|
81
88
|
/**
|
|
82
|
-
* 保护代码块
|
|
83
|
-
*
|
|
89
|
+
* 保护代码块 (```)
|
|
90
|
+
* 生成 Key 格式:%%MD-MASK-BLOCK-0
|
|
84
91
|
*/
|
|
85
92
|
maskCodeBlocks(text) {
|
|
86
|
-
// 匹配 3个或更多反引号/波浪线
|
|
87
93
|
const codeBlockRegex = /(`{3,}|~{3,})(\w*)\n([\s\S]*?)\1/g;
|
|
88
94
|
return text.replace(codeBlockRegex, (_match, _fence, lang, code) => {
|
|
89
|
-
const key = `${this.maskPrefix}
|
|
95
|
+
const key = `${this.maskPrefix}BLOCK-${this.maskCounter++}`;
|
|
90
96
|
const langTag = lang ? ` [${lang}]` : '';
|
|
91
|
-
|
|
92
|
-
const formatted = `\n────────────────────${langTag}\n${code.replace(/^\n+|\n+$/g, '')}\n────────────────────\n`;
|
|
97
|
+
const formatted = `\n───code───${langTag}\n${code.replace(/^\n+|\n+$/g, '')}\n──────────\n`;
|
|
93
98
|
this.codeBlockStore.set(key, formatted);
|
|
94
99
|
return key;
|
|
95
100
|
});
|
|
96
101
|
}
|
|
97
102
|
/**
|
|
98
|
-
* 保护行内代码
|
|
99
|
-
*
|
|
103
|
+
* 保护行内代码 (`)
|
|
104
|
+
* 生成 Key 格式:%%MD-MASK-INLINE-0
|
|
100
105
|
*/
|
|
101
106
|
maskInlineCode(text) {
|
|
102
107
|
return text.replace(/`([^`]+)`/g, (_match, code) => {
|
|
103
|
-
const key = `${this.maskPrefix}
|
|
104
|
-
this.codeBlockStore.set(key, ` ‘${code}’ `);
|
|
108
|
+
const key = `${this.maskPrefix}INLINE-${this.maskCounter++}`;
|
|
109
|
+
this.codeBlockStore.set(key, ` ‘${code}’ `);
|
|
105
110
|
return key;
|
|
106
111
|
});
|
|
107
112
|
}
|
|
108
113
|
/**
|
|
109
|
-
*
|
|
114
|
+
* 还原掩码内容
|
|
115
|
+
* 必须匹配连字符,支持 %%MD-MASK-BLOCK-1 格式
|
|
110
116
|
*/
|
|
111
117
|
unmaskContent(text) {
|
|
112
|
-
//
|
|
113
|
-
const maskRegex = new RegExp(`${this.maskPrefix}\\w
|
|
118
|
+
// 这里的正则 [\w-]+ 允许匹配字母、数字、下划线和连字符
|
|
119
|
+
const maskRegex = new RegExp(`${this.maskPrefix}[\\w-]+`, 'g');
|
|
114
120
|
return text.replace(maskRegex, (key) => {
|
|
115
121
|
return this.codeBlockStore.get(key) || '';
|
|
116
122
|
});
|