@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 +69 -21
- package/dist/config.d.ts +15 -0
- package/dist/config.js +33 -0
- package/dist/connection.js +11 -4
- package/dist/handlers/process-inbound.js +131 -29
- package/dist/index.js +21 -1
- package/dist/markdown-to-html.js +3 -3
- package/dist/og-image.d.ts +20 -3
- package/dist/og-image.js +127 -17
- package/dist/setup.js +10 -1
- package/dist/types.d.ts +5 -1
- package/openclaw.plugin.json +6 -1
- package/package.json +4 -2
- package/skills/onebot-ops/config.md +2 -1
- package/themes/dust.css +4 -2
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
|
-
|
|
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
|
-
- ✅
|
|
30
|
-
- ✅
|
|
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 再生成图片发送(需安装 `
|
|
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 转图片」在不同主题下的渲染效果(需安装 `
|
|
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
|
+
}
|
package/dist/connection.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
568
|
+
traceLog.info(`checking og_image: isLong=${isLong}, mode=${longMessageMode}`);
|
|
487
569
|
if (isLong && longMessageMode === "og_image") {
|
|
488
|
-
|
|
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:
|
|
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
|
-
|
|
669
|
+
traceLog.error(`deliver failed: ${e?.message}`);
|
|
588
670
|
}
|
|
589
671
|
},
|
|
590
672
|
onError: async (err, info) => {
|
|
591
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
if (logger?.info) {
|
|
52
|
+
logger.info(`[${pluginName}] v${pluginVersion} 加载完成`);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
console.log(`[${pluginName}] v${pluginVersion} 加载完成`);
|
|
56
|
+
}
|
|
37
57
|
}
|
package/dist/markdown-to-html.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
93
|
+
extra = `<style>body{background:var(--background) !important;padding:24px;max-width:450px;}${dustCss}</style>`;
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
catch {
|
package/dist/og-image.d.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Markdown 转 OG 图片
|
|
3
|
-
* 依赖可选的
|
|
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
|
|
18
|
+
export interface MarkdownToImageOptions {
|
|
6
19
|
theme?: string;
|
|
7
|
-
|
|
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
|
-
* 依赖可选的
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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("
|
|
16
|
-
|
|
75
|
+
const mod = await import("puppeteer-core");
|
|
76
|
+
return mod;
|
|
17
77
|
}
|
|
18
78
|
catch {
|
|
19
79
|
return null;
|
|
20
80
|
}
|
|
21
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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: "生成图片发送(需安装
|
|
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
|
-
|
|
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 {
|
package/openclaw.plugin.json
CHANGED
|
@@ -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 生成图片(需
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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:
|
|
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 {
|