@kirigaya/openclaw-onebot 1.0.1 → 1.0.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.
@@ -2,12 +2,38 @@
2
2
  * 入站消息处理
3
3
  */
4
4
  import { getOneBotConfig } from "../config.js";
5
- import { getRawText, isMentioned } from "../message.js";
6
- import { sendPrivateMsg, sendGroupMsg, sendPrivateImage, sendGroupImage, setMsgEmojiLike, } from "../connection.js";
7
- import { setActiveReplyTarget, clearActiveReplyTarget } from "../reply-context.js";
5
+ import { getRawText, getTextFromSegments, getReplyMessageId, getTextFromMessageContent, isMentioned, } from "../message.js";
6
+ import { getRenderMarkdownToPlain, getCollapseDoubleNewlines, getWhitelistUserIds } from "../config.js";
7
+ import { markdownToPlain, collapseDoubleNewlines } from "../markdown.js";
8
+ import { markdownToImage } from "../og-image.js";
9
+ import { sendPrivateMsg, sendGroupMsg, sendPrivateImage, sendGroupImage, sendGroupForwardMsg, sendPrivateForwardMsg, setMsgEmojiLike, getMsg, } from "../connection.js";
10
+ import { setActiveReplyTarget, clearActiveReplyTarget, setActiveReplySessionId, setForwardSuppressDelivery, setActiveReplySelfId } from "../reply-context.js";
8
11
  import { loadPluginSdk, getSdk } from "../sdk.js";
12
+ import { handleGroupIncrease } from "./group-increase.js";
9
13
  const DEFAULT_HISTORY_LIMIT = 20;
10
14
  export const sessionHistories = new Map();
