Merge pull request #2 from sliverp/feature/group

Feature/group
This commit is contained in:
Bijin
2026-01-29 12:21:07 +08:00
committed by GitHub
7 changed files with 679 additions and 233 deletions

View File

@@ -16,6 +16,7 @@ export default plugin;
export { qqbotPlugin } from "./src/channel.js"; export { qqbotPlugin } from "./src/channel.js";
export { setQQBotRuntime, getQQBotRuntime } from "./src/runtime.js"; export { setQQBotRuntime, getQQBotRuntime } from "./src/runtime.js";
export { qqbotOnboardingAdapter } from "./src/onboarding.js";
export * from "./src/types.js"; export * from "./src/types.js";
export * from "./src/api.js"; export * from "./src/api.js";
export * from "./src/config.js"; export * from "./src/config.js";

View File

@@ -43,6 +43,32 @@ export function clearTokenCache(): void {
cachedToken = null; cachedToken = null;
} }
/**
* msg_seq 追踪器 - 用于对同一条消息的多次回复
* key: msg_id, value: 当前 seq 值
*/
const msgSeqTracker = new Map<string, number>();
/**
* 获取并递增消息序号
*/
export function getNextMsgSeq(msgId: string): number {
const current = msgSeqTracker.get(msgId) ?? 0;
const next = current + 1;
msgSeqTracker.set(msgId, next);
// 清理过期的序号(超过 5 次或 60 分钟后无意义)
// 简单策略:保留最近 1000 条
if (msgSeqTracker.size > 1000) {
const keys = Array.from(msgSeqTracker.keys());
for (let i = 0; i < 500; i++) {
msgSeqTracker.delete(keys[i]);
}
}
return next;
}
/** /**
* API 请求封装 * API 请求封装
*/ */
@@ -93,9 +119,11 @@ export async function sendC2CMessage(
content: string, content: string,
msgId?: string msgId?: string
): Promise<{ id: string; timestamp: number }> { ): Promise<{ id: string; timestamp: number }> {
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, { return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
content, content,
msg_type: 0, msg_type: 0,
msg_seq: msgSeq,
...(msgId ? { msg_id: msgId } : {}), ...(msgId ? { msg_id: msgId } : {}),
}); });
} }
@@ -124,9 +152,11 @@ export async function sendGroupMessage(
content: string, content: string,
msgId?: string msgId?: string
): Promise<{ id: string; timestamp: string }> { ): Promise<{ id: string; timestamp: string }> {
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, { return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
content, content,
msg_type: 0, msg_type: 0,
msg_seq: msgSeq,
...(msgId ? { msg_id: msgId } : {}), ...(msgId ? { msg_id: msgId } : {}),
}); });
} }

View File

@@ -3,6 +3,7 @@ import type { ResolvedQQBotAccount } from "./types.js";
import { listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig } from "./config.js"; import { listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig } from "./config.js";
import { sendText } from "./outbound.js"; import { sendText } from "./outbound.js";
import { startGateway } from "./gateway.js"; import { startGateway } from "./gateway.js";
import { qqbotOnboardingAdapter } from "./onboarding.js";
const DEFAULT_ACCOUNT_ID = "default"; const DEFAULT_ACCOUNT_ID = "default";
@@ -23,6 +24,8 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
threads: false, threads: false,
}, },
reload: { configPrefixes: ["channels.qqbot"] }, reload: { configPrefixes: ["channels.qqbot"] },
// CLI onboarding wizard
onboarding: qqbotOnboardingAdapter,
config: { config: {
listAccountIds: (cfg) => listQQBotAccountIds(cfg), listAccountIds: (cfg) => listQQBotAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId), resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),

View File

