1
index.ts
1
index.ts
@@ -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";
|
||||||
|
|||||||
30
src/api.ts
30
src/api.ts
@@ -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 } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
626
src/gateway.ts
626
src/gateway.ts
@@ -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,256 +37,412 @@ 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 handleMessage = async (event: {
|
|
||||||
type: "c2c" | "guild" | "dm" | "group";
|
|
||||||
senderId: string;
|
|
||||||
senderName?: string;
|
|
||||||
content: string;
|
|
||||||
messageId: string;
|
|
||||||
timestamp: string;
|
|
||||||
channelId?: string;
|
|
||||||
guildId?: string;
|
|
||||||
groupOpenid?: string;
|
|
||||||
}) => {
|
|
||||||
log?.info(`[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`);
|
|
||||||
|
|
||||||
pluginRuntime.channel.activity.record({
|
|
||||||
channel: "qqbot",
|
|
||||||
accountId: account.accountId,
|
|
||||||
direction: "inbound",
|
|
||||||
});
|
|
||||||
|
|
||||||
const isGroup = event.type === "guild" || event.type === "group";
|
|
||||||
const peerId = event.type === "guild" ? `channel:${event.channelId}`
|
|
||||||
: event.type === "group" ? `group:${event.groupOpenid}`
|
|
||||||
: event.senderId;
|
|
||||||
|
|
||||||
const route = pluginRuntime.channel.routing.resolveAgentRoute({
|
|
||||||
cfg,
|
|
||||||
channel: "qqbot",
|
|
||||||
accountId: account.accountId,
|
|
||||||
peer: {
|
|
||||||
kind: isGroup ? "group" : "dm",
|
|
||||||
id: peerId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
||||||
|
|
||||||
const body = pluginRuntime.channel.reply.formatInboundEnvelope({
|
|
||||||
channel: "QQBot",
|
|
||||||
from: event.senderName ?? event.senderId,
|
|
||||||
timestamp: new Date(event.timestamp).getTime(),
|
|
||||||
body: event.content,
|
|
||||||
chatType: isGroup ? "group" : "direct",
|
|
||||||
sender: {
|
|
||||||
id: event.senderId,
|
|
||||||
name: event.senderName,
|
|
||||||
},
|
|
||||||
envelope: envelopeOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}`
|
|
||||||
: event.type === "group" ? `qqbot:group:${event.groupOpenid}`
|
|
||||||
: `qqbot:${event.senderId}`;
|
|
||||||
const toAddress = fromAddress;
|
|
||||||
|
|
||||||
const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
|
|
||||||
Body: body,
|
|
||||||
RawBody: event.content,
|
|
||||||
CommandBody: event.content,
|
|
||||||
From: fromAddress,
|
|
||||||
To: toAddress,
|
|
||||||
SessionKey: route.sessionKey,
|
|
||||||
AccountId: route.accountId,
|
|
||||||
ChatType: isGroup ? "group" : "direct",
|
|
||||||
SenderId: event.senderId,
|
|
||||||
SenderName: event.senderName,
|
|
||||||
Provider: "qqbot",
|
|
||||||
Surface: "qqbot",
|
|
||||||
MessageSid: event.messageId,
|
|
||||||
Timestamp: new Date(event.timestamp).getTime(),
|
|
||||||
OriginatingChannel: "qqbot",
|
|
||||||
OriginatingTo: toAddress,
|
|
||||||
// QQBot 特有字段
|
|
||||||
QQChannelId: event.channelId,
|
|
||||||
QQGuildId: event.guildId,
|
|
||||||
QQGroupOpenid: event.groupOpenid,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 分发到 AI 系统
|
|
||||||
try {
|
|
||||||
const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
|
|
||||||
|
|
||||||
await pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg,
|
|
||||||
dispatcherOptions: {
|
|
||||||
responsePrefix: messagesConfig.responsePrefix,
|
|
||||||
deliver: async (payload: { text?: string }) => {
|
|
||||||
const replyText = payload.text ?? "";
|
|
||||||
if (!replyText.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (event.type === "c2c") {
|
|
||||||
await sendC2CMessage(accessToken, event.senderId, replyText, event.messageId);
|
|
||||||
} else if (event.type === "group" && event.groupOpenid) {
|
|
||||||
await sendGroupMessage(accessToken, event.groupOpenid, replyText, event.messageId);
|
|
||||||
} else if (event.channelId) {
|
|
||||||
await sendChannelMessage(accessToken, event.channelId, replyText, event.messageId);
|
|
||||||
}
|
|
||||||
log?.info(`[qqbot:${account.accountId}] Sent reply`);
|
|
||||||
|
|
||||||
pluginRuntime.channel.activity.record({
|
|
||||||
channel: "qqbot",
|
|
||||||
accountId: account.accountId,
|
|
||||||
direction: "outbound",
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err: unknown) => {
|
|
||||||
log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
replyOptions: {},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.on("open", () => {
|
const scheduleReconnect = () => {
|
||||||
log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
|
if (isAborted || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||||
});
|
log?.error(`[qqbot:${account.accountId}] Max reconnect attempts reached or aborted`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ws.on("message", async (data) => {
|
const delay = getReconnectDelay();
|
||||||
try {
|
reconnectAttempts++;
|
||||||
const payload = JSON.parse(data.toString()) as WSPayload;
|
log?.info(`[qqbot:${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
|
||||||
const { op, d, s, t } = payload;
|
|
||||||
|
|
||||||
if (s) lastSeq = s;
|
setTimeout(() => {
|
||||||
|
if (!isAborted) {
|
||||||
log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`);
|
connect();
|
||||||
|
|
||||||
switch (op) {
|
|
||||||
case 10: // Hello
|
|
||||||
log?.info(`[qqbot:${account.accountId}] Hello received, starting heartbeat`);
|
|
||||||
// Identify
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
op: 2,
|
|
||||||
d: {
|
|
||||||
token: `QQBot ${accessToken}`,
|
|
||||||
intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C,
|
|
||||||
shard: [0, 1],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
// Heartbeat
|
|
||||||
const interval = (d as { heartbeat_interval: number }).heartbeat_interval;
|
|
||||||
heartbeatInterval = setInterval(() => {
|
|
||||||
ws.send(JSON.stringify({ op: 1, d: lastSeq }));
|
|
||||||
}, interval);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 0: // Dispatch
|
|
||||||
if (t === "READY") {
|
|
||||||
log?.info(`[qqbot:${account.accountId}] Ready`);
|
|
||||||
onReady?.(d);
|
|
||||||
} else if (t === "C2C_MESSAGE_CREATE") {
|
|
||||||
const event = d as C2CMessageEvent;
|
|
||||||
await handleMessage({
|
|
||||||
type: "c2c",
|
|
||||||
senderId: event.author.user_openid,
|
|
||||||
content: event.content,
|
|
||||||
messageId: event.id,
|
|
||||||
timestamp: event.timestamp,
|
|
||||||
});
|
|
||||||
} else if (t === "AT_MESSAGE_CREATE") {
|
|
||||||
const event = d as GuildMessageEvent;
|
|
||||||
await handleMessage({
|
|
||||||
type: "guild",
|
|
||||||
senderId: event.author.id,
|
|
||||||
senderName: event.author.username,
|
|
||||||
content: event.content,
|
|
||||||
messageId: event.id,
|
|
||||||
timestamp: event.timestamp,
|
|
||||||
channelId: event.channel_id,
|
|
||||||
guildId: event.guild_id,
|
|
||||||
});
|
|
||||||
} else if (t === "DIRECT_MESSAGE_CREATE") {
|
|
||||||
const event = d as GuildMessageEvent;
|
|
||||||
await handleMessage({
|
|
||||||
type: "dm",
|
|
||||||
senderId: event.author.id,
|
|
||||||
senderName: event.author.username,
|
|
||||||
content: event.content,
|
|
||||||
messageId: event.id,
|
|
||||||
timestamp: event.timestamp,
|
|
||||||
guildId: event.guild_id,
|
|
||||||
});
|
|
||||||
} else if (t === "GROUP_AT_MESSAGE_CREATE") {
|
|
||||||
const event = d as GroupMessageEvent;
|
|
||||||
await handleMessage({
|
|
||||||
type: "group",
|
|
||||||
senderId: event.author.member_openid,
|
|
||||||
content: event.content,
|
|
||||||
messageId: event.id,
|
|
||||||
timestamp: event.timestamp,
|
|
||||||
groupOpenid: event.group_openid,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 11: // Heartbeat ACK
|
|
||||||
log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 9: // Invalid Session
|
|
||||||
log?.error(`[qqbot:${account.accountId}] Invalid session`);
|
|
||||||
onError?.(new Error("Invalid session"));
|
|
||||||
cleanup();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
}, 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: {
|
||||||
|
type: "c2c" | "guild" | "dm" | "group";
|
||||||
|
senderId: string;
|
||||||
|
senderName?: string;
|
||||||
|
content: string;
|
||||||
|
messageId: string;
|
||||||
|
timestamp: string;
|
||||||
|
channelId?: string;
|
||||||
|
guildId?: string;
|
||||||
|
groupOpenid?: string;
|
||||||
|
}) => {
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`);
|
||||||
|
|
||||||
|
pluginRuntime.channel.activity.record({
|
||||||
|
channel: "qqbot",
|
||||||
|
accountId: account.accountId,
|
||||||
|
direction: "inbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
const isGroup = event.type === "guild" || event.type === "group";
|
||||||
|
const peerId = event.type === "guild" ? `channel:${event.channelId}`
|
||||||
|
: event.type === "group" ? `group:${event.groupOpenid}`
|
||||||
|
: event.senderId;
|
||||||
|
|
||||||
|
const route = pluginRuntime.channel.routing.resolveAgentRoute({
|
||||||
|
cfg,
|
||||||
|
channel: "qqbot",
|
||||||
|
accountId: account.accountId,
|
||||||
|
peer: {
|
||||||
|
kind: isGroup ? "group" : "dm",
|
||||||
|
id: peerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
||||||
|
channel: "QQBot",
|
||||||
|
from: event.senderName ?? event.senderId,
|
||||||
|
timestamp: new Date(event.timestamp).getTime(),
|
||||||
|
body: messageBody,
|
||||||
|
chatType: isGroup ? "group" : "direct",
|
||||||
|
sender: {
|
||||||
|
id: event.senderId,
|
||||||
|
name: event.senderName,
|
||||||
|
},
|
||||||
|
envelope: envelopeOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}`
|
||||||
|
: event.type === "group" ? `qqbot:group:${event.groupOpenid}`
|
||||||
|
: `qqbot:${event.senderId}`;
|
||||||
|
const toAddress = fromAddress;
|
||||||
|
|
||||||
|
const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
|
||||||
|
Body: body,
|
||||||
|
RawBody: event.content,
|
||||||
|
CommandBody: event.content,
|
||||||
|
From: fromAddress,
|
||||||
|
To: toAddress,
|
||||||
|
SessionKey: route.sessionKey,
|
||||||
|
AccountId: route.accountId,
|
||||||
|
ChatType: isGroup ? "group" : "direct",
|
||||||
|
SenderId: event.senderId,
|
||||||
|
SenderName: event.senderName,
|
||||||
|
Provider: "qqbot",
|
||||||
|
Surface: "qqbot",
|
||||||
|
MessageSid: event.messageId,
|
||||||
|
Timestamp: new Date(event.timestamp).getTime(),
|
||||||
|
OriginatingChannel: "qqbot",
|
||||||
|
OriginatingTo: toAddress,
|
||||||
|
QQChannelId: event.channelId,
|
||||||
|
QQGuildId: event.guildId,
|
||||||
|
QQGroupOpenid: event.groupOpenid,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送错误提示的辅助函数
|
||||||
|
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 {
|
||||||
|
const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
|
||||||
|
|
||||||
|
// 每次发消息前刷新 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,
|
||||||
|
cfg,
|
||||||
|
dispatcherOptions: {
|
||||||
|
responsePrefix: messagesConfig.responsePrefix,
|
||||||
|
deliver: async (payload: { text?: string }) => {
|
||||||
|
hasResponse = true;
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyText = payload.text ?? "";
|
||||||
|
if (!replyText.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (event.type === "c2c") {
|
||||||
|
await sendC2CMessage(freshToken, event.senderId, replyText, event.messageId);
|
||||||
|
} else if (event.type === "group" && event.groupOpenid) {
|
||||||
|
await sendGroupMessage(freshToken, event.groupOpenid, replyText, event.messageId);
|
||||||
|
} else if (event.channelId) {
|
||||||
|
await sendChannelMessage(freshToken, event.channelId, replyText, event.messageId);
|
||||||
|
}
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Sent reply`);
|
||||||
|
|
||||||
|
pluginRuntime.channel.activity.record({
|
||||||
|
channel: "qqbot",
|
||||||
|
accountId: account.accountId,
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: async (err: unknown) => {
|
||||||
|
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: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待分发完成或超时
|
||||||
|
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) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
||||||
|
await sendErrorMessage(`[ClawdBot] 处理消息失败: ${String(err).slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.on("open", () => {
|
||||||
|
log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
|
||||||
|
reconnectAttempts = 0; // 连接成功,重置重试计数
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("message", async (data) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data.toString()) as WSPayload;
|
||||||
|
const { op, d, s, t } = payload;
|
||||||
|
|
||||||
|
if (s) lastSeq = s;
|
||||||
|
|
||||||
|
log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`);
|
||||||
|
|
||||||
|
switch (op) {
|
||||||
|
case 10: // Hello
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Hello received`);
|
||||||
|
|
||||||
|
// 如果有 session_id,尝试 Resume
|
||||||
|
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,
|
||||||
|
d: {
|
||||||
|
token: `QQBot ${accessToken}`,
|
||||||
|
intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C,
|
||||||
|
shard: [0, 1],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动心跳
|
||||||
|
const interval = (d as { heartbeat_interval: number }).heartbeat_interval;
|
||||||
|
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ op: 1, d: lastSeq }));
|
||||||
|
log?.debug?.(`[qqbot:${account.accountId}] Heartbeat sent`);
|
||||||
|
}
|
||||||
|
}, interval);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 0: // Dispatch
|
||||||
|
if (t === "READY") {
|
||||||
|
const readyData = d as { session_id: string };
|
||||||
|
sessionId = readyData.session_id;
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Ready, session: ${sessionId}`);
|
||||||
|
onReady?.(d);
|
||||||
|
} else if (t === "RESUMED") {
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
||||||
|
} else if (t === "C2C_MESSAGE_CREATE") {
|
||||||
|
const event = d as C2CMessageEvent;
|
||||||
|
await handleMessage({
|
||||||
|
type: "c2c",
|
||||||
|
senderId: event.author.user_openid,
|
||||||
|
content: event.content,
|
||||||
|
messageId: event.id,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
});
|
||||||
|
} else if (t === "AT_MESSAGE_CREATE") {
|
||||||
|
const event = d as GuildMessageEvent;
|
||||||
|
await handleMessage({
|
||||||
|
type: "guild",
|
||||||
|
senderId: event.author.id,
|
||||||
|
senderName: event.author.username,
|
||||||
|
content: event.content,
|
||||||
|
messageId: event.id,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
channelId: event.channel_id,
|
||||||
|
guildId: event.guild_id,
|
||||||
|
});
|
||||||
|
} else if (t === "DIRECT_MESSAGE_CREATE") {
|
||||||
|
const event = d as GuildMessageEvent;
|
||||||
|
await handleMessage({
|
||||||
|
type: "dm",
|
||||||
|
senderId: event.author.id,
|
||||||
|
senderName: event.author.username,
|
||||||
|
content: event.content,
|
||||||
|
messageId: event.id,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
guildId: event.guild_id,
|
||||||
|
});
|
||||||
|
} else if (t === "GROUP_AT_MESSAGE_CREATE") {
|
||||||
|
const event = d as GroupMessageEvent;
|
||||||
|
await handleMessage({
|
||||||
|
type: "group",
|
||||||
|
senderId: event.author.member_openid,
|
||||||
|
content: event.content,
|
||||||
|
messageId: event.id,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
groupOpenid: event.group_openid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 11: // Heartbeat ACK
|
||||||
|
log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 7: // Reconnect
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Server requested reconnect`);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Message parse error: ${err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", (code, reason) => {
|
||||||
|
log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
// 非正常关闭则重连
|
||||||
|
if (!isAborted && code !== 1000) {
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", (err) => {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`);
|
||||||
|
onError?.(err);
|
||||||
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log?.error(`[qqbot:${account.accountId}] Message parse error: ${err}`);
|
log?.error(`[qqbot:${account.accountId}] Connection failed: ${err}`);
|
||||||
|
scheduleReconnect();
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
ws.on("close", (code, reason) => {
|
// 开始连接
|
||||||
log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason}`);
|
await connect();
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("error", (err) => {
|
|
||||||
log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`);
|
|
||||||
onError?.(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 等待 abort 信号
|
// 等待 abort 信号
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
|||||||
246
src/onboarding.ts
Normal file
246
src/onboarding.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user