15
+ /** forward 模式下待处理的会话,用于定期清理未完成的缓冲 */
16
+ const forwardPendingSessions = new Map();
17
+ /** 每个 replySessionId 已发送的 chunk 数量,用于支持多次 final(如工具调用后追加内容) */
18
+ const lastSentChunkCountBySession = new Map();
19
+ const FORWARD_PENDING_TTL_MS = 5 * 60 * 1000; // 5 分钟
20
+ const FORWARD_CLEANUP_INTERVAL_MS = 60 * 1000; // 每分钟清理一次
21
+ function cleanupForwardPendingSessions() {
22
+ const now = Date.now();
23
+ const toDelete = [];
24
+ for (const [id, ts] of forwardPendingSessions) {
25
+ if (now - ts > FORWARD_PENDING_TTL_MS)
26
+ toDelete.push(id);
27
+ }
28
+ for (const id of toDelete)
29
+ forwardPendingSessions.delete(id);
30
+ }
31
+ let forwardCleanupTimer = null;
32
+ export function startForwardCleanupTimer() {
33
+ if (forwardCleanupTimer)
34
+ return;
35
+ forwardCleanupTimer = setInterval(cleanupForwardPendingSessions, FORWARD_CLEANUP_INTERVAL_MS);
36
+ }
11
37
  export async function processInboundMessage(api, msg) {
12
38
  await loadPluginSdk();
13
39
  const { buildPendingHistoryContextFromMap, recordPendingHistoryEntry, clearHistoryEntriesIfEnabled } = getSdk();
@@ -21,20 +47,70 @@ export async function processInboundMessage(api, msg) {
21
47
  api.logger?.warn?.("[onebot] not configured");
22
48
  return;
23
49
  }
24
- const cfg = api.config;
25
- const messageText = getRawText(msg);
50
+ const selfId = msg.self_id ?? 0;
51
+ if (msg.user_id != null && Number(msg.user_id) === Number(selfId)) {
52
+ return;
53
+ }
54
+ const replyId = getReplyMessageId(msg);
55
+ let messageText;
56
+ if (replyId != null) {
57
+ const userText = getTextFromSegments(msg);
58
+ try {
59
+ const quoted = await getMsg(replyId);
60
+ const quotedText = quoted ? getTextFromMessageContent(quoted.message) : "";
61
+ const senderLabel = quoted?.sender?.nickname ?? quoted?.sender?.user_id ?? "某人";
62
+ messageText = quotedText.trim()
63
+ ? `[引用 ${String(senderLabel)} 的消息:${quotedText.trim()}]\n${userText}`
64
+ : userText;
65
+ }
66
+ catch {
67
+ messageText = userText;
68
+ }
69
+ }
70
+ else {
71
+ messageText = getRawText(msg);
72
+ }
26
73
  if (!messageText?.trim()) {
27
74
  api.logger?.info?.(`[onebot] ignoring empty message`);
28
75
  return;
29
76
  }
30
77
  const isGroup = msg.message_type === "group";
31
- const selfId = msg.self_id ?? 0;
78
+ const cfg = api.config;
32
79
  const requireMention = cfg?.channels?.onebot?.requireMention ?? true;
33
80
  if (isGroup && requireMention && !isMentioned(msg, selfId)) {
34
81
  api.logger?.info?.(`[onebot] ignoring group message without @mention`);
35
82
  return;
36
83
  }
84
+ const gi = cfg?.channels?.onebot?.groupIncrease;
85
+ // 测试欢迎:@ 机器人并发送 /group-increase,模拟当前发送者入群,触发欢迎(使用该人的 id、nickname 等)
86
+ // 使用 getTextFromSegments 提取纯文本,避免 raw_message 中 [CQ:at,qq=xxx] 等 CQ 码导致匹配失败
87
+ const cmdText = getTextFromSegments(msg).trim() || messageText.trim();
88
+ const groupIncreaseTrigger = isGroup && isMentioned(msg, selfId) && /^\/group-increase\s*$/i.test(cmdText) && gi?.enabled;
89
+ if (groupIncreaseTrigger) {
90
+ const fakeMsg = {
91
+ post_type: "notice",
92
+ notice_type: "group_increase",
93
+ group_id: msg.group_id,
94
+ user_id: msg.user_id,
95
+ };
96
+ await handleGroupIncrease(api, fakeMsg);
97
+ return;
98
+ }
37
99
  const userId = msg.user_id;
100
+ const whitelist = getWhitelistUserIds(cfg);
101
+ if (whitelist.length > 0 && !whitelist.includes(Number(userId))) {
102
+ const denyMsg = "权限不足,请向管理员申请权限";
103
+ const getConfig = () => getOneBotConfig(api);
104
+ try {
105
+ if (msg.message_type === "group" && msg.group_id)
106
+ await sendGroupMsg(msg.group_id, denyMsg, getConfig);
107
+ else
108
+ await sendPrivateMsg(userId, denyMsg, getConfig);
109
+ }
110
+ catch (_) { }
111
+ api.logger?.info?.(`[onebot] user ${userId} not in whitelist, denied`);
112
+ return;
113
+ }
38
114
  const groupId = msg.group_id;
39
115
  const sessionId = isGroup
40
116
  ? `onebot:group:${groupId}`.toLowerCase()
@@ -153,7 +229,32 @@ export async function processInboundMessage(api, msg) {
153
229
  }
154
230
  }
155
231
  api.logger?.info?.(`[onebot] dispatching message for session ${sessionId}`);
232
+ const longMessageMode = onebotCfg.longMessageMode ?? "normal";
233
+ const longMessageThreshold = onebotCfg.longMessageThreshold ?? 300;
234
+ const replySessionId = `onebot-reply-${Date.now()}-${sessionId}`;
156
235
  setActiveReplyTarget(replyTarget);
