@kirigaya/openclaw-onebot 1.0.4 → 1.0.9

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
@@ -75,7 +75,7 @@ openclaw onebot setup
75
75
  | 模式 | 说明 |
76
76
  |------|------|
77
77
  | `normal` | 准流式分段发送:边生成边聚合,按时间窗口或长度阈值增量发送 |
78
- | `og_image` | 将 Markdown 转为 HTML 再生成图片发送(需安装 `node-html-to-image`) |
78
+ | `og_image` | 将 Markdown 转为 HTML 再生成图片发送(需安装 `satori` 和 `sharp`) |
79
79
  | `forward` | 合并转发(发给自己后打包转发) |
80
80
 
81
81
  `normal` 模式默认会开启块流式接收,并在插件侧做短时间聚合,默认规则:
@@ -184,12 +184,28 @@ openclaw message send --channel onebot --target group:987654321 --media "file://
184
184
  {
185
185
  "channels": {
186
186
  "onebot": {
187
- "whitelistUserIds": [1193466151],
187
+ "whitelistUserIds": [1193466151]
188
188
  }
189
189
  }
190
190
  }
191
191
  ```
192
192
 
193
+ ### 黑名单
194
+
195
+ 在群里有时候有些人需要被屏蔽,不管他怎么 @ 还是怎么,都屏蔽他的消息不触发。
196
+
197
+ ```json
198
+ {
199
+ "channels": {
200
+ "onebot": {
201
+ "blacklistUserIds": [123456789]
202
+ }
203
+ }
204
+ }
205
+ ```
206
+
207
+ 注意:白名单优先级高于黑名单。如果同时设置了白名单和黑名单,只有白名单内的用户才能触发,且黑名单内的白名单用户也会被屏蔽。
208
+
193
209
  ## 新人入群触发器
194
210
 
195
211
  如果有人入群之后,可以通过这个来实现触发器。
@@ -227,7 +243,7 @@ npm run test:connect
227
243
 
228
244
  ### 测试 OG 图片渲染效果
229
245
 
230
- 用于预览「Markdown 转图片」在不同主题下的渲染效果(需安装 `node-html-to-image`):
246
+ 用于预览「Markdown 转图片」在不同主题下的渲染效果(需安装 `satori` 和 `sharp`):
231
247
 
232
248
  ```bash
233
249
  cd openclaw-onebot
package/dist/config.d.ts CHANGED
@@ -13,9 +13,22 @@ export declare function getNormalModeFlushIntervalMs(cfg: any): number;
13
13
  export declare function getNormalModeFlushChars(cfg: any): number;
14
14
  /** 白名单 QQ 号列表,为空则所有人可回复;非空则仅白名单内用户可触发 AI */
15
15
  export declare function getWhitelistUserIds(cfg: any): number[];
16
+ /** 黑名单 QQ 号列表,在黑名单内的用户无法触发 AI */
17
+ export declare function getBlacklistUserIds(cfg: any): number[];
16
18
  /**
17
19
  * OG 图片渲染主题:枚举 default(无额外样式)、dust(内置)、custom(使用 ogImageRenderThemePath)
18
20
  * 返回用于 getMarkdownStyles 的值:default | dust | 自定义 CSS 绝对路径
19
21
  */
20
22
  export declare function getOgImageRenderTheme(cfg: any): "default" | "dust" | string;
21
23
  export declare function listAccountIds(apiOrCfg: any): string[];
24
+ /**
25
+ * 触发关键词列表,当 requireMention 为 false 时生效
26
+ * 消息包含这些关键词时触发机器人响应
27
+ */
28
+ export declare function getTriggerKeywords(cfg: any): string[];
29
+ /**
30
+ * 关键词匹配模式
31
+ * - "prefix": 消息以关键词开头(默认)
32
+ * - "contains": 消息包含关键词即可
33
+ */
34
+ export declare function getTriggerMode(cfg: any): "prefix" | "contains";
package/dist/config.js CHANGED
@@ -80,6 +80,13 @@ export function getWhitelistUserIds(cfg) {
80
80
  return [];
81
81
  return v.filter((x) => typeof x === "number" || (typeof x === "string" && /^\d+$/.test(x))).map((x) => Number(x));
82
82
  }
