1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
distnode_modules
|
||||||
|
|||||||
@@ -139,10 +139,8 @@ clawdbot onboard
|
|||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
1. **消息回复限制**:QQ 官方 API 限制每条消息最多回复 5 次,超时 60 分钟
|
1. **群消息**:需要在群内 @机器人 才能触发回复
|
||||||
2. **URL 限制**:QQ 平台不允许消息中包含 URL,插件已内置提示词限制
|
2. **沙箱模式**:新创建的机器人默认在沙箱模式,需要添加测试用户
|
||||||
3. **群消息**:需要在群内 @机器人 才能触发回复
|
|
||||||
4. **沙箱模式**:新创建的机器人默认在沙箱模式,需要添加测试用户
|
|
||||||
|
|
||||||
## 升级
|
## 升级
|
||||||
|
|
||||||
|
|||||||
23
src/api.ts
23
src/api.ts
@@ -153,6 +153,29 @@ export async function sendC2CMessage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 C2C 输入状态提示(告知用户机器人正在输入)
|
||||||
|
*/
|
||||||
|
export async function sendC2CInputNotify(
|
||||||
|
accessToken: string,
|
||||||
|
openid: string,
|
||||||
|
msgId?: string,
|
||||||
|
inputSecond: number = 60
|
||||||
|
): Promise<void> {
|
||||||
|
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
||||||
|
const body = {
|
||||||
|
msg_type: 6,
|
||||||
|
input_notify: {
|
||||||
|
input_type: 1,
|
||||||
|
input_second: inputSecond,
|
||||||
|
},
|
||||||
|
msg_seq: msgSeq,
|
||||||
|
...(msgId ? { msg_id: msgId } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送频道消息
|
* 发送频道消息
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
// 支持纯 openid(32位十六进制)
|
||||||
|
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),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
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, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
|
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, sendC2CInputNotify } from "./api.js";
|
||||||
import { getQQBotRuntime } from "./runtime.js";
|
import { getQQBotRuntime } from "./runtime.js";
|
||||||
import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
||||||
|
|
||||||
@@ -218,6 +218,17 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
|
log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 对于 C2C 消息,先发送输入状态提示用户机器人正在输入
|
||||||
|
if (event.type === "c2c") {
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken(account.appId, account.clientSecret);
|
||||||
|
await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}`);
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to send input notify: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pluginRuntime.channel.activity.record({
|
pluginRuntime.channel.activity.record({
|
||||||
channel: "qqbot",
|
channel: "qqbot",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@@ -242,7 +253,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||||
|
|
||||||
// 组装消息体,添加系统提示词
|
// 组装消息体,添加系统提示词
|
||||||
let builtinPrompt = "由于平台限制,你的回复中不可以包含任何URL。";
|
let builtinPrompt = "";
|
||||||
|
|
||||||
// 只有配置了图床公网地址,才告诉 AI 可以发送图片
|
// 只有配置了图床公网地址,才告诉 AI 可以发送图片
|
||||||
if (imageServerBaseUrl) {
|
if (imageServerBaseUrl) {
|
||||||
@@ -309,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({
|
||||||
@@ -334,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 {
|
||||||
@@ -375,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) => {
|
||||||
@@ -386,19 +400,25 @@ 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 ?? "";
|
||||||
|
|
||||||
@@ -518,57 +538,42 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从文本中移除图片 URL,避免被 QQ 拦截
|
|
||||||
let textWithoutImages = replyText;
|
|
||||||
for (const match of urlMatches) {
|
|
||||||
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理剩余文本中的 URL 点号(只有在没有图片的情况下才替换,避免误伤)
|
|
||||||
const hasImages = imageUrls.length > 0;
|
|
||||||
let hasReplacement = false;
|
|
||||||
if (!hasImages) {
|
|
||||||
const originalText = textWithoutImages;
|
|
||||||
textWithoutImages = textWithoutImages.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
|
|
||||||
hasReplacement = textWithoutImages !== originalText;
|
|
||||||
if (hasReplacement && textWithoutImages.trim()) {
|
|
||||||
textWithoutImages += "\n\n(由于平台限制,回复中的部分符号已被替换)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 先发送图片(如果有)
|
// 先发送图片(如果有)
|
||||||
for (const imageUrl of imageUrls) {
|
for (const imageUrl of imageUrls) {
|
||||||
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);
|
||||||
textWithoutImages = `[图片发送失败: ${errMsg}]\n${textWithoutImages}`;
|
replyText = `[图片发送失败: ${errMsg}]\n${replyText}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 再发送文本(如果有)
|
// 再发送文本(如果有)
|
||||||
if (textWithoutImages.trim()) {
|
if (replyText.trim()) {
|
||||||
await sendWithTokenRetry(async (token) => {
|
await sendWithTokenRetry(async (token) => {
|
||||||
if (event.type === "c2c") {
|
if (event.type === "c2c") {
|
||||||
await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId);
|
log?.info(`[qqbot:${account.accountId}] sendC2CText -> ${event.senderId}, len=${replyText.length}`);
|
||||||
|
await sendC2CMessage(token, event.senderId, replyText, event.messageId);
|
||||||
} else if (event.type === "group" && event.groupOpenid) {
|
} else if (event.type === "group" && event.groupOpenid) {
|
||||||
await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
log?.info(`[qqbot:${account.accountId}] sendGroupText -> ${event.groupOpenid}, len=${replyText.length}`);
|
||||||
|
await sendGroupMessage(token, event.groupOpenid, replyText, event.messageId);
|
||||||
} else if (event.channelId) {
|
} else if (event.channelId) {
|
||||||
await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
log?.info(`[qqbot:${account.accountId}] sendChannelText -> ${event.channelId}, len=${replyText.length}`);
|
||||||
|
await sendChannelMessage(token, event.channelId, replyText, event.messageId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
log?.info(`[qqbot:${account.accountId}] Sent text reply`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pluginRuntime.channel.activity.record({
|
pluginRuntime.channel.activity.record({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user