@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 +15 -19
- package/dist/src/adapters/message.js +16 -3
- package/dist/src/channel.d.ts +1 -1
- package/dist/src/channel.js +16 -2
- 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 -3
- package/dist/src/utils/markdown.js +49 -48
- package/package.json +1 -1
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
|
|
200
|
-
│ │
|
|
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
|
-
│ │ └──
|
|
208
|
-
│
|
|
209
|
-
│
|
|
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
|
|
216
|
-
│ └── plugin-development-guide.md
|
|
217
|
-
├──
|
|
218
|
-
├──
|
|
219
|
-
└──
|
|
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
|
-
|
|
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,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 {
|
|
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
|
}
|
|
@@ -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 图片 -> [
|
|
24
|
-
text = text.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (_match, alt
|
|
25
|
-
|
|
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
|
|
31
|
-
text = text.replace(/\[([^\]]+)]\(([^)]+)\)/g, (
|
|
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
|
|
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
|
|
50
|
-
text = text.replace(/^##\s+(.*)$/gm, '\n
|
|
51
|
-
text = text.replace(/^(#{3,6})\s+(.*)$/gm, '\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,
|
|
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
|
|
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, '
|
|
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 实体 (& -> &)
|
|
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
|
|
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}]
|
|
99
|
-
const formatted = `\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, `
|
|
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) || '';
|