@kirigaya/openclaw-onebot 1.0.9 → 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
 
@@ -190,7 +216,7 @@ openclaw message send --channel onebot --target group:987654321 --media "file://
190
216
  }
191
217
  ```
192
218
 
193
- ### 黑名单
219
+ ## 黑名单
194
220
 
195
221
  在群里有时候有些人需要被屏蔽,不管他怎么 @ 还是怎么,都屏蔽他的消息不触发。
196
222
 
@@ -204,7 +230,7 @@ openclaw message send --channel onebot --target group:987654321 --media "file://
204
230
  }
205
231
  ```
206
232
 
207
- 注意:白名单优先级高于黑名单。如果同时设置了白名单和黑名单,只有白名单内的用户才能触发,且黑名单内的白名单用户也会被屏蔽。
233
+ **注意**:白名单优先级高于黑名单。如果同时设置了白名单和黑名单,只有白名单内的用户才能触发,且黑名单内的白名单用户也会被屏蔽。
208
234
 
209
235
  ## 新人入群触发器
210
236
 
@@ -264,6 +290,12 @@ npm run test:render-og-image -- "C:/path/to/your-theme.css"
264
290
  - [Lagrange.Core](https://github.com/LSTM-Kirigaya/Lagrange.Core)
265
291
  - [NapCat](https://github.com/NapNeko/NapCatQQ)
266
292
 
293
+ ## 联系
294
+
295
+ zhelonghuang@qq.com
296
+
297
+ 要是我不回你,可以选择进我的QQ群。782833642
298
+
267
299
  ## License
268
300
 
269
301
  MIT © [LSTM-Kirigaya](https://github.com/LSTM-Kirigaya)
package/dist/config.d.ts CHANGED
@@ -32,3 +32,5 @@ export declare function getTriggerKeywords(cfg: any): string[];
32
32
  * - "contains": 消息包含关键词即可
33
33
  */
34
34
  export declare function getTriggerMode(cfg: any): "prefix" | "contains";
35
+ /** 是否在用户不在白名单时回复“权限不足”,默认 true */
36
+ export declare function getReplyWhenWhitelistDenied(cfg: any): boolean;
package/dist/config.js CHANGED
@@ -131,3 +131,8 @@ export function getTriggerMode(cfg) {
131
131
  return "contains";
132
132
  return "prefix"; // 默认为前缀匹配
133
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
  });
@@ -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, getBlacklistUserIds, getOgImageRenderTheme, getNormalModeFlushIntervalMs, getNormalModeFlushChars, getTriggerKeywords, getTriggerMode, } 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";
@@ -150,15 +150,17 @@ export async function processInboundMessage(api, msg) {
150
150
  // 白名单检查
151
151
  const whitelist = getWhitelistUserIds(cfg);
152
152
  if (whitelist.length > 0 && !whitelist.includes(Number(userId))) {
153
- const denyMsg = "权限不足,请向管理员申请权限";
154
- const getConfig = () => getOneBotConfig(api);
155
- try {
156
- if (msg.message_type === "group" && msg.group_id)
157
- await sendGroupMsg(msg.group_id, denyMsg, getConfig);
158
- else
159
- 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 (_) { }
160
163
  }
161
- catch (_) { }
162
164
  api.logger?.info?.(`[onebot] user ${userId} not in whitelist, denied`);
163
165
  return;
164
166
  }
@@ -185,7 +187,11 @@ export async function processInboundMessage(api, msg) {
185
187
  }) ?? "";
186
188
  const envelopeOptions = runtime.channel.reply?.resolveEnvelopeFormatOptions?.(cfg) ?? {};
187
189
  const chatType = isGroup ? "group" : "direct";
188
- 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);
189
195
  // 添加日志:打印插件接收到的原始消息内容
190
196
  api.logger?.info?.(`[onebot] received message from user ${userId}: "${messageText}"`);
