@kirigaya/openclaw-onebot 1.0.3 → 1.0.4

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 LSTM-Kirigaya
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LSTM-Kirigaya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -23,9 +23,13 @@
23
23
 
24
24
  - ✅ 私聊:所有消息 AI 都会回复
25
25
  - ✅ 群聊:仅当用户 @ 机器人时回复(可配置)
26
- - ✅ 正向 / 反向 WebSocket 连接
27
- - ✅ TUI 配置向导:`openclaw onebot setup`
26
+ - ✅ 自动获取上下文
28
27
  - ✅ 新成员入群欢迎
28
+ - ✅ 自动合并转发长消息
29
+ - ✅ normal 模式准流式回复:按短时间窗口聚合后增量发送,避免等到最后一次性吐出
30
+ - ✅ **长消息生成图片**:超过阈值可将 Markdown 渲染为图片发送(可选主题:default / dust / custom 自定义 CSS)
31
+ - ✅ 支持文件,图像读取/上传
32
+ - ✅ 支持白名单系统
29
33
  - ✅ 通过 `openclaw message send` CLI 发送(无 Agent 工具,降低 token 消耗)
30
34
 
31
35
  ## 安装
@@ -64,6 +68,76 @@ openclaw onebot setup
64
68
  2. 重启 Gateway:`openclaw gateway restart`
65
69
  3. 在 QQ 私聊或群聊中发消息(群聊需 @ 机器人)
66
70
 
71
+ ## 长消息处理与 OG 图片渲染
72
+
73
+ 当单次回复超过**长消息阈值**(默认 300 字)时,可选用三种模式(`openclaw onebot setup` 中配置):
74
+
75
+ | 模式 | 说明 |
76
+ |------|------|
77
+ | `normal` | 准流式分段发送:边生成边聚合,按时间窗口或长度阈值增量发送 |
78
+ | `og_image` | 将 Markdown 转为 HTML 再生成图片发送(需安装 `node-html-to-image`) |
79
+ | `forward` | 合并转发(发给自己后打包转发) |
80
+
81
+ `normal` 模式默认会开启块流式接收,并在插件侧做短时间聚合,默认规则:
82
+
83
+ - `normalModeFlushIntervalMs`: `1200`
84
+ - `normalModeFlushChars`: `160`
85
+
86
+ 也就是回复不会逐 token 刷屏,而是大约每 1.2 秒或累计到 160 字左右就发送一段。可在 `openclaw.json` 中手动调整:
87
+
88
+ ```json
89
+ {
90
+ "channels": {
91
+ "onebot": {
92
+ "longMessageMode": "normal",
93
+ "normalModeFlushIntervalMs": 1200,
94
+ "normalModeFlushChars": 160
95
+ }
96
+ }
97
+ }
98
+ ```
99
+
100
+ 选择 **生成图片发送(og_image)** 时,会额外询问**渲染主题**:
101
+
102
+ | 选项 | 说明 |
103
+ |------|------|
104
+ | **default** | 无额外样式,默认白底黑字 |
105
+ | **dust** | 内置主题:暖色、旧纸质感 |
106
+ | **custom** | 自定义:在 `ogImageRenderThemePath` 中填写 CSS 文件绝对路径 |
107
+
108
+ 配置项(枚举 + 可选路径):
109
+
110
+ - `ogImageRenderTheme`:`"default"` | `"dust"` | `"custom"`
111
+ - `ogImageRenderThemePath`:当为 `custom` 时必填,CSS 文件绝对路径
112
+
113
+ 示例(`openclaw.json`):
114
+
115
+ ```json
116
+ {
117
+ "channels": {
118
+ "onebot": {
119
+ "longMessageMode": "og_image",
120
+ "longMessageThreshold": 300,
121
+ "ogImageRenderTheme": "dust"
122
+ }
123
+ }
124
+ }
125
+ ```
126
+
127
+ 自定义主题示例:
128
+
129
+ ```json
130
+ {
131
+ "channels": {
132
+ "onebot": {
133
+ "longMessageMode": "og_image",
134
+ "ogImageRenderTheme": "custom",
135
+ "ogImageRenderThemePath": "C:/path/to/your-theme.css"
136
+ }
137
+ }
138
+ }
139
+ ```
140
+
67
141
  ## 主动发送消息
68
142
 
69
143
  通过 `openclaw message send` CLI(无需 Agent 工具):
