@kirigaya/openclaw-onebot 1.0.5 → 1.1.0

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
@@ -15,29 +15,29 @@
15
15
 
16
16
  ---
17
17
 
18
+ ## 安装
19
+
20
+ ```bash
21
+ openclaw plugins install @kirigaya/openclaw-onebot
22
+ openclaw onebot setup
23
+ ```
24
+
18
25
  ## 教程
19
26
 
20
- 📖 **完整安装与配置教程**:[让 QQ 接入 openclaw!让你的助手掌管千人大群](https://kirigaya.cn/blog/article?seq=368)
27
+ [让 QQ 接入 openclaw!让你的助手掌管千人大群](https://kirigaya.cn/blog/article?seq=368)
28
+
29
+ <img src="./figure/arch.png" />
30
+
21
31
 
22
32
  ## 功能
23
33
 
24
34
  - ✅ 私聊:所有消息 AI 都会回复
25
- - ✅ 群聊:仅当用户 @ 机器人时回复(可配置)
35
+ - ✅ 触发:支持 @触发 和基于关键字的触发
26
36
  - ✅ 自动获取上下文
27
- - ✅ 新成员入群欢迎
28
- - ✅ 自动合并转发长消息
29
- - ✅ normal 模式准流式回复:按短时间窗口聚合后增量发送,避免等到最后一次性吐出
30
- - ✅ **长消息生成图片**:超过阈值可将 Markdown 渲染为图片发送(可选主题:default / dust / custom 自定义 CSS)
31
- - ✅ 支持文件,图像读取/上传
32
- - ✅ 支持白名单系统
33
- - ✅ 通过 `openclaw message send` CLI 发送(无 Agent 工具,降低 token 消耗)
34
-
35
- ## 安装
36
-
37
- ```bash
38
- openclaw plugins install @kirigaya/openclaw-onebot
39
- openclaw onebot setup
40
- ```
37
+ - ✅ 自定义新成员入群欢迎触发器
38
+ - ✅ 自动合并转发长消息:超过阈值可渲染为图片发送或者合并发送
39
+ - ✅ 支持文件,图像读取/发送
40
+ - ✅ 支持黑白名单系统
41
41
 
42
42
  ## 安装 onebot 服务端
43
43
 
@@ -48,9 +48,11 @@ openclaw onebot setup
48
48
 
49
49
  | 类型 | 说明 |
50
50
  |------|------|
51
- | `forward-websocket` | 插件主动连接 OneBot(go-cqhttp、Lagrange.Core 正向 WS) |
51
+ | `forward-websocket` | 插件主动连接 OneBot(go-cqhttp、Lagrange.Core 正向 WS/WSS) |
52
52
  | `backward-websocket` | 插件作为服务端,OneBot 连接过来 |
53
53
 
54
+ > 💡 **提示**:支持 `ws://` 和 `wss://`(WebSocket Secure)协议,可填写完整 URL 如 `wss://ws-napcatqq.example.com`
55
+
54
56
  ### 环境变量
55
57
 
56
58
  可替代配置文件,适用于 Lagrange 等:
@@ -66,7 +68,31 @@ openclaw onebot setup
66
68
 
67
69
  1. 安装并配置
68
70
  2. 重启 Gateway:`openclaw gateway restart`
69
- 3. 在 QQ 私聊或群聊中发消息(群聊需 @ 机器人)
71
+ 3. 在 QQ 私聊或群聊中发消息(群聊需 @ 机器人,或配置关键字触发)
72
+
73
+ ## 关键字触发回复
74
+
75
+ 除了 @ 机器人外,还可以配置关键字检测,当群消息中包含指定关键字时自动触发回复(无需 @)。
76
+
77
+ ```json
78
+ {
79
+ "channels": {
80
+ "onebot": {
81
+ "keywordTriggers": {
82
+ "enabled": true,
83
+ "keywords": ["AI", "助手", "帮我问"],
84
+ "caseSensitive": false
85
+ }
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
91
+ | 配置项 | 说明 |
92
+ |--------|------|
93
+ | `enabled` | 是否启用关键字触发 |
94
+ | `keywords` | 关键字列表,包含任一关键字即触发 |
95
+ | `caseSensitive` | 是否区分大小写 |
70
96
 
71
97
  ## 长消息处理与 OG 图片渲染
72
98
 
@@ -75,7 +101,7 @@ openclaw onebot setup
75
101
  | 模式 | 说明 |
76
102
  |------|------|
77
103
  | `normal` | 准流式分段发送:边生成边聚合,按时间窗口或长度阈值增量发送 |
78
- | `og_image` | 将 Markdown 转为 HTML 再生成图片发送(需安装 `node-html-to-image`) |
104
+ | `og_image` | 将 Markdown 转为 HTML 再生成图片发送(需安装 `satori` 和 `sharp`) |
79
105
  | `forward` | 合并转发(发给自己后打包转发) |
80
106
 
81
107
  `normal` 模式默认会开启块流式接收,并在插件侧做短时间聚合,默认规则:
@@ -184,12 +210,28 @@ openclaw message send --channel onebot --target group:987654321 --media "file://
184
210
  {
185
211
  "channels": {
186
212
  "onebot": {
187
- "whitelistUserIds": [1193466151],
213
+ "whitelistUserIds": [1193466151]
188
214
  }
189
215
  }
190
216
  }
191
217
  ```
192
218
 
219
+ ## 黑名单
220
+
221
+ 在群里有时候有些人需要被屏蔽,不管他怎么 @ 还是怎么,都屏蔽他的消息不触发。
222
+
223
+ ```json
224
+ {
225
+ "channels": {
226
+ "onebot": {
227
+ "blacklistUserIds": [123456789]
228
+ }
229
+ }
230
+ }
231
+ ```
232
+
233
+ **注意**:白名单优先级高于黑名单。如果同时设置了白名单和黑名单,只有白名单内的用户才能触发,且黑名单内的白名单用户也会被屏蔽。
234
+
193
235
  ## 新人入群触发器
194
236
 
195
237
  如果有人入群之后,可以通过这个来实现触发器。
@@ -227,7 +269,7 @@ npm run test:connect
227
269
 
228
270
  ### 测试 OG 图片渲染效果
229
271
 
230
- 用于预览「Markdown 转图片」在不同主题下的渲染效果(需安装 `node-html-to-image`):
272
+ 用于预览「Markdown 转图片」在不同主题下的渲染效果(需安装 `satori` 和 `sharp`):
231
273
 
232
274
  ```bash
233
275
  cd openclaw-onebot
@@ -248,6 +290,12 @@ npm run test:render-og-image -- "C:/path/to/your-theme.css"
248
290
  - [Lagrange.Core](https://github.com/LSTM-Kirigaya/Lagrange.Core)
249
291
  - [NapCat](https://github.com/NapNeko/NapCatQQ)
250
292
 
293
+ ## 联系
294
+
295
+ zhelonghuang@qq.com
296
+
297
+ 要是我不回你,可以选择进我的QQ群。782833642
298
+
251
299
  ## License
252
300
 
253
301
  MIT © [LSTM-Kirigaya](https://github.com/LSTM-Kirigaya)
package/dist/config.d.ts CHANGED
@@ -13,9 +13,24 @@ 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";
35
+ /** 是否在用户不在白名单时回复“权限不足”,默认 true */
36
+ export declare function getReplyWhenWhitelistDenied(cfg: any): boolean;
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,29 @@ 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
+ }
134
+ /** 是否在用户不在白名单时回复“权限不足”,默认 true */
135
+ export function getReplyWhenWhitelistDenied(cfg) {
136
+ const v = cfg?.channels?.onebot?.replyWhenWhitelistDenied;
137
+ return v === undefined ? true : Boolean(v);
138
+ }
@@ -177,18 +177,22 @@ function getLogger() {
177
177
  function sendOneBotAction(wsocket, action, params, log = getLogger()) {
178
178
  const echo = nextEcho();
179
179
  const payload = { action, params, echo };
180
+ // Log the initiation of the action with basic target info
181
+ const targetInfo = params.group_id ? `group=${params.group_id}` : (params.user_id ? `user=${params.user_id}` : "");
182
+ log.info?.(`[onebot-trace] sendOneBotAction action=${action} echo=${echo} ${targetInfo}`);
180
183
  return new Promise((resolve, reject) => {
181
184
  const timeout = setTimeout(() => {
182
185
  pendingEcho.delete(echo);
183
- log.warn?.(`[onebot] sendOneBotAction ${action} timeout`);
184
- reject(new Error(`OneBot action ${action} timeout`));
186
+ log.warn?.(`[onebot-trace] sendOneBotAction ${action} timeout for echo=${echo}, ws.readyState=${wsocket.readyState}`);
187
+ reject(new Error(`OneBot action ${action} timeout (echo=${echo}, ws.readyState=${wsocket.readyState})`));
185
188
  }, 15000);
186
189
  pendingEcho.set(echo, {
187
190
  resolve: (v) => {
188
191
  clearTimeout(timeout);
189
192
  pendingEcho.delete(echo);
193
+ log.info?.(`[onebot-trace] echo ${echo} resolved with retcode=${v?.retcode} message_id=${v?.data?.message_id ?? "unknown"}`);
190
194
  if (v?.retcode !== 0)
191
- log.warn?.(`[onebot] sendOneBotAction ${action} retcode=${v?.retcode} msg=${v?.msg ?? ""}`);
195
+ log.warn?.(`[onebot-trace] sendOneBotAction ${action} retcode=${v?.retcode} msg=${v?.msg ?? ""}`);
192
196
  resolve(v);
193
197
  },
194
198
  });
@@ -196,6 +200,7 @@ function sendOneBotAction(wsocket, action, params, log = getLogger()) {
196
200
  if (err) {
197
201
  pendingEcho.delete(echo);
198
202
  clearTimeout(timeout);
203
+ log.warn?.(`[onebot-trace] sendOneBotAction ${action} wsocket.send error for echo=${echo}: ${err.message}`);
199
204
  reject(err);
200
205
  }
201
206
  });
@@ -612,7 +617,9 @@ export async function getGroupMsgHistoryInRange(groupId, opts = {}) {
612
617
  export async function connectForward(config) {
613
618
  const path = config.path ?? "/onebot/v11/ws";
614
619
  const pathNorm = path.startsWith("/") ? path : `/${path}`;
615
- const addr = `ws://${config.host}:${config.port}${pathNorm}`;
620
+ // 端口为 443 时使用 wss,其余端口使用 ws
621
+ const scheme = config.port === 443 ? "wss" : "ws";
622
+ const addr = `${scheme}://${config.host}:${config.port}${pathNorm}`;
616
623
  const headers = {};
617
624
  if (config.accessToken) {
618
625
  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, getReplyWhenWhitelistDenied, } 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,36 +147,53 @@ 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
- 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);
153
+ if (getReplyWhenWhitelistDenied(cfg)) {
154
+ const denyMsg = "权限不足,请向管理员申请权限";
155
+ const getConfig = () => getOneBotConfig(api);
156
+ try {
157
+ if (msg.message_type === "group" && msg.group_id)
158
+ await sendGroupMsg(msg.group_id, denyMsg, getConfig);
159
+ else
160
+ await sendPrivateMsg(userId, denyMsg, getConfig);
161
+ }
162
+ catch (_) { }
109
163
  }
110
- catch (_) { }
111
164
  api.logger?.info?.(`[onebot] user ${userId} not in whitelist, denied`);
112
165
  return;
113
166
  }
