@kirigaya/openclaw-onebot 1.0.2 → 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.
Files changed (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +174 -7
  3. package/dist/channel.js +2 -2
  4. package/dist/cli-commands.d.ts +4 -0
  5. package/dist/cli-commands.js +119 -0
  6. package/dist/config.d.ts +15 -0
  7. package/dist/config.js +43 -0
  8. package/dist/connection.d.ts +63 -6
  9. package/dist/connection.js +263 -23
  10. package/dist/handlers/group-increase.d.ts +2 -1
  11. package/dist/handlers/group-increase.js +78 -12
  12. package/dist/handlers/process-inbound.d.ts +18 -0
  13. package/dist/handlers/process-inbound.js +420 -25
  14. package/dist/index.js +5 -1
  15. package/dist/load-script.d.ts +5 -1
  16. package/dist/load-script.js +7 -3
  17. package/dist/markdown-to-html.d.ts +10 -0
  18. package/dist/markdown-to-html.js +111 -0
  19. package/dist/markdown.d.ts +7 -0
  20. package/dist/markdown.js +43 -0
  21. package/dist/message.d.ts +6 -0
  22. package/dist/message.js +45 -5
  23. package/dist/og-image.d.ts +7 -0
  24. package/dist/og-image.js +51 -0
  25. package/dist/reply-context.d.ts +19 -0
  26. package/dist/reply-context.js +54 -0
  27. package/dist/send-debug-log.d.ts +27 -0
  28. package/dist/send-debug-log.js +28 -0
  29. package/dist/send.d.ts +16 -2
  30. package/dist/send.js +65 -8
  31. package/dist/setup.js +58 -5
  32. package/dist/tools.d.ts +3 -1
  33. package/dist/tools.js +60 -7
  34. package/openclaw.plugin.json +59 -4
  35. package/package.json +37 -12
  36. package/skills/onebot-ops/SKILL.md +14 -3
  37. package/skills/onebot-ops/agent-tools.md +116 -0
  38. package/skills/onebot-ops/config.md +71 -55
  39. package/skills/onebot-ops/receive.md +88 -12
  40. package/skills/onebot-ops/send.md +56 -39
  41. package/themes/dust.css +1096 -0
  42. package/dist/gateway-proxy.d.ts +0 -8
  43. package/dist/gateway-proxy.js +0 -36
@@ -1,16 +1,21 @@
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
  */
9
+ import Fuse from "fuse.js";
7
10
  import WebSocket from "ws";
8
11
  import { createServer } from "http";
9
12
  import https from "https";
10
13
  import http from "http";
11
- import { writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
14
+ import { writeFileSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "fs";
12
15
  import { join } from "path";
13
16
  import { tmpdir } from "os";
17
+ import { logSend } from "./send-debug-log.js";
18
+ import { shouldBlockSendInForwardMode, getActiveReplyTarget, getActiveReplySessionId } from "./reply-context.js";
14
19
  const IMAGE_TEMP_DIR = join(tmpdir(), "openclaw-onebot");
15
20
  const DOWNLOAD_TIMEOUT_MS = 30000;
16
21
  /** 使用 Node 内置 http(s) 下载 URL,避免 fetch 在某些环境下的兼容性问题 */
@@ -94,6 +99,48 @@ async function resolveImageToLocalPath(image) {
94
99
  }
95
100
  return trimmed.replace(/\\/g, "/");
96
101
  }
102
+ async function resolveImageToBuffer(image) {
103
+ const trimmed = image?.trim();
104
+ if (!trimmed)
105
+ throw new Error("Empty image");
106
+ if (/^https?:\/\//i.test(trimmed)) {
107
+ return downloadUrl(trimmed);
108
+ }
109
+ if (trimmed.startsWith("base64://")) {
110
+ return Buffer.from(trimmed.slice(9), "base64");
111
+ }
112
+ if (trimmed.startsWith("file://")) {
113
+ return readFileSync(trimmed.slice(7));
114
+ }
115
+ return readFileSync(trimmed);
116
+ }
117
+ function normalizePeerHost(host) {
118
+ const trimmed = String(host ?? "").trim().toLowerCase();
119
+ if (!trimmed)
120
+ return "";
121
+ const unwrapped = trimmed.replace(/^\[/, "").replace(/\]$/, "");
122
+ return unwrapped.startsWith("::ffff:") ? unwrapped.slice(7) : unwrapped;
123
+ }
124
+ function isLoopbackHost(host) {
125
+ const normalized = normalizePeerHost(host);
126
+ return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
127
+ }
128
+ function getSocketPeerHost(socket, getConfig) {
129
+ const peerHost = socket.__onebotPeerHost;
130
+ if (peerHost)
131
+ return peerHost;
132
+ return getConfig?.()?.host ?? "";
133
+ }
134
+ function shouldEncodeImageAsBase64(socket, getConfig) {
135
+ const peerHost = getSocketPeerHost(socket, getConfig);
136
+ return !!peerHost && !isLoopbackHost(peerHost);
137
+ }
138
+ async function resolveImageFileForSend(image, socket, getConfig) {
139
+ if (shouldEncodeImageAsBase64(socket, getConfig)) {
140
+ return `base64://${(await resolveImageToBuffer(image)).toString("base64")}`;
141
+ }
142
+ return resolveImageToLocalPath(image);
143
+ }
97
144
  /** 启动临时图片定期清理(每小时执行一次) */
98
145
  export function startImageTempCleanup() {
99
146
  stopImageTempCleanup();
@@ -206,6 +253,18 @@ export async function ensureConnection(getConfig, timeoutMs = 30000) {
206
253
  return waitForConnection(timeoutMs);
207
254
  }
208
255
  export async function sendPrivateMsg(userId, text, getConfig) {
256
+ if (shouldBlockSendInForwardMode("private", userId)) {
257
+ logSend("connection", "sendPrivateMsg", { targetId: userId, blocked: true, sessionId: getActiveReplyTarget(), replySessionId: getActiveReplySessionId() });
258
+ return undefined;
259
+ }
260
+ logSend("connection", "sendPrivateMsg", {
261
+ targetType: "user",
262
+ targetId: userId,
263
+ textPreview: text?.slice(0, 80),
264
+ textLen: text?.length,
265
+ sessionId: getActiveReplyTarget(),
266
+ replySessionId: getActiveReplySessionId(),
267
+ });
209
268
  const socket = getConfig
210
269
  ? await ensureConnection(getConfig)
211
270
  : await waitForConnection();
@@ -213,9 +272,23 @@ export async function sendPrivateMsg(userId, text, getConfig) {
213
272
  if (res?.retcode !== 0) {
214
273
  throw new Error(res?.msg ?? `OneBot send_private_msg failed (retcode=${res?.retcode})`);
215
274
  }
216
- return res?.data?.message_id;
275
+ const mid = res?.data?.message_id;
276
+ logSend("connection", "sendPrivateMsg", { targetId: userId, messageId: mid, sessionId: getActiveReplyTarget(), replySessionId: getActiveReplySessionId() });
277
+ return mid;
217
278
  }
218
279
  export async function sendGroupMsg(groupId, text, getConfig) {
280
+ if (shouldBlockSendInForwardMode("group", groupId)) {
281
+ logSend("connection", "sendGroupMsg", { targetId: groupId, blocked: true, sessionId: getActiveReplyTarget(), replySessionId: getActiveReplySessionId() });
282
+ return undefined;
283
+ }
284
+ logSend("connection", "sendGroupMsg", {
285
+ targetType: "group",
286
+ targetId: groupId,
287
+ textPreview: text?.slice(0, 80),
288
+ textLen: text?.length,
289
+ sessionId: getActiveReplyTarget(),
290
+ replySessionId: getActiveReplySessionId(),
291
+ });
219
292
  const socket = getConfig
220
293
  ? await ensureConnection(getConfig)
221
294
  : await waitForConnection();
@@ -223,14 +296,27 @@ export async function sendGroupMsg(groupId, text, getConfig) {
223
296
  if (res?.retcode !== 0) {
224
297
  throw new Error(res?.msg ?? `OneBot send_group_msg failed (retcode=${res?.retcode})`);
225
298
  }
226
- return res?.data?.message_id;
299
+ const mid = res?.data?.message_id;
300
+ logSend("connection", "sendGroupMsg", { targetId: groupId, messageId: mid, sessionId: getActiveReplyTarget(), replySessionId: getActiveReplySessionId() });
301
+ return mid;
227
302
  }
228
303
  export async function sendGroupImage(groupId, image, log = getLogger(), getConfig) {
304
+ if (shouldBlockSendInForwardMode("group", groupId)) {
305
+ logSend("connection", "sendGroupImage", { targetId: groupId, blocked: true, sessionId: getActiveReplyTarget(), replySessionId: getActiveReplySessionId() });
306
+ return undefined;
307
+ }
308
+ logSend("connection", "sendGroupImage", {
309
+ targetType: "group",
310
+ targetId: groupId,
311
+ imagePreview: image?.slice?.(0, 60),
312
+ sessionId: getActiveReplyTarget(),
313
+ replySessionId: getActiveReplySessionId(),
314
+ });
229
315
  log.info?.(`[onebot] sendGroupImage entry: groupId=${groupId} image=${image?.slice?.(0, 80) ?? ""}`);
230
316
  const socket = getConfig ? await ensureConnection(getConfig) : await waitForConnection();
231
317
  log.info?.(`222[onebot] sendGroupImage entry: groupId=${groupId} image=${image?.slice?.(0, 80) ?? ""}`);
232
318
  try {
233
- const filePath = image.startsWith("[") ? null : await resolveImageToLocalPath(image);
319
+ const filePath = image.startsWith("[") ? null : await resolveImageFileForSend(image, socket, getConfig);
234
320
  const seg = image.startsWith("[")
235
321
  ? JSON.parse(image)
236
322
  : [{ type: "image", data: { file: filePath } }];
@@ -240,16 +326,61 @@ export async function sendGroupImage(groupId, image, log = getLogger(), getConfi
240
326
  throw new Error(res?.msg ?? `OneBot send_group_msg (image) failed (retcode=${res?.retcode})`);
241
327
  }
242
328
  log.info?.(`[onebot] sendGroupImage done: retcode=${res?.retcode ?? "?"}`);
243
- return res?.data?.message_id;
329
+ const mid = res?.data?.message_id;
330
+ logSend("connection", "sendGroupImage", { targetId: groupId, messageId: mid, sessionId: getActiveReplyTarget(), replySessionId: getActiveReplySessionId() });
331
+ return mid;
244
332
  }
245
333
  catch (error) {
246
334
  log.warn?.(`[onebot] sendGroupImage error: ${error}`);
247
335
  }
248
336
  }
337
+ /** 发送群合并转发消息。messages 为节点数组,每节点 { type: "node", data: { id } } 或 { type: "node", data: { user_id, nickname, content } } */
338
+ export async function sendGroupForwardMsg(groupId, messages, getConfig) {
339
+ logSend("connection", "sendGroupForwardMsg", {
340
+ targetType: "group",
341
+ targetId: groupId,
342
+ nodeCount: messages.length,
343
+ isForward: true,
344
+ sessionId: getActiveReplyTarget(),
345
+ replySessionId: getActiveReplySessionId(),
346
+ });
347
+ const socket = getConfig ? await ensureConnection(getConfig) : await waitForConnection();
348
+ const res = await sendOneBotAction(socket, "send_group_forward_msg", { group_id: groupId, messages });
349
+ if (res?.retcode !== 0) {
350
+ throw new Error(res?.msg ?? `OneBot send_group_forward_msg failed (retcode=${res?.retcode})`);
351
+ }
352
+ }
353
+ /** 发送私聊合并转发消息 */
354
+ export async function sendPrivateForwardMsg(userId, messages, getConfig) {
355
+ logSend("connection", "sendPrivateForwardMsg", {
356
+ targetType: "user",
357
+ targetId: userId,
358
+ nodeCount: messages.length,
359
+ isForward: true,
360
+ sessionId: getActiveReplyTarget(),
361
+ replySessionId: getActiveReplySessionId(),
362
+ });
363
+ const socket = getConfig ? await ensureConnection(getConfig) : await waitForConnection();
364
+ const res = await sendOneBotAction(socket, "send_private_forward_msg", { user_id: userId, messages });
365
+ if (res?.retcode !== 0) {
366
+ throw new Error(res?.msg ?? `OneBot send_private_forward_msg failed (retcode=${res?.retcode})`);
367
+ }
368
+ }
249
369
  export async function sendPrivateImage(userId, image, log = getLogger(), getConfig) {
370
+ if (shouldBlockSendInForwardMode("private", userId)) {
371
+ logSend("connection", "sendPrivateImage", { targetId: userId, blocked: true, sessionId: getActiveReplyTarget(), replySessionId: getActiveReplySessionId() });
372
+ return undefined;
373
+ }
374
+ logSend("connection", "sendPrivateImage", {
375
+ targetType: "user",
376
+ targetId: userId,
377
+ imagePreview: image?.slice?.(0, 60),
378
+ sessionId: getActiveReplyTarget(),
379
+ replySessionId: getActiveReplySessionId(),
380
+ });
250
381
  log.info?.(`[onebot] sendPrivateImage entry: userId=${userId} image=${image?.slice?.(0, 80) ?? ""}`);
251
382
  const socket = getConfig ? await ensureConnection(getConfig) : await waitForConnection();
252
- const filePath = image.startsWith("[") ? null : await resolveImageToLocalPath(image);
383
+ const filePath = image.startsWith("[") ? null : await resolveImageFileForSend(image, socket, getConfig);
253
384
  const seg = image.startsWith("[")
254
385
  ? JSON.parse(image)
255
386
  : [{ type: "image", data: { file: filePath } }];
@@ -258,17 +389,27 @@ export async function sendPrivateImage(userId, image, log = getLogger(), getConf
258
389
  throw new Error(res?.msg ?? `OneBot send_private_msg (image) failed (retcode=${res?.retcode})`);
259
390
  }
260
391
  log.info?.(`[onebot] sendPrivateImage done: retcode=${res?.retcode ?? "?"}`);
261
- return res?.data?.message_id;
392
+ const mid = res?.data?.message_id;
393
+ logSend("connection", "sendPrivateImage", { targetId: userId, messageId: mid, sessionId: getActiveReplyTarget(), replySessionId: getActiveReplySessionId() });
394
+ return mid;
262
395
  }
263
- export async function uploadGroupFile(groupId, file, name) {
264
- if (!ws || ws.readyState !== WebSocket.OPEN)
265
- throw new Error("OneBot WebSocket not connected");
266
- await sendOneBotAction(ws, "upload_group_file", { group_id: groupId, file, name });
396
+ export async function uploadGroupFile(groupId, file, name, getConfig) {
397
+ const socket = getConfig
398
+ ? await ensureConnection(getConfig)
399
+ : await waitForConnection();
400
+ const res = await sendOneBotAction(socket, "upload_group_file", { group_id: groupId, file, name });
401
+ if (res?.retcode !== 0) {
402
+ throw new Error(res?.msg ?? `OneBot upload_group_file failed (retcode=${res?.retcode})`);
403
+ }
267
404
  }
268
- export async function uploadPrivateFile(userId, file, name) {
269
- if (!ws || ws.readyState !== WebSocket.OPEN)
270
- throw new Error("OneBot WebSocket not connected");
271
- await sendOneBotAction(ws, "upload_private_file", { user_id: userId, file, name });
405
+ export async function uploadPrivateFile(userId, file, name, getConfig) {
406
+ const socket = getConfig
407
+ ? await ensureConnection(getConfig)
408
+ : await waitForConnection();
409
+ const res = await sendOneBotAction(socket, "upload_private_file", { user_id: userId, file, name });
410
+ if (res?.retcode !== 0) {
411
+ throw new Error(res?.msg ?? `OneBot upload_private_file failed (retcode=${res?.retcode})`);
412
+ }
272
413
  }
273
414
  /** 撤回消息 */
274
415
  export async function deleteMsg(messageId) {
@@ -316,6 +457,49 @@ export async function getGroupMemberInfo(groupId, userId) {
316
457
  return null;
317
458
  }
318
459
  }
460
+ /**
461
+ * 获取群成员列表(OneBot get_group_member_list)
462
+ */
463
+ export async function getGroupMemberList(groupId) {
464
+ if (!ws || ws.readyState !== WebSocket.OPEN)
465
+ return [];
466
+ try {
467
+ const res = await sendOneBotAction(ws, "get_group_member_list", { group_id: groupId });
468
+ if (res?.retcode !== 0 || !Array.isArray(res?.data))
469
+ return [];
470
+ return res.data.map((m) => ({
471
+ user_id: Number(m.user_id),
472
+ nickname: String(m.nickname ?? ""),
473
+ card: String(m.card ?? ""),
474
+ }));
475
+ }
476
+ catch {
477
+ return [];
478
+ }
479
+ }
480
+ /**
481
+ * 按名字模糊匹配群成员(匹配群名片 card 与昵称 nickname),返回匹配到的 QQ 与展示名。
482
+ * 使用 Fuse.js 模糊匹配,结果按相关度排序。
483
+ */
484
+ export async function searchGroupMemberByName(groupId, name) {
485
+ const list = await getGroupMemberList(groupId);
486
+ const keyword = (name || "").trim();
487
+ if (!keyword)
488
+ return [];
489
+ const fuse = new Fuse(list, {
490
+ keys: ["card", "nickname"],
491
+ includeScore: true,
492
+ threshold: 0.4,
493
+ ignoreLocation: true,
494
+ });
495
+ const results = fuse.search(keyword);
496
+ return results.map(({ item: m }) => ({
497
+ user_id: m.user_id,
498
+ nickname: m.nickname,
499
+ card: m.card,
500
+ displayName: m.card || m.nickname || String(m.user_id),
501
+ }));
502
+ }
319
503
  /** 获取群信息(含 group_name) */
320
504
  export async function getGroupInfo(groupId) {
321
505
  if (!ws || ws.readyState !== WebSocket.OPEN)
@@ -349,20 +533,24 @@ export async function getMsg(messageId) {
349
533
  }
350
534
  }
351
535
  /**
352
- * 获取群聊历史消息(Lagrange.Core 扩展 API,go-cqhttp 等可能不支持)
536
+ * 获取群聊历史消息(Lagrange.Core 扩展 API,与 Lagrange.onebot context 一致)
537
+ * 仅使用 message_seq 分页(不传 message_id),与 Tiphareth getLast24HGroupMessages 调用方式一致。
353
538
  * @param groupId 群号
354
- * @param opts message_seq 起始序号;message_id 起始消息 ID;count 数量
539
+ * @param opts message_seq 起始序号(不传表示从最新一页);count 本页条数;reverse_order true 表示从旧到新,便于用 batch[0].message_seq 向前翻页
355
540
  */
356
541
  export async function getGroupMsgHistory(groupId, opts = { count: 20 }) {
357
542
  if (!ws || ws.readyState !== WebSocket.OPEN)
358
543
  return [];
359
544
  try {
360
- const res = await sendOneBotAction(ws, "get_group_msg_history", {
545
+ const params = {
361
546
  group_id: groupId,
362
- message_seq: opts.message_seq,
363
- message_id: opts.message_id,
364
547
  count: opts.count ?? 20,
365
- });
548
+ reverse_order: opts.reverse_order !== false,
549
+ };
550
+ if (opts.message_seq != null && Number.isFinite(opts.message_seq)) {
551
+ params.message_seq = opts.message_seq;
552
+ }
553
+ const res = await sendOneBotAction(ws, "get_group_msg_history", params);
366
554
  if (res?.retcode === 0 && res?.data?.messages)
367
555
  return res.data.messages;
368
556
  return [];
@@ -371,6 +559,56 @@ export async function getGroupMsgHistory(groupId, opts = { count: 20 }) {
371
559
  return [];
372
560
  }
373
561
  }
562
+ /** 单页请求之间的延迟(毫秒),与 Tiphareth historyMessages 一致 */
563
+ const HISTORY_PAGE_DELAY_MS = 500;
564
+ /**
565
+ * 按时间范围分页获取群历史消息,严格对齐 Tiphareth getLast24HGroupMessages 算法:
566
+ * getGroupMsgHistory(groupId, messageSeq, chunkSize, true),用 batch[0] 的 message_seq 向前翻页,去重与时间截断。
567
+ * @param groupId 群号
568
+ * @param opts startTime 仅保留 >= startTime 的消息(Unix 秒);limit 最多条数;chunkSize 每页条数
569
+ */
570
+ export async function getGroupMsgHistoryInRange(groupId, opts = {}) {
571
+ const { startTime = 0, limit = 3000, chunkSize = 100 } = opts;
572
+ let messageSeq = undefined;
573
+ const allMessages = [];
574
+ const seenMessageIds = new Set();
575
+ let stopLoop = false;
576
+ let pageCount = 0;
577
+ while (!stopLoop) {
578
+ pageCount++;
579
+ const batch = await getGroupMsgHistory(groupId, {
580
+ message_seq: messageSeq,
581
+ count: chunkSize,
582
+ reverse_order: true,
583
+ });
584
+ if (!batch.length) {
585
+ break;
586
+ }
587
+ await new Promise((r) => setTimeout(r, HISTORY_PAGE_DELAY_MS));
588
+ for (const msg of batch) {
589
+ if (seenMessageIds.has(msg.message_id))
590
+ continue;
591
+ seenMessageIds.add(msg.message_id);
592
+ if (msg.time < startTime) {
593
+ stopLoop = true;
594
+ }
595
+ else {
596
+ allMessages.push(msg);
597
+ }
598
+ }
599
+ const oldest = batch[0];
600
+ const nextSeq = oldest.message_seq ?? oldest.message_id;
601
+ if (nextSeq == null || nextSeq === messageSeq) {
602
+ break;
603
+ }
604
+ messageSeq = nextSeq;
605
+ if (allMessages.length >= limit) {
606
+ break;
607
+ }
608
+ }
609
+ allMessages.sort((a, b) => a.time - b.time);
610
+ return allMessages;
611
+ }
374
612
  export async function connectForward(config) {
375
613
  const path = config.path ?? "/onebot/v11/ws";
376
614
  const pathNorm = path.startsWith("/") ? path : `/${path}`;
@@ -384,6 +622,7 @@ export async function connectForward(config) {
384
622
  w.on("open", () => resolve());
385
623
  w.on("error", reject);
386
624
  });
625
+ w.__onebotPeerHost = config.host;
387
626
  return w;
388
627
  }
389
628
  export async function createServerAndWait(config) {
@@ -398,7 +637,8 @@ export async function createServerAndWait(config) {
398
637
  server.listen(config.port, host);
399
638
  wsServer = wss;
400
639
  return new Promise((resolve) => {
401
- wss.on("connection", (socket) => {
640
+ wss.on("connection", (socket, req) => {
641
+ socket.__onebotPeerHost = req.socket.remoteAddress ?? undefined;
402
642
  resolve(socket);
403
643
  });
404
644
  });
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * 支持:
5
5
  * 1. 简单文本模板(message),占位符:{name}、{userId}、{groupName}、{groupId}、{avatarUrl}
6
- * 2. 自定义 handler 脚本,可生成图片并返回,接收完整上下文
6
+ * 2. 自定义 command:在 cwd 下用系统 shell 执行命令,通过环境变量传入上下文
7
+ * 命令自行负责发送(如调用 openclaw message send),或向 stdout 输出 JSON 行供本 handler 发送
7
8
  */
8
9
  import type { OneBotMessage } from "../types.js";
9
10
  export interface GroupIncreaseContext {
@@ -3,11 +3,14 @@
3
3
  *
4
4
  * 支持:
5
5
  * 1. 简单文本模板(message),占位符:{name}、{userId}、{groupName}、{groupId}、{avatarUrl}
6
- * 2. 自定义 handler 脚本,可生成图片并返回,接收完整上下文
6
+ * 2. 自定义 command:在 cwd 下用系统 shell 执行命令,通过环境变量传入上下文
7
+ * 命令自行负责发送(如调用 openclaw message send),或向 stdout 输出 JSON 行供本 handler 发送
7
8
  */
8
9
  import { sendGroupMsg, sendGroupImage, getStrangerInfo, getGroupMemberInfo, getGroupInfo, getAvatarUrl, } from "../connection.js";
9
- import { loadScript } from "../load-script.js";
10
+ import { getRenderMarkdownToPlain } from "../config.js";
11
+ import { markdownToPlain } from "../markdown.js";
10
12
  import { resolve } from "path";
13
+ import { spawn } from "child_process";
11
14
  async function resolveContext(groupId, userId) {
12
15
  const [groupInfo, memberInfo] = await Promise.all([
13
16
  getGroupInfo(groupId),
@@ -38,6 +41,49 @@ function applyTemplate(template, ctx) {
38
41
  .replace(/\{groupId\}/g, String(ctx.groupId))
39
42
  .replace(/\{avatarUrl\}/g, ctx.avatarUrl);
40
43
  }
44
+ function escapeForShell(s) {
45
+ const str = String(s);
46
+ if (process.platform === "win32") {
47
+ return '"' + str.replace(/"/g, '""') + '"';
48
+ }
49
+ return '"' + str.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
50
+ }
51
+ function runCommand(command, cwd, args, env) {
52
+ const fullCmd = `${command} --userId ${args.userId} --username ${escapeForShell(args.username)} --groupId ${args.groupId}`;
53
+ return new Promise((resolvePromise) => {
54
+ const isWin = process.platform === "win32";
55
+ const shell = isWin ? "cmd.exe" : "sh";
56
+ const shellArg = isWin ? "/c" : "-c";
57
+ const child = spawn(shell, [shellArg, fullCmd], {
58
+ cwd,
59
+ env: { ...process.env, ...env },
60
+ stdio: ["ignore", "pipe", "pipe"],
61
+ });
62
+ let stdout = "";
63
+ let stderr = "";
64
+ child.stdout?.on("data", (d) => { stdout += d.toString(); });
65
+ child.stderr?.on("data", (d) => { stderr += d.toString(); });
66
+ child.on("close", (code) => {
67
+ resolvePromise({ stdout, stderr, code });
68
+ });
69
+ });
70
+ }
71
+ function parseCommandOutput(stdout) {
72
+ const line = stdout.trim().split("\n").pop();
73
+ if (!line)
74
+ return null;
75
+ try {
76
+ const data = JSON.parse(line);
77
+ return {
78
+ text: typeof data.text === "string" ? data.text : undefined,
79
+ imagePath: typeof data.imagePath === "string" ? data.imagePath : undefined,
80
+ imageUrl: typeof data.imageUrl === "string" ? data.imageUrl : undefined,
81
+ };
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
41
87
  export async function handleGroupIncrease(api, msg) {
42
88
  const cfg = api.config;
43
89
  const gi = cfg?.channels?.onebot?.groupIncrease;
@@ -54,24 +100,43 @@ export async function handleGroupIncrease(api, msg) {
54
100
  return;
55
101
  }
56
102
  let result = {};
57
- const handlerPath = gi?.handler;
58
- if (handlerPath?.trim()) {
103
+ const command = gi?.command?.trim();
104
+ const cwd = gi?.cwd?.trim();
105
+ if (command && cwd) {
106
+ const env = {
107
+ GROUP_ID: String(ctx.groupId),
108
+ GROUP_NAME: ctx.groupName,
109
+ USER_ID: String(ctx.userId),
110
+ USER_NAME: ctx.userName,
111
+ AVATAR_URL: ctx.avatarUrl,
112
+ };
113
+ const args = {
114
+ userId: String(ctx.userId),
115
+ username: ctx.userName,
116
+ groupId: String(ctx.groupId),
117
+ };
59
118
  try {
60
- const mod = await loadScript(handlerPath);
61
- const fn = mod?.default ?? mod?.generate ?? mod;
62
- if (typeof fn === "function") {
63
- result = (await fn(ctx)) ?? {};
119
+ const { stdout, stderr, code } = await runCommand(command, resolve(cwd), args, env);
120
+ if (stderr)
121
+ api.logger?.warn?.(`[onebot] groupIncrease command stderr: ${stderr}`);
122
+ if (code !== 0)
123
+ api.logger?.warn?.(`[onebot] groupIncrease command exit code: ${code}`);
124
+ const parsed = parseCommandOutput(stdout);
125
+ if (parsed && (parsed.text || parsed.imagePath || parsed.imageUrl)) {
126
+ result = parsed;
64
127
  }
65
128
  }
66
129
  catch (e) {
67
- api.logger?.error?.(`[onebot] groupIncrease handler failed: ${e?.message}`);
130
+ api.logger?.error?.(`[onebot] groupIncrease command failed: ${e?.message}`);
68
131
  }
69
132
  }
70
133
  const message = gi?.message;
71
- if (message?.trim() && !result.text) {
134
+ if (message?.trim() && !result.text && !command) {
72
135
  result.text = applyTemplate(message, ctx);
73
136
  }
74
- const text = (result.text ?? "").trim();
137
+ let text = (result.text ?? "").trim();
138
+ if (text && getRenderMarkdownToPlain(cfg))
139
+ text = markdownToPlain(text);
75
140
  const imagePath = result.imagePath?.trim();
76
141
  const imageUrl = result.imageUrl?.trim();
77
142
  if (!text && !imagePath && !imageUrl)
@@ -80,9 +145,10 @@ export async function handleGroupIncrease(api, msg) {
80
145
  if (text)
81
146
  await sendGroupMsg(groupId, text);
82
147
  if (imagePath) {
148
+ const baseDir = cwd || process.cwd();
83
149
  const abs = imagePath.startsWith("file://") || imagePath.startsWith("http://") || imagePath.startsWith("https://")
84
150
  ? imagePath
85
- : resolve(process.cwd(), imagePath);
151
+ : resolve(baseDir, imagePath);
86
152
  await sendGroupImage(groupId, abs);
87
153
  }
88
154
  if (imageUrl && !imagePath)
@@ -8,4 +8,22 @@ export declare const sessionHistories: Map<string, {
8
8
  timestamp: number;
9
9
  messageId: string;
10
10
  }[]>;
11
+ export declare function startForwardCleanupTimer(): void;
11
12
  export declare function processInboundMessage(api: any, msg: OneBotMessage): Promise<void>;
13
+ /** 回复会话上下文,供 onReplySessionEnd 钩子使用 */
14
+ export interface ReplySessionContext {
15
+ /** 本次回复会话的唯一 ID,同一用户问题下的多次 deliver 共享此 ID */
16
+ replySessionId: string;
17
+ /** 会话标识,如 onebot:group:123 或 onebot:456 */
18
+ sessionId: string;
19
+ /** 回复目标,如 onebot:group:123 或 onebot:456 */
20
+ to: string;
21
+ /** 本次回复中已发送的所有块(按顺序) */
22
+ chunks: Array<{
23
+ index: number;
24
+ text?: string;
25
+ mediaUrl?: string;
26
+ }>;
27
+ /** 用户原始消息 */
28
+ userMessage: string;
29
+ }