import type { Gateway, GatewayChannelKey } from "../../../gateway/index.js";
import type { ChannelAdapter, ChannelHandle, ChannelLogger, ChannelStartDeps } from "../protocol/ChannelAdapter.js";
import { QQBotGateway, type QQBotCredentials, type QQGroupMessageEvent, type QQC2CMessageEvent } from "./qqbot-gateway.js";
import { QQSessionMapper } from "./QQSessionMapper.js";
import { renderQQEvent } from "./qq-render.js";
export type QQChannelOptions = {
appId?: string;
clientSecret?: string;
allowGroups?: string[];
triggerPrefixes?: string[];
mapper?: QQSessionMapper;
maxMessageLength?: number;
};
const DEFAULT_PREFIXES = ["/ask", "/chat"];
const DEFAULT_MAX_MSG_LEN = 2000;
export class QQChannel implements ChannelAdapter {
readonly channelKey: GatewayChannelKey = "qq";
private readonly credentials: QQBotCredentials;
private readonly allowGroups: string[];
private readonly triggerPrefixes: string[];
private readonly mapper: QQSessionMapper;
private readonly maxMessageLength: number;
private gateway?: Gateway;
private logger?: ChannelLogger;
private botGateway?: QQBotGateway;
private activeChats = new Set<string>();
constructor(options: QQChannelOptions = {}) {
this.credentials = {
appId: options.appId ?? process.env.QQ_BOT_APPID ?? "",
clientSecret: options.clientSecret ?? process.env.QQ_BOT_SECRET ?? "",
};
this.allowGroups = options.allowGroups ?? this.parseEnvList(process.env.QQ_ALLOW_GROUPS) ?? ["*"];
this.triggerPrefixes = options.triggerPrefixes ?? DEFAULT_PREFIXES;
this.mapper = options.mapper ?? new QQSessionMapper();
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MSG_LEN;
}
async start(deps: ChannelStartDeps): Promise<ChannelHandle> {
this.gateway = deps.gateway;
this.logger = deps.logger;
if (!this.credentials.appId || !this.credentials.clientSecret) {
this.logger?.info?.("qq: QQ_BOT_APPID or QQ_BOT_SECRET not set, QQ channel disabled.");
return { stop: async () => undefined };
}
this.botGateway = new QQBotGateway(this.credentials);
this.botGateway.on("connected", () => {
this.logger?.info?.("qq: WebSocket connected to QQ Gateway");
});
this.botGateway.on("ready", () => {
this.logger?.info?.("qq: authenticated, listening for group messages");
});
this.botGateway.on("resumed", () => {
this.logger?.info?.("qq: session resumed");
});
this.botGateway.on("disconnected", ({ code }: { code: number }) => {
this.logger?.warn?.(`qq: disconnected (code=${code}), will reconnect...`);
});
this.botGateway.on("reconnecting", () => {
this.logger?.info?.("qq: reconnecting...");
});
this.botGateway.on("error", (err: Error) => {
this.logger?.error?.(`qq: error: ${err.message}`);
});
this.botGateway.on("raw_dispatch", (event: { type: string; data: unknown }) => {
this.logger?.info?.(`qq: [dispatch] type=${event.type} data=${JSON.stringify(event.data).slice(0, 200)}`);
});
this.botGateway.on("group_message", (event: QQGroupMessageEvent) => {
this.logger?.info?.(`qq: [group_msg] group=${event.group_openid} user=${event.author.member_openid} content="${event.content}"`);
void this.handleGroupMessage(event);
});
this.botGateway.on("c2c_message", (event: QQC2CMessageEvent) => {
this.logger?.info?.(`qq: [c2c_msg] user=${event.author.user_openid} content="${event.content}"`);
void this.handleC2CMessage(event);
});
try {
await this.botGateway.connect();
this.logger?.info?.(`qq: connecting to QQ Official Bot Gateway (appId=${this.credentials.appId})...`);
} catch (e) {
this.logger?.error?.(`qq: failed to connect: ${e}`);
}
return {
stop: async (reason?: string) => {
this.logger?.info?.(`qq: stopping (${reason ?? "no reason"})`);
this.botGateway?.close();
this.botGateway = undefined;
},
};
}
private async handleGroupMessage(event: QQGroupMessageEvent): Promise<void> {
const groupOpenId = event.group_openid;
const userOpenId = event.author.member_openid;
if (!groupOpenId || !userOpenId) return;
if (!this.isGroupAllowed(groupOpenId)) return;
const rawText = (event.content ?? "").trim();
if (!rawText) return;
const text = this.extractText(rawText);
if (!text) return;
const chatKey = `${groupOpenId}:${userOpenId}`;
if (this.activeChats.has(chatKey)) {
this.logger?.info?.(`qq: chat ${chatKey} already active, skipping`);
return;
}
const mapped = this.mapper.resolve({ groupId: groupOpenId, userId: userOpenId, text });
if (mapped.command === "new" && !mapped.message) {
await this.sendReply(groupOpenId, "已创建新会话。", event.id);
return;
}
if (!mapped.message) return;
this.activeChats.add(chatKey);
try {
await this.processMessage(groupOpenId, mapped.sessionKey, mapped.message, event.id);
} finally {
this.activeChats.delete(chatKey);
}
}
private async handleC2CMessage(event: QQC2CMessageEvent): Promise<void> {
const userOpenId = event.author.user_openid;
if (!userOpenId) return;
const rawText = (event.content ?? "").trim();
if (!rawText) return;
const chatKey = `c2c:${userOpenId}`;
if (this.activeChats.has(chatKey)) {
this.logger?.info?.(`qq: c2c ${chatKey} already active, skipping`);
return;
}
const sessionKey = `qq:c2c:${userOpenId}:general`;
this.activeChats.add(chatKey);
try {
await this.processC2CMessage(userOpenId, sessionKey, rawText, event.id);
} finally {
this.activeChats.delete(chatKey);
}
}
private async processC2CMessage(
userOpenId: string,
sessionKey: string,
message: string,
msgId: string,
): Promise<void> {
if (!this.gateway) return;
let replyText = "";
try {
for await (const event of this.gateway.submitTurn({
sessionKey,
channelKey: "qq",
message,
})) {
const fragment = renderQQEvent(event);
if (fragment != null) replyText += fragment;
}
} catch (e) {
this.logger?.error?.(`qq: submitTurn error (c2c): ${e}`);
replyText = "处理消息时发生错误,请重试。";
}
const finalText = replyText.trim();
if (finalText) {
await this.sendC2CReplyChunked(userOpenId, finalText, msgId);
}
}
private async sendC2CReply(userOpenId: string, text: string, msgId?: string, msgSeq?: number): Promise<void> {
if (!this.botGateway) return;
try {
await this.botGateway.sendC2CMessage(userOpenId, text, msgId, msgSeq);
} catch (e) {
this.logger?.error?.(`qq: sendC2CMessage failed: ${e}`);
}
}
private async sendC2CReplyChunked(userOpenId: string, text: string, msgId: string): Promise<void> {
const chunks = this.splitText(text, this.maxMessageLength);
for (let i = 0; i < chunks.length; i++) {
await this.sendC2CReply(userOpenId, chunks[i], msgId, i + 1);
if (chunks.length > 1) {
await this.sleep(500);
}
}
}
private extractText(raw: string): string | null {
for (const prefix of this.triggerPrefixes) {
if (raw.startsWith(prefix)) {
const text = raw.slice(prefix.length).trim();
return text || null;
}
}
return raw;
}
private async processMessage(
groupOpenId: string,
sessionKey: string,
message: string,
msgId: string,
): Promise<void> {
if (!this.gateway) return;
let replyText = "";
try {
for await (const event of this.gateway.submitTurn({
sessionKey,
channelKey: "qq",
message,
})) {
const fragment = renderQQEvent(event);
if (fragment != null) replyText += fragment;
}
} catch (e) {
this.logger?.error?.(`qq: submitTurn error: ${e}`);
replyText = "处理消息时发生错误,请重试。";
}
const finalText = replyText.trim();
if (finalText) {
await this.sendReplyChunked(groupOpenId, finalText, msgId);
}
}
private async sendReply(groupOpenId: string, text: string, msgId?: string, msgSeq?: number): Promise<void> {
if (!this.botGateway) return;
try {
await this.botGateway.sendGroupMessage(groupOpenId, text, msgId, msgSeq);
} catch (e) {
this.logger?.error?.(`qq: sendGroupMessage failed: ${e}`);
}
}
private async sendReplyChunked(groupOpenId: string, text: string, msgId: string): Promise<void> {
const chunks = this.splitText(text, this.maxMessageLength);
for (let i = 0; i < chunks.length; i++) {
await this.sendReply(groupOpenId, chunks[i], msgId, i + 1);
if (chunks.length > 1) {
await this.sleep(500);
}
}
}
private splitText(text: string, maxLen: number): string[] {
if (text.length <= maxLen) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxLen) {
chunks.push(remaining);
break;
}
let splitAt = remaining.lastIndexOf("\n", maxLen);
if (splitAt < maxLen * 0.3) {
splitAt = maxLen;
}
chunks.push(remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt).replace(/^\n/, "");
}
return chunks;
}
private isGroupAllowed(groupOpenId: string): boolean {
if (this.allowGroups.includes("*")) return true;
return this.allowGroups.includes(groupOpenId);
}
private parseEnvList(value: string | undefined): string[] | undefined {
if (!value) return undefined;
return value.split(",").map((s) => s.trim()).filter(Boolean);
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}