@@ -62,6 +62,7 @@ export function resolveQQBotAccount(
clientSecretFile: qqbot?.clientSecretFile, clientSecretFile: qqbot?.clientSecretFile,
dmPolicy: qqbot?.dmPolicy, dmPolicy: qqbot?.dmPolicy,
allowFrom: qqbot?.allowFrom, allowFrom: qqbot?.allowFrom,
systemPrompt: qqbot?.systemPrompt,
}; };
appId = qqbot?.appId ?? ""; appId = qqbot?.appId ?? "";
} else { } else {
@@ -95,6 +96,7 @@ export function resolveQQBotAccount(
appId, appId,
clientSecret, clientSecret,
secretSource, secretSource,
systemPrompt: accountConfig.systemPrompt,
config: accountConfig, config: accountConfig,
}; };
} }

View File

@@ -1,6 +1,6 @@
import WebSocket from "ws"; import WebSocket from "ws";
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js"; import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage } from "./api.js"; import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache } from "./api.js";
import { getQQBotRuntime } from "./runtime.js"; import { getQQBotRuntime } from "./runtime.js";
// QQ Bot intents // QQ Bot intents
@@ -10,6 +10,10 @@ const INTENTS = {
GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊 GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊
}; };
// 重连配置
const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟
const MAX_RECONNECT_ATTEMPTS = 100;
export interface GatewayContext { export interface GatewayContext {
account: ResolvedQQBotAccount; account: ResolvedQQBotAccount;
abortSignal: AbortSignal; abortSignal: AbortSignal;
@@ -24,7 +28,7 @@ export interface GatewayContext {
} }
/** /**
* 启动 Gateway WebSocket 连接 * 启动 Gateway WebSocket 连接(带自动重连)
*/ */
export async function startGateway(ctx: GatewayContext): Promise<void> { export async function startGateway(ctx: GatewayContext): Promise<void> {
const { account, abortSignal, cfg, onReady, onError, log } = ctx; const { account, abortSignal, cfg, onReady, onError, log } = ctx;
@@ -33,27 +37,66 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
throw new Error("QQBot not configured (missing appId or clientSecret)"); throw new Error("QQBot not configured (missing appId or clientSecret)");
} }
const pluginRuntime = getQQBotRuntime(); let reconnectAttempts = 0;
const accessToken = await getAccessToken(account.appId, account.clientSecret); let isAborted = false;
const gatewayUrl = await getGatewayUrl(accessToken); let currentWs: WebSocket | null = null;
log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
const ws = new WebSocket(gatewayUrl);
let heartbeatInterval: ReturnType<typeof setInterval> | null = null; let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let sessionId: string | null = null;
let lastSeq: number | null = null; let lastSeq: number | null = null;
abortSignal.addEventListener("abort", () => {
isAborted = true;
cleanup();
});
const cleanup = () => { const cleanup = () => {
if (heartbeatInterval) { if (heartbeatInterval) {
clearInterval(heartbeatInterval); clearInterval(heartbeatInterval);
heartbeatInterval = null; heartbeatInterval = null;
} }
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { if (currentWs && (currentWs.readyState === WebSocket.OPEN || currentWs.readyState === WebSocket.CONNECTING)) {
ws.close(); currentWs.close();
} }
currentWs = null;
}; };
abortSignal.addEventListener("abort", cleanup); const getReconnectDelay = () => {
const idx = Math.min(reconnectAttempts, RECONNECT_DELAYS.length - 1);
return RECONNECT_DELAYS[idx];
};
const scheduleReconnect = () => {
if (isAborted || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
log?.error(`[qqbot:${account.accountId}] Max reconnect attempts reached or aborted`);
return;
}
const delay = getReconnectDelay();
reconnectAttempts++;
log?.info(`[qqbot:${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
setTimeout(() => {
if (!isAborted) {
connect();
}
}, delay);
};
const connect = async () => {
try {
cleanup();
// 刷新 token可能过期了
clearTokenCache();
const accessToken = await getAccessToken(account.appId, account.clientSecret);
const gatewayUrl = await getGatewayUrl(accessToken);
log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
const ws = new WebSocket(gatewayUrl);
currentWs = ws;
const pluginRuntime = getQQBotRuntime();
// 处理收到的消息 // 处理收到的消息
const handleMessage = async (event: { const handleMessage = async (event: {
@@ -92,11 +135,19 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg); const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
// 组装消息体,添加系统提示词
const builtinPrompt = "由于平台限制你的回复中不可以包含任何URL";
const systemPrompts = [builtinPrompt];
if (account.systemPrompt) {
systemPrompts.push(account.systemPrompt);
}
const messageBody = `【系统提示】\n${systemPrompts.join("\n")}\n\n【用户输入】\n${event.content}`;
const body = pluginRuntime.channel.reply.formatInboundEnvelope({ const body = pluginRuntime.channel.reply.formatInboundEnvelope({
channel: "QQBot", channel: "QQBot",
from: event.senderName ?? event.senderId, from: event.senderName ?? event.senderId,
timestamp: new Date(event.timestamp).getTime(), timestamp: new Date(event.timestamp).getTime(),
body: event.content, body: messageBody,
chatType: isGroup ? "group" : "direct", chatType: isGroup ? "group" : "direct",
sender: { sender: {
id: event.senderId, id: event.senderId,
@@ -127,32 +178,68 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
Timestamp: new Date(event.timestamp).getTime(), Timestamp: new Date(event.timestamp).getTime(),
OriginatingChannel: "qqbot", OriginatingChannel: "qqbot",
OriginatingTo: toAddress, OriginatingTo: toAddress,
// QQBot 特有字段
QQChannelId: event.channelId, QQChannelId: event.channelId,
QQGuildId: event.guildId, QQGuildId: event.guildId,
QQGroupOpenid: event.groupOpenid, QQGroupOpenid: event.groupOpenid,
}); });
// 分发到 AI 系统 // 发送错误提示的辅助函数
const sendErrorMessage = async (errorText: string) => {
try {
const token = await getAccessToken(account.appId, account.clientSecret);
if (event.type === "c2c") {
await sendC2CMessage(token, event.senderId, errorText, event.messageId);
} else if (event.type === "group" && event.groupOpenid) {
await sendGroupMessage(token, event.groupOpenid, errorText, event.messageId);
} else if (event.channelId) {
await sendChannelMessage(token, event.channelId, errorText, event.messageId);
}
} catch (sendErr) {
log?.error(`[qqbot:${account.accountId}] Failed to send error message: ${sendErr}`);
}
};
try { try {
const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId); const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
await pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ // 每次发消息前刷新 token
const freshToken = await getAccessToken(account.appId, account.clientSecret);
// 追踪是否有响应
let hasResponse = false;
const responseTimeout = 30000; // 30秒超时
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => {
if (!hasResponse) {
reject(new Error("Response timeout"));
}
}, responseTimeout);
});
const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,
cfg, cfg,
dispatcherOptions: { dispatcherOptions: {
responsePrefix: messagesConfig.responsePrefix, responsePrefix: messagesConfig.responsePrefix,
deliver: async (payload: { text?: string }) => { deliver: async (payload: { text?: string }) => {
hasResponse = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
const replyText = payload.text ?? ""; const replyText = payload.text ?? "";
if (!replyText.trim()) return; if (!replyText.trim()) return;
try { try {
if (event.type === "c2c") { if (event.type === "c2c") {
await sendC2CMessage(accessToken, event.senderId, replyText, event.messageId); await sendC2CMessage(freshToken, event.senderId, replyText, event.messageId);
} else if (event.type === "group" && event.groupOpenid) { } else if (event.type === "group" && event.groupOpenid) {
await sendGroupMessage(accessToken, event.groupOpenid, replyText, event.messageId); await sendGroupMessage(freshToken, event.groupOpenid, replyText, event.messageId);
} else if (event.channelId) { } else if (event.channelId) {
await sendChannelMessage(accessToken, event.channelId, replyText, event.messageId); await sendChannelMessage(freshToken, event.channelId, replyText, event.messageId);
} }
log?.info(`[qqbot:${account.accountId}] Sent reply`); log?.info(`[qqbot:${account.accountId}] Sent reply`);
@@ -165,19 +252,46 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`); log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
} }
}, },
onError: (err: unknown) => { onError: async (err: unknown) => {
log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`); log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`);
hasResponse = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
// 发送错误提示给用户
const errMsg = String(err);
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
await sendErrorMessage("[ClawdBot] 大模型 API Key 可能无效,请检查配置");
} else {
await sendErrorMessage(`[ClawdBot] 处理消息时出错: ${errMsg.slice(0, 100)}`);
}
}, },
}, },
replyOptions: {}, replyOptions: {},
}); });
// 等待分发完成或超时
try {
await Promise.race([dispatchPromise, timeoutPromise]);
} catch (err) {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (!hasResponse) {
log?.error(`[qqbot:${account.accountId}] No response within timeout`);
await sendErrorMessage("[ClawdBot] 未收到响应,请检查大模型 API Key 是否正确配置");
}
}
} catch (err) { } catch (err) {
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`); log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
await sendErrorMessage(`[ClawdBot] 处理消息失败: ${String(err).slice(0, 100)}`);
} }
}; };
ws.on("open", () => { ws.on("open", () => {
log?.info(`[qqbot:${account.accountId}] WebSocket connected`); log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
reconnectAttempts = 0; // 连接成功,重置重试计数
}); });
ws.on("message", async (data) => { ws.on("message", async (data) => {
@@ -191,29 +305,50 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
switch (op) { switch (op) {
case 10: // Hello case 10: // Hello
log?.info(`[qqbot:${account.accountId}] Hello received, starting heartbeat`); log?.info(`[qqbot:${account.accountId}] Hello received`);
// Identify
ws.send( // 如果有 session_id尝试 Resume
JSON.stringify({ if (sessionId && lastSeq !== null) {
log?.info(`[qqbot:${account.accountId}] Attempting to resume session ${sessionId}`);
ws.send(JSON.stringify({
op: 6, // Resume
d: {
token: `QQBot ${accessToken}`,
session_id: sessionId,
seq: lastSeq,
},
}));
} else {
// 新连接,发送 Identify
ws.send(JSON.stringify({
op: 2, op: 2,
d: { d: {
token: `QQBot ${accessToken}`, token: `QQBot ${accessToken}`,
intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C, intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C,
shard: [0, 1], shard: [0, 1],
}, },
}) }));
); }
// Heartbeat
// 启动心跳
const interval = (d as { heartbeat_interval: number }).heartbeat_interval; const interval = (d as { heartbeat_interval: number }).heartbeat_interval;
if (heartbeatInterval) clearInterval(heartbeatInterval);
heartbeatInterval = setInterval(() => { heartbeatInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ op: 1, d: lastSeq })); ws.send(JSON.stringify({ op: 1, d: lastSeq }));
log?.debug?.(`[qqbot:${account.accountId}] Heartbeat sent`);
}
}, interval); }, interval);
break; break;
case 0: // Dispatch case 0: // Dispatch
if (t === "READY") { if (t === "READY") {
log?.info(`[qqbot:${account.accountId}] Ready`); const readyData = d as { session_id: string };
sessionId = readyData.session_id;
log?.info(`[qqbot:${account.accountId}] Ready, session: ${sessionId}`);
onReady?.(d); onReady?.(d);
} else if (t === "RESUMED") {
log?.info(`[qqbot:${account.accountId}] Session resumed`);
} else if (t === "C2C_MESSAGE_CREATE") { } else if (t === "C2C_MESSAGE_CREATE") {
const event = d as C2CMessageEvent; const event = d as C2CMessageEvent;
await handleMessage({ await handleMessage({
@@ -263,10 +398,21 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`); log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`);
break; break;
case 9: // Invalid Session case 7: // Reconnect
log?.error(`[qqbot:${account.accountId}] Invalid session`); log?.info(`[qqbot:${account.accountId}] Server requested reconnect`);
onError?.(new Error("Invalid session"));
cleanup(); cleanup();
scheduleReconnect();
break;
case 9: // Invalid Session
const canResume = d as boolean;
log?.error(`[qqbot:${account.accountId}] Invalid session, can resume: ${canResume}`);
if (!canResume) {
sessionId = null;
lastSeq = null;
}
cleanup();
scheduleReconnect();
break; break;
} }
} catch (err) { } catch (err) {
@@ -275,8 +421,13 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
}); });
ws.on("close", (code, reason) => { ws.on("close", (code, reason) => {
log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason}`); log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`);
cleanup(); cleanup();
// 非正常关闭则重连
if (!isAborted && code !== 1000) {
scheduleReconnect();
}
}); });
ws.on("error", (err) => { ws.on("error", (err) => {
@@ -284,6 +435,15 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
onError?.(err); onError?.(err);
}); });
} catch (err) {
log?.error(`[qqbot:${account.accountId}] Connection failed: ${err}`);
scheduleReconnect();
}
};
// 开始连接
await connect();
// 等待 abort 信号 // 等待 abort 信号
return new Promise((resolve) => { return new Promise((resolve) => {
abortSignal.addEventListener("abort", () => resolve()); abortSignal.addEventListener("abort", () => resolve());

246
src/onboarding.ts Normal file
View File

@@ -0,0 +1,246 @@
/**
* QQBot CLI Onboarding Adapter
*
* 提供 moltbot onboard 命令的交互式配置支持
*/
import type {
ChannelOnboardingAdapter,
ChannelOnboardingStatus,
ChannelOnboardingStatusContext,
ChannelOnboardingConfigureContext,
ChannelOnboardingResult,
} from "clawdbot/plugin-sdk";
import { listQQBotAccountIds, resolveQQBotAccount } from "./config.js";
const DEFAULT_ACCOUNT_ID = "default";
// 内部类型(避免循环依赖)
interface MoltbotConfig {
channels?: {
qqbot?: QQBotChannelConfig;
[key: string]: unknown;
};
[key: string]: unknown;
}
interface QQBotChannelConfig {
enabled?: boolean;
appId?: string;
clientSecret?: string;
clientSecretFile?: string;
name?: string;
accounts?: Record<string, {
enabled?: boolean;
appId?: string;
clientSecret?: string;
clientSecretFile?: string;
name?: string;
}>;
}
/**
* 解析默认账户 ID
*/
function resolveDefaultQQBotAccountId(cfg: MoltbotConfig): string {
const ids = listQQBotAccountIds(cfg);
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
/**
* QQBot Onboarding Adapter
*/
export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
channel: "qqbot" as any,
getStatus: async (ctx: ChannelOnboardingStatusContext): Promise<ChannelOnboardingStatus> => {
const { cfg } = ctx;
const configured = listQQBotAccountIds(cfg as MoltbotConfig).some((accountId) => {
const account = resolveQQBotAccount(cfg as MoltbotConfig, accountId);
return Boolean(account.appId && account.clientSecret);
});
return {
channel: "qqbot" as any,
configured,
statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`],
selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊",
quickstartScore: configured ? 1 : 20,
};
},
configure: async (ctx: ChannelOnboardingConfigureContext): Promise<ChannelOnboardingResult> => {
const { cfg, prompter, accountOverrides, shouldPromptAccountIds } = ctx;
const moltbotCfg = cfg as MoltbotConfig;
const qqbotOverride = (accountOverrides as Record<string, string>).qqbot?.trim();
const defaultAccountId = resolveDefaultQQBotAccountId(moltbotCfg);
let accountId = qqbotOverride ?? defaultAccountId;
// 是否需要提示选择账户
if (shouldPromptAccountIds && !qqbotOverride) {
const existingIds = listQQBotAccountIds(moltbotCfg);
if (existingIds.length > 1) {
accountId = await prompter.select({
message: "选择 QQBot 账户",
options: existingIds.map((id) => ({
value: id,
label: id === DEFAULT_ACCOUNT_ID ? "默认账户" : id,
})),
initialValue: accountId,
});
}
}
let next = moltbotCfg;
const resolvedAccount = resolveQQBotAccount(next, accountId);
const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.clientSecret);
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envAppId = typeof process !== "undefined" ? process.env?.QQBOT_APP_ID?.trim() : undefined;
const envSecret = typeof process !== "undefined" ? process.env?.QQBOT_CLIENT_SECRET?.trim() : undefined;
const canUseEnv = allowEnv && Boolean(envAppId && envSecret);
const hasConfigCredentials = Boolean(resolvedAccount.config.appId && resolvedAccount.config.clientSecret);
let appId: string | null = null;
let clientSecret: string | null = null;
// 显示帮助
if (!accountConfigured) {
await prompter.note(
[
"1) 打开 QQ 开放平台: https://q.qq.com/",
"2) 创建机器人应用,获取 AppID 和 ClientSecret",
"3) 在「开发设置」中添加沙箱成员(测试阶段)",
"4) 你也可以设置环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET",
"",
"文档: https://bot.q.qq.com/wiki/",
].join("\n"),
"QQ Bot 配置",
);
}
// 检测环境变量
if (canUseEnv && !hasConfigCredentials) {
const keepEnv = await prompter.confirm({
message: "检测到环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET是否使用",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
qqbot: {
...next.channels?.qqbot,
enabled: true,
},
},
};
} else {
// 手动输入
appId = String(
await prompter.text({
message: "请输入 QQ Bot AppID",
placeholder: "例如: 102146862",
initialValue: resolvedAccount.appId || undefined,
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
}),
).trim();
clientSecret = String(
await prompter.text({
message: "请输入 QQ Bot ClientSecret",
placeholder: "你的 ClientSecret",
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
}),
).trim();
}
} else if (hasConfigCredentials) {
// 已有配置
const keep = await prompter.confirm({
message: "QQ Bot 已配置,是否保留当前配置?",
initialValue: true,
});
if (!keep) {
appId = String(
await prompter.text({
message: "请输入 QQ Bot AppID",
placeholder: "例如: 102146862",
initialValue: resolvedAccount.appId || undefined,
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
}),
).trim();
clientSecret = String(
await prompter.text({
message: "请输入 QQ Bot ClientSecret",
placeholder: "你的 ClientSecret",
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
}),
).trim();
}
} else {
// 没有配置,需要输入
appId = String(
await prompter.text({
message: "请输入 QQ Bot AppID",
placeholder: "例如: 102146862",
initialValue: resolvedAccount.appId || undefined,
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
}),
).trim();
clientSecret = String(
await prompter.text({
message: "请输入 QQ Bot ClientSecret",
placeholder: "你的 ClientSecret",
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
}),
).trim();
}
// 应用配置
if (appId && clientSecret) {
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
qqbot: {
...next.channels?.qqbot,
enabled: true,
appId,
clientSecret,
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
qqbot: {
...next.channels?.qqbot,
enabled: true,
accounts: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts,
[accountId]: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId],
enabled: true,
appId,
clientSecret,
},
},
},
},
};
}
}
return { cfg: next as any, accountId };
},
disable: (cfg) => ({
...cfg,
channels: {
...(cfg as MoltbotConfig).channels,
qqbot: { ...(cfg as MoltbotConfig).channels?.qqbot, enabled: false },
},
}) as any,
};

View File

@@ -17,6 +17,8 @@ export interface ResolvedQQBotAccount {
appId: string; appId: string;
clientSecret: string; clientSecret: string;
secretSource: "config" | "file" | "env" | "none"; secretSource: "config" | "file" | "env" | "none";
/** 系统提示词 */
systemPrompt?: string;
config: QQBotAccountConfig; config: QQBotAccountConfig;
} }
@@ -31,6 +33,8 @@ export interface QQBotAccountConfig {
clientSecretFile?: string; clientSecretFile?: string;
dmPolicy?: "open" | "pairing" | "allowlist"; dmPolicy?: "open" | "pairing" | "allowlist";
allowFrom?: string[]; allowFrom?: string[];
/** 系统提示词,会添加在用户消息前面 */
systemPrompt?: string;
} }
/** /**