167
+ // 黑名单检查
168
+ const blacklist = getBlacklistUserIds(cfg);
169
+ if (blacklist.length > 0 && blacklist.includes(Number(userId))) {
170
+ api.logger?.info?.(`[onebot] user ${userId} is in blacklist, ignored`);
171
+ return;
172
+ }
114
173
  const groupId = msg.group_id;
115
- const sessionId = isGroup
174
+ const tempSessionId = isGroup
116
175
  ? `onebot:group:${groupId}`.toLowerCase()
117
176
  : `onebot:${userId}`.toLowerCase();
118
177
  const route = runtime.channel.routing?.resolveAgentRoute?.({
119
178
  cfg,
120
- sessionKey: sessionId,
179
+ sessionKey: tempSessionId,
121
180
  channel: "onebot",
122
181
  accountId: config.accountId ?? "default",
123
182
  }) ?? { agentId: "main" };
183
+ // 修复构造符合 OpenClaw 规范的全局 SessionKey格式必须为 agent:{agentId}:{channel}:{type}:{id},否则下方的dispatchReplyWithBufferedBlockDispatcher会触发自动兜底机制,直接在 main 代理下“克隆”出一个一模一样的会话,导致多agent配置达不到效果
184
+ const sessionId = `agent:${route.agentId}:${tempSessionId}`;
124
185
  const storePath = runtime.channel.session?.resolveStorePath?.(cfg?.session?.store, {
125
186
  agentId: route.agentId,
126
187
  }) ?? "";
