@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.
- package/README.md +188 -3
- package/README.zh.md +190 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/src/api.d.ts +2 -0
- package/dist/src/api.js +16 -3
- package/dist/src/config.d.ts +5 -1
- package/dist/src/config.js +12 -2
- package/dist/src/gateway.js +131 -169
- package/dist/src/slash-commands.js +119 -3
- package/dist/src/tools/channel.js +1 -4
- package/dist/src/tools/remind.js +0 -1
- package/dist/src/transport/index.d.ts +10 -0
- package/dist/src/transport/index.js +9 -0
- package/dist/src/transport/webhook-transport.d.ts +67 -0
- package/dist/src/transport/webhook-transport.js +245 -0
- package/dist/src/transport/webhook-verify.d.ts +48 -0
- package/dist/src/transport/webhook-verify.js +98 -0
- package/dist/src/types.d.ts +19 -0
- package/dist/src/utils/audio-convert.js +37 -9
- package/index.ts +1 -0
- package/package.json +1 -1
- package/scripts/postinstall-link-sdk.js +44 -0
- package/scripts/upgrade-via-npm.sh +358 -62
- package/scripts/upgrade-via-source.sh +122 -85
- package/src/api.ts +18 -4
- package/src/config.ts +15 -2
- package/src/gateway.ts +135 -167
- package/src/onboarding.ts +8 -0
- package/src/slash-commands.ts +137 -3
- package/src/tools/channel.ts +1 -7
- package/src/tools/remind.ts +0 -2
- package/src/transport/index.ts +11 -0
- package/src/transport/webhook-transport.ts +332 -0
- package/src/transport/webhook-verify.ts +119 -0
- package/src/types.ts +22 -1
- package/src/typings/openclaw-webhook-ingress.d.ts +66 -0
- package/src/utils/audio-convert.ts +37 -9
package/src/gateway.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import fs from "node:fs";
|
|
4
|
-
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent, InteractionEvent, MsgElement } from "./types.js";
|
|
4
|
+
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent, InteractionEvent, MsgElement, TransportMode } from "./types.js";
|
|
5
5
|
import { MSG_TYPE_QUOTE } from "./types.js";
|
|
6
|
+
import { startWebhookTransport } from "./transport/index.js";
|
|
6
7
|
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, getPluginUserAgent, sendProactiveGroupMessage, acknowledgeInteraction, getApiPluginVersion, setApiLogger } from "./api.js";
|
|
7
8
|
import { loadSession, saveSession, clearSession } from "./session-store.js";
|
|
8
9
|
import { recordKnownUser, flushKnownUsers } from "./known-users.js";
|
|
@@ -418,6 +419,21 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
418
419
|
throw new Error("QQBot not configured (missing appId or clientSecret)");
|
|
419
420
|
}
|
|
420
421
|
|
|
422
|
+
// 安全网:捕获 approval-handler / SDK 内部 WS 握手异步错误(如 403),避免进程崩溃
|
|
423
|
+
const wsUncaughtHandler = (err: Error) => {
|
|
424
|
+
if (err.message?.includes("Unexpected server response")) {
|
|
425
|
+
log?.error(`[qqbot:${account.accountId}] Caught WS handshake error (non-fatal): ${err.message}`);
|
|
426
|
+
// 不重新抛出,防止进程退出
|
|
427
|
+
} else {
|
|
428
|
+
// 非 WS 握手错误,重新抛出交给上层处理
|
|
429
|
+
throw err;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
process.on("uncaughtException", wsUncaughtHandler);
|
|
433
|
+
abortSignal.addEventListener("abort", () => {
|
|
434
|
+
process.removeListener("uncaughtException", wsUncaughtHandler);
|
|
435
|
+
}, { once: true });
|
|
436
|
+
|
|
421
437
|
// 启动环境诊断(首次连接时执行)
|
|
422
438
|
const diag = await runDiagnostics();
|
|
423
439
|
if (diag.warnings.length > 0) {
|
|
@@ -511,6 +527,13 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
511
527
|
log?.info(`[qqbot:${account.accountId}] Image server disabled (no imageServerBaseUrl configured)`);
|
|
512
528
|
}
|
|
513
529
|
|
|
530
|
+
// ============ Transport 模式标记 ============
|
|
531
|
+
const transportMode: TransportMode = account.config.transport ?? "websocket";
|
|
532
|
+
if (transportMode === "webhook") {
|
|
533
|
+
log?.info(`[qqbot:${account.accountId}] Using webhook transport mode`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ============ WebSocket / Webhook 公共初始化 ============
|
|
514
537
|
let reconnectAttempts = 0;
|
|
515
538
|
let isAborted = false;
|
|
516
539
|
let currentWs: WebSocket | null = null;
|
|
@@ -545,7 +568,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
545
568
|
log,
|
|
546
569
|
});
|
|
547
570
|
registerApprovalHandler(account.accountId, approvalHandler);
|
|
548
|
-
|
|
571
|
+
approvalHandler.start().catch((err) => {
|
|
572
|
+
log?.error(`[qqbot:${account.accountId}] approval-handler: uncaught start error: ${err}`);
|
|
573
|
+
});
|
|
549
574
|
|
|
550
575
|
// ============ 消息队列(复用 createMessageQueue,内置群消息合并/淘汰策略) ============
|
|
551
576
|
const msgQueue = createMessageQueue({
|
|
@@ -739,15 +764,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
739
764
|
clearTokenCache(account.appId);
|
|
740
765
|
shouldRefreshToken = false;
|
|
741
766
|
}
|
|
742
|
-
|
|
743
|
-
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
744
|
-
log?.info(`[qqbot:${account.accountId}] ✅ Access token obtained successfully`);
|
|
745
|
-
const gatewayUrl = await getGatewayUrl(accessToken);
|
|
746
|
-
|
|
747
|
-
log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
|
|
748
|
-
|
|
749
|
-
const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent": getPluginUserAgent() } });
|
|
750
|
-
currentWs = ws;
|
|
751
767
|
|
|
752
768
|
const pluginRuntime = getQQBotRuntime();
|
|
753
769
|
|
|
@@ -1502,6 +1518,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1502
1518
|
});
|
|
1503
1519
|
}
|
|
1504
1520
|
|
|
1521
|
+
// 打印 runId 用于调试
|
|
1522
|
+
log?.info?.(`[qqbot:${account.accountId}] Dispatching with runId: ${event.messageId}`);
|
|
1523
|
+
|
|
1505
1524
|
const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1506
1525
|
ctx: ctxPayload,
|
|
1507
1526
|
cfg,
|
|
@@ -1772,7 +1791,10 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1772
1791
|
}
|
|
1773
1792
|
},
|
|
1774
1793
|
},
|
|
1794
|
+
|
|
1775
1795
|
replyOptions: {
|
|
1796
|
+
// 使用消息ID作为 runId,用于追踪一次完整的 AI 对话运行
|
|
1797
|
+
runId: event.messageId,
|
|
1776
1798
|
// 流式模式时禁用 block streaming
|
|
1777
1799
|
disableBlockStreaming: !useStreaming,
|
|
1778
1800
|
// 流式模式下注册 onPartialReply 回调,接收流式文本增量
|
|
@@ -1868,6 +1890,99 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1868
1890
|
}); // end runWithRequestContext
|
|
1869
1891
|
};
|
|
1870
1892
|
|
|
1893
|
+
// ============ 统一事件分发(WebSocket/Webhook 共用) ============
|
|
1894
|
+
const dispatchInboundEvent = async (t: string, d: unknown): Promise<void> => {
|
|
1895
|
+
if (t === "C2C_MESSAGE_CREATE") {
|
|
1896
|
+
const ev = d as C2CMessageEvent;
|
|
1897
|
+
recordKnownUser({ openid: ev.author.user_openid, type: "c2c", accountId: account.accountId });
|
|
1898
|
+
const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements);
|
|
1899
|
+
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 });
|
|
1900
|
+
} else if (t === "AT_MESSAGE_CREATE") {
|
|
1901
|
+
const ev = d as GuildMessageEvent;
|
|
1902
|
+
recordKnownUser({ openid: ev.author.id, type: "c2c", nickname: ev.author.username, accountId: account.accountId });
|
|
1903
|
+
const refs = parseRefIndices((ev as any).message_scene?.ext, (ev as any).message_type, (ev as any).msg_elements);
|
|
1904
|
+
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 as any).message_type });
|
|
1905
|
+
} else if (t === "DIRECT_MESSAGE_CREATE") {
|
|
1906
|
+
const ev = d as GuildMessageEvent;
|
|
1907
|
+
recordKnownUser({ openid: ev.author.id, type: "c2c", nickname: ev.author.username, accountId: account.accountId });
|
|
1908
|
+
const refs = parseRefIndices((ev as any).message_scene?.ext, (ev as any).message_type, (ev as any).msg_elements);
|
|
1909
|
+
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 as any).message_type });
|
|
1910
|
+
} else if (t === "GROUP_AT_MESSAGE_CREATE" || t === "GROUP_MESSAGE_CREATE") {
|
|
1911
|
+
const ev = d as GroupMessageEvent;
|
|
1912
|
+
recordKnownUser({ openid: ev.author.member_openid, type: "group", nickname: ev.author.username, groupOpenid: ev.group_openid, accountId: account.accountId });
|
|
1913
|
+
const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements);
|
|
1914
|
+
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 });
|
|
1915
|
+
} else if (t === "GROUP_ADD_ROBOT") {
|
|
1916
|
+
const ev = d as { timestamp: string; group_openid: string; op_member_openid: string };
|
|
1917
|
+
log?.info(`[qqbot:${account.accountId}] Bot added to group: ${ev.group_openid} by ${ev.op_member_openid}`);
|
|
1918
|
+
recordKnownUser({ openid: ev.op_member_openid, type: "group", groupOpenid: ev.group_openid, accountId: account.accountId });
|
|
1919
|
+
} else if (t === "GROUP_DEL_ROBOT") {
|
|
1920
|
+
const ev = d as { timestamp: string; group_openid: string; op_member_openid: string };
|
|
1921
|
+
log?.info(`[qqbot:${account.accountId}] Bot removed from group: ${ev.group_openid} by ${ev.op_member_openid}`);
|
|
1922
|
+
} else if (t === "GROUP_MSG_REJECT") {
|
|
1923
|
+
const ev = d as { timestamp: number; group_openid: string; op_member_openid: string };
|
|
1924
|
+
log?.info(`[qqbot:${account.accountId}] Group ${ev.group_openid} rejected bot proactive messages (by ${ev.op_member_openid})`);
|
|
1925
|
+
} else if (t === "GROUP_MSG_RECEIVE") {
|
|
1926
|
+
const ev = d as { timestamp: number; group_openid: string; op_member_openid: string };
|
|
1927
|
+
log?.info(`[qqbot:${account.accountId}] Group ${ev.group_openid} accepted bot proactive messages (by ${ev.op_member_openid})`);
|
|
1928
|
+
} else if (t === "INTERACTION_CREATE") {
|
|
1929
|
+
const ev = d as InteractionEvent;
|
|
1930
|
+
const resolved = ev.data?.resolved;
|
|
1931
|
+
const sceneDesc = ev.scene ?? (ev.chat_type === 0 ? "guild" : ev.chat_type === 1 ? "group" : "c2c");
|
|
1932
|
+
log?.info(`[qqbot:${account.accountId}] Interaction: scene=${sceneDesc}, type=${ev.data?.type}, button_id=${resolved?.button_id}, button_data=${resolved?.button_data}`);
|
|
1933
|
+
handleInteractionCreate({ event: ev, account, cfg, log }).catch((err) => {
|
|
1934
|
+
log?.error(`[qqbot:${account.accountId}] Failed to handle interaction ${ev.id}: ${err}`);
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
};
|
|
1938
|
+
|
|
1939
|
+
// ============ Webhook 模式:共享 handleMessage,不走 WS ============
|
|
1940
|
+
if (transportMode === "webhook") {
|
|
1941
|
+
isConnecting = false;
|
|
1942
|
+
msgQueue.startProcessor(handleMessage);
|
|
1943
|
+
startBackgroundTokenRefresh(account.appId, account.clientSecret, {
|
|
1944
|
+
log: log as { info: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void },
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
await startWebhookTransport({
|
|
1948
|
+
account,
|
|
1949
|
+
abortSignal,
|
|
1950
|
+
onEvent: async (event) => {
|
|
1951
|
+
const { eventType: t, data: d } = event;
|
|
1952
|
+
log?.info(`[qqbot:${account.accountId}:webhook] 📩 Dispatch event: t=${t}, d=${JSON.stringify(d)}`);
|
|
1953
|
+
await dispatchInboundEvent(t, d);
|
|
1954
|
+
},
|
|
1955
|
+
onReady: () => {
|
|
1956
|
+
log?.info(`[qqbot:${account.accountId}:webhook] Transport ready`);
|
|
1957
|
+
log?.info(`[qqbot:${account.accountId}] ✅ Webhook transport started successfully (path: ${account.config.webhook?.path ?? "/qqbot/webhook"})`);
|
|
1958
|
+
onReady?.({ transport: "webhook" });
|
|
1959
|
+
if (_pendingFirstReady.has(account.accountId)) {
|
|
1960
|
+
_pendingFirstReady.delete(account.accountId);
|
|
1961
|
+
sendStartupGreetings(adminCtx, "READY");
|
|
1962
|
+
}
|
|
1963
|
+
},
|
|
1964
|
+
onError: (error) => {
|
|
1965
|
+
log?.error(`[qqbot:${account.accountId}:webhook] Error: ${error.message}`);
|
|
1966
|
+
onError?.(error);
|
|
1967
|
+
},
|
|
1968
|
+
log,
|
|
1969
|
+
});
|
|
1970
|
+
|
|
1971
|
+
stopBackgroundTokenRefresh();
|
|
1972
|
+
unregisterApprovalHandler(account.accountId);
|
|
1973
|
+
return; // webhook transport 结束,不继续 WS 逻辑
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// ============ WebSocket 模式:获取 token 并建立 WS 连接 ============
|
|
1977
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
1978
|
+
log?.info(`[qqbot:${account.accountId}] ✅ Access token obtained successfully`);
|
|
1979
|
+
const gatewayUrl = await getGatewayUrl(accessToken);
|
|
1980
|
+
|
|
1981
|
+
log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
|
|
1982
|
+
|
|
1983
|
+
const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent": getPluginUserAgent() } });
|
|
1984
|
+
currentWs = ws;
|
|
1985
|
+
|
|
1871
1986
|
ws.on("open", () => {
|
|
1872
1987
|
log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
|
|
1873
1988
|
isConnecting = false; // 连接完成,释放锁
|
|
@@ -1990,159 +2105,10 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1990
2105
|
appId: account.appId,
|
|
1991
2106
|
});
|
|
1992
2107
|
}
|
|
1993
|
-
} else
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
openid: event.author.user_openid,
|
|
1998
|
-
type: "c2c",
|
|
1999
|
-
accountId: account.accountId,
|
|
2000
|
-
});
|
|
2001
|
-
// 解析引用索引
|
|
2002
|
-
const c2cRefs = parseRefIndices(event.message_scene?.ext, event.message_type, event.msg_elements);
|
|
2003
|
-
// 斜杠指令拦截 → 不匹配则入队
|
|
2004
|
-
trySlashCommandOrEnqueue({
|
|
2005
|
-
type: "c2c",
|
|
2006
|
-
senderId: event.author.user_openid,
|
|
2007
|
-
content: event.content,
|
|
2008
|
-
messageId: event.id,
|
|
2009
|
-
timestamp: event.timestamp,
|
|
2010
|
-
attachments: event.attachments,
|
|
2011
|
-
refMsgIdx: c2cRefs.refMsgIdx,
|
|
2012
|
-
msgIdx: c2cRefs.msgIdx,
|
|
2013
|
-
msgElements: event.msg_elements,
|
|
2014
|
-
msgType: event.message_type,
|
|
2015
|
-
});
|
|
2016
|
-
} else if (t === "AT_MESSAGE_CREATE") {
|
|
2017
|
-
const event = d as GuildMessageEvent;
|
|
2018
|
-
// P1-3: 记录已知用户(频道用户)
|
|
2019
|
-
recordKnownUser({
|
|
2020
|
-
openid: event.author.id,
|
|
2021
|
-
type: "c2c", // 频道用户按 c2c 类型存储
|
|
2022
|
-
nickname: event.author.username,
|
|
2023
|
-
accountId: account.accountId,
|
|
2024
|
-
});
|
|
2025
|
-
const guildRefs = parseRefIndices((event as any).message_scene?.ext, (event as any).message_type, (event as any).msg_elements);
|
|
2026
|
-
trySlashCommandOrEnqueue({
|
|
2027
|
-
type: "guild",
|
|
2028
|
-
senderId: event.author.id,
|
|
2029
|
-
senderName: event.author.username,
|
|
2030
|
-
content: event.content,
|
|
2031
|
-
messageId: event.id,
|
|
2032
|
-
timestamp: event.timestamp,
|
|
2033
|
-
channelId: event.channel_id,
|
|
2034
|
-
guildId: event.guild_id,
|
|
2035
|
-
attachments: event.attachments,
|
|
2036
|
-
refMsgIdx: guildRefs.refMsgIdx,
|
|
2037
|
-
msgIdx: guildRefs.msgIdx,
|
|
2038
|
-
msgType: (event as any).message_type,
|
|
2039
|
-
});
|
|
2040
|
-
} else if (t === "DIRECT_MESSAGE_CREATE") {
|
|
2041
|
-
const event = d as GuildMessageEvent;
|
|
2042
|
-
// P1-3: 记录已知用户(频道私信用户)
|
|
2043
|
-
recordKnownUser({
|
|
2044
|
-
openid: event.author.id,
|
|
2045
|
-
type: "c2c",
|
|
2046
|
-
nickname: event.author.username,
|
|
2047
|
-
accountId: account.accountId,
|
|
2048
|
-
});
|
|
2049
|
-
const dmRefs = parseRefIndices((event as any).message_scene?.ext, (event as any).message_type, (event as any).msg_elements);
|
|
2050
|
-
trySlashCommandOrEnqueue({
|
|
2051
|
-
type: "dm",
|
|
2052
|
-
senderId: event.author.id,
|
|
2053
|
-
senderName: event.author.username,
|
|
2054
|
-
content: event.content,
|
|
2055
|
-
messageId: event.id,
|
|
2056
|
-
timestamp: event.timestamp,
|
|
2057
|
-
guildId: event.guild_id,
|
|
2058
|
-
attachments: event.attachments,
|
|
2059
|
-
refMsgIdx: dmRefs.refMsgIdx,
|
|
2060
|
-
msgIdx: dmRefs.msgIdx,
|
|
2061
|
-
msgType: (event as any).message_type,
|
|
2062
|
-
});
|
|
2063
|
-
} else if (t === "GROUP_AT_MESSAGE_CREATE") {
|
|
2064
|
-
const event = d as GroupMessageEvent;
|
|
2065
|
-
// 被 @ 的消息,直接入队回复
|
|
2066
|
-
recordKnownUser({
|
|
2067
|
-
openid: event.author.member_openid,
|
|
2068
|
-
type: "group",
|
|
2069
|
-
nickname: event.author.username,
|
|
2070
|
-
groupOpenid: event.group_openid,
|
|
2071
|
-
accountId: account.accountId,
|
|
2072
|
-
});
|
|
2073
|
-
const groupRefs = parseRefIndices(event.message_scene?.ext, event.message_type, event.msg_elements);
|
|
2074
|
-
trySlashCommandOrEnqueue({
|
|
2075
|
-
type: "group",
|
|
2076
|
-
senderId: event.author.member_openid,
|
|
2077
|
-
senderName: event.author.username,
|
|
2078
|
-
content: event.content,
|
|
2079
|
-
messageId: event.id,
|
|
2080
|
-
timestamp: event.timestamp,
|
|
2081
|
-
groupOpenid: event.group_openid,
|
|
2082
|
-
attachments: event.attachments,
|
|
2083
|
-
refMsgIdx: groupRefs.refMsgIdx,
|
|
2084
|
-
msgIdx: groupRefs.msgIdx,
|
|
2085
|
-
eventType: "GROUP_AT_MESSAGE_CREATE",
|
|
2086
|
-
mentions: event.mentions,
|
|
2087
|
-
messageScene: event.message_scene,
|
|
2088
|
-
msgElements: event.msg_elements,
|
|
2089
|
-
msgType: event.message_type,
|
|
2090
|
-
});
|
|
2091
|
-
} else if (t === "GROUP_MESSAGE_CREATE") {
|
|
2092
|
-
const event = d as GroupMessageEvent;
|
|
2093
|
-
recordKnownUser({
|
|
2094
|
-
openid: event.author.member_openid,
|
|
2095
|
-
type: "group",
|
|
2096
|
-
nickname: event.author.username,
|
|
2097
|
-
groupOpenid: event.group_openid,
|
|
2098
|
-
accountId: account.accountId,
|
|
2099
|
-
});
|
|
2100
|
-
const groupRefs = parseRefIndices(event.message_scene?.ext, event.message_type, event.msg_elements);
|
|
2101
|
-
trySlashCommandOrEnqueue({
|
|
2102
|
-
type: "group",
|
|
2103
|
-
senderId: event.author.member_openid,
|
|
2104
|
-
senderName: event.author.username,
|
|
2105
|
-
senderIsBot: event.author.bot,
|
|
2106
|
-
content: event.content,
|
|
2107
|
-
messageId: event.id,
|
|
2108
|
-
timestamp: event.timestamp,
|
|
2109
|
-
groupOpenid: event.group_openid,
|
|
2110
|
-
attachments: event.attachments,
|
|
2111
|
-
refMsgIdx: groupRefs.refMsgIdx,
|
|
2112
|
-
msgIdx: groupRefs.msgIdx,
|
|
2113
|
-
eventType: "GROUP_MESSAGE_CREATE",
|
|
2114
|
-
mentions: event.mentions,
|
|
2115
|
-
messageScene: event.message_scene,
|
|
2116
|
-
msgElements: event.msg_elements,
|
|
2117
|
-
msgType: event.message_type,
|
|
2118
|
-
});
|
|
2119
|
-
} else if (t === "GROUP_ADD_ROBOT") {
|
|
2120
|
-
const event = d as { timestamp: string; group_openid: string; op_member_openid: string };
|
|
2121
|
-
log?.info(`[qqbot:${account.accountId}] Bot added to group: ${event.group_openid} by ${event.op_member_openid}`);
|
|
2122
|
-
recordKnownUser({
|
|
2123
|
-
openid: event.op_member_openid,
|
|
2124
|
-
type: "group",
|
|
2125
|
-
groupOpenid: event.group_openid,
|
|
2126
|
-
accountId: account.accountId,
|
|
2127
|
-
});
|
|
2128
|
-
|
|
2129
|
-
} else if (t === "GROUP_DEL_ROBOT") {
|
|
2130
|
-
const event = d as { timestamp: string; group_openid: string; op_member_openid: string };
|
|
2131
|
-
log?.info(`[qqbot:${account.accountId}] Bot removed from group: ${event.group_openid} by ${event.op_member_openid}`);
|
|
2132
|
-
} else if (t === "GROUP_MSG_REJECT") {
|
|
2133
|
-
const event = d as { timestamp: number; group_openid: string; op_member_openid: string };
|
|
2134
|
-
log?.info(`[qqbot:${account.accountId}] Group ${event.group_openid} rejected bot proactive messages (by ${event.op_member_openid})`);
|
|
2135
|
-
} else if (t === "GROUP_MSG_RECEIVE") {
|
|
2136
|
-
const event = d as { timestamp: number; group_openid: string; op_member_openid: string };
|
|
2137
|
-
log?.info(`[qqbot:${account.accountId}] Group ${event.group_openid} accepted bot proactive messages (by ${event.op_member_openid})`);
|
|
2138
|
-
} else if (t === "INTERACTION_CREATE") {
|
|
2139
|
-
const event = d as InteractionEvent;
|
|
2140
|
-
const resolved = event.data?.resolved;
|
|
2141
|
-
const sceneDesc = event.scene ?? (event.chat_type === 0 ? "guild" : event.chat_type === 1 ? "group" : "c2c");
|
|
2142
|
-
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"}`);
|
|
2143
|
-
|
|
2144
|
-
handleInteractionCreate({ event, account, cfg, log }).catch((err) => {
|
|
2145
|
-
log?.error(`[qqbot:${account.accountId}] Failed to handle interaction ${event.id}: ${err}`);
|
|
2108
|
+
} else {
|
|
2109
|
+
// 所有其他事件统一分发
|
|
2110
|
+
dispatchInboundEvent(t!, d).catch((err) => {
|
|
2111
|
+
log?.error(`[qqbot:${account.accountId}] Event dispatch error (t=${t}): ${err}`);
|
|
2146
2112
|
});
|
|
2147
2113
|
}
|
|
2148
2114
|
break;
|
|
@@ -2297,8 +2263,10 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2297
2263
|
// 开始连接
|
|
2298
2264
|
await connect();
|
|
2299
2265
|
|
|
2300
|
-
// 等待 abort
|
|
2301
|
-
|
|
2302
|
-
|
|
2266
|
+
// 等待 abort 信号(如果 connect() 返回时 signal 已经 aborted,直接 resolve)
|
|
2267
|
+
if (abortSignal.aborted) return;
|
|
2268
|
+
return new Promise<void>((resolve) => {
|
|
2269
|
+
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
2303
2270
|
});
|
|
2304
2271
|
}
|
|
2272
|
+
|
package/src/onboarding.ts
CHANGED
|
@@ -23,6 +23,14 @@ interface QQBotChannelConfig {
|
|
|
23
23
|
imageServerBaseUrl?: string;
|
|
24
24
|
markdownSupport?: boolean;
|
|
25
25
|
allowFrom?: string[];
|
|
26
|
+
/** HTTP/WebSocket User-Agent 追加后缀 */
|
|
27
|
+
userAgentSuffix?: string;
|
|
28
|
+
/** 群消息是否默认需要 @提及才触发回复 */
|
|
29
|
+
defaultRequireMention?: boolean;
|
|
30
|
+
/** 消息接收传输方式:websocket(默认)| webhook */
|
|
31
|
+
transport?: string;
|
|
32
|
+
/** webhook 传输配置(transport="webhook" 时生效) */
|
|
33
|
+
webhook?: { path?: string };
|
|
26
34
|
accounts?: Record<string, {
|
|
27
35
|
enabled?: boolean;
|
|
28
36
|
appId?: string;
|
package/src/slash-commands.ts
CHANGED
|
@@ -34,11 +34,16 @@ export function getFrameworkVersion(): string {
|
|
|
34
34
|
// Windows 上 npm 安装的 CLI 通常是 .cmd wrapper,execFileSync 需要 shell:true 才能执行
|
|
35
35
|
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
|
|
36
36
|
try {
|
|
37
|
-
const
|
|
37
|
+
const rawOut = execFileSync(cli, ["--version"], {
|
|
38
38
|
timeout: 3000, encoding: "utf8",
|
|
39
39
|
...(isWindows() ? { shell: true } : {}),
|
|
40
40
|
}).trim();
|
|
41
41
|
// 输出格式: "OpenClaw 2026.3.13 (61d171a)"
|
|
42
|
+
// CLI 启动时可能输出 proxy 等初始化日志到 stdout,需过滤出真正的版本行
|
|
43
|
+
const out = rawOut
|
|
44
|
+
.split("\n")
|
|
45
|
+
.find((line) => /^(OpenClaw|clawdbot|moltbot)\s/i.test(line))
|
|
46
|
+
?.trim() ?? rawOut;
|
|
42
47
|
if (out) {
|
|
43
48
|
return out;
|
|
44
49
|
}
|
|
@@ -332,7 +337,7 @@ registerCommand({
|
|
|
332
337
|
].join("\n"),
|
|
333
338
|
handler: (ctx) => {
|
|
334
339
|
// 群聊场景排除仅限私聊的指令
|
|
335
|
-
const GROUP_EXCLUDED_COMMANDS = new Set(["bot-upgrade", "bot-clear-storage"]);
|
|
340
|
+
const GROUP_EXCLUDED_COMMANDS = new Set(["bot-upgrade", "bot-clear-storage", "bot-logs", "bot-approve", "bot-group-allways", "bot-streaming"]);
|
|
336
341
|
const isGroup = ctx.type === "group";
|
|
337
342
|
|
|
338
343
|
const lines = [`### QQBot插件内置调试指令`, ``];
|
|
@@ -1637,7 +1642,12 @@ registerCommand({
|
|
|
1637
1642
|
`导出最近的 OpenClaw 日志文件(最多 4 个)。`,
|
|
1638
1643
|
`每个文件最多保留最后 1000 行,以文件形式返回。`,
|
|
1639
1644
|
].join("\n"),
|
|
1640
|
-
handler: () => {
|
|
1645
|
+
handler: (ctx) => {
|
|
1646
|
+
// 日志导出仅在私聊中可用
|
|
1647
|
+
if (ctx.type !== "c2c") {
|
|
1648
|
+
return `💡 请在私聊中使用此指令`;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1641
1651
|
const logDirs = collectCandidateLogDirs();
|
|
1642
1652
|
const recentFiles = collectRecentLogFiles(logDirs).slice(0, 4);
|
|
1643
1653
|
|
|
@@ -2037,6 +2047,11 @@ registerCommand({
|
|
|
2037
2047
|
`/bot-approve status 查看当前审批配置`,
|
|
2038
2048
|
].join("\n"),
|
|
2039
2049
|
handler: async (ctx) => {
|
|
2050
|
+
// 审批管理仅在私聊中可用
|
|
2051
|
+
if (ctx.type !== "c2c") {
|
|
2052
|
+
return `💡 请在私聊中使用此指令`;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2040
2055
|
const arg = ctx.args.trim().toLowerCase();
|
|
2041
2056
|
|
|
2042
2057
|
// 审批功能需要 openclaw >= 3.22(gateway-runtime 模块)
|
|
@@ -2259,6 +2274,125 @@ registerCommand({
|
|
|
2259
2274
|
},
|
|
2260
2275
|
});
|
|
2261
2276
|
|
|
2277
|
+
// ============ /bot-group-allways ============
|
|
2278
|
+
|
|
2279
|
+
/**
|
|
2280
|
+
* /bot-group-allways on|off — 修改群消息默认响应模式
|
|
2281
|
+
*
|
|
2282
|
+
* 直接修改当前账户的 defaultRequireMention 配置项并持久化到 openclaw.json。
|
|
2283
|
+
* 修改后即时生效(下一条消息起按新配置处理)。
|
|
2284
|
+
*
|
|
2285
|
+
* on = AI 自主判断何时发言(无需 @),off = 仅被 @ 时回复
|
|
2286
|
+
*/
|
|
2287
|
+
registerCommand({
|
|
2288
|
+
name: "bot-group-allways",
|
|
2289
|
+
description: "修改群消息默认响应模式",
|
|
2290
|
+
usage: [
|
|
2291
|
+
`/bot-group-allways on AI 自主判断何时发言(无需 @)`,
|
|
2292
|
+
`/bot-group-allways off 仅在被 @ 时回复`,
|
|
2293
|
+
`/bot-group-allways 查看当前设置`,
|
|
2294
|
+
``,
|
|
2295
|
+
`设为 on 后,AI 会自主判断每条消息是否需要回复(无需 @)。`,
|
|
2296
|
+
`仍可通过 groups.{groupId}.requireMention 对单个群覆盖。`,
|
|
2297
|
+
``,
|
|
2298
|
+
`优先级:具体群配置 > 通配符 "*" > defaultRequireMention(本指令)> 默认 true`,
|
|
2299
|
+
].join("\n"),
|
|
2300
|
+
handler: async (ctx) => {
|
|
2301
|
+
// 群响应模式设置仅在私聊中可用
|
|
2302
|
+
if (ctx.type !== "c2c") {
|
|
2303
|
+
return `💡 请在私聊中使用此指令`;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
const arg = ctx.args.trim().toLowerCase();
|
|
2307
|
+
|
|
2308
|
+
// 读取当前 defaultRequireMention 状态
|
|
2309
|
+
const currentVal = ctx.accountConfig?.defaultRequireMention;
|
|
2310
|
+
const currentRequireMention = currentVal ?? true; // 未设置时硬编码默认为 true
|
|
2311
|
+
|
|
2312
|
+
// 无参数:查看当前状态
|
|
2313
|
+
if (!arg) {
|
|
2314
|
+
return [
|
|
2315
|
+
`🤖 群自主发言状态:${currentRequireMention ? "❌ 仅被 @ 时回复" : "✅ 自主判断何时发言"}`,
|
|
2316
|
+
`使用 <qqbot-cmd-input text="/bot-group-allways on" show="/bot-group-allways on"/> 设为自主发言`,
|
|
2317
|
+
`使用 <qqbot-cmd-input text="/bot-group-allways off" show="/bot-group-allways off"/> 设为仅被 @ 时回复`,
|
|
2318
|
+
].join("\n");
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
if (arg !== "on" && arg !== "off") {
|
|
2322
|
+
return `❌ 参数错误,请使用 on 或 off\n\n示例:/bot-group-allways on`;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
const newRequireMention = arg === "off"; // on=自主发言(requireMention=false), off=仅被@时回复(requireMention=true)
|
|
2326
|
+
|
|
2327
|
+
// 如果状态没变,直接返回
|
|
2328
|
+
if (newRequireMention === currentRequireMention) {
|
|
2329
|
+
return `🤖 群自主发言已经是"${arg}"状态,无需操作`;
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// 更新配置
|
|
2333
|
+
try {
|
|
2334
|
+
const runtime = getQQBotRuntime();
|
|
2335
|
+
const configApi = runtime.config as {
|
|
2336
|
+
loadConfig: () => Record<string, unknown>;
|
|
2337
|
+
writeConfigFile: (cfg: unknown) => Promise<void>;
|
|
2338
|
+
};
|
|
2339
|
+
|
|
2340
|
+
const currentCfg = structuredClone(configApi.loadConfig()) as Record<string, unknown>;
|
|
2341
|
+
const qqbot = ((currentCfg.channels ?? {}) as Record<string, unknown>).qqbot as Record<string, unknown> | undefined;
|
|
2342
|
+
|
|
2343
|
+
if (!qqbot) {
|
|
2344
|
+
return `❌ 配置文件中未找到 qqbot 通道配置`;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
const accountId = ctx.accountId;
|
|
2348
|
+
const isNamedAccount = accountId !== "default" && (qqbot.accounts as Record<string, Record<string, unknown>> | undefined)?.[accountId];
|
|
2349
|
+
|
|
2350
|
+
if (isNamedAccount) {
|
|
2351
|
+
// 命名账户:更新 accounts.{accountId}.defaultRequireMention
|
|
2352
|
+
const accounts = qqbot.accounts as Record<string, Record<string, unknown>>;
|
|
2353
|
+
const acct = accounts[accountId] ?? {};
|
|
2354
|
+
acct.defaultRequireMention = newRequireMention;
|
|
2355
|
+
accounts[accountId] = acct;
|
|
2356
|
+
qqbot.accounts = accounts;
|
|
2357
|
+
} else {
|
|
2358
|
+
// 默认账户:更新 qqbot.defaultRequireMention
|
|
2359
|
+
qqbot.defaultRequireMention = newRequireMention;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
await configApi.writeConfigFile(currentCfg);
|
|
2363
|
+
|
|
2364
|
+
return [
|
|
2365
|
+
`✅ 群自主发言已设置为 ${newRequireMention ? "**off**(仅被 @ 时回复)" : "**on**(AI 自主判断何时发言)"}`,
|
|
2366
|
+
``,
|
|
2367
|
+
newRequireMention
|
|
2368
|
+
? `仅在被 @ 机器人才会回复。`
|
|
2369
|
+
: `AI 将自主判断群消息是否需要回复,无需被 @ 即可发言。`,
|
|
2370
|
+
``,
|
|
2371
|
+
].join("\n");
|
|
2372
|
+
} catch (err) {
|
|
2373
|
+
const fwVer = getFrameworkVersion();
|
|
2374
|
+
return [
|
|
2375
|
+
`❌ 当前版本不支持该指令`,
|
|
2376
|
+
``,
|
|
2377
|
+
`🦞框架版本:${fwVer}`,
|
|
2378
|
+
`🤖QQBot 插件版本:v${PLUGIN_VERSION}`,
|
|
2379
|
+
``,
|
|
2380
|
+
`可通过以下命令手动设置:`,
|
|
2381
|
+
``,
|
|
2382
|
+
`\`\`\`shell`,
|
|
2383
|
+
`# 设为 AI 自主判断何时发言(defaultRequireMention=false)`,
|
|
2384
|
+
`openclaw config set channels.qqbot.defaultRequireMention false`,
|
|
2385
|
+
`# 或设为仅被 @ 时回复(defaultRequireMention=true)`,
|
|
2386
|
+
`openclaw config set channels.qqbot.defaultRequireMention true`,
|
|
2387
|
+
``,
|
|
2388
|
+
`# 重启网关使配置生效`,
|
|
2389
|
+
`openclaw gateway restart`,
|
|
2390
|
+
`\`\`\``,
|
|
2391
|
+
].join("\n");
|
|
2392
|
+
}
|
|
2393
|
+
},
|
|
2394
|
+
});
|
|
2395
|
+
|
|
2262
2396
|
// ============ 匹配入口 ============
|
|
2263
2397
|
|
|
2264
2398
|
/**
|
package/src/tools/channel.ts
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
2
|
import { resolveQQBotAccount } from "../config.js";
|
|
3
3
|
import { listQQBotAccountIds } from "../config.js";
|
|
4
|
-
import { getAccessToken } from "../api.js";
|
|
5
|
-
|
|
6
|
-
// ========== 常量 ==========
|
|
7
|
-
|
|
8
|
-
const API_BASE = "https://api.sgroup.qq.com";
|
|
4
|
+
import { getAccessToken, API_BASE } from "../api.js";
|
|
9
5
|
|
|
10
6
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
11
7
|
|
|
@@ -276,6 +272,4 @@ export function registerChannelTool(api: OpenClawPluginApi): void {
|
|
|
276
272
|
},
|
|
277
273
|
{ name: "qqbot_channel_api" },
|
|
278
274
|
);
|
|
279
|
-
|
|
280
|
-
console.log("[qqbot-channel-api] Registered QQ channel API proxy tool");
|
|
281
275
|
}
|
package/src/tools/remind.ts
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
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
|
+
|
|
9
|
+
export type { WebhookInboundEvent, WebhookTransportOptions, QQBotWebhookTarget } from "./webhook-transport.js";
|
|
10
|
+
export { startWebhookTransport, resolveWebhookPath } from "./webhook-transport.js";
|
|
11
|
+
export { verifyWebhookSignature, signValidationResponse, ed25519Sign } from "./webhook-verify.js";
|