@tencent-connect/openclaw-qqbot 1.7.1 → 1.7.2

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 (38) hide show
  1. package/README.md +188 -3
  2. package/README.zh.md +190 -3
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.js +1 -0
  5. package/dist/src/api.d.ts +2 -0
  6. package/dist/src/api.js +16 -3
  7. package/dist/src/config.d.ts +5 -1
  8. package/dist/src/config.js +12 -2
  9. package/dist/src/gateway.js +131 -169
  10. package/dist/src/slash-commands.js +119 -3
  11. package/dist/src/tools/channel.js +1 -4
  12. package/dist/src/tools/remind.js +0 -1
  13. package/dist/src/transport/index.d.ts +10 -0
  14. package/dist/src/transport/index.js +9 -0
  15. package/dist/src/transport/webhook-transport.d.ts +67 -0
  16. package/dist/src/transport/webhook-transport.js +245 -0
  17. package/dist/src/transport/webhook-verify.d.ts +48 -0
  18. package/dist/src/transport/webhook-verify.js +98 -0
  19. package/dist/src/types.d.ts +19 -0
  20. package/dist/src/utils/audio-convert.js +37 -9
  21. package/index.ts +1 -0
  22. package/package.json +1 -1
  23. package/scripts/postinstall-link-sdk.js +44 -0
  24. package/scripts/upgrade-via-npm.sh +358 -62
  25. package/scripts/upgrade-via-source.sh +122 -85
  26. package/src/api.ts +18 -4
  27. package/src/config.ts +15 -2
  28. package/src/gateway.ts +135 -167
  29. package/src/onboarding.ts +8 -0
  30. package/src/slash-commands.ts +137 -3
  31. package/src/tools/channel.ts +1 -7
  32. package/src/tools/remind.ts +0 -2
  33. package/src/transport/index.ts +11 -0
  34. package/src/transport/webhook-transport.ts +332 -0
  35. package/src/transport/webhook-verify.ts +119 -0
  36. package/src/types.ts +22 -1
  37. package/src/typings/openclaw-webhook-ingress.d.ts +66 -0
  38. package/src/utils/audio-convert.ts +37 -9
@@ -2,6 +2,7 @@ import WebSocket from "ws";
2
2
  import path from "node:path";
3
3
  import fs from "node:fs";
4
4
  import { MSG_TYPE_QUOTE } from "./types.js";
5
+ import { startWebhookTransport } from "./transport/index.js";
5
6
  import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, getPluginUserAgent, acknowledgeInteraction, getApiPluginVersion, setApiLogger } from "./api.js";
6
7
  import { loadSession, saveSession, clearSession } from "./session-store.js";
7
8
  import { recordKnownUser, flushKnownUsers } from "./known-users.js";
@@ -332,6 +333,21 @@ export async function startGateway(ctx) {
332
333
  if (!account.appId || !account.clientSecret) {
333
334
  throw new Error("QQBot not configured (missing appId or clientSecret)");
334
335
  }
336
+ // 安全网:捕获 approval-handler / SDK 内部 WS 握手异步错误(如 403),避免进程崩溃
337
+ const wsUncaughtHandler = (err) => {
338
+ if (err.message?.includes("Unexpected server response")) {
339
+ log?.error(`[qqbot:${account.accountId}] Caught WS handshake error (non-fatal): ${err.message}`);
340
+ // 不重新抛出,防止进程退出
341
+ }
342
+ else {
343
+ // 非 WS 握手错误,重新抛出交给上层处理
344
+ throw err;
345
+ }
346
+ };
347
+ process.on("uncaughtException", wsUncaughtHandler);
348
+ abortSignal.addEventListener("abort", () => {
349
+ process.removeListener("uncaughtException", wsUncaughtHandler);
350
+ }, { once: true });
335
351
  // 启动环境诊断(首次连接时执行)
336
352
  const diag = await runDiagnostics();
337
353
  if (diag.warnings.length > 0) {
@@ -422,6 +438,12 @@ export async function startGateway(ctx) {
422
438
  else {
423
439
  log?.info(`[qqbot:${account.accountId}] Image server disabled (no imageServerBaseUrl configured)`);
424
440
  }
441
+ // ============ Transport 模式标记 ============
442
+ const transportMode = account.config.transport ?? "websocket";
443
+ if (transportMode === "webhook") {
444
+ log?.info(`[qqbot:${account.accountId}] Using webhook transport mode`);
445
+ }
446
+ // ============ WebSocket / Webhook 公共初始化 ============
425
447
  let reconnectAttempts = 0;
426
448
  let isAborted = false;
427
449
  let currentWs = null;
@@ -453,7 +475,9 @@ export async function startGateway(ctx) {
453
475
  log,
454
476
  });
455
477
  registerApprovalHandler(account.accountId, approvalHandler);
456
- void approvalHandler.start();
478
+ approvalHandler.start().catch((err) => {
479
+ log?.error(`[qqbot:${account.accountId}] approval-handler: uncaught start error: ${err}`);
480
+ });
457
481
  // ============ 消息队列(复用 createMessageQueue,内置群消息合并/淘汰策略) ============
458
482
  const msgQueue = createMessageQueue({
459
483
  accountId: account.accountId,
@@ -630,12 +654,6 @@ export async function startGateway(ctx) {
630
654
  clearTokenCache(account.appId);
631
655
  shouldRefreshToken = false;
632
656
  }
633
- const accessToken = await getAccessToken(account.appId, account.clientSecret);
634
- log?.info(`[qqbot:${account.accountId}] ✅ Access token obtained successfully`);
635
- const gatewayUrl = await getGatewayUrl(accessToken);
636
- log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
637
- const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent": getPluginUserAgent() } });
638
- currentWs = ws;
639
657
  const pluginRuntime = getQQBotRuntime();
640
658
  // 群历史消息缓存:非@消息写入此 Map,被@时一次性注入上下文后清空
641
659
  const groupHistories = new Map();
@@ -1287,6 +1305,8 @@ export async function startGateway(ctx) {
1287
1305
  },
1288
1306
  });
1289
1307
  }
1308
+ // 打印 runId 用于调试
1309
+ log?.info?.(`[qqbot:${account.accountId}] Dispatching with runId: ${event.messageId}`);
1290
1310
  const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1291
1311
  ctx: ctxPayload,
1292
1312
  cfg,
@@ -1537,6 +1557,8 @@ export async function startGateway(ctx) {
1537
1557
  },
1538
1558
  },