127
188
  const envelopeOptions = runtime.channel.reply?.resolveEnvelopeFormatOptions?.(cfg) ?? {};
128
189
  const chatType = isGroup ? "group" : "direct";
129
- const fromLabel = String(userId);
190
+ // 优先使用群名片(card),其次是昵称(nickname),都没有则为空串
191
+ const senderNickname = (isGroup ? msg.sender?.card?.trim() : undefined)
192
+ || msg.sender?.nickname?.trim()
193
+ || "";
194
+ const fromLabel = senderNickname || String(userId);
195
+ // 添加日志:打印插件接收到的原始消息内容
196
+ api.logger?.info?.(`[onebot] received message from user ${userId}: "${messageText}"`);
130
197
  const formattedBody = runtime.channel.reply?.formatInboundEnvelope?.({
131
198
  channel: "OneBot",
132
199
  from: fromLabel,
@@ -228,10 +295,16 @@ export async function processInboundMessage(api, msg) {
228
295
  api.logger?.warn?.("[onebot] setMsgEmojiLike failed (maybe OneBot doesn't support it)");
229
296
  }
230
297
  }
231
- api.logger?.info?.(`[onebot] dispatching message for session ${sessionId}`);
298
+ const traceId = `trace-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
299
+ const traceLog = {
300
+ info: (m) => api.logger?.info?.(`[${traceId}] ${m}`),
301
+ warn: (m) => api.logger?.warn?.(`[${traceId}] ${m}`),
302
+ error: (m) => api.logger?.error?.(`[${traceId}] ${m}`)
303
+ };
304
+ traceLog.info(`dispatching message for session ${sessionId}`);
232
305
  const longMessageMode = onebotCfg.longMessageMode ?? "normal";
233
306
  const longMessageThreshold = onebotCfg.longMessageThreshold ?? 300;
234
- api.logger?.info?.(`[onebot] longMessageMode=${longMessageMode}, threshold=${longMessageThreshold}`);
307
+ traceLog.info(`longMessageMode=${longMessageMode}, threshold=${longMessageThreshold}`);
235
308
  const normalModeFlushIntervalMs = getNormalModeFlushIntervalMs(cfg);
236
309
  const normalModeFlushChars = getNormalModeFlushChars(cfg);
237
310
  const replySessionId = `onebot-reply-${Date.now()}-${sessionId}`;
@@ -246,11 +319,13 @@ export async function processInboundMessage(api, msg) {
246
319
  let normalModeBufferedRawText = "";
247
320
  let normalModeFlushTimer = null;
248
321
  let normalModeFlushChain = Promise.resolve();
322
+ let receivedFinal = false;
249
323
  const getConfig = () => getOneBotConfig(api);
250
324
  const onReplySessionEnd = onebotCfg.onReplySessionEnd;
251
325
  const normalModePunctuationFlushMinChars = 24;
252
- const clearNormalModeFlushTimer = () => {
326
+ const clearNormalModeFlushTimer = (reason = "unknown") => {
253
327
  if (normalModeFlushTimer) {
328
+ traceLog.info(`clearNormalModeFlushTimer: clearing timer, reason=${reason}`);
254
329
  clearTimeout(normalModeFlushTimer);
255
330
  normalModeFlushTimer = null;
256
331
  }
@@ -260,7 +335,7 @@ export async function processInboundMessage(api, msg) {
260
335
  normalModeFlushChain = normalModeFlushChain
261
336
  .then(action)
262
337
  .catch((e) => {
263
- api.logger?.error?.(`[onebot] normal-mode flush failed: ${e?.message ?? e}`);
338
+ traceLog.error(`normal-mode flush failed: ${e?.message ?? e}`);
264
339
  });
265
340
  return normalModeFlushChain;
266
341
  };
@@ -279,11 +354,12 @@ export async function processInboundMessage(api, msg) {
279
354
  }
280
355
  };
281
356
  const flushBufferedNormalModeText = async (effectiveIsGroup, effectiveGroupId, uid) => {
282
- clearNormalModeFlushTimer();
357
+ clearNormalModeFlushTimer("flushBufferedNormalModeText");
283
358
  if (!hasBufferedNormalModeText())
284
359
  return;
285
360
  const text = normalModeBufferedText;
286
361
  const rawText = normalModeBufferedRawText;
362
+ traceLog.info(`flushBufferedNormalModeText: textLen=${text.length}, textPreview="${text.slice(0, 30).replace(/\n/g, '\\n')}"`);
287
363
  normalModeBufferedText = "";
288
364
  normalModeBufferedRawText = "";
289
365
  await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, text, undefined);
@@ -296,7 +372,9 @@ export async function processInboundMessage(api, msg) {
296
372
  const scheduleNormalModeFlush = (effectiveIsGroup, effectiveGroupId, uid) => {
297
373
  if (normalModeFlushTimer)
298
374
  return;
375
+ traceLog.info(`scheduleNormalModeFlush: scheduled (interval=${normalModeFlushIntervalMs}ms)`);
299
376
  normalModeFlushTimer = setTimeout(() => {
377
+ traceLog.info(`scheduleNormalModeFlush: timer triggered`);
300
378
  void queueNormalModeFlush(() => flushBufferedNormalModeText(effectiveIsGroup, effectiveGroupId, uid));
301
379
  }, normalModeFlushIntervalMs);
302
380
  };
@@ -333,6 +411,10 @@ export async function processInboundMessage(api, msg) {
333
411
  const replyText = typeof p === "string" ? p : (p?.text ?? p?.body ?? "");
334
412
  const mediaUrl = typeof p === "string" ? undefined : (p?.mediaUrl ?? p?.mediaUrls?.[0]);
335
413
  const trimmed = (replyText || "").trim();
414
+ traceLog.info(`deliver entry: kind=${info.kind}, textLen=${replyText.length}, mediaUrl=${!!mediaUrl}, deliveredChunks=${deliveredChunks.length}`);
415
+ if (info.kind === "final") {
416
+ receivedFinal = true;
417
+ }
336
418
  if ((!trimmed || trimmed === "NO_REPLY" || trimmed.endsWith("NO_REPLY")) && !mediaUrl)
337
419
  return;
338
420
  const { userId: uid, groupId: gid, isGroup: ig } = ctxPayload._onebot || {};
@@ -399,7 +481,7 @@ export async function processInboundMessage(api, msg) {
399
481
  const isLong = totalLen > longMessageThreshold;
400
482
  const isIncrementalLong = incrementalLen > longMessageThreshold;
401
483
  const isIncremental = lastSentCount > 0;
402
- api.logger?.info?.(`[onebot] final check: totalLen=${totalLen}, threshold=${longMessageThreshold}, isLong=${isLong}, isIncremental=${isIncremental}, deliveredChunks=${deliveredChunks.length}`);
484
+ traceLog.info(`final check: totalLen=${totalLen}, threshold=${longMessageThreshold}, isLong=${isLong}, isIncremental=${isIncremental}, deliveredChunks=${deliveredChunks.length}`);
403
485
  if (isIncremental) {
404
486
  setForwardSuppressDelivery(false);
405
487
  // normal 模式下增量 chunk 已在 deliver 中实时发出;这里不能在 final 再补发一次。
@@ -416,7 +498,7 @@ export async function processInboundMessage(api, msg) {
416
498
  await sendPrivateImage(uid, imgUrl, api.logger, getConfig);
417
499
  }
418
500
  else {
419
- api.logger?.warn?.("[onebot] og_image (incremental): node-html-to-image not installed, falling back to normal send");
501
+ api.logger?.warn?.("[onebot] og_image (incremental): satori or sharp not installed, falling back to normal send");
420
502
  for (const c of chunksToSend) {
421
503
  if (c.text || c.mediaUrl)
422
504
  await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
@@ -483,9 +565,9 @@ export async function processInboundMessage(api, msg) {
483
565
  }
484
566
  }
485
567
  else if (!shouldSendNow && (longMessageMode === "og_image" || longMessageMode === "forward")) {
486
- api.logger?.info?.(`[onebot] checking og_image: isLong=${isLong}, mode=${longMessageMode}`);
568
+ traceLog.info(`checking og_image: isLong=${isLong}, mode=${longMessageMode}`);
487
569
  if (isLong && longMessageMode === "og_image") {
488
- api.logger?.info?.(`[onebot] triggering og_image for ${totalLen} chars`);
570
+ traceLog.info(`triggering og_image for ${totalLen} chars`);
489
571
  const fullRaw = deliveredChunks.map((c) => c.rawText ?? c.text ?? "").join("\n\n");
490
572
  if (fullRaw.trim()) {
491
573
  try {
@@ -497,7 +579,7 @@ export async function processInboundMessage(api, msg) {
497
579
  await sendPrivateImage(uid, imgUrl, api.logger, getConfig);
498
580
  }
499
581
  else {
500
- api.logger?.warn?.("[onebot] og_image: node-html-to-image not installed, falling back to normal send");
582
+ api.logger?.warn?.("[onebot] og_image: satori or sharp not installed, falling back to normal send");
501
583
  setForwardSuppressDelivery(false);
502
584
  for (const c of deliveredChunks) {
503
585
  if (c.text || c.mediaUrl)
@@ -584,20 +666,24 @@ export async function processInboundMessage(api, msg) {
584
666
  }
585
667
  }
586
668
  catch (e) {
587
- api.logger?.error?.(`[onebot] deliver failed: ${e?.message}`);
669
+ traceLog.error(`deliver failed: ${e?.message}`);
588
670
  }
589
671
  },
590
672
  onError: async (err, info) => {
591
- api.logger?.error?.(`[onebot] ${info?.kind} reply failed: ${err}`);
673
+ traceLog.error(`${info?.kind} reply failed: ${err}`);
592
674
  await clearEmojiReaction();
593
675
  },
594
676
  },
595
677
  replyOptions: { disableBlockStreaming: longMessageMode !== "normal" },
596
678
  });
679
+ traceLog.info(`dispatchReplyWithBufferedBlockDispatcher returned successfully.`);
597
680
  }
598
681
  catch (err) {
599
682
  await clearEmojiReaction();
600
- api.logger?.error?.(`[onebot] dispatch failed: ${err?.message}`);
683
+ // 异常时清空缓冲,避免 finally 补发半截正文后再发错误消息
684
+ traceLog.error(`dispatch catch block: err=${err?.message}, receivedFinal=${receivedFinal}, chunkIndex=${chunkIndex}`);
685
+ normalModeBufferedText = "";
686
+ normalModeBufferedRawText = "";
601
687
  try {
602
688
  const { userId: uid, groupId: gid, isGroup: ig } = ctxPayload._onebot || {};
603
689
  if (ig && gid)
@@ -608,7 +694,23 @@ export async function processInboundMessage(api, msg) {
608
694
  catch (_) { }
609
695
  }
610
696
  finally {
611
- clearNormalModeFlushTimer();
697
+ traceLog.info(`dispatch finally block: receivedFinal=${receivedFinal}, hasBuffered=${hasBufferedNormalModeText()}, bufferLen=${normalModeBufferedText.length}, hasTimer=${!!normalModeFlushTimer}, chunks=${deliveredChunks.length}`);
698
+ // 补发缓冲池中残留的文本(引擎未发送 final 帧时会走到这里)
699
+ if (hasBufferedNormalModeText()) {
700
+ try {
701
+ const { userId: uid, groupId: gid, isGroup: ig } = ctxPayload._onebot || {};
702
+ const sessionKey = String(ctxPayload.SessionKey ?? sessionId);
703
+ const groupMatch = sessionKey.match(/^onebot:group:(\d+)$/i);
704
+ const effectiveIsGroup = groupMatch != null || Boolean(ig);
705
+ const effectiveGroupId = (groupMatch ? parseInt(groupMatch[1], 10) : undefined) ?? gid;
706
+ queueNormalModeFlush(() => flushBufferedNormalModeText(effectiveIsGroup, effectiveGroupId, uid));
707
+ await normalModeFlushChain;
708
+ }
709
+ catch (e) {
710
+ traceLog.error(`finally flush failed: ${e?.message ?? e}`);
711
+ }
712
+ }
713
+ clearNormalModeFlushTimer("finally");
612
714
  setForwardSuppressDelivery(false);
613
715
  setActiveReplySelfId(null);
614
716
  lastSentChunkCountBySession.delete(replySessionId);
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");
package/dist/types.d.ts CHANGED
@@ -14,7 +14,11 @@ export interface OneBotMessage {
14
14
  raw_message?: string;
15
15
  self_id?: number;
16
16
  time?: number;
17
- notice_type?: string;
17
+ sender?: {
18
+ user_id?: number;
19
+ nickname?: string;
20
+ card?: string;
21
+ };
18
22
  [key: string]: unknown;
19
23
  }
20
24
  export interface OneBotAccountConfig {
@@ -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.5",
3
+ "version": "1.1.0",
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 {