83
+ /** 黑名单 QQ 号列表,在黑名单内的用户无法触发 AI */
84
+ export function getBlacklistUserIds(cfg) {
85
+ const v = cfg?.channels?.onebot?.blacklistUserIds;
86
+ if (!Array.isArray(v))
87
+ return [];
88
+ return v.filter((x) => typeof x === "number" || (typeof x === "string" && /^\d+$/.test(x))).map((x) => Number(x));
89
+ }
83
90
  /**
84
91
  * OG 图片渲染主题:枚举 default(无额外样式)、dust(内置)、custom(使用 ogImageRenderThemePath)
85
92
  * 返回用于 getMarkdownStyles 的值:default | dust | 自定义 CSS 绝对路径
@@ -103,3 +110,24 @@ export function listAccountIds(apiOrCfg) {
103
110
  return ["default"];
104
111
  return [];
105
112
  }
113
+ /**
114
+ * 触发关键词列表,当 requireMention 为 false 时生效
115
+ * 消息包含这些关键词时触发机器人响应
116
+ */
117
+ export function getTriggerKeywords(cfg) {
118
+ const v = cfg?.channels?.onebot?.triggerKeywords;
119
+ if (!Array.isArray(v))
120
+ return [];
121
+ return v.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim());
122
+ }
123
+ /**
124
+ * 关键词匹配模式
125
+ * - "prefix": 消息以关键词开头(默认)
126
+ * - "contains": 消息包含关键词即可
127
+ */
128
+ export function getTriggerMode(cfg) {
129
+ const v = cfg?.channels?.onebot?.triggerMode;
130
+ if (v === "contains")
131
+ return "contains";
132
+ return "prefix"; // 默认为前缀匹配
133
+ }
@@ -612,7 +612,9 @@ export async function getGroupMsgHistoryInRange(groupId, opts = {}) {
612
612
  export async function connectForward(config) {
613
613
  const path = config.path ?? "/onebot/v11/ws";
614
614
  const pathNorm = path.startsWith("/") ? path : `/${path}`;
615
- const addr = `ws://${config.host}:${config.port}${pathNorm}`;
615
+ // 端口为 443 时使用 wss,其余端口使用 ws
616
+ const scheme = config.port === 443 ? "wss" : "ws";
617
+ const addr = `${scheme}://${config.host}:${config.port}${pathNorm}`;
616
618
  const headers = {};
617
619
  if (config.accessToken) {
618
620
  headers["Authorization"] = `Bearer ${config.accessToken}`;
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { getOneBotConfig } from "../config.js";
5
5
  import { getRawText, getTextFromSegments, getReplyMessageId, getTextFromMessageContent, isMentioned, } from "../message.js";
6
- import { getRenderMarkdownToPlain, getCollapseDoubleNewlines, getWhitelistUserIds, getOgImageRenderTheme, getNormalModeFlushIntervalMs, getNormalModeFlushChars, } from "../config.js";
6
+ import { getRenderMarkdownToPlain, getCollapseDoubleNewlines, getWhitelistUserIds, getBlacklistUserIds, getOgImageRenderTheme, getNormalModeFlushIntervalMs, getNormalModeFlushChars, getTriggerKeywords, getTriggerMode, } from "../config.js";
7
7
  import { markdownToPlain, collapseDoubleNewlines } from "../markdown.js";
8
8
  import { markdownToImage } from "../og-image.js";
9
9
  import { sendPrivateMsg, sendGroupMsg, sendPrivateImage, sendGroupImage, sendGroupForwardMsg, sendPrivateForwardMsg, setMsgEmojiLike, getMsg, } from "../connection.js";
@@ -12,6 +12,34 @@ import { loadPluginSdk, getSdk } from "../sdk.js";
12
12
  import { handleGroupIncrease } from "./group-increase.js";
13
13
  const DEFAULT_HISTORY_LIMIT = 20;
14
14
  export const sessionHistories = new Map();
15
+ /**
16
+ * 检查消息是否匹配触发关键词
17
+ * @param text 消息文本
18
+ * @param keywords 关键词列表
19
+ * @param mode 匹配模式:prefix-前缀匹配,contains-包含匹配
20
+ */
21
+ function checkTriggerKeyword(text, keywords, mode) {
22
+ if (!text || keywords.length === 0)
23
+ return false;
24
+ for (const keyword of keywords) {
25
+ if (!keyword)
26
+ continue;
27
+ if (mode === "prefix") {
28
+ // 前缀匹配:消息以关键词开头(忽略前导空格)
29
+ const trimmedText = text.trimStart();
30
+ if (trimmedText.toLowerCase().startsWith(keyword.toLowerCase())) {
31
+ return true;
32
+ }
33
+ }
34
+ else {
35
+ // 包含匹配:消息中包含关键词即可
36
+ if (text.toLowerCase().includes(keyword.toLowerCase())) {
37
+ return true;
38
+ }
39
+ }
40
+ }
41
+ return false;
42
+ }
15
43
  /** forward 模式下待处理的会话,用于定期清理未完成的缓冲 */
16
44
  const forwardPendingSessions = new Map();
17
45
  /** 每个 replySessionId 已发送的 chunk 数量,用于支持多次 final(如工具调用后追加内容) */
@@ -77,9 +105,31 @@ export async function processInboundMessage(api, msg) {
77
105
  const isGroup = msg.message_type === "group";
78
106
  const cfg = api.config;
79
107
  const requireMention = cfg?.channels?.onebot?.requireMention ?? true;
80
- if (isGroup && requireMention && !isMentioned(msg, selfId)) {
81
- api.logger?.info?.(`[onebot] ignoring group message without @mention`);
82
- return;
108
+ // 触发检查逻辑
109
+ if (isGroup) {
110
+ const isAtMentioned = isMentioned(msg, selfId);
111
+ if (requireMention) {
112
+ // 传统模式:必须 @ 才响应
113
+ if (!isAtMentioned) {
114
+ api.logger?.info?.(`[onebot] ignoring group message without @mention`);
115
+ return;
116
+ }
117
+ }
118
+ else {
119
+ // 关键词模式:配置 triggerKeywords 时,需匹配关键词才响应
120
+ const triggerKeywords = getTriggerKeywords(cfg);
121
+ if (triggerKeywords.length > 0) {
122
+ const triggerMode = getTriggerMode(cfg);
123
+ const textFromMsg = getTextFromSegments(msg).trim() || messageText.trim();
124
+ const matched = checkTriggerKeyword(textFromMsg, triggerKeywords, triggerMode);
125
+ if (!matched) {
126
+ api.logger?.info?.(`[onebot] ignoring group message, no trigger keyword matched`);
127
+ return;
128
+ }
129
+ api.logger?.info?.(`[onebot] trigger keyword matched, processing message`);
130
+ }
131
+ // 如果没有配置关键词,则所有消息都响应(降级到原逻辑)
132
+ }
83
133
  }
84
134
  const gi = cfg?.channels?.onebot?.groupIncrease;
85
135
  // 测试欢迎:@ 机器人并发送 /group-increase,模拟当前发送者入群,触发欢迎(使用该人的 id、nickname 等)
@@ -97,6 +147,7 @@ export async function processInboundMessage(api, msg) {
97
147
  return;
98
148
  }
99
149
  const userId = msg.user_id;
150
+ // 白名单检查
100
151
  const whitelist = getWhitelistUserIds(cfg);
101
152
  if (whitelist.length > 0 && !whitelist.includes(Number(userId))) {
102
153
  const denyMsg = "权限不足,请向管理员申请权限";
@@ -111,22 +162,32 @@ export async function processInboundMessage(api, msg) {
111
162
  api.logger?.info?.(`[onebot] user ${userId} not in whitelist, denied`);
112
163
  return;
113
164
  }
165
+ // 黑名单检查
166
+ const blacklist = getBlacklistUserIds(cfg);
167
+ if (blacklist.length > 0 && blacklist.includes(Number(userId))) {
168
+ api.logger?.info?.(`[onebot] user ${userId} is in blacklist, ignored`);
169
+ return;
170
+ }
114
171
  const groupId = msg.group_id;
115
- const sessionId = isGroup
172
+ const tempSessionId = isGroup
116
173
  ? `onebot:group:${groupId}`.toLowerCase()
117
174
  : `onebot:${userId}`.toLowerCase();
118
175
  const route = runtime.channel.routing?.resolveAgentRoute?.({
119
176
  cfg,
120
- sessionKey: sessionId,
177
+ sessionKey: tempSessionId,
121
178
  channel: "onebot",
122
179
  accountId: config.accountId ?? "default",
123
180
  }) ?? { agentId: "main" };
181
+ // 修复构造符合 OpenClaw 规范的全局 SessionKey格式必须为 agent:{agentId}:{channel}:{type}:{id},否则下方的dispatchReplyWithBufferedBlockDispatcher会触发自动兜底机制,直接在 main 代理下“克隆”出一个一模一样的会话,导致多agent配置达不到效果
182
+ const sessionId = `agent:${route.agentId}:${tempSessionId}`;
124
183
  const storePath = runtime.channel.session?.resolveStorePath?.(cfg?.session?.store, {
125
184
  agentId: route.agentId,
126
185
  }) ?? "";
127
186
  const envelopeOptions = runtime.channel.reply?.resolveEnvelopeFormatOptions?.(cfg) ?? {};
128
187
  const chatType = isGroup ? "group" : "direct";
129
188
  const fromLabel = String(userId);
189
+ // 添加日志:打印插件接收到的原始消息内容
190
+ api.logger?.info?.(`[onebot] received message from user ${userId}: "${messageText}"`);
130
191
  const formattedBody = runtime.channel.reply?.formatInboundEnvelope?.({
131
192
  channel: "OneBot",
132
193
  from: fromLabel,
@@ -416,7 +477,7 @@ export async function processInboundMessage(api, msg) {
416
477
  await sendPrivateImage(uid, imgUrl, api.logger, getConfig);
417
478
  }
418
479
  else {
419
- api.logger?.warn?.("[onebot] og_image (incremental): node-html-to-image not installed, falling back to normal send");
480
+ api.logger?.warn?.("[onebot] og_image (incremental): satori or sharp not installed, falling back to normal send");
420
481
  for (const c of chunksToSend) {
421
482
  if (c.text || c.mediaUrl)
422
483
  await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
@@ -497,7 +558,7 @@ export async function processInboundMessage(api, msg) {
497
558
  await sendPrivateImage(uid, imgUrl, api.logger, getConfig);
498
559
  }
499
560
  else {
500
- api.logger?.warn?.("[onebot] og_image: node-html-to-image not installed, falling back to normal send");
561
+ api.logger?.warn?.("[onebot] og_image: satori or sharp not installed, falling back to normal send");
501
562
  setForwardSuppressDelivery(false);
502
563
  for (const c of deliveredChunks) {
503
564
  if (c.text || c.mediaUrl)
package/dist/index.js CHANGED
@@ -13,9 +13,24 @@ import { registerService } from "./service.js";
13
13
  import { startImageTempCleanup } from "./connection.js";
14
14
  import { startForwardCleanupTimer } from "./handlers/process-inbound.js";
15
15
  import { registerOneBotCli } from "./cli-commands.js";
16
+ import { createRequire } from "module";
17
+ const require = createRequire(import.meta.url);
18
+ const pkg = require("../package.json");
16
19
  export default function register(api) {
17
20
  globalThis.__onebotApi = api;
18
21
  globalThis.__onebotGatewayConfig = api.config;
22
+ // 打印插件加载信息
23
+ const pluginName = pkg.name;
24
+ const pluginVersion = pkg.version;
25
+ const logger = api.logger;
26
+ if (logger?.info) {
27
+ logger.info(`[${pluginName}] v${pluginVersion} 加载中...`);
28
+ logger.info(`[${pluginName}] OneBot v11 协议渠道插件`);
29
+ }
30
+ else {
31
+ console.log(`[${pluginName}] v${pluginVersion} 加载中...`);
32
+ console.log(`[${pluginName}] OneBot v11 协议渠道插件`);
33
+ }
19
34
  startImageTempCleanup();
20
35
  startForwardCleanupTimer();
21
36
  api.registerChannel({ plugin: OneBotChannelPlugin });
@@ -33,5 +48,10 @@ export default function register(api) {
33
48
  }, { commands: ["onebot"] });
34
49
  }
35
50
  registerService(api);
36
- api.logger?.info?.("[onebot] plugin loaded");
51
+ if (logger?.info) {
52
+ logger.info(`[${pluginName}] v${pluginVersion} 加载完成`);
53
+ }
54
+ else {
55
+ console.log(`[${pluginName}] v${pluginVersion} 加载完成`);
56
+ }
37
57
  }
@@ -13,7 +13,7 @@ function getDustThemePath() {
13
13
  return join(__dirname, "..", "themes", "dust.css");
14
14
  }
15
15
  const HIGHLIGHT_CSS = `
16
- .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}
16
+ .hljs{display:block;overflow-x:auto;padding:1em;background:#1e1e1e;color:#d4d4d4;border-radius:6px;font-family:Consolas,Monaco,monospace;font-size:15px;line-height:1.5}
17
17
  .hljs-keyword{color:#569cd6}
18
18
  .hljs-string{color:#ce9178}
19
19
  .hljs-number{color:#b5cea8}
@@ -53,7 +53,7 @@ marked.use({
53
53
  const WRAPPER_STYLE = `
54
54
  <style>
55
55
  *{margin:0;padding:0;box-sizing:border-box}
56
- 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}
56
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:18px;line-height:1.6;color:#24292e;background:#fff;padding:24px;max-width:450px}
57
57
  h1,h2,h3,h4,h5,h6{margin:16px 0 8px;font-weight:600;line-height:1.25}
58
58
  h1{font-size:1.5em}
59
59
  h2{font-size:1.3em}
@@ -90,7 +90,7 @@ export function getMarkdownStyles(theme) {
90
90
  try {
91
91
  if (existsSync(dustPath)) {
92
92
  const dustCss = readFileSync(dustPath, "utf-8");
93
- extra = `<style>body{background:var(--background) !important;padding:24px;max-width:800px;}${dustCss}</style>`;
93
+ extra = `<style>body{background:var(--background) !important;padding:24px;max-width:450px;}${dustCss}</style>`;
94
94
  }
95
95
  }
96
96
  catch {
@@ -1,7 +1,24 @@
1
1
  /**
2
2
  * Markdown 转 OG 图片
3
- * 依赖可选的 node-html-to-image(需安装:npm install node-html-to-image
3
+ * 依赖可选的 puppeteer-core(需安装:npm install puppeteer-core
4
+ *
5
+ * 兼容性说明:
6
+ * - 支持 Ubuntu 24+、macOS、Windows
7
+ * - 自动检测系统 Chromium/Chrome 路径
8
+ * - 无需下载 Chromium,使用系统已安装的浏览器
9
+ *
10
+ * 安装系统 Chromium(Ubuntu/Debian):
11
+ * sudo apt update
12
+ * sudo apt install -y chromium-browser
13
+ *
14
+ * 安装系统 Chromium(macOS):
15
+ * brew install --cask chromium
16
+ * # 或直接使用系统 Chrome
4
17
  */
5
- export declare function markdownToImage(md: string, opts?: {
18
+ export interface MarkdownToImageOptions {
6
19
  theme?: string;
7
- }): Promise<string | null>;
20
+ width?: number;
21
+ height?: number;
22
+ fullPage?: boolean;
23
+ }
24
+ export declare function markdownToImage(md: string, opts?: MarkdownToImageOptions): Promise<string | null>;
package/dist/og-image.js CHANGED
@@ -1,25 +1,98 @@
1
1
  /**
2
2
  * Markdown 转 OG 图片
3
- * 依赖可选的 node-html-to-image(需安装:npm install node-html-to-image
3
+ * 依赖可选的 puppeteer-core(需安装:npm install puppeteer-core
4
+ *
5
+ * 兼容性说明:
6
+ * - 支持 Ubuntu 24+、macOS、Windows
7
+ * - 自动检测系统 Chromium/Chrome 路径
8
+ * - 无需下载 Chromium,使用系统已安装的浏览器
9
+ *
10
+ * 安装系统 Chromium(Ubuntu/Debian):
11
+ * sudo apt update
12
+ * sudo apt install -y chromium-browser
13
+ *
14
+ * 安装系统 Chromium(macOS):
15
+ * brew install --cask chromium
16
+ * # 或直接使用系统 Chrome
4
17
  */
5
18
  import { unlinkSync, mkdirSync } from "fs";
6
19
  import { join } from "path";
7
20
  import { tmpdir } from "os";
8
21
  import { markdownToHtml, getMarkdownStyles } from "./markdown-to-html.js";
9
22
  const OG_TEMP_DIR = join(tmpdir(), "openclaw-onebot-og");
10
- export async function markdownToImage(md, opts) {
11
- if (!md?.trim())
12
- return null;
13
- let nodeHtmlToImage;
23
+ /**
24
+ * 自动检测系统 Chromium/Chrome 可执行文件路径
25
+ */
26
+ function detectChromiumPath() {
27
+ const platform = process.platform;
28
+ const envPath = process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH;
29
+ if (envPath) {
30
+ return envPath;
31
+ }
32
+ // 各平台常见路径
33
+ const paths = {
34
+ linux: [
35
+ "/usr/bin/chromium",
36
+ "/usr/bin/chromium-browser",
37
+ "/usr/bin/google-chrome",
38
+ "/usr/bin/google-chrome-stable",
39
+ "/snap/bin/chromium",
40
+ "/usr/lib/chromium/chromium",
41
+ ],
42
+ darwin: [
43
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
44
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
45
+ "/usr/bin/google-chrome",
46
+ "/opt/homebrew/bin/chromium",
47
+ "/usr/local/bin/chromium",
48
+ ],
49
+ win32: [
50
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
51
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
52
+ "C:\\Program Files\\Chromium\\Application\\chromium.exe",
53
+ ],
54
+ };
55
+ const candidates = paths[platform] || [];
56
+ // 简单检查文件是否存在(同步检查)
57
+ for (const p of candidates) {
58
+ try {
59
+ // 使用 fs.accessSync 检查文件是否可读
60
+ const { accessSync, constants } = require("fs");
61
+ accessSync(p, constants.X_OK);
62
+ return p;
63
+ }
64
+ catch {
65
+ continue;
66
+ }
67
+ }
68
+ return undefined;
69
+ }
70
+ /**
71
+ * 动态导入 puppeteer-core
72
+ */
73
+ async function loadPuppeteer() {
14
74
  try {
15
- const mod = await import("node-html-to-image");
16
- nodeHtmlToImage = mod.default;
75
+ const mod = await import("puppeteer-core");
76
+ return mod;
17
77
  }
18
78
  catch {
19
79
  return null;
20
80
  }
21
- if (!nodeHtmlToImage)
81
+ }
82
+ export async function markdownToImage(md, opts) {
83
+ if (!md?.trim())
84
+ return null;
85
+ const puppeteer = await loadPuppeteer();
86
+ if (!puppeteer) {
87
+ return null;
88
+ }
89
+ const chromiumPath = detectChromiumPath();
90
+ if (!chromiumPath) {
91
+ console.warn("[onebot] 未找到系统 Chromium/Chrome,请安装或设置 PUPPETEER_EXECUTABLE_PATH 环境变量");
92
+ console.warn("[onebot] Ubuntu/Debian: sudo apt install chromium-browser");
93
+ console.warn("[onebot] macOS: brew install --cask chromium");
22
94
  return null;
95
+ }
23
96
  const bodyHtml = markdownToHtml(md);
24
97
  const styles = getMarkdownStyles(opts?.theme);
25
98
  const theme = (opts?.theme || "").trim();
@@ -27,21 +100,58 @@ export async function markdownToImage(md, opts) {
27
100
  const fullHtml = `<!DOCTYPE html><html><head><meta charset="utf-8">${styles}</head><body>${wrappedBody}</body></html>`;
28
101
  mkdirSync(OG_TEMP_DIR, { recursive: true });
29
102
  const outPath = join(OG_TEMP_DIR, `og-${Date.now()}-${Math.random().toString(36).slice(2)}.png`);
103
+ let browser;
30
104
  try {
31
- await nodeHtmlToImage({
32
- output: outPath,
33
- html: fullHtml,
105
+ browser = await puppeteer.launch({
106
+ executablePath: chromiumPath,
107
+ headless: true,
108
+ args: [
109
+ "--no-sandbox",
110
+ "--disable-setuid-sandbox",
111
+ "--disable-dev-shm-usage",
112
+ "--disable-gpu",
113
+ "--disable-software-rasterizer",
114
+ "--disable-extensions",
115
+ "--disable-background-networking",
116
+ "--disable-background-timer-throttling",
117
+ "--disable-backgrounding-occluded-windows",
118
+ "--disable-renderer-backgrounding",
119
+ ],
120
+ });
121
+ const page = await browser.newPage();
122
+ // 设置视口
123
+ await page.setViewport({
124
+ width: opts?.width || 800,
125
+ height: opts?.height || 600,
126
+ deviceScaleFactor: 2, // 高清截图
127
+ });
128
+ // 设置内容并等待渲染完成
129
+ await page.setContent(fullHtml, {
130
+ waitUntil: ["networkidle0", "domcontentloaded"],
131
+ });
132
+ // 等待字体和样式加载
133
+ await page.evaluate(() => document.fonts.ready);
134
+ // 截图
135
+ await page.screenshot({
136
+ path: outPath,
34
137
  type: "png",
35
- quality: 90,
36
- puppeteerArgs: {
37
- headless: true,
38
- args: ["--no-sandbox", "--disable-setuid-sandbox"],
39
- executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
40
- },
138
+ fullPage: opts?.fullPage !== false, // 默认全页截图
41
139
  });
140
+ await browser.close();
141
+ browser = undefined;
42
142
  return `file://${outPath.replace(/\\/g, "/")}`;
43
143
  }
44
144
  catch (e) {
145
+ // 清理浏览器资源
146
+ if (browser) {
147
+ try {
148
+ await browser.close();
149
+ }
150
+ catch {
151
+ // 忽略关闭错误
152
+ }
153
+ }
154
+ // 清理临时文件
45
155
  try {
46
156
  unlinkSync(outPath);
47
157
  }
package/dist/setup.js CHANGED
@@ -44,7 +44,7 @@ export async function runOneBotSetup() {
44
44
  message: "长消息处理模式(单次回复超过阈值时):",
45
45
  options: [
46
46
  { value: "normal", label: "正常发送(分段发送)" },
47
- { value: "og_image", label: "生成图片发送(需安装 node-html-to-image)" },
47
+ { value: "og_image", label: "生成图片发送(需安装 satori 和 sharp)" },
48
48
  { value: "forward", label: "合并转发发送(发给自己后打包转发)" },
49
49
  ],
50
50
  initialValue: "normal",
@@ -89,6 +89,13 @@ export async function runOneBotSetup() {
89
89
  message: "白名单 QQ 号(逗号分隔,留空则所有人可回复)",
90
90
  initialValue: whitelistInitial,
91
91
  }));
92
+ const blacklistInitial = Array.isArray(prevOnebot?.blacklistUserIds)
93
+ ? prevOnebot.blacklistUserIds.join(", ")
94
+ : "";
95
+ const blacklistInput = guardCancel(await clackText({
96
+ message: "黑名单 QQ 号(逗号分隔,留空则不禁用任何人)",
97
+ initialValue: blacklistInitial,
98
+ }));
92
99
  const port = parseInt(String(portStr).trim(), 10);
93
100
  if (!Number.isFinite(port)) {
94
101
  console.error("端口必须为数字");
@@ -97,6 +104,7 @@ export async function runOneBotSetup() {
97
104
  const channels = existing.channels || {};
98
105
  const thresholdNum = parseInt(String(longMessageThreshold).trim(), 10);
99
106
  const whitelistIds = (String(whitelistInput).trim().split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).map((s) => (/^\d+$/.test(s) ? Number(s) : null)).filter((n) => n != null));
107
+ const blacklistIds = (String(blacklistInput).trim().split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).map((s) => (/^\d+$/.test(s) ? Number(s) : null)).filter((n) => n != null));
100
108
  channels.onebot = {
101
109
  ...(channels.onebot || {}),
102
110
  type,
@@ -110,6 +118,7 @@ export async function runOneBotSetup() {
110
118
  ...(longMessageMode === "og_image" ? { ogImageRenderTheme, ...(ogImageRenderThemePath != null ? { ogImageRenderThemePath } : {}) } : {}),
111
119
  longMessageThreshold: Number.isFinite(thresholdNum) ? thresholdNum : 300,
112
120
  ...(whitelistIds.length > 0 ? { whitelistUserIds: whitelistIds } : {}),
121
+ ...(blacklistIds.length > 0 ? { blacklistUserIds: blacklistIds } : {}),
113
122
  };
114
123
  const next = { ...existing, channels };
115
124
  writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2), "utf-8");
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-onebot",
3
3
  "name": "OneBot Channel",
4
- "version": "1.0.1",
4
+ "version": "1.0.5",
5
5
  "description": "OneBot v11 protocol channel (QQ/Lagrange.Core via WebSocket)",
6
6
  "author": "Lagrange.Onebot",
7
7
  "channels": ["onebot"],
@@ -29,6 +29,11 @@
29
29
  "items": { "type": "number" },
30
30
  "description": "白名单 QQ 号,非空时仅白名单内用户可触发 AI;为空则所有人可回复"
31
31
  },
32
+ "blacklistUserIds": {
33
+ "type": "array",
34
+ "items": { "type": "number" },
35
+ "description": "黑名单 QQ 号,在黑名单内的用户无法触发 AI(优先级低于白名单)"
36
+ },
32
37
  "renderMarkdownToPlain": {
33
38
  "type": "boolean",
34
39
  "default": true,
@@ -75,7 +80,7 @@
75
80
  "type": "string",
76
81
  "enum": ["normal", "og_image", "forward"],
77
82
  "default": "normal",
78
- "description": "长消息处理模式:normal 正常发送;og_image 生成图片(需 node-html-to-image);forward 合并转发"
83
+ "description": "长消息处理模式:normal 正常发送;og_image 生成图片(需 satori 和 sharp);forward 合并转发"
79
84
  },
80
85
  "longMessageThreshold": {
81
86
  "type": "number",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kirigaya/openclaw-onebot",
3
- "version": "1.0.4",
3
+ "version": "1.0.9",
4
4
  "description": "OneBot v11 protocol channel plugin for OpenClaw (QQ/Lagrange.Core/go-cqhttp)",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -71,11 +71,13 @@
71
71
  "fuse.js": "^7.1.0",
72
72
  "highlight.js": "^11.10.0",
73
73
  "marked": "^15.0.0",
74
+ "puppeteer-core": "^24.39.1",
74
75
  "tsx": "^4.0.0",
75
76
  "ws": "^8.17.0"
76
77
  },
77
78
  "optionalDependencies": {
78
- "node-html-to-image": "^3.0.0"
79
+ "satori": "^0.12.0",
80
+ "sharp": "^0.33.0"
79
81
  },
80
82
  "peerDependencies": {
81
83
  "clawdbot": "*"
@@ -11,6 +11,7 @@
11
11
  | path | 反向 WS 路径,默认 `/onebot/v11/ws` |
12
12
  | requireMention | 群聊是否需 @ 才回复,默认 `true` |
13
13
  | whitelistUserIds | 白名单 QQ 号数组,非空时仅白名单内用户可触发 AI;为空则所有人可回复 |
14
+ | blacklistUserIds | 黑名单 QQ 号数组,在黑名单内的用户无法触发 AI(优先级低于白名单) |
14
15
  | renderMarkdownToPlain | 是否将 Markdown 转为纯文本再发送,默认 `true` |
15
16
  | collapseDoubleNewlines | 是否将连续多个换行压缩为单个,默认 `true`(减少 AI 输出的双空行) |
16
17
  | longMessageMode | 长消息模式:`normal` 正常发送、`og_image` 生成图片、`forward` 合并转发 |
@@ -67,5 +68,5 @@
67
68
  | 模式 | 说明 |
68
69
  |------|------|
69
70
  | normal | 正常分段发送(默认) |
70
- | og_image | 将 Markdown 渲染为 HTML 并生成图片发送。需安装 `node-html-to-image`:`npm install node-html-to-image`。此模式下保留 Markdown 格式与代码高亮 |
71
+ | og_image | 将 Markdown 渲染为图片发送。需安装 `satori` `sharp`:`npm install satori sharp`。此模式下保留 Markdown 格式与代码高亮,无需浏览器内核 |
71
72
  | forward | 将各块消息先发给自己,再打包为合并转发发送。需 OneBot 实现支持 `send_group_forward_msg` / `send_private_forward_msg` |
package/themes/dust.css CHANGED
@@ -104,7 +104,7 @@ body {
104
104
  font-family: var(--base-font);
105
105
  color: var(--font-main-color);
106
106
  line-height: 1.8;
107
- font-size: 16px;
107
+ font-size: 18px;
108
108
  }
109
109
 
110
110
  .markdown p {
@@ -360,7 +360,8 @@ body {
360
360
  white-space: normal;
361
361
  }
362
362
 
363
- /* 代码块阴影效果 */
363
+ /* 代码块阴影效果 - OG图片模式下禁用,避免截图显示不完整 */
364
+ /*
364
365
  .markdown pre:after,
365
366
  .markdown pre:before {
366
367
  content: '';
@@ -381,6 +382,7 @@ body {
381
382
  left: auto;
382
383
  transform: rotate(2deg);
383
384
  }
385
+ */
384
386
 
385
387
  /* ==================== 代码工具箱样式 ==================== */
386
388
  .code-toolbox {