feat: 支持完整chunk直接发送消息,适配主动消息发送调用

This commit is contained in:
leoqlin
2026-01-31 22:18:46 +08:00
parent eea1f40684
commit 38b0c07367
4 changed files with 80 additions and 24 deletions

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules node_modules
dist dist
distnode_modules

View File

@@ -26,6 +26,26 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
reload: { configPrefixes: ["channels.qqbot"] }, reload: { configPrefixes: ["channels.qqbot"] },
// CLI onboarding wizard // CLI onboarding wizard
onboarding: qqbotOnboardingAdapter, onboarding: qqbotOnboardingAdapter,
// 消息目标解析
messaging: {
normalizeTarget: (target) => {
// 支持格式: qqbot:c2c:xxx, qqbot:group:xxx, c2c:xxx, group:xxx, openid
const normalized = target.replace(/^qqbot:/i, "");
return { ok: true, to: normalized };
},
targetResolver: {
looksLikeId: (id) => {
// 先去掉 qqbot: 前缀
const normalized = id.replace(/^qqbot:/i, "");
// 支持 c2c:xxx, group:xxx, channel:xxx 格式
if (normalized.startsWith("c2c:") || normalized.startsWith("group:") || normalized.startsWith("channel:")) return true;
// 支持纯 openid32位十六进制
if (/^[A-F0-9]{32}$/i.test(normalized)) return true;
return false;
},
hint: "c2c:<openid> or group:<groupOpenid>",
},
},
config: { config: {
listAccountIds: (cfg) => listQQBotAccountIds(cfg), listAccountIds: (cfg) => listQQBotAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId), resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),

View File