1539
1559
  replyOptions: {
1560
+ // 使用消息ID作为 runId,用于追踪一次完整的 AI 对话运行
1561
+ runId: event.messageId,
1540
1562
  // 流式模式时禁用 block streaming
1541
1563
  disableBlockStreaming: !useStreaming,
1542
1564
  // 流式模式下注册 onPartialReply 回调,接收流式文本增量
@@ -1636,6 +1658,100 @@ export async function startGateway(ctx) {
1636
1658
  }
1637
1659
  }); // end runWithRequestContext
1638
1660
  };
1661
+ // ============ 统一事件分发(WebSocket/Webhook 共用) ============
1662
+ const dispatchInboundEvent = async (t, d) => {
1663
+ if (t === "C2C_MESSAGE_CREATE") {
1664
+ const ev = d;
1665
+ recordKnownUser({ openid: ev.author.user_openid, type: "c2c", accountId: account.accountId });
1666
+ const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements);
1667
+ await trySlashCommandOrEnqueue({ type: "c2c", senderId: ev.author.user_openid, content: ev.content, messageId: ev.id, timestamp: ev.timestamp, attachments: ev.attachments, refMsgIdx: refs.refMsgIdx, msgIdx: refs.msgIdx, msgElements: ev.msg_elements, msgType: ev.message_type });
1668
+ }
1669
+ else if (t === "AT_MESSAGE_CREATE") {
1670
+ const ev = d;
1671
+ recordKnownUser({ openid: ev.author.id, type: "c2c", nickname: ev.author.username, accountId: account.accountId });
1672
+ const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements);
1673
+ await trySlashCommandOrEnqueue({ type: "guild", senderId: ev.author.id, senderName: ev.author.username, content: ev.content, messageId: ev.id, timestamp: ev.timestamp, channelId: ev.channel_id, guildId: ev.guild_id, attachments: ev.attachments, refMsgIdx: refs.refMsgIdx, msgIdx: refs.msgIdx, msgType: ev.message_type });
1674
+ }
1675
+ else if (t === "DIRECT_MESSAGE_CREATE") {
1676
+ const ev = d;
1677
+ recordKnownUser({ openid: ev.author.id, type: "c2c", nickname: ev.author.username, accountId: account.accountId });
1678
+ const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements);
1679
+ await trySlashCommandOrEnqueue({ type: "dm", senderId: ev.author.id, senderName: ev.author.username, content: ev.content, messageId: ev.id, timestamp: ev.timestamp, guildId: ev.guild_id, attachments: ev.attachments, refMsgIdx: refs.refMsgIdx, msgIdx: refs.msgIdx, msgType: ev.message_type });
1680
+ }
1681
+ else if (t === "GROUP_AT_MESSAGE_CREATE" || t === "GROUP_MESSAGE_CREATE") {
1682
+ const ev = d;
1683
+ recordKnownUser({ openid: ev.author.member_openid, type: "group", nickname: ev.author.username, groupOpenid: ev.group_openid, accountId: account.accountId });
1684
+ const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements);
1685
+ await trySlashCommandOrEnqueue({ type: "group", senderId: ev.author.member_openid, senderName: ev.author.username, senderIsBot: ev.author.bot, content: ev.content, messageId: ev.id, timestamp: ev.timestamp, groupOpenid: ev.group_openid, attachments: ev.attachments, refMsgIdx: refs.refMsgIdx, msgIdx: refs.msgIdx, eventType: t, mentions: ev.mentions, messageScene: ev.message_scene, msgElements: ev.msg_elements, msgType: ev.message_type });
1686
+ }
1687
+ else if (t === "GROUP_ADD_ROBOT") {
1688
+ const ev = d;
1689
+ log?.info(`[qqbot:${account.accountId}] Bot added to group: ${ev.group_openid} by ${ev.op_member_openid}`);
1690
+ recordKnownUser({ openid: ev.op_member_openid, type: "group", groupOpenid: ev.group_openid, accountId: account.accountId });
1691
+ }
1692
+ else if (t === "GROUP_DEL_ROBOT") {
1693
+ const ev = d;
1694
+ log?.info(`[qqbot:${account.accountId}] Bot removed from group: ${ev.group_openid} by ${ev.op_member_openid}`);
1695
+ }
1696
+ else if (t === "GROUP_MSG_REJECT") {
1697
+ const ev = d;
1698
+ log?.info(`[qqbot:${account.accountId}] Group ${ev.group_openid} rejected bot proactive messages (by ${ev.op_member_openid})`);
1699
+ }
1700
+ else if (t === "GROUP_MSG_RECEIVE") {
1701
+ const ev = d;
1702
+ log?.info(`[qqbot:${account.accountId}] Group ${ev.group_openid} accepted bot proactive messages (by ${ev.op_member_openid})`);
1703
+ }
1704
+ else if (t === "INTERACTION_CREATE") {
1705
+ const ev = d;
1706
+ const resolved = ev.data?.resolved;
1707
+ const sceneDesc = ev.scene ?? (ev.chat_type === 0 ? "guild" : ev.chat_type === 1 ? "group" : "c2c");
1708
+ log?.info(`[qqbot:${account.accountId}] Interaction: scene=${sceneDesc}, type=${ev.data?.type}, button_id=${resolved?.button_id}, button_data=${resolved?.button_data}`);
1709
+ handleInteractionCreate({ event: ev, account, cfg, log }).catch((err) => {
1710
+ log?.error(`[qqbot:${account.accountId}] Failed to handle interaction ${ev.id}: ${err}`);
1711
+ });
1712
+ }
1713
+ };
1714
+ // ============ Webhook 模式:共享 handleMessage,不走 WS ============
1715
+ if (transportMode === "webhook") {
1716
+ isConnecting = false;
1717
+ msgQueue.startProcessor(handleMessage);
1718
+ startBackgroundTokenRefresh(account.appId, account.clientSecret, {
1719
+ log: log,
1720
+ });
1721
+ await startWebhookTransport({
1722
+ account,
1723
+ abortSignal,
1724
+ onEvent: async (event) => {
1725
+ const { eventType: t, data: d } = event;
1726
+ log?.info(`[qqbot:${account.accountId}:webhook] 📩 Dispatch event: t=${t}, d=${JSON.stringify(d)}`);
1727
+ await dispatchInboundEvent(t, d);
1728
+ },
1729
+ onReady: () => {
1730
+ log?.info(`[qqbot:${account.accountId}:webhook] Transport ready`);
1731
+ log?.info(`[qqbot:${account.accountId}] ✅ Webhook transport started successfully (path: ${account.config.webhook?.path ?? "/qqbot/webhook"})`);
1732
+ onReady?.({ transport: "webhook" });
1733
+ if (_pendingFirstReady.has(account.accountId)) {
1734
+ _pendingFirstReady.delete(account.accountId);
1735
+ sendStartupGreetings(adminCtx, "READY");
1736
+ }
1737
+ },
1738
+ onError: (error) => {
1739
+ log?.error(`[qqbot:${account.accountId}:webhook] Error: ${error.message}`);
1740
+ onError?.(error);
1741
+ },
1742
+ log,
1743
+ });
1744
+ stopBackgroundTokenRefresh();
1745
+ unregisterApprovalHandler(account.accountId);
1746
+ return; // webhook transport 结束,不继续 WS 逻辑
1747
+ }
1748
+ // ============ WebSocket 模式:获取 token 并建立 WS 连接 ============
1749
+ const accessToken = await getAccessToken(account.appId, account.clientSecret);
1750
+ log?.info(`[qqbot:${account.accountId}] ✅ Access token obtained successfully`);
1751
+ const gatewayUrl = await getGatewayUrl(accessToken);
1752
+ log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
1753
+ const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent": getPluginUserAgent() } });
1754
+ currentWs = ws;
1639
1755
  ws.on("open", () => {
1640
1756
  log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
1641
1757
  isConnecting = false; // 连接完成,释放锁
@@ -1755,166 +1871,10 @@ export async function startGateway(ctx) {
1755
1871
  });
1756
1872
  }
