diff --git a/src/utils/payload.ts b/src/utils/payload.ts new file mode 100644 index 0000000..dc21801 --- /dev/null +++ b/src/utils/payload.ts @@ -0,0 +1,265 @@ +/** + * QQBot 结构化消息载荷工具 + * + * 用于处理 AI 输出的结构化消息载荷,包括: + * - 定时提醒载荷 (cron_reminder) + * - 媒体消息载荷 (media) + */ + +// ============================================ +// 类型定义 +// ============================================ + +/** + * 定时提醒载荷 + */ +export interface CronReminderPayload { + type: 'cron_reminder'; + /** 提醒内容 */ + content: string; + /** 目标类型:c2c (私聊) 或 group (群聊) */ + targetType: 'c2c' | 'group'; + /** 目标地址:user_openid 或 group_openid */ + targetAddress: string; + /** 原始消息 ID(可选) */ + originalMessageId?: string; +} + +/** + * 媒体消息载荷 + */ +export interface MediaPayload { + type: 'media'; + /** 媒体类型:image, audio, video */ + mediaType: 'image' | 'audio' | 'video'; + /** 来源类型:url 或 file */ + source: 'url' | 'file'; + /** 媒体路径或 URL */ + path: string; + /** 媒体描述(可选) */ + caption?: string; +} + +/** + * QQBot 载荷联合类型 + */ +export type QQBotPayload = CronReminderPayload | MediaPayload; + +/** + * 解析结果 + */ +export interface ParseResult { + /** 是否为结构化载荷 */ + isPayload: boolean; + /** 解析后的载荷对象(如果是结构化载荷) */ + payload?: QQBotPayload; + /** 原始文本(如果不是结构化载荷) */ + text?: string; + /** 解析错误信息(如果解析失败) */ + error?: string; +} + +// ============================================ +// 常量定义 +// ============================================ + +/** AI 输出的结构化载荷前缀 */ +const PAYLOAD_PREFIX = 'QQBOT_PAYLOAD:'; + +/** Cron 消息存储的前缀 */ +const CRON_PREFIX = 'QQBOT_CRON:'; + +// ============================================ +// 解析函数 +// ============================================ + +/** + * 解析 AI 输出的结构化载荷 + * + * 检测消息是否以 QQBOT_PAYLOAD: 前缀开头,如果是则提取并解析 JSON + * + * @param text AI 输出的原始文本 + * @returns 解析结果 + * + * @example + * const result = parseQQBotPayload('QQBOT_PAYLOAD:\n{"type": "media", "mediaType": "image", ...}'); + * if (result.isPayload && result.payload) { + * // 处理结构化载荷 + * } + */ +export function parseQQBotPayload(text: string): ParseResult { + const trimmedText = text.trim(); + + // 检查是否以 QQBOT_PAYLOAD: 开头 + if (!trimmedText.startsWith(PAYLOAD_PREFIX)) { + return { + isPayload: false, + text: text + }; + } + + // 提取 JSON 内容(去掉前缀) + const jsonContent = trimmedText.slice(PAYLOAD_PREFIX.length).trim(); + + if (!jsonContent) { + return { + isPayload: true, + error: '载荷内容为空' + }; + } + + try { + const payload = JSON.parse(jsonContent) as QQBotPayload; + + // 验证必要字段 + if (!payload.type) { + return { + isPayload: true, + error: '载荷缺少 type 字段' + }; + } + + // 根据 type 进行额外验证 + if (payload.type === 'cron_reminder') { + if (!payload.content || !payload.targetType || !payload.targetAddress) { + return { + isPayload: true, + error: 'cron_reminder 载荷缺少必要字段 (content, targetType, targetAddress)' + }; + } + } else if (payload.type === 'media') { + if (!payload.mediaType || !payload.source || !payload.path) { + return { + isPayload: true, + error: 'media 载荷缺少必要字段 (mediaType, source, path)' + }; + } + } + + return { + isPayload: true, + payload + }; + } catch (e) { + return { + isPayload: true, + error: `JSON 解析失败: ${e instanceof Error ? e.message : String(e)}` + }; + } +} + +// ============================================ +// Cron 编码/解码函数 +// ============================================ + +/** + * 将定时提醒载荷编码为 Cron 消息格式 + * + * 将 JSON 编码为 Base64,并添加 QQBOT_CRON: 前缀 + * + * @param payload 定时提醒载荷 + * @returns 编码后的消息字符串,格式为 QQBOT_CRON:{base64} + * + * @example + * const message = encodePayloadForCron({ + * type: 'cron_reminder', + * content: '喝水时间到!', + * targetType: 'c2c', + * targetAddress: 'user_openid_xxx' + * }); + * // 返回: QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs... + */ +export function encodePayloadForCron(payload: CronReminderPayload): string { + const jsonString = JSON.stringify(payload); + const base64 = Buffer.from(jsonString, 'utf-8').toString('base64'); + return `${CRON_PREFIX}${base64}`; +} + +/** + * 解码 Cron 消息中的载荷 + * + * 检测 QQBOT_CRON: 前缀,解码 Base64 并解析 JSON + * + * @param message Cron 触发时收到的消息 + * @returns 解码结果,包含是否为 Cron 载荷、解析后的载荷对象或错误信息 + * + * @example + * const result = decodeCronPayload('QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs...'); + * if (result.isCronPayload && result.payload) { + * // 处理定时提醒 + * } + */ +export function decodeCronPayload(message: string): { + isCronPayload: boolean; + payload?: CronReminderPayload; + error?: string; +} { + const trimmedMessage = message.trim(); + + // 检查是否以 QQBOT_CRON: 开头 + if (!trimmedMessage.startsWith(CRON_PREFIX)) { + return { + isCronPayload: false + }; + } + + // 提取 Base64 内容 + const base64Content = trimmedMessage.slice(CRON_PREFIX.length); + + if (!base64Content) { + return { + isCronPayload: true, + error: 'Cron 载荷内容为空' + }; + } + + try { + // Base64 解码 + const jsonString = Buffer.from(base64Content, 'base64').toString('utf-8'); + const payload = JSON.parse(jsonString) as CronReminderPayload; + + // 验证类型 + if (payload.type !== 'cron_reminder') { + return { + isCronPayload: true, + error: `期望 type 为 cron_reminder,实际为 ${payload.type}` + }; + } + + // 验证必要字段 + if (!payload.content || !payload.targetType || !payload.targetAddress) { + return { + isCronPayload: true, + error: 'Cron 载荷缺少必要字段' + }; + } + + return { + isCronPayload: true, + payload + }; + } catch (e) { + return { + isCronPayload: true, + error: `Cron 载荷解码失败: ${e instanceof Error ? e.message : String(e)}` + }; + } +} + +// ============================================ +// 辅助函数 +// ============================================ + +/** + * 判断载荷是否为定时提醒类型 + */ +export function isCronReminderPayload(payload: QQBotPayload): payload is CronReminderPayload { + return payload.type === 'cron_reminder'; +} + +/** + * 判断载荷是否为媒体消息类型 + */ +export function isMediaPayload(payload: QQBotPayload): payload is MediaPayload { + return payload.type === 'media'; +}