236
+ setActiveReplySessionId(replySessionId);
237
+ setActiveReplySelfId(selfId);
238
+ if (longMessageMode === "forward")
239
+ setForwardSuppressDelivery(true);
240
+ const deliveredChunks = [];
241
+ let chunkIndex = 0;
242
+ const getConfig = () => getOneBotConfig(api);
243
+ const onReplySessionEnd = onebotCfg.onReplySessionEnd;
244
+ const doSendChunk = async (effectiveIsGroup, effectiveGroupId, uid, text, mediaUrl) => {
245
+ if (text) {
246
+ if (effectiveIsGroup && effectiveGroupId)
247
+ await sendGroupMsg(effectiveGroupId, text, getConfig);
248
+ else if (uid)
249
+ await sendPrivateMsg(uid, text, getConfig);
250
+ }
251
+ if (mediaUrl) {
252
+ if (effectiveIsGroup && effectiveGroupId)
253
+ await sendGroupImage(effectiveGroupId, mediaUrl, api.logger, getConfig);
254
+ else if (uid)
255
+ await sendPrivateImage(uid, mediaUrl, api.logger, getConfig);
256
+ }
257
+ };
157
258
  try {
158
259
  await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
159
260
  ctx: ctxPayload,
@@ -168,30 +269,145 @@ export async function processInboundMessage(api, msg) {
168
269
  if ((!trimmed || trimmed === "NO_REPLY" || trimmed.endsWith("NO_REPLY")) && !mediaUrl)
169
270
  return;
170
271
  const { userId: uid, groupId: gid, isGroup: ig } = ctxPayload._onebot || {};
171
- // 兜底:sessionId 格式 onebot:group:群号 为权威来源,某些实现 _onebot.groupId 可能为空
172
272
  const sessionKey = String(ctxPayload.SessionKey ?? sessionId);
173
273
  const groupMatch = sessionKey.match(/^onebot:group:(\d+)$/i);
174
274
  const effectiveIsGroup = groupMatch != null || Boolean(ig);
175
275
  const effectiveGroupId = (groupMatch ? parseInt(groupMatch[1], 10) : undefined) ?? gid;
276
+ const usePlain = getRenderMarkdownToPlain(cfg);
277
+ let textPlain = usePlain ? markdownToPlain(trimmed) : trimmed;
278
+ if (getCollapseDoubleNewlines(cfg))
279
+ textPlain = collapseDoubleNewlines(textPlain);
280
+ deliveredChunks.push({
281
+ index: chunkIndex++,
282
+ text: textPlain || undefined,
283
+ rawText: trimmed || undefined,
284
+ mediaUrl: mediaUrl || undefined,
285
+ });
286
+ const shouldSendNow = longMessageMode === "normal";
287
+ // forward 模式且非最后一条:仅暂存,绝不发送,等 final 时再统一处理
288
+ if (longMessageMode === "forward" && info.kind !== "final") {
289
+ forwardPendingSessions.set(replySessionId, Date.now());
290
+ return;
291
+ }
292
+ if (info.kind === "final" && longMessageMode === "forward") {
293
+ forwardPendingSessions.delete(replySessionId);
294
+ }
176
295
  try {
177
- if (trimmed) {
178
- if (effectiveIsGroup && effectiveGroupId)
179
- await sendGroupMsg(effectiveGroupId, trimmed);
180
- else if (uid)
181
- await sendPrivateMsg(uid, trimmed);
182
- }
183
- if (mediaUrl) {
184
- if (effectiveIsGroup && effectiveGroupId)
185
- await sendGroupImage(effectiveGroupId, mediaUrl);
186
- else if (uid)
187
- await sendPrivateImage(uid, mediaUrl);
296
+ if (shouldSendNow) {
297
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, textPlain, mediaUrl);
188
298
  }
189
- if (info.kind === "final" && clearHistoryEntriesIfEnabled) {
190
- clearHistoryEntriesIfEnabled({
191
- historyMap: sessionHistories,
192
- historyKey: sessionId,
193
- limit: DEFAULT_HISTORY_LIMIT,
194
- });
299
+ if (info.kind === "final") {
300
+ const lastSentCount = lastSentChunkCountBySession.get(replySessionId) ?? 0;
301
+ const chunksToSend = deliveredChunks.slice(lastSentCount);
302
+ if (chunksToSend.length === 0)
303
+ return;
304
+ const totalLen = deliveredChunks.reduce((s, c) => s + (c.rawText ?? c.text ?? "").length, 0);
305
+ const isLong = totalLen > longMessageThreshold;
306
+ const isIncremental = lastSentCount > 0;
307
+ if (isIncremental) {
308
+ setForwardSuppressDelivery(false);
309
+ for (const c of chunksToSend) {
310
+ if (c.text || c.mediaUrl)
311
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
312
+ }
313
+ }
314
+ else if (!shouldSendNow && (longMessageMode === "og_image" || longMessageMode === "forward")) {
315
+ if (isLong && longMessageMode === "og_image") {
316
+ const fullRaw = deliveredChunks.map((c) => c.rawText ?? c.text ?? "").join("\n\n");
317
+ if (fullRaw.trim()) {
318
+ try {
319
+ const imgUrl = await markdownToImage(fullRaw);
320
+ if (imgUrl) {
321
+ if (effectiveIsGroup && effectiveGroupId)
322
+ await sendGroupImage(effectiveGroupId, imgUrl, api.logger, getConfig);
323
+ else if (uid)
324
+ await sendPrivateImage(uid, imgUrl, api.logger, getConfig);
325
+ }
326
+ else {
327
+ api.logger?.warn?.("[onebot] og_image: node-html-to-image not installed, falling back to normal send");
328
+ setForwardSuppressDelivery(false);
329
+ for (const c of deliveredChunks) {
330
+ if (c.text || c.mediaUrl)
331
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
332
+ }
333
+ }
334
+ }
335
+ catch (e) {
336
+ api.logger?.error?.(`[onebot] og_image failed: ${e?.message}`);
337
+ setForwardSuppressDelivery(false);
338
+ for (const c of deliveredChunks) {
339
+ if (c.text || c.mediaUrl)
340
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
341
+ }
342
+ }
343
+ }
344
+ }
345
+ else if (isLong && longMessageMode === "forward") {
346
+ try {
347
+ const nodes = [];
348
+ for (const c of deliveredChunks) {
349
+ if (c.mediaUrl) {
350
+ const mid = await sendPrivateImage(selfId, c.mediaUrl, api.logger, getConfig);
351
+ if (mid)
352
+ nodes.push({ type: "node", data: { id: String(mid) } });
353
+ }
354
+ else if (c.text) {
355
+ const mid = await sendPrivateMsg(selfId, c.text, getConfig);
356
+ if (mid)
357
+ nodes.push({ type: "node", data: { id: String(mid) } });
358
+ }
359
+ }
360
+ if (nodes.length > 0) {
361
+ if (effectiveIsGroup && effectiveGroupId)
362
+ await sendGroupForwardMsg(effectiveGroupId, nodes, getConfig);
363
+ else if (uid)
364
+ await sendPrivateForwardMsg(uid, nodes, getConfig);
365
+ }
366
+ }
367
+ catch (e) {
368
+ api.logger?.error?.(`[onebot] forward failed: ${e?.message}`);
369
+ setForwardSuppressDelivery(false);
370
+ for (const c of deliveredChunks) {
371
+ if (c.text || c.mediaUrl)
372
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
373
+ }
374
+ }
375
+ }
376
+ else {
377
+ setForwardSuppressDelivery(false);
378
+ for (const c of deliveredChunks) {
379
+ if (c.text || c.mediaUrl)
380
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
381
+ }
382
+ }
383
+ }
384
+ lastSentChunkCountBySession.set(replySessionId, deliveredChunks.length);
385
+ if (clearHistoryEntriesIfEnabled) {
386
+ clearHistoryEntriesIfEnabled({
387
+ historyMap: sessionHistories,
388
+ historyKey: sessionId,
389
+ limit: DEFAULT_HISTORY_LIMIT,
390
+ });
391
+ }
392
+ if (onReplySessionEnd) {
393
+ const ctx = {
394
+ replySessionId,
395
+ sessionId,
396
+ to: replyTarget,
397
+ chunks: deliveredChunks.map(({ index, text: t, mediaUrl: m }) => ({ index, text: t, mediaUrl: m })),
398
+ userMessage: messageText,
399
+ };
400
+ if (typeof onReplySessionEnd === "function") {
401
+ await onReplySessionEnd(ctx);
402
+ }
403
+ else if (typeof onReplySessionEnd === "string" && onReplySessionEnd.trim()) {
404
+ const { loadScript } = await import("../load-script.js");
405
+ const mod = await loadScript(onReplySessionEnd.trim());
406
+ const fn = mod?.default ?? mod?.onReplySessionEnd;
407
+ if (typeof fn === "function")
408
+ await fn(ctx);
409
+ }
410
+ }
195
411
  }
196
412
  }
197
413
  catch (e) {
@@ -219,6 +435,11 @@ export async function processInboundMessage(api, msg) {
219
435
  catch (_) { }
220
436
  }
221
437
  finally {
438
+ setForwardSuppressDelivery(false);
439
+ setActiveReplySelfId(null);
440
+ lastSentChunkCountBySession.delete(replySessionId);
441
+ forwardPendingSessions.delete(replySessionId);
442
+ setActiveReplySessionId(null);
222
443
  clearActiveReplyTarget();
223
444
  }
224
445
  }
package/dist/index.js CHANGED
@@ -11,10 +11,12 @@
11
11
  import { OneBotChannelPlugin } from "./channel.js";
12
12
  import { registerService } from "./service.js";
13
13
  import { startImageTempCleanup } from "./connection.js";
14
+ import { startForwardCleanupTimer } from "./handlers/process-inbound.js";
14
15
  export default function register(api) {
15
16
  globalThis.__onebotApi = api;
16
17
  globalThis.__onebotGatewayConfig = api.config;
17
18
  startImageTempCleanup();
19
+ startForwardCleanupTimer();
18
20
  api.registerChannel({ plugin: OneBotChannelPlugin });
19
21
  if (typeof api.registerCli === "function") {
20
22
  api.registerCli((ctx) => {
@@ -2,4 +2,8 @@
2
2
  * 动态加载用户脚本(支持 .js/.mjs/.ts/.mts)
3
3
  * .ts/.mts 依赖 tsx 运行时
4
4
  */
5
- export declare function loadScript(scriptPath: string): Promise<Record<string, unknown>>;
5
+ export interface LoadScriptOptions {
6
+ /** 脚本执行时的 CWD 绝对路径,用于解析相对路径及脚本内 process.cwd() */
7
+ cwd?: string;
8
+ }
9
+ export declare function loadScript(scriptPath: string, options?: LoadScriptOptions): Promise<Record<string, unknown>>;
@@ -6,8 +6,11 @@ import { resolve } from "path";
6
6
  import { pathToFileURL } from "url";
7
7
  import { extname } from "path";
8
8
  const TS_EXT = [".ts", ".mts"];
9
- export async function loadScript(scriptPath) {
10
- const absPath = resolve(process.cwd(), scriptPath.trim());
9
+ export async function loadScript(scriptPath, options) {
10
+ const baseDir = options?.cwd?.trim() ? resolve(options.cwd) : process.cwd();
11
+ const absPath = scriptPath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(scriptPath)
12
+ ? resolve(scriptPath.trim())
13
+ : resolve(baseDir, scriptPath.trim());
11
14
  const ext = extname(absPath).toLowerCase();
12
15
  if (TS_EXT.includes(ext)) {
13
16
  try {
@@ -17,6 +20,7 @@ export async function loadScript(scriptPath) {
17
20
  throw new Error("执行 .ts/.mts 脚本需要安装 tsx 依赖:npm install tsx");
18
21
  }
19
22
  }
23
+ // 使用 pathToFileURL 确保 file:// URL 格式正确(Windows 反斜杠会转为正斜杠)
20
24
  const url = pathToFileURL(absPath).href;
21
- return import(url);
25
+ return (await import(url));
22
26
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Markdown 转 HTML(保留格式,含代码高亮)
3
+ * 用于 OG 图片模式下的 Markdown 渲染
4
+ */
5
+ export declare function markdownToHtml(md: string): string;
6
+ export declare function getMarkdownStyles(): string;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Markdown 转 HTML(保留格式,含代码高亮)
3
+ * 用于 OG 图片模式下的 Markdown 渲染
4
+ */
5
+ import { marked } from "marked";
6
+ import hljs from "highlight.js";
7
+ const HIGHLIGHT_CSS = `
8
+ .hljs{display:block;overflow-x:auto;padding:1em;background:#1e1e1e;color:#d4d4d4;border-radius:6px;font-family:Consolas,Monaco,monospace;font-size:13px;line-height:1.5}
9
+ .hljs-keyword{color:#569cd6}
10
+ .hljs-string{color:#ce9178}
11
+ .hljs-number{color:#b5cea8}
12
+ .hljs-comment{color:#6a9955}
13
+ .hljs-function{color:#dcdcaa}
14
+ .hljs-title{color:#4ec9b0}
15
+ .hljs-params{color:#9cdcfe}
16
+ .hljs-built_in{color:#4ec9b0}
17
+ .hljs-class{color:#4ec9b0}
18
+ .hljs-variable{color:#9cdcfe}
19
+ .hljs-attr{color:#9cdcfe}
20
+ .hljs-tag{color:#569cd6}
21
+ .hljs-name{color:#569cd6}
22
+ .hljs-meta{color:#808080}
23
+ `;
24
+ function highlightCode(code, lang) {
25
+ if (lang && hljs.getLanguage(lang)) {
26
+ try {
27
+ return hljs.highlight(code, { language: lang }).value;
28
+ }
29
+ catch {
30
+ return hljs.highlightAuto(code).value;
31
+ }
32
+ }
33
+ return hljs.highlightAuto(code).value;
34
+ }
35
+ marked.use({
36
+ breaks: true,
37
+ gfm: true,
38
+ renderer: {
39
+ code({ text, lang }) {
40
+ const escaped = highlightCode(text, lang);
41
+ return `<pre><code class="hljs language-${lang || ""}">${escaped}</code></pre>`;
42
+ },
43
+ },
44
+ });
45
+ const WRAPPER_STYLE = `
46
+ <style>
47
+ *{margin:0;padding:0;box-sizing:border-box}
48
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:15px;line-height:1.6;color:#24292e;background:#fff;padding:24px;max-width:800px}
49
+ h1,h2,h3,h4,h5,h6{margin:16px 0 8px;font-weight:600;line-height:1.25}
50
+ h1{font-size:1.5em}
51
+ h2{font-size:1.3em}
52
+ h3{font-size:1.15em}
53
+ p{margin:8px 0}
54
+ ul,ol{margin:8px 0;padding-left:24px}
55
+ li{margin:4px 0}
56
+ code{background:#f6f8fa;padding:2px 6px;border-radius:4px;font-size:0.9em;font-family:Consolas,Monaco,monospace}
57
+ pre{margin:12px 0;overflow-x:auto}
58
+ pre code{background:transparent;padding:0}
59
+ blockquote{border-left:4px solid #dfe2e5;padding-left:16px;margin:8px 0;color:#6a737d}
60
+ a{color:#0366d6;text-decoration:none}
61
+ a:hover{text-decoration:underline}
62
+ table{border-collapse:collapse;margin:12px 0}
63
+ th,td{border:1px solid #dfe2e5;padding:8px 12px;text-align:left}
64
+ th{background:#f6f8fa;font-weight:600}
65
+ ${HIGHLIGHT_CSS}
66
+ </style>
67
+ `;
68
+ export function markdownToHtml(md) {
69
+ if (!md || typeof md !== "string")
70
+ return "";
71
+ return marked.parse(md, { async: false });
72
+ }
73
+ export function getMarkdownStyles() {
74
+ return WRAPPER_STYLE;
75
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Markdown 转纯文本(去除 **、#、` 等标记)
3
+ * 用于将 AI 的 Markdown 回复渲染为 QQ 可读的纯文本
4
+ */
5
+ export declare function markdownToPlain(text: string): string;
6
+ /** 将连续多个换行压缩为单个,减少 AI 输出中的多余空行 */
7
+ export declare function collapseDoubleNewlines(text: string): string;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Markdown 转纯文本(去除 **、#、` 等标记)
3
+ * 用于将 AI 的 Markdown 回复渲染为 QQ 可读的纯文本
4
+ */
5
+ export function markdownToPlain(text) {
6
+ if (!text || typeof text !== "string")
7
+ return "";
8
+ let s = text;
9
+ // 代码块 ```...```
10
+ s = s.replace(/```[\s\S]*?```/g, (m) => {
11
+ const inner = m.slice(3, -3).trim();
12
+ return inner ? `${inner}\n` : "";
13
+ });
14
+ // 行内代码 `...`
15
+ s = s.replace(/`([^`]+)`/g, "$1");
16
+ // 粗体 **...** 或 __...__
17
+ s = s.replace(/\*\*([^*]+)\*\*/g, "$1");
18
+ s = s.replace(/__([^_]+)__/g, "$1");
19
+ // 斜体 *...* 或 _..._(在粗体之后处理)
20
+ s = s.replace(/\*([^*]+)\*/g, "$1");
21
+ s = s.replace(/_([^_]+)_/g, "$1");
22
+ // 标题 # ## ### ...
23
+ s = s.replace(/^#{1,6}\s+/gm, "");
24
+ // 链接 [text](url) -> text
25
+ s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
26
+ // 图片 ![alt](url) -> alt 或 [图片]
27
+ s = s.replace(/!\[([^\]]*)\]\([^)]+\)/g, (_, alt) => (alt?.trim() ? alt : "[图片]"));
28
+ // 删除线 ~~...~~
29
+ s = s.replace(/~~([^~]+)~~/g, "$1");
30
+ // 引用 > 行首
31
+ s = s.replace(/^>\s*/gm, "");
32
+ // 无序列表 - * +
33
+ s = s.replace(/^[\s]*[-*+]\s+/gm, "");
34
+ // 有序列表 1. 2.
35
+ s = s.replace(/^[\s]*\d+\.\s+/gm, "");
36
+ return s.trim();
37
+ }
38
+ /** 将连续多个换行压缩为单个,减少 AI 输出中的多余空行 */
39
+ export function collapseDoubleNewlines(text) {
40
+ if (!text || typeof text !== "string")
41
+ return "";
42
+ return text.replace(/\n{2,}/g, "\n");
43
+ }
package/dist/message.d.ts CHANGED
@@ -2,5 +2,11 @@
2
2
  * OneBot 消息解析
3
3
  */
4
4
  import type { OneBotMessage } from "./types.js";
5
+ /** 从消息段数组中提取引用/回复的消息 ID(OneBot reply 段) */
6
+ export declare function getReplyMessageId(msg: OneBotMessage): number | undefined;
7
+ /** 从 get_msg 返回的 message 字段中提取文本和图片链接(供 AI 理解引用内容) */
8
+ export declare function getTextFromMessageContent(content: string | unknown[] | undefined): string;
9
+ /** 仅从 message 段数组提取 text 段(不含 raw_message,用于有引用时避免 CQ 码) */
10
+ export declare function getTextFromSegments(msg: OneBotMessage): string;
5
11
  export declare function getRawText(msg: OneBotMessage): string;
6
12
  export declare function isMentioned(msg: OneBotMessage, selfId: number): boolean;
package/dist/message.js CHANGED
@@ -1,13 +1,45 @@
1
1
  /**
2
2
  * OneBot 消息解析
3
3
  */
4
- export function getRawText(msg) {
5
- if (!msg)
4
+ /** 从消息段数组中提取引用/回复的消息 ID(OneBot reply 段) */
5
+ export function getReplyMessageId(msg) {
6
+ if (!msg?.message || !Array.isArray(msg.message))
7
+ return undefined;
8
+ const replySeg = msg.message.find((m) => m?.type === "reply");
9
+ if (!replySeg?.data)
10
+ return undefined;
11
+ const id = replySeg.data?.id;
12
+ if (id == null)
13
+ return undefined;
14
+ const num = typeof id === "number" ? id : parseInt(String(id), 10);
15
+ return Number.isNaN(num) ? undefined : num;
16
+ }
17
+ /** 从 get_msg 返回的 message 字段中提取文本和图片链接(供 AI 理解引用内容) */
18
+ export function getTextFromMessageContent(content) {
19
+ if (!content)
6
20
  return "";
7
- if (typeof msg.raw_message === "string" && msg.raw_message) {
8
- return msg.raw_message;
21
+ if (typeof content === "string")
22
+ return content;
23
+ if (!Array.isArray(content))
24
+ return "";
25
+ const parts = [];
26
+ for (const m of content) {
27
+ const seg = m;
28
+ if (seg?.type === "text") {
29
+ const t = seg.data?.text ?? "";
30
+ if (t)
31
+ parts.push(t);
32
+ }
33
+ else if (seg?.type === "image") {
34
+ const url = seg.data?.url ?? seg.data?.file ?? "";
35
+ parts.push(url ? `[图片: ${url}]` : "[图片]");
36
+ }
9
37
  }
10
- const arr = msg.message;
38
+ return parts.join("");
39
+ }
40
+ /** 仅从 message 段数组提取 text 段(不含 raw_message,用于有引用时避免 CQ 码) */
41
+ export function getTextFromSegments(msg) {
42
+ const arr = msg?.message;
11
43
  if (!Array.isArray(arr))
12
44
  return "";
13
45
  return arr
@@ -15,6 +47,14 @@ export function getRawText(msg) {
15
47
  .map((m) => m?.data?.text ?? "")
16
48
  .join("");
17
49
  }
50
+ export function getRawText(msg) {
51
+ if (!msg)
52
+ return "";
53
+ if (typeof msg.raw_message === "string" && msg.raw_message) {
54
+ return msg.raw_message;
55
+ }
56
+ return getTextFromSegments(msg);
57
+ }
18
58
  export function isMentioned(msg, selfId) {
19
59
  const arr = msg.message;
20
60
  if (!Array.isArray(arr))
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Markdown 转 OG 图片
3
+ * 依赖可选的 node-html-to-image(需安装:npm install node-html-to-image)
4
+ */
5
+ export declare function markdownToImage(md: string): Promise<string | null>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Markdown 转 OG 图片
3
+ * 依赖可选的 node-html-to-image(需安装:npm install node-html-to-image)
4
+ */
5
+ import { unlinkSync, mkdirSync } from "fs";
6
+ import { join } from "path";
7
+ import { tmpdir } from "os";
8
+ import { markdownToHtml, getMarkdownStyles } from "./markdown-to-html.js";
9
+ const OG_TEMP_DIR = join(tmpdir(), "openclaw-onebot-og");
10
+ export async function markdownToImage(md) {
11
+ if (!md?.trim())
12
+ return null;
13
+ let nodeHtmlToImage;
14
+ try {
15
+ const mod = await import("node-html-to-image");
16
+ nodeHtmlToImage = mod.default;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ if (!nodeHtmlToImage)
22
+ return null;
23
+ const bodyHtml = markdownToHtml(md);
24
+ const styles = getMarkdownStyles();
25
+ const fullHtml = `<!DOCTYPE html><html><head><meta charset="utf-8">${styles}</head><body>${bodyHtml}</body></html>`;
26
+ mkdirSync(OG_TEMP_DIR, { recursive: true });
27
+ const outPath = join(OG_TEMP_DIR, `og-${Date.now()}-${Math.random().toString(36).slice(2)}.png`);
28
+ try {
29
+ await nodeHtmlToImage({
30
+ output: outPath,
31
+ html: fullHtml,
32
+ type: "png",
33
+ quality: 90,
34
+ puppeteerArgs: { headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] },
35
+ });
36
+ return `file://${outPath.replace(/\\/g, "/")}`;
37
+ }
38
+ catch (e) {
39
+ try {
40
+ unlinkSync(outPath);
41
+ }
42
+ catch { }
43
+ throw e;
44
+ }
45
+ }