@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 +19 -3
- package/dist/config.d.ts +13 -0
- package/dist/config.js +28 -0
- package/dist/connection.js +3 -1
- package/dist/handlers/process-inbound.js +69 -8
- 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/openclaw.plugin.json +7 -2
- package/package.json +4 -2
- package/skills/onebot-ops/config.md +2 -1
- package/themes/dust.css +4 -2
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ openclaw onebot setup
|
|
|
75
75
|
| 模式 | 说明 |
|
|
76
76
|
|------|------|
|
|
77
77
|
| `normal` | 准流式分段发送:边生成边聚合,按时间窗口或长度阈值增量发送 |
|
|
78
|
-
| `og_image` | 将 Markdown 转为 HTML 再生成图片发送(需安装 `
|
|
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 转图片」在不同主题下的渲染效果(需安装 `
|
|
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
|
+
}
|
package/dist/connection.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,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
|
|
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:
|
|
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):
|
|
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:
|
|
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
|
-
|
|
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/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-onebot",
|
|
3
3
|
"name": "OneBot Channel",
|
|
4
|
-
"version": "1.0.
|
|
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 生成图片(需
|
|
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.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
|
-
"
|
|
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 {
|