Files
qqbot/src/outbound.ts
rianli 93f284891c feat(qqbot): 添加图片发送功能及优化定时任务载荷格式
新增功能:
- 新增 qqbot-media 技能,支持 <qqimg> 标签发送本地图片
- 添加图片尺寸检测工具 (image-size.ts),自动识别常见图片格式
- 支持将本地图片上传至 QQ 富媒体服务器

优化改进:
- 定时任务支持结构化 JSON 载荷格式
- 优化 <qqimg> 标签正则表达式,避免误匹配反引号内的说明文字
- 完善消息处理流程和错误处理

文件变更:
- src/gateway.ts: 添加图片处理、上传逻辑
- src/outbound.ts: 增强外发消息能力
- src/utils/image-size.ts: 新增图片尺寸解析工具
- skills/qqbot-media/SKILL.md: 新增图片功能说明文档
- skills/qqbot-cron/SKILL.md: 补充结构化载荷说明
2026-02-03 13:14:22 +08:00

572 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* QQ Bot 消息发送模块
*/
import * as fs from "fs";
import * as path from "path";
import type { ResolvedQQBotAccount } from "./types.js";
import { decodeCronPayload } from "./utils/payload.js";
import {
getAccessToken,
sendC2CMessage,
sendChannelMessage,
sendGroupMessage,
sendProactiveC2CMessage,
sendProactiveGroupMessage,
sendC2CImageMessage,
sendGroupImageMessage,
} from "./api.js";
// ============ 消息回复限流器 ============
// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
const MESSAGE_REPLY_LIMIT = 4;
const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时
interface MessageReplyRecord {
count: number;
firstReplyAt: number;
}
const messageReplyTracker = new Map<string, MessageReplyRecord>();
/** 限流检查结果 */
export interface ReplyLimitResult {
/** 是否允许被动回复 */
allowed: boolean;
/** 剩余被动回复次数 */
remaining: number;
/** 是否需要降级为主动消息(超期或超过次数) */
shouldFallbackToProactive: boolean;
/** 降级原因 */
fallbackReason?: "expired" | "limit_exceeded";
/** 提示消息 */
message?: string;
}
/**
* 检查是否可以回复该消息(限流检查)
* @param messageId 消息ID
* @returns ReplyLimitResult 限流检查结果
*/
export function checkMessageReplyLimit(messageId: string): ReplyLimitResult {
const now = Date.now();
const record = messageReplyTracker.get(messageId);
// 清理过期记录(定期清理,避免内存泄漏)
if (messageReplyTracker.size > 10000) {
for (const [id, rec] of messageReplyTracker) {
if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) {
messageReplyTracker.delete(id);
}
}
}
// 新消息,首次回复
if (!record) {
return {
allowed: true,
remaining: MESSAGE_REPLY_LIMIT,
shouldFallbackToProactive: false,
};
}
// 检查是否超过1小时message_id 过期)
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
// 超过1小时被动回复不可用需要降级为主动消息
return {
allowed: false,
remaining: 0,
shouldFallbackToProactive: true,
fallbackReason: "expired",
message: `消息已超过1小时有效期将使用主动消息发送`,
};
}
// 检查是否超过回复次数限制
const remaining = MESSAGE_REPLY_LIMIT - record.count;
if (remaining <= 0) {
return {
allowed: false,
remaining: 0,
shouldFallbackToProactive: true,
fallbackReason: "limit_exceeded",
message: `该消息已达到1小时内最大回复次数(${MESSAGE_REPLY_LIMIT}次),将使用主动消息发送`,
};
}
return {
allowed: true,
remaining,
shouldFallbackToProactive: false,
};
}
/**
* 记录一次消息回复
* @param messageId 消息ID
*/
export function recordMessageReply(messageId: string): void {
const now = Date.now();
const record = messageReplyTracker.get(messageId);
if (!record) {
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
} else {
// 检查是否过期,过期则重新计数
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
} else {
record.count++;
}
}
console.log(`[qqbot] recordMessageReply: ${messageId}, count=${messageReplyTracker.get(messageId)?.count}`);
}
/**
* 获取消息回复统计信息
*/
export function getMessageReplyStats(): { trackedMessages: number; totalReplies: number } {
let totalReplies = 0;
for (const record of messageReplyTracker.values()) {
totalReplies += record.count;
}
return { trackedMessages: messageReplyTracker.size, totalReplies };
}
/**
* 获取消息回复限制配置(供外部查询)
*/
export function getMessageReplyConfig(): { limit: number; ttlMs: number; ttlHours: number } {
return {
limit: MESSAGE_REPLY_LIMIT,
ttlMs: MESSAGE_REPLY_TTL,
ttlHours: MESSAGE_REPLY_TTL / (60 * 60 * 1000),
};
}
export interface OutboundContext {
to: string;
text: string;
accountId?: string | null;
replyToId?: string | null;
account: ResolvedQQBotAccount;
}
export interface MediaOutboundContext extends OutboundContext {
mediaUrl: string;
}
export interface OutboundResult {
channel: string;
messageId?: string;
timestamp?: string | number;
error?: string;
}
/**
* 解析目标地址
* 格式:
* - openid (32位十六进制) -> C2C 单聊
* - group:xxx -> 群聊
* - channel:xxx -> 频道
* - 纯数字 -> 频道
*/
function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
// 去掉 qqbot: 前缀
let id = to.replace(/^qqbot:/i, "");
if (id.startsWith("c2c:")) {
return { type: "c2c", id: id.slice(4) };
}
if (id.startsWith("group:")) {
return { type: "group", id: id.slice(6) };
}
if (id.startsWith("channel:")) {
return { type: "channel", id: id.slice(8) };
}
// 默认当作 c2c私聊
return { type: "c2c", id };
}
/**
* 发送文本消息
* - 有 replyToId: 被动回复1小时内最多回复4次
* - 无 replyToId: 主动发送有配额限制每月4条/用户/群)
*
* 注意:
* 1. 主动消息(无 replyToId必须有消息内容不支持流式发送
* 2. 当被动回复不可用(超期或超过次数)时,自动降级为主动消息
*/
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
const { to, text, account } = ctx;
let { replyToId } = ctx;
let fallbackToProactive = false;
console.log("[qqbot] sendText ctx:", JSON.stringify({ to, text: text?.slice(0, 50), replyToId, accountId: account.accountId }, null, 2));
// ============ 消息回复限流检查 ============
// 如果有 replyToId检查是否可以被动回复
if (replyToId) {
const limitCheck = checkMessageReplyLimit(replyToId);
if (!limitCheck.allowed) {
// 检查是否需要降级为主动消息
if (limitCheck.shouldFallbackToProactive) {
console.warn(`[qqbot] sendText: 被动回复不可用,降级为主动消息 - ${limitCheck.message}`);
fallbackToProactive = true;
replyToId = null; // 清除 replyToId改为主动消息
} else {
// 不应该发生,但作为保底
console.error(`[qqbot] sendText: 消息回复被限流但未设置降级 - ${limitCheck.message}`);
return {
channel: "qqbot",
error: limitCheck.message
};
}
} else {
console.log(`[qqbot] sendText: 消息 ${replyToId} 剩余被动回复次数: ${limitCheck.remaining}/${MESSAGE_REPLY_LIMIT}`);
}
}
// ============ 主动消息校验(参考 Telegram 机制) ============
// 如果是主动消息(无 replyToId 或降级后),必须有消息内容
if (!replyToId) {
if (!text || text.trim().length === 0) {
console.error("[qqbot] sendText error: 主动消息的内容不能为空 (text is empty)");
return {
channel: "qqbot",
error: "主动消息必须有内容 (--message 参数不能为空)"
};
}
if (fallbackToProactive) {
console.log(`[qqbot] sendText: [降级] 发送主动消息到 ${to}, 内容长度: ${text.length}`);
} else {
console.log(`[qqbot] sendText: 发送主动消息到 ${to}, 内容长度: ${text.length}`);
}
}
if (!account.appId || !account.clientSecret) {
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
}
try {
const accessToken = await getAccessToken(account.appId, account.clientSecret);
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") {
const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
// 记录回复次数
recordMessageReply(replyToId);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
} else if (target.type === "group") {
const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
// 记录回复次数
recordMessageReply(replyToId);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
} else {
const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
// 记录回复次数
recordMessageReply(replyToId);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { channel: "qqbot", error: message };
}
}
/**
* 主动发送消息(不需要 replyToId有配额限制每月 4 条/用户/群)
*
* @param account - 账户配置
* @param to - 目标地址格式openid单聊或 group:xxx群聊
* @param text - 消息内容
*/
export async function sendProactiveMessage(
account: ResolvedQQBotAccount,
to: string,
text: string
): Promise<OutboundResult> {
if (!account.appId || !account.clientSecret) {
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
}
try {
const accessToken = await getAccessToken(account.appId, account.clientSecret);
const target = parseTarget(to);
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 };
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { channel: "qqbot", error: message };
}
}
/**
* 发送富媒体消息(图片)
*
* 支持以下 mediaUrl 格式:
* - 公网 URL: https://example.com/image.png
* - Base64 Data URL: data:image/png;base64,xxxxx
* - 本地文件路径: /path/to/image.png自动读取并转换为 Base64
*
* @param ctx - 发送上下文,包含 mediaUrl
* @returns 发送结果
*
* @example
* ```typescript
* // 发送网络图片
* const result = await sendMedia({
* to: "group:xxx",
* text: "这是图片说明",
* mediaUrl: "https://example.com/image.png",
* account,
* replyToId: msgId,
* });
*
* // 发送 Base64 图片
* const result = await sendMedia({
* to: "group:xxx",
* text: "这是图片说明",
* mediaUrl: "data:image/png;base64,iVBORw0KGgo...",
* account,
* replyToId: msgId,
* });
*
* // 发送本地文件(自动读取并转换为 Base64
* const result = await sendMedia({
* to: "group:xxx",
* text: "这是图片说明",
* mediaUrl: "/tmp/generated-chart.png",
* account,
* replyToId: msgId,
* });
* ```
*/
export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> {
const { to, text, replyToId, account } = ctx;
const { mediaUrl } = ctx;
if (!account.appId || !account.clientSecret) {
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
}
if (!mediaUrl) {
return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
}
// 验证 mediaUrl 格式:支持公网 URL、Base64 Data URL 或本地文件路径
const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
const isDataUrl = mediaUrl.startsWith("data:");
const isLocalPath = mediaUrl.startsWith("/") ||
/^[a-zA-Z]:[\\/]/.test(mediaUrl) ||
mediaUrl.startsWith("./") ||
mediaUrl.startsWith("../");
// 处理本地文件路径:读取文件并转换为 Base64 Data URL
let processedMediaUrl = mediaUrl;
if (isLocalPath) {
console.log(`[qqbot] sendMedia: local file path detected: ${mediaUrl}`);
try {
// 检查文件是否存在
if (!fs.existsSync(mediaUrl)) {
return {
channel: "qqbot",
error: `本地文件不存在: ${mediaUrl}`
};
}
// 读取文件内容
const fileBuffer = fs.readFileSync(mediaUrl);
const base64Data = fileBuffer.toString("base64");
// 根据文件扩展名确定 MIME 类型
const ext = path.extname(mediaUrl).toLowerCase();
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
};
const mimeType = mimeTypes[ext];
if (!mimeType) {
return {
channel: "qqbot",
error: `不支持的图片格式: ${ext}。支持的格式: ${Object.keys(mimeTypes).join(", ")}`
};
}
// 构造 Data URL
processedMediaUrl = `data:${mimeType};base64,${base64Data}`;
console.log(`[qqbot] sendMedia: local file converted to Base64 (size: ${fileBuffer.length} bytes, type: ${mimeType})`);
} catch (readErr) {
const errMsg = readErr instanceof Error ? readErr.message : String(readErr);
console.error(`[qqbot] sendMedia: failed to read local file: ${errMsg}`);
return {
channel: "qqbot",
error: `读取本地文件失败: ${errMsg}`
};
}
} else if (!isHttpUrl && !isDataUrl) {
console.log(`[qqbot] sendMedia: unsupported media format: ${mediaUrl.slice(0, 50)}`);
return {
channel: "qqbot",
error: `不支持的图片格式: ${mediaUrl.slice(0, 50)}...。支持的格式: 公网 URL (http/https)、Base64 Data URL (data:image/...) 或本地文件路径。`
};
} else if (isDataUrl) {
console.log(`[qqbot] sendMedia: sending Base64 image (length: ${mediaUrl.length})`);
} else {
console.log(`[qqbot] sendMedia: sending image URL: ${mediaUrl.slice(0, 80)}...`);
}
try {
const accessToken = await getAccessToken(account.appId, account.clientSecret);
const target = parseTarget(to);
// 先发送图片(使用处理后的 URL可能是 Base64 Data URL
let imageResult: { id: string; timestamp: number | string };
if (target.type === "c2c") {
imageResult = await sendC2CImageMessage(
accessToken,
target.id,
processedMediaUrl,
replyToId ?? undefined,
undefined // content 参数,图片消息不支持同时带文本
);
} else if (target.type === "group") {
imageResult = await sendGroupImageMessage(
accessToken,
target.id,
processedMediaUrl,
replyToId ?? undefined,
undefined
);
} else {
// 频道暂不支持富媒体消息,只发送文本 + URL本地文件路径无法在频道展示
const displayUrl = isLocalPath ? "[本地文件]" : mediaUrl;
const textWithUrl = text ? `${text}\n${displayUrl}` : displayUrl;
const result = await sendChannelMessage(accessToken, target.id, textWithUrl, replyToId ?? undefined);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
}
// 如果有文本说明,再发送一条文本消息
if (text?.trim()) {
try {
if (target.type === "c2c") {
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
} else if (target.type === "group") {
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
}
} catch (textErr) {
// 文本发送失败不影响整体结果,图片已发送成功
console.error(`[qqbot] Failed to send text after image: ${textErr}`);
}
}
return { channel: "qqbot", messageId: imageResult.id, timestamp: imageResult.timestamp };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { channel: "qqbot", error: message };
}
}
/**
* 发送 Cron 触发的消息
*
* 当 OpenClaw cron 任务触发时,消息内容可能是:
* 1. QQBOT_CRON:{base64} 格式的结构化载荷 - 解码后根据 targetType 和 targetAddress 发送
* 2. 普通文本 - 直接发送到指定目标
*
* @param account - 账户配置
* @param to - 目标地址(作为后备,如果载荷中没有指定)
* @param message - 消息内容(可能是 QQBOT_CRON: 格式或普通文本)
* @returns 发送结果
*
* @example
* ```typescript
* // 处理结构化载荷
* const result = await sendCronMessage(
* account,
* "user_openid", // 后备地址
* "QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs..." // Base64 编码的载荷
* );
*
* // 处理普通文本
* const result = await sendCronMessage(
* account,
* "user_openid",
* "这是一条普通的提醒消息"
* );
* ```
*/
export async function sendCronMessage(
account: ResolvedQQBotAccount,
to: string,
message: string
): Promise<OutboundResult> {
console.log(`[qqbot] sendCronMessage: to=${to}, message length=${message.length}`);
// 检测是否是 QQBOT_CRON: 格式的结构化载荷
const cronResult = decodeCronPayload(message);
if (cronResult.isCronPayload) {
if (cronResult.error) {
console.error(`[qqbot] sendCronMessage: cron payload decode error: ${cronResult.error}`);
return {
channel: "qqbot",
error: `Cron 载荷解码失败: ${cronResult.error}`
};
}
if (cronResult.payload) {
const payload = cronResult.payload;
console.log(`[qqbot] sendCronMessage: decoded cron payload, targetType=${payload.targetType}, targetAddress=${payload.targetAddress}`);
// 使用载荷中的目标地址和类型发送消息
const targetTo = payload.targetType === "group"
? `group:${payload.targetAddress}`
: payload.targetAddress;
// 发送提醒内容
return await sendProactiveMessage(account, targetTo, payload.content);
}
}
// 非结构化载荷,作为普通文本处理
console.log(`[qqbot] sendCronMessage: plain text message, sending to ${to}`);
return await sendProactiveMessage(account, to, message);
}