191
197
  const formattedBody = runtime.channel.reply?.formatInboundEnvelope?.({
@@ -289,10 +295,16 @@ export async function processInboundMessage(api, msg) {
289
295
  api.logger?.warn?.("[onebot] setMsgEmojiLike failed (maybe OneBot doesn't support it)");
290
296
  }
291
297
  }
292
- 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}`);
293
305
  const longMessageMode = onebotCfg.longMessageMode ?? "normal";
294
306
  const longMessageThreshold = onebotCfg.longMessageThreshold ?? 300;
295
- api.logger?.info?.(`[onebot] longMessageMode=${longMessageMode}, threshold=${longMessageThreshold}`);
307
+ traceLog.info(`longMessageMode=${longMessageMode}, threshold=${longMessageThreshold}`);
296
308
  const normalModeFlushIntervalMs = getNormalModeFlushIntervalMs(cfg);
297
309
  const normalModeFlushChars = getNormalModeFlushChars(cfg);
298
310
  const replySessionId = `onebot-reply-${Date.now()}-${sessionId}`;
@@ -307,11 +319,13 @@ export async function processInboundMessage(api, msg) {
307
319
  let normalModeBufferedRawText = "";
308
320
  let normalModeFlushTimer = null;
309
321
  let normalModeFlushChain = Promise.resolve();
322
+ let receivedFinal = false;
310
323
  const getConfig = () => getOneBotConfig(api);
311
324
  const onReplySessionEnd = onebotCfg.onReplySessionEnd;
312
325
  const normalModePunctuationFlushMinChars = 24;
313
- const clearNormalModeFlushTimer = () => {
326
+ const clearNormalModeFlushTimer = (reason = "unknown") => {
314
327
  if (normalModeFlushTimer) {
328
+ traceLog.info(`clearNormalModeFlushTimer: clearing timer, reason=${reason}`);
315
329
  clearTimeout(normalModeFlushTimer);
316
330
  normalModeFlushTimer = null;
317
331
  }
@@ -321,7 +335,7 @@ export async function processInboundMessage(api, msg) {
321
335
  normalModeFlushChain = normalModeFlushChain
322
336
  .then(action)
323
337
  .catch((e) => {
324
- api.logger?.error?.(`[onebot] normal-mode flush failed: ${e?.message ?? e}`);
338
+ traceLog.error(`normal-mode flush failed: ${e?.message ?? e}`);
325
339
  });
326
340
  return normalModeFlushChain;
327
341
  };
@@ -340,11 +354,12 @@ export async function processInboundMessage(api, msg) {
340
354
  }
341
355
  };
342
356
  const flushBufferedNormalModeText = async (effectiveIsGroup, effectiveGroupId, uid) => {
343
- clearNormalModeFlushTimer();
357
+ clearNormalModeFlushTimer("flushBufferedNormalModeText");
344
358
  if (!hasBufferedNormalModeText())
345
359
  return;
346
360
  const text = normalModeBufferedText;
347
361
  const rawText = normalModeBufferedRawText;
362
+ traceLog.info(`flushBufferedNormalModeText: textLen=${text.length}, textPreview="${text.slice(0, 30).replace(/\n/g, '\\n')}"`);
348
363
  normalModeBufferedText = "";
349
364
  normalModeBufferedRawText = "";
350
365
  await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, text, undefined);
@@ -357,7 +372,9 @@ export async function processInboundMessage(api, msg) {
357
372
  const scheduleNormalModeFlush = (effectiveIsGroup, effectiveGroupId, uid) => {
358
373
  if (normalModeFlushTimer)
359
374
  return;
375
+ traceLog.info(`scheduleNormalModeFlush: scheduled (interval=${normalModeFlushIntervalMs}ms)`);
360
376
  normalModeFlushTimer = setTimeout(() => {
377
+ traceLog.info(`scheduleNormalModeFlush: timer triggered`);
361
378
  void queueNormalModeFlush(() => flushBufferedNormalModeText(effectiveIsGroup, effectiveGroupId, uid));
362
379
  }, normalModeFlushIntervalMs);
363
380
  };
@@ -394,6 +411,10 @@ export async function processInboundMessage(api, msg) {
394
411
  const replyText = typeof p === "string" ? p : (p?.text ?? p?.body ?? "");
395
412
  const mediaUrl = typeof p === "string" ? undefined : (p?.mediaUrl ?? p?.mediaUrls?.[0]);
396
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
+ }
397
418
  if ((!trimmed || trimmed === "NO_REPLY" || trimmed.endsWith("NO_REPLY")) && !mediaUrl)
398
419
  return;
399
420
  const { userId: uid, groupId: gid, isGroup: ig } = ctxPayload._onebot || {};
@@ -460,7 +481,7 @@ export async function processInboundMessage(api, msg) {
460
481
  const isLong = totalLen > longMessageThreshold;
461
482
  const isIncrementalLong = incrementalLen > longMessageThreshold;
462
483
  const isIncremental = lastSentCount > 0;
463
- 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}`);
464
485
  if (isIncremental) {
465
486
  setForwardSuppressDelivery(false);
466
487
  // normal 模式下增量 chunk 已在 deliver 中实时发出;这里不能在 final 再补发一次。
@@ -544,9 +565,9 @@ export async function processInboundMessage(api, msg) {
544
565
  }
545
566
  }
546
567
  else if (!shouldSendNow && (longMessageMode === "og_image" || longMessageMode === "forward")) {
547
- api.logger?.info?.(`[onebot] checking og_image: isLong=${isLong}, mode=${longMessageMode}`);
568
+ traceLog.info(`checking og_image: isLong=${isLong}, mode=${longMessageMode}`);
548
569
  if (isLong && longMessageMode === "og_image") {
549
- api.logger?.info?.(`[onebot] triggering og_image for ${totalLen} chars`);
570
+ traceLog.info(`triggering og_image for ${totalLen} chars`);
550
571
  const fullRaw = deliveredChunks.map((c) => c.rawText ?? c.text ?? "").join("\n\n");
551
572
  if (fullRaw.trim()) {
552
573
  try {
@@ -645,20 +666,24 @@ export async function processInboundMessage(api, msg) {
645
666
  }
646
667
  }
647
668
  catch (e) {
648
- api.logger?.error?.(`[onebot] deliver failed: ${e?.message}`);
669
+ traceLog.error(`deliver failed: ${e?.message}`);
649
670
  }
650
671
  },
651
672
  onError: async (err, info) => {
652
- api.logger?.error?.(`[onebot] ${info?.kind} reply failed: ${err}`);
673
+ traceLog.error(`${info?.kind} reply failed: ${err}`);
653
674
  await clearEmojiReaction();
654
675
  },
655
676
  },
656
677
  replyOptions: { disableBlockStreaming: longMessageMode !== "normal" },
657
678
  });
679
+ traceLog.info(`dispatchReplyWithBufferedBlockDispatcher returned successfully.`);
658
680
  }
659
681
  catch (err) {
660
682
  await clearEmojiReaction();
661
- 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 = "";
662
687
  try {
663
688
  const { userId: uid, groupId: gid, isGroup: ig } = ctxPayload._onebot || {};
664
689
  if (ig && gid)
@@ -669,7 +694,23 @@ export async function processInboundMessage(api, msg) {
669
694
  catch (_) { }
670
695
  }
671
696
  finally {
672
- 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");
673
714
  setForwardSuppressDelivery(false);
674
715
  setActiveReplySelfId(null);
675
716
  lastSentChunkCountBySession.delete(replySessionId);
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kirigaya/openclaw-onebot",
3
- "version": "1.0.9",
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": {