1757
1873
  }
1758
- else if (t === "C2C_MESSAGE_CREATE") {
1759
- const event = d;
1760
- // P1-3: 记录已知用户
1761
- recordKnownUser({
1762
- openid: event.author.user_openid,
1763
- type: "c2c",
1764
- accountId: account.accountId,
1765
- });
1766
- // 解析引用索引
1767
- const c2cRefs = parseRefIndices(event.message_scene?.ext, event.message_type, event.msg_elements);
1768
- // 斜杠指令拦截 → 不匹配则入队
1769
- trySlashCommandOrEnqueue({
1770
- type: "c2c",
1771
- senderId: event.author.user_openid,
1772
- content: event.content,
1773
- messageId: event.id,
1774
- timestamp: event.timestamp,
1775
- attachments: event.attachments,
1776
- refMsgIdx: c2cRefs.refMsgIdx,
1777
- msgIdx: c2cRefs.msgIdx,
1778
- msgElements: event.msg_elements,
1779
- msgType: event.message_type,
1780
- });
1781
- }
1782
- else if (t === "AT_MESSAGE_CREATE") {
1783
- const event = d;
1784
- // P1-3: 记录已知用户(频道用户)
1785
- recordKnownUser({
1786
- openid: event.author.id,
1787
- type: "c2c", // 频道用户按 c2c 类型存储
1788
- nickname: event.author.username,
1789
- accountId: account.accountId,
1790
- });
1791
- const guildRefs = parseRefIndices(event.message_scene?.ext, event.message_type, event.msg_elements);
1792
- trySlashCommandOrEnqueue({
1793
- type: "guild",
1794
- senderId: event.author.id,
1795
- senderName: event.author.username,
1796
- content: event.content,
1797
- messageId: event.id,
1798
- timestamp: event.timestamp,
1799
- channelId: event.channel_id,
1800
- guildId: event.guild_id,
1801
- attachments: event.attachments,
1802
- refMsgIdx: guildRefs.refMsgIdx,
1803
- msgIdx: guildRefs.msgIdx,
1804
- msgType: event.message_type,
1805
- });
1806
- }
1807
- else if (t === "DIRECT_MESSAGE_CREATE") {
1808
- const event = d;
1809
- // P1-3: 记录已知用户(频道私信用户)
1810
- recordKnownUser({
1811
- openid: event.author.id,
1812
- type: "c2c",
1813
- nickname: event.author.username,
1814
- accountId: account.accountId,
1815
- });
1816
- const dmRefs = parseRefIndices(event.message_scene?.ext, event.message_type, event.msg_elements);
1817
- trySlashCommandOrEnqueue({
1818
- type: "dm",
1819
- senderId: event.author.id,
1820
- senderName: event.author.username,
1821
- content: event.content,
1822
- messageId: event.id,
1823
- timestamp: event.timestamp,
1824
- guildId: event.guild_id,
1825
- attachments: event.attachments,
1826
- refMsgIdx: dmRefs.refMsgIdx,
1827
- msgIdx: dmRefs.msgIdx,
1828
- msgType: event.message_type,
1829
- });
1830
- }
1831
- else if (t === "GROUP_AT_MESSAGE_CREATE") {
1832
- const event = d;
1833
- // 被 @ 的消息,直接入队回复
1834
- recordKnownUser({
1835
- openid: event.author.member_openid,
1836
- type: "group",
1837
- nickname: event.author.username,
1838
- groupOpenid: event.group_openid,
1839
- accountId: account.accountId,
1840
- });
1841
- const groupRefs = parseRefIndices(event.message_scene?.ext, event.message_type, event.msg_elements);
1842
- trySlashCommandOrEnqueue({
1843
- type: "group",
1844
- senderId: event.author.member_openid,
1845
- senderName: event.author.username,
1846
- content: event.content,
1847
- messageId: event.id,
1848
- timestamp: event.timestamp,
1849
- groupOpenid: event.group_openid,
1850
- attachments: event.attachments,
1851
- refMsgIdx: groupRefs.refMsgIdx,
1852
- msgIdx: groupRefs.msgIdx,
1853
- eventType: "GROUP_AT_MESSAGE_CREATE",
1854
- mentions: event.mentions,
1855
- messageScene: event.message_scene,
1856
- msgElements: event.msg_elements,
1857
- msgType: event.message_type,
1858
- });
1859
- }
1860
- else if (t === "GROUP_MESSAGE_CREATE") {
1861
- const event = d;
1862
- recordKnownUser({
1863
- openid: event.author.member_openid,
1864
- type: "group",
1865
- nickname: event.author.username,
1866
- groupOpenid: event.group_openid,
1867
- accountId: account.accountId,
1868
- });
1869
- const groupRefs = parseRefIndices(event.message_scene?.ext, event.message_type, event.msg_elements);
1870
- trySlashCommandOrEnqueue({
1871
- type: "group",
1872
- senderId: event.author.member_openid,
1873
- senderName: event.author.username,
1874
- senderIsBot: event.author.bot,
1875
- content: event.content,
1876
- messageId: event.id,
1877
- timestamp: event.timestamp,
1878
- groupOpenid: event.group_openid,
1879
- attachments: event.attachments,
1880
- refMsgIdx: groupRefs.refMsgIdx,
1881
- msgIdx: groupRefs.msgIdx,
1882
- eventType: "GROUP_MESSAGE_CREATE",
1883
- mentions: event.mentions,
1884
- messageScene: event.message_scene,
1885
- msgElements: event.msg_elements,
1886
- msgType: event.message_type,
1887
- });
1888
- }
1889
- else if (t === "GROUP_ADD_ROBOT") {
1890
- const event = d;
1891
- log?.info(`[qqbot:${account.accountId}] Bot added to group: ${event.group_openid} by ${event.op_member_openid}`);
1892
- recordKnownUser({
1893
- openid: event.op_member_openid,
1894
- type: "group",
1895
- groupOpenid: event.group_openid,
1896
- accountId: account.accountId,
1897
- });
1898
- }
1899
- else if (t === "GROUP_DEL_ROBOT") {
1900
- const event = d;
1901
- log?.info(`[qqbot:${account.accountId}] Bot removed from group: ${event.group_openid} by ${event.op_member_openid}`);
1902
- }
1903
- else if (t === "GROUP_MSG_REJECT") {
1904
- const event = d;
1905
- log?.info(`[qqbot:${account.accountId}] Group ${event.group_openid} rejected bot proactive messages (by ${event.op_member_openid})`);
1906
- }
1907
- else if (t === "GROUP_MSG_RECEIVE") {
1908
- const event = d;
1909
- log?.info(`[qqbot:${account.accountId}] Group ${event.group_openid} accepted bot proactive messages (by ${event.op_member_openid})`);
1910
- }
1911
- else if (t === "INTERACTION_CREATE") {
1912
- const event = d;
1913
- const resolved = event.data?.resolved;
1914
- const sceneDesc = event.scene ?? (event.chat_type === 0 ? "guild" : event.chat_type === 1 ? "group" : "c2c");
1915
- log?.info(`[qqbot:${account.accountId}] Interaction: scene=${sceneDesc}, type=${event.data?.type}, button_id=${resolved?.button_id}, button_data=${resolved?.button_data}, user=${event.group_member_openid || event.user_openid || resolved?.user_id || "unknown"}`);
1916
- handleInteractionCreate({ event, account, cfg, log }).catch((err) => {
1917
- log?.error(`[qqbot:${account.accountId}] Failed to handle interaction ${event.id}: ${err}`);
1874
+ else {
1875
+ // 所有其他事件统一分发
1876
+ dispatchInboundEvent(t, d).catch((err) => {
1877
+ log?.error(`[qqbot:${account.accountId}] Event dispatch error (t=${t}): ${err}`);
1918
1878
  });
1919
1879
  }
1920
1880
  break;
@@ -2056,8 +2016,10 @@ export async function startGateway(ctx) {
2056
2016
  };
2057
2017
  // 开始连接
2058
2018
  await connect();
2059
- // 等待 abort 信号
2019
+ // 等待 abort 信号(如果 connect() 返回时 signal 已经 aborted,直接 resolve)
2020
+ if (abortSignal.aborted)
2021
+ return;
2060
2022
  return new Promise((resolve) => {
2061
- abortSignal.addEventListener("abort", () => resolve());
2023
+ abortSignal.addEventListener("abort", () => resolve(), { once: true });
2062
2024
  });
2063
2025
  }
@@ -30,11 +30,16 @@ export function getFrameworkVersion() {
30
30
  // Windows 上 npm 安装的 CLI 通常是 .cmd wrapper,execFileSync 需要 shell:true 才能执行
31
31
  for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
32
32
  try {
33
- const out = execFileSync(cli, ["--version"], {
33
+ const rawOut = execFileSync(cli, ["--version"], {
34
34
  timeout: 3000, encoding: "utf8",
35
35
  ...(isWindows() ? { shell: true } : {}),
36
36
  }).trim();
37
37
  // 输出格式: "OpenClaw 2026.3.13 (61d171a)"
38
+ // CLI 启动时可能输出 proxy 等初始化日志到 stdout,需过滤出真正的版本行
39
+ const out = rawOut
40
+ .split("\n")
41
+ .find((line) => /^(OpenClaw|clawdbot|moltbot)\s/i.test(line))
42
+ ?.trim() ?? rawOut;
38
43
  if (out) {
39
44
  return out;
40
45
  }
@@ -240,7 +245,7 @@ registerCommand({
240
245
  ].join("\n"),
241
246
  handler: (ctx) => {
242
247
  // 群聊场景排除仅限私聊的指令
243
- const GROUP_EXCLUDED_COMMANDS = new Set(["bot-upgrade", "bot-clear-storage"]);
248
+ const GROUP_EXCLUDED_COMMANDS = new Set(["bot-upgrade", "bot-clear-storage", "bot-logs", "bot-approve", "bot-group-allways", "bot-streaming"]);
244
249
  const isGroup = ctx.type === "group";
245
250
  const lines = [`### QQBot插件内置调试指令`, ``];
246
251
  for (const [name, cmd] of commands) {
@@ -1505,7 +1510,11 @@ registerCommand({
1505
1510
  `导出最近的 OpenClaw 日志文件(最多 4 个)。`,
1506
1511
  `每个文件最多保留最后 1000 行,以文件形式返回。`,
1507
1512
  ].join("\n"),
1508
- handler: () => {
1513
+ handler: (ctx) => {
1514
+ // 日志导出仅在私聊中可用
1515
+ if (ctx.type !== "c2c") {
1516
+ return `💡 请在私聊中使用此指令`;
1517
+ }
1509
1518
  const logDirs = collectCandidateLogDirs();
1510
1519
  const recentFiles = collectRecentLogFiles(logDirs).slice(0, 4);
1511
1520
  if (recentFiles.length === 0) {
@@ -1870,6 +1879,10 @@ registerCommand({
1870
1879
  `/bot-approve status 查看当前审批配置`,
1871
1880
  ].join("\n"),
1872
1881
  handler: async (ctx) => {
1882
+ // 审批管理仅在私聊中可用
1883
+ if (ctx.type !== "c2c") {
1884
+ return `💡 请在私聊中使用此指令`;
1885
+ }
1873
1886
  const arg = ctx.args.trim().toLowerCase();
1874
1887
  // 审批功能需要 openclaw >= 3.22(gateway-runtime 模块)
1875
1888
  if (!isApprovalFeatureAvailable()) {
@@ -2083,6 +2096,109 @@ registerCommand({
2083
2096
  ].join("\n");
2084
2097
  },
2085
2098
  });
2099
+ // ============ /bot-group-allways ============
2100
+ /**
2101
+ * /bot-group-allways on|off — 修改群消息默认响应模式
2102
+ *
2103
+ * 直接修改当前账户的 defaultRequireMention 配置项并持久化到 openclaw.json。
2104
+ * 修改后即时生效(下一条消息起按新配置处理)。
2105
+ *
2106
+ * on = AI 自主判断何时发言(无需 @),off = 仅被 @ 时回复
2107
+ */
2108
+ registerCommand({
2109
+ name: "bot-group-allways",
2110
+ description: "修改群消息默认响应模式",
2111
+ usage: [
2112
+ `/bot-group-allways on AI 自主判断何时发言(无需 @)`,
2113
+ `/bot-group-allways off 仅在被 @ 时回复`,
2114
+ `/bot-group-allways 查看当前设置`,
2115
+ ``,
2116
+ `设为 on 后,AI 会自主判断每条消息是否需要回复(无需 @)。`,
2117
+ `仍可通过 groups.{groupId}.requireMention 对单个群覆盖。`,
2118
+ ``,
2119
+ `优先级:具体群配置 > 通配符 "*" > defaultRequireMention(本指令)> 默认 true`,
2120
+ ].join("\n"),
2121
+ handler: async (ctx) => {
2122
+ // 群响应模式设置仅在私聊中可用
2123
+ if (ctx.type !== "c2c") {
2124
+ return `💡 请在私聊中使用此指令`;
2125
+ }
2126
+ const arg = ctx.args.trim().toLowerCase();
2127
+ // 读取当前 defaultRequireMention 状态
2128
+ const currentVal = ctx.accountConfig?.defaultRequireMention;
2129
+ const currentRequireMention = currentVal ?? true; // 未设置时硬编码默认为 true
2130
+ // 无参数:查看当前状态
2131
+ if (!arg) {
2132
+ return [
2133
+ `🤖 群自主发言状态:${currentRequireMention ? "❌ 仅被 @ 时回复" : "✅ 自主判断何时发言"}`,
2134
+ `使用 <qqbot-cmd-input text="/bot-group-allways on" show="/bot-group-allways on"/> 设为自主发言`,
2135
+ `使用 <qqbot-cmd-input text="/bot-group-allways off" show="/bot-group-allways off"/> 设为仅被 @ 时回复`,
2136
+ ].join("\n");
2137
+ }
2138
+ if (arg !== "on" && arg !== "off") {
2139
+ return `❌ 参数错误,请使用 on 或 off\n\n示例:/bot-group-allways on`;
2140
+ }
2141
+ const newRequireMention = arg === "off"; // on=自主发言(requireMention=false), off=仅被@时回复(requireMention=true)
2142
+ // 如果状态没变,直接返回
2143
+ if (newRequireMention === currentRequireMention) {
2144
+ return `🤖 群自主发言已经是"${arg}"状态,无需操作`;
2145
+ }
2146
+ // 更新配置
2147
+ try {
2148
+ const runtime = getQQBotRuntime();
2149
+ const configApi = runtime.config;
2150
+ const currentCfg = structuredClone(configApi.loadConfig());
2151
+ const qqbot = (currentCfg.channels ?? {}).qqbot;
2152
+ if (!qqbot) {
2153
+ return `❌ 配置文件中未找到 qqbot 通道配置`;
2154
+ }
2155
+ const accountId = ctx.accountId;
2156
+ const isNamedAccount = accountId !== "default" && qqbot.accounts?.[accountId];
2157
+ if (isNamedAccount) {
2158
+ // 命名账户:更新 accounts.{accountId}.defaultRequireMention
2159
+ const accounts = qqbot.accounts;
2160
+ const acct = accounts[accountId] ?? {};
2161
+ acct.defaultRequireMention = newRequireMention;
2162
+ accounts[accountId] = acct;
2163
+ qqbot.accounts = accounts;
2164
+ }
2165
+ else {
2166
+ // 默认账户:更新 qqbot.defaultRequireMention
2167
+ qqbot.defaultRequireMention = newRequireMention;
2168
+ }
2169
+ await configApi.writeConfigFile(currentCfg);
2170
+ return [
2171
+ `✅ 群自主发言已设置为 ${newRequireMention ? "**off**(仅被 @ 时回复)" : "**on**(AI 自主判断何时发言)"}`,
2172
+ ``,
2173
+ newRequireMention
2174
+ ? `仅在被 @ 机器人才会回复。`
2175
+ : `AI 将自主判断群消息是否需要回复,无需被 @ 即可发言。`,
2176
+ ``,
2177
+ ].join("\n");
2178
+ }
2179
+ catch (err) {
2180
+ const fwVer = getFrameworkVersion();
2181
+ return [
2182
+ `❌ 当前版本不支持该指令`,
2183
+ ``,
2184
+ `🦞框架版本:${fwVer}`,
2185
+ `🤖QQBot 插件版本:v${PLUGIN_VERSION}`,
2186
+ ``,
2187
+ `可通过以下命令手动设置:`,
2188
+ ``,
2189
+ `\`\`\`shell`,
2190
+ `# 设为 AI 自主判断何时发言(defaultRequireMention=false)`,
2191
+ `openclaw config set channels.qqbot.defaultRequireMention false`,
2192
+ `# 或设为仅被 @ 时回复(defaultRequireMention=true)`,
2193
+ `openclaw config set channels.qqbot.defaultRequireMention true`,
2194
+ ``,
2195
+ `# 重启网关使配置生效`,
2196
+ `openclaw gateway restart`,
2197
+ `\`\`\``,
2198
+ ].join("\n");
2199
+ }
2200
+ },
2201
+ });
2086
2202
  // ============ 匹配入口 ============
2087
2203
  /**
2088
2204
  * 尝试匹配并执行插件级斜杠指令
@@ -1,8 +1,6 @@
1
1
  import { resolveQQBotAccount } from "../config.js";
2
2
  import { listQQBotAccountIds } from "../config.js";
3
- import { getAccessToken } from "../api.js";
4
- // ========== 常量 ==========
5
- const API_BASE = "https://api.sgroup.qq.com";
3
+ import { getAccessToken, API_BASE } from "../api.js";
6
4
  const DEFAULT_TIMEOUT_MS = 30000;
7
5
  // ========== JSON Schema ==========
8
6
  const ChannelApiSchema = {
@@ -230,5 +228,4 @@ export function registerChannelTool(api) {
230
228
  }
231
229
  },
232
230
  }, { name: "qqbot_channel_api" });
233
- console.log("[qqbot-channel-api] Registered QQ channel API proxy tool");
234
231
  }
@@ -252,5 +252,4 @@ export function registerRemindTool(api) {
252
252
  });
253
253
  },
254
254
  }, { name: "qqbot_remind" });
255
- console.log("[qqbot-remind] Registered QQBot remind tool");
256
255
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Transport module — QQ Bot event receiving mechanisms.
3
+ *
4
+ * Supports two transport modes:
5
+ * - **WebSocket** (default): long-lived WS connection with heartbeat, RESUME, etc.
6
+ * - **Webhook** (HTTP callback): QQ platform POSTs events to registered path.
7
+ */
8
+ export type { WebhookInboundEvent, WebhookTransportOptions, QQBotWebhookTarget } from "./webhook-transport.js";
9
+ export { startWebhookTransport, resolveWebhookPath } from "./webhook-transport.js";
10
+ export { verifyWebhookSignature, signValidationResponse, ed25519Sign } from "./webhook-verify.js";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Transport module — QQ Bot event receiving mechanisms.
3
+ *
4
+ * Supports two transport modes:
5
+ * - **WebSocket** (default): long-lived WS connection with heartbeat, RESUME, etc.
6
+ * - **Webhook** (HTTP callback): QQ platform POSTs events to registered path.
7
+ */
8
+ export { startWebhookTransport, resolveWebhookPath } from "./webhook-transport.js";
9
+ export { verifyWebhookSignature, signValidationResponse, ed25519Sign } from "./webhook-verify.js";
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Webhook Transport — receive QQ Bot events via HTTP POST callbacks.
3
+ *
4
+ * Uses OpenClaw plugin-sdk's `registerWebhookTargetWithPluginRoute` (模式 C)
5
+ * to register webhook HTTP routes through the framework's gateway HTTP server,
6
+ * sharing the same port and benefiting from built-in rate limiting & in-flight guards.
7
+ *
8
+ * Architecture:
9
+ * 1. On gateway startAccount, register a webhook target via plugin-sdk
10
+ * 2. Framework routes POST requests to our handler
11
+ * 3. Handler verifies Ed25519 signatures and dispatches events
12
+ * 4. Returns op:12 ACK immediately, processes events asynchronously
13
+ * 5. On account stop (abortSignal), unregister the target
14
+ *
15
+ * Configuration (openclaw.yaml):
16
+ * ```yaml
17
+ * channels:
18
+ * qqbot:
19
+ * appId: "xxx"
20
+ * clientSecret: "xxx"
21
+ * transport: webhook
22
+ * webhook:
23
+ * path: /qqbot/webhook
24
+ * ```
25
+ */
26
+ import type { ResolvedQQBotAccount } from "../types.js";
27
+ /** Webhook target registered per account */
28
+ export interface QQBotWebhookTarget {
29
+ path: string;
30
+ accountId: string;
31
+ appId: string;
32
+ clientSecret: string;
33
+ }
34
+ /** Webhook event dispatched to the consumer */
35
+ export interface WebhookInboundEvent {
36
+ eventType: string;
37
+ data: unknown;
38
+ seq?: number;
39
+ }
40
+ /** Options for starting webhook transport */
41
+ export interface WebhookTransportOptions {
42
+ account: ResolvedQQBotAccount;
43
+ abortSignal: AbortSignal;
44
+ onEvent: (event: WebhookInboundEvent) => void | Promise<void>;
45
+ onReady?: () => void;
46
+ onError?: (error: Error) => void;
47
+ log?: {
48
+ info: (msg: string) => void;
49
+ warn?: (msg: string) => void;
50
+ error: (msg: string) => void;
51
+ debug?: (msg: string) => void;
52
+ };
53
+ }
54
+ /**
55
+ * Start the webhook transport for a given account.
56
+ *
57
+ * Registers a webhook target on the framework's plugin HTTP route system.
58
+ * The handler verifies Ed25519 signatures, ACKs immediately, and dispatches
59
+ * events asynchronously via the provided `onEvent` callback.
60
+ *
61
+ * Returns when the abortSignal is triggered (account stopped).
62
+ */
63
+ export declare function startWebhookTransport(opts: WebhookTransportOptions): Promise<void>;
64
+ /**
65
+ * Resolve the webhook path for a given account (for external configuration / setWebhook calls).
66
+ */
67
+ export declare function resolveWebhookPath(account: ResolvedQQBotAccount): string;