@@ -320,7 +320,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}` const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}`
: event.type === "group" ? `qqbot:group:${event.groupOpenid}` : event.type === "group" ? `qqbot:group:${event.groupOpenid}`
: `qqbot:${event.senderId}`; : `qqbot:c2c:${event.senderId}`;
const toAddress = fromAddress; const toAddress = fromAddress;
const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({ const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
@@ -345,6 +345,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
QQGroupOpenid: event.groupOpenid, QQGroupOpenid: event.groupOpenid,
}); });
// 打印 ctxPayload 详细信息(便于调试)
log?.info(`[qqbot:${account.accountId}] ctxPayload: From=${fromAddress}, To=${toAddress}, SessionKey=${route.sessionKey}, AccountId=${route.accountId}, ChatType=${isGroup ? "group" : "direct"}, SenderId=${event.senderId}, MessageSid=${event.messageId}, BodyLen=${body?.length ?? 0}`);
// 发送消息的辅助函数,带 token 过期重试 // 发送消息的辅助函数,带 token 过期重试
const sendWithTokenRetry = async (sendFn: (token: string) => Promise<unknown>) => { const sendWithTokenRetry = async (sendFn: (token: string) => Promise<unknown>) => {
try { try {
@@ -386,7 +389,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
// 追踪是否有响应 // 追踪是否有响应
let hasResponse = false; let hasResponse = false;
const responseTimeout = 30000; // 30秒超时 const responseTimeout = 300000; // 30秒超时
let timeoutId: ReturnType<typeof setTimeout> | null = null; let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<void>((_, reject) => { const timeoutPromise = new Promise<void>((_, reject) => {
@@ -397,20 +400,26 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
}, responseTimeout); }, responseTimeout);
}); });
// 调用 dispatchReply
log?.info(`[qqbot:${account.accountId}] dispatchReply: agentId=${route.agentId}, prefix=${messagesConfig.responsePrefix ?? "(none)"}`);
const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ 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; mediaUrls?: string[]; mediaUrl?: string }) => { deliver: async (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, info: { kind: string }) => {
hasResponse = true; hasResponse = true;
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
timeoutId = null; timeoutId = null;
} }
log?.info(`[qqbot:${account.accountId}] deliver called, payload keys: ${Object.keys(payload).join(", ")}`); log?.info(`[qqbot:${account.accountId}] deliver(${info.kind}): textLen=${payload.text?.length ?? 0}, mediaUrls=${payload.mediaUrls?.length ?? 0}, mediaUrl=${payload.mediaUrl ? "yes" : "no"}`);
if (payload.text) {
log?.info(`[qqbot:${account.accountId}] text preview: ${payload.text.slice(0, 150).replace(/\n/g, "\\n")}...`);
}
let replyText = payload.text ?? ""; let replyText = payload.text ?? "";
// 收集所有图片路径 // 收集所有图片路径
@@ -535,15 +544,16 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
try { try {
await sendWithTokenRetry(async (token) => { await sendWithTokenRetry(async (token) => {
if (event.type === "c2c") { if (event.type === "c2c") {
log?.info(`[qqbot:${account.accountId}] sendC2CImage -> ${event.senderId}`);
await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId); await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
} else if (event.type === "group" && event.groupOpenid) { } else if (event.type === "group" && event.groupOpenid) {
log?.info(`[qqbot:${account.accountId}] sendGroupImage -> ${event.groupOpenid}`);
await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId); await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
} }
// 频道消息暂不支持富媒体,跳过图片 // 频道消息暂不支持富媒体,跳过图片
}); });
log?.info(`[qqbot:${account.accountId}] Sent image: ${imageUrl.slice(0, 50)}...`);
} catch (imgErr) { } catch (imgErr) {
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`); log?.error(`[qqbot:${account.accountId}] Image send failed: ${imgErr}`);
// 图片发送失败时,显示错误信息而不是 URL // 图片发送失败时,显示错误信息而不是 URL
const errMsg = String(imgErr).slice(0, 200); const errMsg = String(imgErr).slice(0, 200);
replyText = `[图片发送失败: ${errMsg}]\n${replyText}`; replyText = `[图片发送失败: ${errMsg}]\n${replyText}`;
@@ -554,14 +564,16 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
if (replyText.trim()) { if (replyText.trim()) {
await sendWithTokenRetry(async (token) => { await sendWithTokenRetry(async (token) => {
if (event.type === "c2c") { if (event.type === "c2c") {
log?.info(`[qqbot:${account.accountId}] sendC2CText -> ${event.senderId}, len=${replyText.length}`);
await sendC2CMessage(token, event.senderId, replyText, event.messageId); await sendC2CMessage(token, event.senderId, replyText, event.messageId);
} else if (event.type === "group" && event.groupOpenid) { } else if (event.type === "group" && event.groupOpenid) {
log?.info(`[qqbot:${account.accountId}] sendGroupText -> ${event.groupOpenid}, len=${replyText.length}`);
await sendGroupMessage(token, event.groupOpenid, replyText, event.messageId); await sendGroupMessage(token, event.groupOpenid, replyText, event.messageId);
} else if (event.channelId) { } else if (event.channelId) {
log?.info(`[qqbot:${account.accountId}] sendChannelText -> ${event.channelId}, len=${replyText.length}`);
await sendChannelMessage(token, event.channelId, replyText, event.messageId); await sendChannelMessage(token, event.channelId, replyText, event.messageId);
} }
}); });
log?.info(`[qqbot:${account.accountId}] Sent text reply`);
} }
pluginRuntime.channel.activity.record({ pluginRuntime.channel.activity.record({

View File

@@ -26,32 +26,38 @@ export interface OutboundResult {
/** /**
* 解析目标地址 * 解析目标地址
* 格式: * 格式:
* - openid (32位十六进制) -> C2C 单聊 * - c2c:xxx -> C2C 单聊
* - group:xxx -> 群聊 * - group:xxx -> 群聊
* - channel:xxx -> 频道 * - channel:xxx -> 频道
* - 纯数字 -> 频道 * - 无前缀 -> 默认当作 C2C 单聊
*/ */
function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } { function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
if (to.startsWith("group:")) { // 去掉 qqbot: 前缀
return { type: "group", id: to.slice(6) }; let id = to.replace(/^qqbot:/i, "");
if (id.startsWith("c2c:")) {
return { type: "c2c", id: id.slice(4) };
} }
if (to.startsWith("channel:")) { if (id.startsWith("group:")) {
return { type: "channel", id: to.slice(8) }; return { type: "group", id: id.slice(6) };
} }
// openid 通常是 32 位十六进制 if (id.startsWith("channel:")) {
if (/^[A-F0-9]{32}$/i.test(to)) { return { type: "channel", id: id.slice(8) };
return { type: "c2c", id: to };
} }
// 默认当作频道 ID // 默认当作 c2c私聊
return { type: "channel", id: to }; return { type: "c2c", id };
} }
/** /**
* 发送文本消息(被动回复,需要 replyToId * 发送文本消息
* - 有 replyToId: 被动回复,无配额限制
* - 无 replyToId: 主动发送有配额限制每月4条/用户/群)
*/ */
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> { export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
const { to, text, replyToId, account } = ctx; const { to, text, replyToId, account } = ctx;
console.log("[qqbot] sendText ctx:", JSON.stringify({ to, text: text?.slice(0, 50), replyToId, accountId: account.accountId }, null, 2));
if (!account.appId || !account.clientSecret) { if (!account.appId || !account.clientSecret) {
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" }; return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
} }
@@ -59,15 +65,32 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
try { try {
const accessToken = await getAccessToken(account.appId, account.clientSecret); const accessToken = await getAccessToken(account.appId, account.clientSecret);
const target = parseTarget(to); const target = parseTarget(to);
console.log("[qqbot] sendText target:", JSON.stringify(target));
// 如果没有 replyToId使用主动发送接口
if (!replyToId) {
if (target.type === "c2c") {
const result = await sendProactiveC2CMessage(accessToken, target.id, text);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
} else if (target.type === "group") {
const result = await sendProactiveGroupMessage(accessToken, target.id, text);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
} else {
// 频道暂不支持主动消息
const result = await sendChannelMessage(accessToken, target.id, text);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
}
}
// 有 replyToId使用被动回复接口
if (target.type === "c2c") { if (target.type === "c2c") {
const result = await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined); const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
} else if (target.type === "group") { } else if (target.type === "group") {
const result = await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined); const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
} else { } else {
const result = await sendChannelMessage(accessToken, target.id, text, replyToId ?? undefined); const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
} }
} catch (err) { } catch (err) {