@@ -140,7 +214,9 @@ openclaw message send --channel onebot --target group:987654321 --media "file://
140
214
  --userId ${userId} --username ${username} --groupId ${groupId}
141
215
  ```
142
216
 
143
- ## 测试连接
217
+ ## 测试
218
+
219
+ ### 测试连接
144
220
 
145
221
  项目内提供测试脚本(需 `.env` 或环境变量):
146
222
 
@@ -149,6 +225,22 @@ cd openclaw-onebot
149
225
  npm run test:connect
150
226
  ```
151
227
 
228
+ ### 测试 OG 图片渲染效果
229
+
230
+ 用于预览「Markdown 转图片」在不同主题下的渲染效果(需安装 `node-html-to-image`):
231
+
232
+ ```bash
233
+ cd openclaw-onebot
234
+ # 无额外样式
235
+ npm run test:render-og-image -- default
236
+ # 内置 dust 主题
237
+ npm run test:render-og-image -- dust
238
+ # 自定义 CSS 文件(绝对路径)
239
+ npm run test:render-og-image -- "C:/path/to/your-theme.css"
240
+ ```
241
+
242
+ 生成图片保存在 `test/output-render-<主题>.png`,可直接打开查看。
243
+
152
244
  ## 参考
153
245
 
154
246
  - [OneBot 11](https://github.com/botuniverse/onebot-11)
@@ -0,0 +1,4 @@
1
+ /**
2
+ * OneBot CLI 子命令:与 Agent 工具等价,供人工/工作流/AI 调用
3
+ */
4
+ export declare function registerOneBotCli(onebot: any, api: any): void;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * OneBot CLI 子命令:与 Agent 工具等价,供人工/工作流/AI 调用
3
+ */
4
+ import { getOneBotConfig } from "./config.js";
5
+ import { ensureConnection, getGroupMsgHistory, getGroupMsgHistoryInRange, searchGroupMemberByName, uploadGroupFile, uploadPrivateFile, } from "./connection.js";
6
+ function getApi() {
7
+ return globalThis.__onebotApi;
8
+ }
9
+ async function ensureOneBotConnection() {
10
+ const api = getApi();
11
+ const config = getOneBotConfig(api);
12
+ if (!config) {
13
+ console.error("OneBot 未配置,请先运行 openclaw onebot setup 或配置 openclaw.json channels.onebot");
14
+ process.exit(1);
15
+ }
16
+ await ensureConnection(() => getOneBotConfig(getApi()), 15000);
17
+ }
18
+ function parseGroupId(v) {
19
+ const n = parseInt(String(v).trim(), 10);
20
+ if (!Number.isFinite(n)) {
21
+ console.error("--group-id 必须为数字");
22
+ process.exit(1);
23
+ }
24
+ return n;
25
+ }
26
+ export function registerOneBotCli(onebot, api) {
27
+ if (!onebot || typeof onebot.command !== "function")
28
+ return;
29
+ onebot
30
+ .command("get-group-msg-history")
31
+ .description("获取群历史消息(单页或最近 N 小时内,始终从旧到新),需 Lagrange.Core")
32
+ .requiredOption("--group-id <id>", "群号")
33
+ .option("--hours <n>", "获取最近 N 小时内的消息(指定后按时间范围分页拉取)")
34
+ .option("--count <n>", "条数(未指定 --hours 时生效)", "50")
35
+ .option("--limit <n>", "指定 --hours 时最多条数", "3000")
36
+ .option("--chunk-size <n>", "指定 --hours 时每页条数", "100")
37
+ .option("--message-seq <seq>", "起始消息序号(分页用,未指定 --hours 时生效)")
38
+ .action(async (opts) => {
39
+ await ensureOneBotConnection();
40
+ const groupId = parseGroupId(opts.groupId);
41
+ const hoursOpt = opts.hours != null && opts.hours !== "" ? parseInt(String(opts.hours), 10) : undefined;
42
+ if (Number.isFinite(hoursOpt) && hoursOpt > 0) {
43
+ const hours = hoursOpt;
44
+ const limit = parseInt(String(opts.limit || 3000), 10) || 3000;
45
+ const chunkSize = parseInt(String(opts.chunkSize || 100), 10) || 100;
46
+ const startTime = Math.floor(Date.now() / 1000) - hours * 3600;
47
+ const msgs = await getGroupMsgHistoryInRange(groupId, { startTime, limit, chunkSize });
48
+ const lines = msgs.map((m) => {
49
+ const text = typeof m.message === "string" ? m.message : JSON.stringify(m.message);
50
+ const nick = m.sender?.nickname ?? m.sender?.user_id ?? "?";
51
+ return `[${new Date(m.time * 1000).toISOString()}] ${nick}: ${text.slice(0, 200)}`;
52
+ });
53
+ console.log(lines.join("\n") || "无历史消息");
54
+ return;
55
+ }
56
+ const count = parseInt(String(opts.count || 50), 10) || 50;
57
+ const messageSeq = opts.messageSeq != null ? parseInt(String(opts.messageSeq), 10) : undefined;
58
+ const msgs = await getGroupMsgHistory(groupId, {
59
+ count,
60
+ reverse_order: true,
61
+ message_seq: Number.isFinite(messageSeq) ? messageSeq : undefined,
62
+ });
63
+ const lines = msgs.map((m) => {
64
+ const text = typeof m.message === "string" ? m.message : JSON.stringify(m.message);
65
+ const nick = m.sender?.nickname ?? m.sender?.user_id ?? "?";
66
+ return `[${new Date(m.time * 1000).toISOString()}] ${nick}: ${text.slice(0, 200)}`;
67
+ });
68
+ console.log(lines.join("\n") || "无历史消息");
69
+ });
70
+ onebot
71
+ .command("search-group-member")
72
+ .description("按名字模糊搜索群成员,返回 QQ 与展示名")
73
+ .requiredOption("--group-id <id>", "群号")
74
+ .requiredOption("--name <name>", "要搜索的名字(群名片或昵称)")
75
+ .action(async (opts) => {
76
+ await ensureOneBotConnection();
77
+ const groupId = parseGroupId(opts.groupId);
78
+ const name = String(opts.name || "").trim();
79
+ if (!name) {
80
+ console.error("--name 不能为空");
81
+ process.exit(1);
82
+ }
83
+ const list = await searchGroupMemberByName(groupId, name);
84
+ if (list.length === 0) {
85
+ console.log(`未找到匹配「${name}」的群成员`);
86
+ return;
87
+ }
88
+ list.forEach((m) => console.log(`QQ: ${m.user_id} 展示名: ${m.displayName}`));
89
+ });
90
+ onebot
91
+ .command("upload-file")
92
+ .description("上传文件到群或私聊")
93
+ .requiredOption("--target <t>", "group:<群号> 或 user:<QQ号>")
94
+ .requiredOption("--file <path>", "本地文件绝对路径")
95
+ .requiredOption("--name <name>", "显示文件名")
96
+ .action(async (opts) => {
97
+ await ensureOneBotConnection();
98
+ const t = String(opts.target || "").replace(/^onebot:/i, "").trim();
99
+ const file = String(opts.file || "").trim();
100
+ const name = String(opts.name || "").trim();
101
+ if (!file || !name) {
102
+ console.error("--file 与 --name 必填");
103
+ process.exit(1);
104
+ }
105
+ const getConfig = () => getOneBotConfig(getApi());
106
+ if (t.startsWith("group:")) {
107
+ await uploadGroupFile(parseInt(t.slice(6), 10), file, name, getConfig);
108
+ console.log("群文件上传成功");
109
+ }
110
+ else if (t.startsWith("user:")) {
111
+ await uploadPrivateFile(parseInt(t.replace(/^user:/, ""), 10), file, name, getConfig);
112
+ console.log("私聊文件上传成功");
113
+ }
114
+ else {
115
+ console.error("--target 格式须为 group:<群号> 或 user:<QQ号>");
116
+ process.exit(1);
117
+ }
118
+ });
119
+ }
package/dist/config.d.ts CHANGED
@@ -7,6 +7,15 @@ export declare function getOneBotConfig(api: any, accountId?: string): OneBotAcc
7
7
  export declare function getRenderMarkdownToPlain(cfg: any): boolean;
8
8
  /** 是否将连续多个换行压缩为单个换行,默认 true(AI 常输出 \n\n 导致双空行) */
9
9
  export declare function getCollapseDoubleNewlines(cfg: any): boolean;
10
+ /** normal 模式下聚合发送的等待窗口,默认 1200ms */
11
+ export declare function getNormalModeFlushIntervalMs(cfg: any): number;
12
+ /** normal 模式下聚合发送的字符阈值,达到后提前 flush,默认 160 */
13
+ export declare function getNormalModeFlushChars(cfg: any): number;
10
14
  /** 白名单 QQ 号列表,为空则所有人可回复;非空则仅白名单内用户可触发 AI */
11
15
  export declare function getWhitelistUserIds(cfg: any): number[];
16
+ /**
17
+ * OG 图片渲染主题:枚举 default(无额外样式)、dust(内置)、custom(使用 ogImageRenderThemePath)
18
+ * 返回用于 getMarkdownStyles 的值:default | dust | 自定义 CSS 绝对路径
19
+ */
20
+ export declare function getOgImageRenderTheme(cfg: any): "default" | "dust" | string;
12
21
  export declare function listAccountIds(apiOrCfg: any): string[];
package/dist/config.js CHANGED
@@ -55,11 +55,24 @@ export function getRenderMarkdownToPlain(cfg) {
55
55
  const v = cfg?.channels?.onebot?.renderMarkdownToPlain;
56
56
  return v === undefined ? true : Boolean(v);
57
57
  }
58
+ function getFiniteNumber(value, fallback) {
59
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
60
+ }
58
61
  /** 是否将连续多个换行压缩为单个换行,默认 true(AI 常输出 \n\n 导致双空行) */
59
62
  export function getCollapseDoubleNewlines(cfg) {
60
63
  const v = cfg?.channels?.onebot?.collapseDoubleNewlines;
61
64
  return v === undefined ? true : Boolean(v);
62
65
  }
66
+ /** normal 模式下聚合发送的等待窗口,默认 1200ms */
67
+ export function getNormalModeFlushIntervalMs(cfg) {
68
+ const value = getFiniteNumber(cfg?.channels?.onebot?.normalModeFlushIntervalMs, 1200);
69
+ return Math.max(200, Math.min(5000, Math.round(value)));
70
+ }
71
+ /** normal 模式下聚合发送的字符阈值,达到后提前 flush,默认 160 */
72
+ export function getNormalModeFlushChars(cfg) {
73
+ const value = getFiniteNumber(cfg?.channels?.onebot?.normalModeFlushChars, 160);
74
+ return Math.max(20, Math.min(2000, Math.round(value)));
75
+ }
63
76
  /** 白名单 QQ 号列表,为空则所有人可回复;非空则仅白名单内用户可触发 AI */
64
77
  export function getWhitelistUserIds(cfg) {
65
78
  const v = cfg?.channels?.onebot?.whitelistUserIds;
@@ -67,6 +80,19 @@ export function getWhitelistUserIds(cfg) {
67
80
  return [];
68
81
  return v.filter((x) => typeof x === "number" || (typeof x === "string" && /^\d+$/.test(x))).map((x) => Number(x));
69
82
  }
83
+ /**
84
+ * OG 图片渲染主题:枚举 default(无额外样式)、dust(内置)、custom(使用 ogImageRenderThemePath)
85
+ * 返回用于 getMarkdownStyles 的值:default | dust | 自定义 CSS 绝对路径
86
+ */
87
+ export function getOgImageRenderTheme(cfg) {
88
+ const v = cfg?.channels?.onebot?.ogImageRenderTheme;
89
+ const path = (cfg?.channels?.onebot?.ogImageRenderThemePath ?? "").trim();
90
+ if (v === "dust")
91
+ return "dust";
92
+ if (v === "custom" && path.length > 0)
93
+ return path;
94
+ return "default";
95
+ }
70
96
  export function listAccountIds(apiOrCfg) {
71
97
  const cfg = apiOrCfg?.config ?? apiOrCfg ?? globalThis.__onebotGatewayConfig;
72
98
  const accounts = cfg?.channels?.onebot?.accounts;
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * OneBot WebSocket 连接与 API 调用
3
3
  *
4
- * 图片消息:网络 URL 会先下载到本地再发送(兼容 Lagrange.Core retcode 1200),
4
+ * 图片消息:
5
+ * - 本机回环连接时:网络 URL 会先下载到本地再发送(兼容部分实现的 retcode 1200)
6
+ * - 跨机器连接时:本地文件会自动转成 base64://,避免把宿主机绝对路径发给远端 OneBot
5
7
  * 并定期清理临时文件。
6
8
  */
7
9
  import WebSocket from "ws";
@@ -40,8 +42,8 @@ export declare function sendPrivateImage(userId: number, image: string, log?: {
40
42
  info?: (s: string) => void;
41
43
  warn?: (s: string) => void;
42
44
  }, getConfig?: () => OneBotAccountConfig | null): Promise<number | undefined>;
43
- export declare function uploadGroupFile(groupId: number, file: string, name: string): Promise<void>;
44
- export declare function uploadPrivateFile(userId: number, file: string, name: string): Promise<void>;
45
+ export declare function uploadGroupFile(groupId: number, file: string, name: string, getConfig?: () => OneBotAccountConfig | null): Promise<void>;
46
+ export declare function uploadPrivateFile(userId: number, file: string, name: string, getConfig?: () => OneBotAccountConfig | null): Promise<void>;
45
47
  /** 撤回消息 */
46
48
  export declare function deleteMsg(messageId: number): Promise<void>;
47
49
  /**
@@ -60,6 +62,26 @@ export declare function getGroupMemberInfo(groupId: number, userId: number): Pro
60
62
  nickname: string;
61
63
  card: string;
62
64
  } | null>;
65
+ /** 群成员简要信息(用于列表与搜索) */
66
+ export interface GroupMemberItem {
67
+ user_id: number;
68
+ nickname: string;
69
+ card: string;
70
+ }
71
+ /**
72
+ * 获取群成员列表(OneBot get_group_member_list)
73
+ */
74
+ export declare function getGroupMemberList(groupId: number): Promise<GroupMemberItem[]>;
75
+ /**
76
+ * 按名字模糊匹配群成员(匹配群名片 card 与昵称 nickname),返回匹配到的 QQ 与展示名。
77
+ * 使用 Fuse.js 模糊匹配,结果按相关度排序。
78
+ */
79
+ export declare function searchGroupMemberByName(groupId: number, name: string): Promise<Array<{
80
+ user_id: number;
81
+ nickname: string;
82
+ card: string;
83
+ displayName: string;
84
+ }>>;
63
85
  /** 获取群信息(含 group_name) */
64
86
  export declare function getGroupInfo(groupId: number): Promise<{
65
87
  group_name: string;
@@ -79,19 +101,44 @@ export declare function getMsg(messageId: number): Promise<{
79
101
  message: string | unknown[];
80
102
  } | null>;
81
103
  /**
82
- * 获取群聊历史消息(Lagrange.Core 扩展 API,go-cqhttp 等可能不支持)
104
+ * 获取群聊历史消息(Lagrange.Core 扩展 API,与 Lagrange.onebot context 一致)
105
+ * 仅使用 message_seq 分页(不传 message_id),与 Tiphareth getLast24HGroupMessages 调用方式一致。
83
106
  * @param groupId 群号
84
- * @param opts message_seq 起始序号;message_id 起始消息 ID;count 数量
107
+ * @param opts message_seq 起始序号(不传表示从最新一页);count 本页条数;reverse_order true 表示从旧到新,便于用 batch[0].message_seq 向前翻页
85
108
  */
86
109
  export declare function getGroupMsgHistory(groupId: number, opts?: {
87
110
  message_seq?: number;
88
111
  message_id?: number;
89
112
  count: number;
113
+ reverse_order?: boolean;
90
114
  }): Promise<Array<{
91
115
  time: number;
92
116
  message_type: string;
93
117
  message_id: number;
94
- real_id: number;
118
+ real_id?: number;
119
+ message_seq?: number;
120
+ sender: {
121
+ user_id?: number;
122
+ nickname?: string;
123
+ };
124
+ message: string | unknown[];
125
+ }>>;
126
+ /**
127
+ * 按时间范围分页获取群历史消息,严格对齐 Tiphareth getLast24HGroupMessages 算法:
128
+ * getGroupMsgHistory(groupId, messageSeq, chunkSize, true),用 batch[0] 的 message_seq 向前翻页,去重与时间截断。
129
+ * @param groupId 群号
130
+ * @param opts startTime 仅保留 >= startTime 的消息(Unix 秒);limit 最多条数;chunkSize 每页条数
131
+ */
132
+ export declare function getGroupMsgHistoryInRange(groupId: number, opts?: {
133
+ startTime?: number;
134
+ limit?: number;
135
+ chunkSize?: number;
136
+ }): Promise<Array<{
137
+ time: number;
138
+ message_type: string;
139
+ message_id: number;
140
+ real_id?: number;
141
+ message_seq?: number;
95
142
  sender: {
96
143
  user_id?: number;
97
144
  nickname?: string;