feat(qqbot): 图片收发功能与定时提醒优化
**图片功能** - 支持接收用户发送的图片消息(自动下载到临时目录) - 支持发送本地文件路径(自动读取转为 Base64) - 富媒体消息接口(sendC2CImageMessage/sendGroupImageMessage) - 图片本地代理服务(解决 QQ 图片 URL 直接访问限制) **消息格式** - 默认启用 Markdown 消息格式 **定时提醒优化** - 修复 cron 提醒:移除无效 --system-prompt 参数,改用 --message 直接输出提醒内容 - 精简用户交互话术,避免冗长回复 **代码清理** - 移除过时的流式消息处理代码 - 优化 gateway/outbound/channel 模块结构
This commit is contained in:
154
src/api.ts
154
src/api.ts
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* QQ Bot API 鉴权和请求封装(支持流式消息)
|
||||
* QQ Bot API 鉴权和请求封装
|
||||
*/
|
||||
|
||||
import { StreamState, type StreamConfig } from "./types.js";
|
||||
|
||||
const API_BASE = "https://api.sgroup.qq.com";
|
||||
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
||||
|
||||
@@ -12,10 +10,10 @@ let currentMarkdownSupport = false;
|
||||
|
||||
/**
|
||||
* 初始化 API 配置
|
||||
* @param options.markdownSupport - 是否支持 markdown 消息
|
||||
* @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
|
||||
*/
|
||||
export function initApiConfig(options: { markdownSupport?: boolean }): void {
|
||||
currentMarkdownSupport = options.markdownSupport === true; // 默认为 false
|
||||
currentMarkdownSupport = options.markdownSupport === true; // 默认为 false,需要机器人具备 markdown 消息权限才能启用
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,45 +243,35 @@ export async function getGatewayUrl(accessToken: string): Promise<string> {
|
||||
return data.url;
|
||||
}
|
||||
|
||||
// ============ 流式消息发送接口 ============
|
||||
// ============ 消息发送接口 ============
|
||||
|
||||
/**
|
||||
* 流式消息响应
|
||||
* 消息响应
|
||||
*/
|
||||
export interface StreamMessageResponse {
|
||||
export interface MessageResponse {
|
||||
id: string;
|
||||
timestamp: number | string;
|
||||
/** 流式消息ID,用于后续分片 */
|
||||
stream_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建流式消息体
|
||||
* 构建消息体
|
||||
* 根据 markdownSupport 配置决定消息格式:
|
||||
* - markdown 模式: { markdown: { content }, msg_type: 2 }
|
||||
* - 纯文本模式: { content, msg_type: 0 }
|
||||
*/
|
||||
function buildStreamBody(
|
||||
function buildMessageBody(
|
||||
content: string,
|
||||
msgId: string | undefined,
|
||||
msgSeq: number,
|
||||
stream?: StreamConfig
|
||||
msgSeq: number
|
||||
): Record<string, unknown> {
|
||||
// 流式 markdown 消息要求每个分片内容必须以换行符结尾
|
||||
// QQ API 错误码 40034017: "流式消息md分片需要\n结束"
|
||||
let finalContent = content;
|
||||
if (stream && currentMarkdownSupport && content && !content.endsWith("\n")) {
|
||||
finalContent = content + "\n";
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = currentMarkdownSupport
|
||||
? {
|
||||
markdown: { content: finalContent },
|
||||
markdown: { content },
|
||||
msg_type: 2,
|
||||
msg_seq: msgSeq,
|
||||
}
|
||||
: {
|
||||
content: finalContent,
|
||||
content,
|
||||
msg_type: 0,
|
||||
msg_seq: msgSeq,
|
||||
};
|
||||
@@ -292,29 +280,20 @@ function buildStreamBody(
|
||||
body.msg_id = msgId;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
body.stream = {
|
||||
state: stream.state,
|
||||
index: stream.index,
|
||||
...(stream.id ? { id: stream.id } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 C2C 单聊消息(支持流式)
|
||||
* 发送 C2C 单聊消息
|
||||
*/
|
||||
export async function sendC2CMessage(
|
||||
accessToken: string,
|
||||
openid: string,
|
||||
content: string,
|
||||
msgId?: string,
|
||||
stream?: StreamConfig
|
||||
): Promise<StreamMessageResponse> {
|
||||
msgId?: string
|
||||
): Promise<MessageResponse> {
|
||||
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
||||
const body = buildStreamBody(content, msgId, msgSeq, stream);
|
||||
const body = buildMessageBody(content, msgId, msgSeq);
|
||||
|
||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
|
||||
}
|
||||
@@ -358,17 +337,16 @@ export async function sendChannelMessage(
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送群聊消息(支持流式)
|
||||
* 发送群聊消息
|
||||
*/
|
||||
export async function sendGroupMessage(
|
||||
accessToken: string,
|
||||
groupOpenid: string,
|
||||
content: string,
|
||||
msgId?: string,
|
||||
stream?: StreamConfig
|
||||
): Promise<StreamMessageResponse> {
|
||||
msgId?: string
|
||||
): Promise<MessageResponse> {
|
||||
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
||||
const body = buildStreamBody(content, msgId, msgSeq, stream);
|
||||
const body = buildMessageBody(content, msgId, msgSeq);
|
||||
|
||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
|
||||
}
|
||||
@@ -458,36 +436,64 @@ export interface UploadMediaResponse {
|
||||
|
||||
/**
|
||||
* 上传富媒体文件到 C2C 单聊
|
||||
* @param url - 公网可访问的图片 URL(与 fileData 二选一)
|
||||
* @param fileData - Base64 编码的文件内容(与 url 二选一)
|
||||
*/
|
||||
export async function uploadC2CMedia(
|
||||
accessToken: string,
|
||||
openid: string,
|
||||
fileType: MediaFileType,
|
||||
url: string,
|
||||
url?: string,
|
||||
fileData?: string,
|
||||
srvSendMsg = false
|
||||
): Promise<UploadMediaResponse> {
|
||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, {
|
||||
if (!url && !fileData) {
|
||||
throw new Error("uploadC2CMedia: url or fileData is required");
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
file_type: fileType,
|
||||
url,
|
||||
srv_send_msg: srvSendMsg,
|
||||
});
|
||||
};
|
||||
|
||||
if (url) {
|
||||
body.url = url;
|
||||
} else if (fileData) {
|
||||
body.file_data = fileData;
|
||||
}
|
||||
|
||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传富媒体文件到群聊
|
||||
* @param url - 公网可访问的图片 URL(与 fileData 二选一)
|
||||
* @param fileData - Base64 编码的文件内容(与 url 二选一)
|
||||
*/
|
||||
export async function uploadGroupMedia(
|
||||
accessToken: string,
|
||||
groupOpenid: string,
|
||||
fileType: MediaFileType,
|
||||
url: string,
|
||||
url?: string,
|
||||
fileData?: string,
|
||||
srvSendMsg = false
|
||||
): Promise<UploadMediaResponse> {
|
||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, {
|
||||
if (!url && !fileData) {
|
||||
throw new Error("uploadGroupMedia: url or fileData is required");
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
file_type: fileType,
|
||||
url,
|
||||
srv_send_msg: srvSendMsg,
|
||||
});
|
||||
};
|
||||
|
||||
if (url) {
|
||||
body.url = url;
|
||||
} else if (fileData) {
|
||||
body.file_data = fileData;
|
||||
}
|
||||
|
||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -532,6 +538,9 @@ export async function sendGroupMediaMessage(
|
||||
|
||||
/**
|
||||
* 发送带图片的 C2C 单聊消息(封装上传+发送)
|
||||
* @param imageUrl - 图片来源,支持:
|
||||
* - 公网 URL: https://example.com/image.png
|
||||
* - Base64 Data URL: data:image/png;base64,xxxxx
|
||||
*/
|
||||
export async function sendC2CImageMessage(
|
||||
accessToken: string,
|
||||
@@ -540,14 +549,32 @@ export async function sendC2CImageMessage(
|
||||
msgId?: string,
|
||||
content?: string
|
||||
): Promise<{ id: string; timestamp: number }> {
|
||||
// 先上传图片获取 file_info
|
||||
const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, false);
|
||||
// 再发送富媒体消息
|
||||
let uploadResult: UploadMediaResponse;
|
||||
|
||||
// 检查是否是 Base64 Data URL
|
||||
if (imageUrl.startsWith("data:")) {
|
||||
// 解析 Base64 Data URL: data:image/png;base64,xxxxx
|
||||
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
throw new Error("Invalid Base64 Data URL format");
|
||||
}
|
||||
const base64Data = matches[2];
|
||||
// 使用 file_data 上传
|
||||
uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, undefined, base64Data, false);
|
||||
} else {
|
||||
// 公网 URL,使用 url 参数上传
|
||||
uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, undefined, false);
|
||||
}
|
||||
|
||||
// 发送富媒体消息
|
||||
return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送带图片的群聊消息(封装上传+发送)
|
||||
* @param imageUrl - 图片来源,支持:
|
||||
* - 公网 URL: https://example.com/image.png
|
||||
* - Base64 Data URL: data:image/png;base64,xxxxx
|
||||
*/
|
||||
export async function sendGroupImageMessage(
|
||||
accessToken: string,
|
||||
@@ -556,9 +583,24 @@ export async function sendGroupImageMessage(
|
||||
msgId?: string,
|
||||
content?: string
|
||||
): Promise<{ id: string; timestamp: string }> {
|
||||
// 先上传图片获取 file_info
|
||||
const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, false);
|
||||
// 再发送富媒体消息
|
||||
let uploadResult: UploadMediaResponse;
|
||||
|
||||
// 检查是否是 Base64 Data URL
|
||||
if (imageUrl.startsWith("data:")) {
|
||||
// 解析 Base64 Data URL: data:image/png;base64,xxxxx
|
||||
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
throw new Error("Invalid Base64 Data URL format");
|
||||
}
|
||||
const base64Data = matches[2];
|
||||
// 使用 file_data 上传
|
||||
uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, undefined, base64Data, false);
|
||||
} else {
|
||||
// 公网 URL,使用 url 参数上传
|
||||
uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, undefined, false);
|
||||
}
|
||||
|
||||
// 发送富媒体消息
|
||||
return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
|
||||
}
|
||||
|
||||
@@ -707,4 +749,4 @@ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,14 @@ import {
|
||||
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId } from "./config.js";
|
||||
import { sendText, sendMedia, createStreamSender, sendTextStream, StreamSender } from "./outbound.js";
|
||||
import { sendText, sendMedia } from "./outbound.js";
|
||||
import { startGateway } from "./gateway.js";
|
||||
import { qqbotOnboardingAdapter } from "./onboarding.js";
|
||||
import { getQQBotRuntime } from "./runtime.js";
|
||||
|
||||
/**
|
||||
* 简单的文本分块函数
|
||||
* QQ Bot 使用流式消息时,不需要预先分块,而是在发送时逐步累积
|
||||
* 但框架可能调用此函数来预分块长文本
|
||||
* 用于预先分块长文本
|
||||
*/
|
||||
function chunkText(text: string, limit: number): string[] {
|
||||
if (text.length <= limit) return [text];
|
||||
@@ -52,10 +51,10 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
id: "qqbot",
|
||||
meta: {
|
||||
id: "qqbot",
|
||||
label: "QQ Bot (Stream)",
|
||||
selectionLabel: "QQ Bot (Stream)",
|
||||
label: "QQ Bot",
|
||||
selectionLabel: "QQ Bot",
|
||||
docsPath: "/docs/channels/qqbot",
|
||||
blurb: "Connect to QQ via official QQ Bot API with streaming message support",
|
||||
blurb: "Connect to QQ via official QQ Bot API",
|
||||
order: 50,
|
||||
},
|
||||
capabilities: {
|
||||
@@ -67,7 +66,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
* blockStreaming: true 表示该 Channel 支持块流式
|
||||
* 框架会收集流式响应,然后通过 deliver 回调发送
|
||||
*/
|
||||
blockStreaming: true,
|
||||
blockStreaming: false,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.qqbot"] },
|
||||
// CLI onboarding wizard
|
||||
@@ -180,7 +179,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
startAccount: async (ctx) => {
|
||||
const { account, abortSignal, log, cfg } = ctx;
|
||||
|
||||
log?.info(`[qqbot:${account.accountId}] Starting gateway (stream-enabled)`);
|
||||
log?.info(`[qqbot:${account.accountId}] Starting gateway`);
|
||||
|
||||
await startGateway({
|
||||
account,
|
||||
@@ -282,36 +281,3 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出流式消息工具函数,供外部使用
|
||||
*
|
||||
* 使用示例:
|
||||
* ```typescript
|
||||
* import { createStreamSender } from "qqbot";
|
||||
*
|
||||
* // 创建流式发送器
|
||||
* const sender = createStreamSender(account, "group:xxx", replyMsgId);
|
||||
*
|
||||
* // 发送第一个分片 (state=1, index=0, id="")
|
||||
* await sender.send("Hello, ", false);
|
||||
*
|
||||
* // 发送中间分片 (state=1, index=1, id=从上次响应获取)
|
||||
* await sender.send("Hello, this is ", false);
|
||||
*
|
||||
* // 发送最后分片并结束 (state=10, index=2)
|
||||
* await sender.end("Hello, this is a streaming message!");
|
||||
* ```
|
||||
*
|
||||
* 或使用 AsyncGenerator:
|
||||
* ```typescript
|
||||
* async function* generateText() {
|
||||
* yield "Hello, ";
|
||||
* yield "this is ";
|
||||
* yield "a streaming message!";
|
||||
* }
|
||||
*
|
||||
* await sendTextStream(ctx, generateText());
|
||||
* ```
|
||||
*/
|
||||
export { createStreamSender, sendTextStream, StreamSender };
|
||||
|
||||
@@ -77,7 +77,6 @@ export function resolveQQBotAccount(
|
||||
systemPrompt: qqbot?.systemPrompt,
|
||||
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
|
||||
markdownSupport: qqbot?.markdownSupport,
|
||||
streamEnabled: qqbot?.streamEnabled,
|
||||
};
|
||||
appId = qqbot?.appId ?? "";
|
||||
} else {
|
||||
@@ -114,7 +113,6 @@ export function resolveQQBotAccount(
|
||||
systemPrompt: accountConfig.systemPrompt,
|
||||
imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
|
||||
markdownSupport: accountConfig.markdownSupport,
|
||||
streamEnabled: accountConfig.streamEnabled,
|
||||
config: accountConfig,
|
||||
};
|
||||
}
|
||||
|
||||
874
src/gateway.ts
874
src/gateway.ts
@@ -1,13 +1,12 @@
|
||||
import WebSocket from "ws";
|
||||
import path from "node:path";
|
||||
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
||||
import { StreamState } from "./types.js";
|
||||
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh } from "./api.js";
|
||||
import { loadSession, saveSession, clearSession, type SessionState } from "./session-store.js";
|
||||
import { recordKnownUser, flushKnownUsers } from "./known-users.js";
|
||||
import { getQQBotRuntime } from "./runtime.js";
|
||||
import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
||||
import { createStreamSender } from "./outbound.js";
|
||||
import { startImageServer, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
||||
import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize, DEFAULT_IMAGE_SIZE } from "./utils/image-size.js";
|
||||
|
||||
// QQ Bot intents - 按权限级别分组
|
||||
const INTENTS = {
|
||||
@@ -54,64 +53,6 @@ const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765
|
||||
// 使用绝对路径,确保文件保存和读取使用同一目录
|
||||
const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || path.join(process.env.HOME || "/home/ubuntu", "clawd", "qqbot-images");
|
||||
|
||||
// 流式消息配置
|
||||
const STREAM_CHUNK_INTERVAL = 500; // 流式消息分片间隔(毫秒)
|
||||
const STREAM_MIN_CHUNK_SIZE = 10; // 最小分片大小(字符)
|
||||
const STREAM_KEEPALIVE_FIRST_DELAY = 3000; // 首次状态保持延迟(毫秒),openclaw 3s 内未回复时发送
|
||||
const STREAM_KEEPALIVE_GAP = 10000; // 状态保持消息之间的间隔(毫秒)
|
||||
const STREAM_KEEPALIVE_MAX_PER_CHUNK = 2; // 每 2 个消息分片之间最多发送的状态保持消息数量
|
||||
const STREAM_MAX_DURATION = 3 * 60 * 1000; // 流式消息最大持续时间(毫秒),超过 3 分钟自动结束
|
||||
|
||||
// ============ 智能断句配置 ============
|
||||
// 首个分片:必须在语义边界处断句,避免奇怪的换行
|
||||
const FIRST_CHUNK_MIN_LENGTH_SOFT = 20; // 软下限:达到此长度后,遇到语义边界就可以发送
|
||||
const FIRST_CHUNK_MIN_LENGTH_HARD = 80; // 硬下限:超过此长度必须发送,避免等待太久
|
||||
const FIRST_CHUNK_MAX_WAIT_TIME = 3000; // 首个分片最长等待时间(毫秒)
|
||||
|
||||
// 语义边界检测:判断文本是否在自然断句位置结束
|
||||
function isAtSemanticBoundary(text: string): boolean {
|
||||
if (!text) return false;
|
||||
const trimmed = text.trimEnd();
|
||||
if (!trimmed) return false;
|
||||
|
||||
// 检查最后一个字符是否是断句标点
|
||||
const lastChar = trimmed[trimmed.length - 1];
|
||||
const sentenceEnders = ['。', '!', '?', '~', '…', '.', '!', '?', '\n'];
|
||||
if (sentenceEnders.includes(lastChar)) return true;
|
||||
|
||||
// 检查是否以 emoji 结尾(常见于提醒消息)
|
||||
const emojiRegex = /[\u{1F300}-\u{1F9FF}]$/u;
|
||||
if (emojiRegex.test(trimmed)) return true;
|
||||
|
||||
// 检查最后几个字符是否是 markdown 列表项结束(如 "- xxx" 后面)
|
||||
// 不算边界,因为列表通常有多项
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 查找最近的语义边界位置
|
||||
function findLastSemanticBoundary(text: string, minPos: number = 0): number {
|
||||
if (!text || text.length <= minPos) return -1;
|
||||
|
||||
const sentenceEnders = ['。', '!', '?', '~', '.', '!', '?'];
|
||||
let lastBoundary = -1;
|
||||
|
||||
for (let i = text.length - 1; i >= minPos; i--) {
|
||||
const char = text[i];
|
||||
if (sentenceEnders.includes(char)) {
|
||||
lastBoundary = i + 1; // 包含这个标点符号
|
||||
break;
|
||||
}
|
||||
// 换行符也是边界
|
||||
if (char === '\n') {
|
||||
lastBoundary = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return lastBoundary;
|
||||
}
|
||||
|
||||
// 消息队列配置(异步处理,防止阻塞心跳)
|
||||
const MESSAGE_QUEUE_SIZE = 1000; // 最大队列长度
|
||||
const MESSAGE_QUEUE_WARN_THRESHOLD = 800; // 队列告警阈值
|
||||
@@ -181,6 +122,94 @@ function recordMessageReply(messageId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 图片发送时的文本智能简化 ============
|
||||
// 当 AI 发送图片时,检测并移除冗余的解释性文字
|
||||
|
||||
/**
|
||||
* 冗余文本模式 - 这些模式表示 AI 在"解释"而不是"回应"
|
||||
* 通常出现在 AI 不确定图片是否发送成功时
|
||||
*/
|
||||
const REDUNDANT_TEXT_PATTERNS = [
|
||||
// 中文冗余模式
|
||||
/让我总结一下[^\n]*/gi,
|
||||
/目前的情况[是::][^\n]*/gi,
|
||||
/由于[^\n]*(?:工具[集]?|插件|集成|API)[^\n]*(?:限制|问题)[^\n]*/gi,
|
||||
/我已经[^\n]*(?:尝试|下载|保存)[^\n]*/gi,
|
||||
/最实用的(?:方法|解决方案)[是::][^\n]*/gi,
|
||||
/如果你希望我继续[^\n]*/gi,
|
||||
/你可以[直接]?点击[^\n]*链接[^\n]*/gi,
|
||||
/我注意到你重复[^\n]*/gi,
|
||||
/我[已经]?多次尝试[^\n]*/gi,
|
||||
/(?:已经|成功)?(?:保存|下载)到本地[^\n]*/gi,
|
||||
/(?:直接)?(?:查看|访问)[该这]?(?:图片|文件|链接)[^\n]*/gi,
|
||||
// 英文冗余模式
|
||||
/let me summarize[^\n]*/gi,
|
||||
/i(?:'ve| have) tried[^\n]*(?:multiple|several)[^\n]*/gi,
|
||||
/due to[^\n]*(?:tool|plugin|integration)[^\n]*limitation[^\n]*/gi,
|
||||
/the most practical[^\n]*solution[^\n]*/gi,
|
||||
];
|
||||
|
||||
/**
|
||||
* 检查文本是否为纯冗余解释
|
||||
* 如果整个文本都是在解释发送过程,而不是描述图片内容,则返回 true
|
||||
*/
|
||||
function isEntirelyRedundantExplanation(text: string): boolean {
|
||||
// 移除空行和空格
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return true;
|
||||
|
||||
// 检查是否包含"步骤列表"类的解释
|
||||
const hasStepList = /^\d+\.\s+/m.test(trimmed) &&
|
||||
(trimmed.includes("下载") || trimmed.includes("尝试") || trimmed.includes("发送"));
|
||||
|
||||
// 检查是否主要由冗余模式组成
|
||||
let cleaned = trimmed;
|
||||
for (const pattern of REDUNDANT_TEXT_PATTERNS) {
|
||||
cleaned = cleaned.replace(pattern, "");
|
||||
}
|
||||
|
||||
// 如果清理后只剩下很少的文字(主要是标点和连接词),认为整体都是冗余
|
||||
const cleanedWords = cleaned.replace(/[\s\n\r.,;:!?,。;:!?·…—""''()()【】[\]{}]+/g, "").trim();
|
||||
const significantContentRemaining = cleanedWords.length > 20;
|
||||
|
||||
return hasStepList || !significantContentRemaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能简化图片发送时的文本
|
||||
* 当检测到发送图片时,移除冗余的解释性文字
|
||||
*
|
||||
* @param text 原始文本
|
||||
* @param hasImages 是否包含图片
|
||||
* @returns 简化后的文本
|
||||
*/
|
||||
function simplifyTextForImageSend(text: string, hasImages: boolean): string {
|
||||
if (!hasImages || !text) return text;
|
||||
|
||||
const trimmed = text.trim();
|
||||
|
||||
// 如果整个文本都是冗余解释,替换为简短的成功提示
|
||||
if (isEntirelyRedundantExplanation(trimmed)) {
|
||||
return "图片如上 ☝️";
|
||||
}
|
||||
|
||||
// 否则,只移除明显的冗余段落
|
||||
let result = trimmed;
|
||||
for (const pattern of REDUNDANT_TEXT_PATTERNS) {
|
||||
result = result.replace(pattern, "");
|
||||
}
|
||||
|
||||
// 清理多余的空行
|
||||
result = result.replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
// 如果清理后文本太短,恢复原文
|
||||
if (result.length < 10 && trimmed.length > 50) {
|
||||
return "图片如上 ☝️";
|
||||
}
|
||||
|
||||
return result || trimmed;
|
||||
}
|
||||
|
||||
export interface GatewayContext {
|
||||
account: ResolvedQQBotAccount;
|
||||
abortSignal: AbortSignal;
|
||||
@@ -250,7 +279,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
initApiConfig({
|
||||
markdownSupport: account.markdownSupport,
|
||||
});
|
||||
log?.info(`[qqbot:${account.accountId}] API config: markdownSupport=${account.markdownSupport !== false}`);
|
||||
log?.info(`[qqbot:${account.accountId}] API config: markdownSupport=${account.markdownSupport === true}`);
|
||||
|
||||
// 如果配置了公网 URL,启动图床服务器
|
||||
let imageServerBaseUrl: string | null = null;
|
||||
@@ -444,10 +473,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
|
||||
}
|
||||
|
||||
// 流式消息开关(默认禁用,仅 c2c 支持,需要在配置中明确启用)
|
||||
const streamEnabled = account.streamEnabled === true;
|
||||
log?.debug?.(`[qqbot:${account.accountId}] Stream enabled: ${streamEnabled}`);
|
||||
|
||||
pluginRuntime.channel.activity.record({
|
||||
channel: "qqbot",
|
||||
accountId: account.accountId,
|
||||
@@ -516,8 +541,7 @@ openclaw cron add \\
|
||||
|
||||
⚠️ 重要注意事项:
|
||||
1. --at 参数格式:相对时间用 \`5m\`、\`1h\` 等(不要加 + 号!);绝对时间用完整 ISO 格式
|
||||
2. 定时提醒消息不支持流式发送,命令中不要添加 --stream 参数
|
||||
3. --message 参数必须有实际内容,不能为空字符串`;
|
||||
2. --message 参数必须有实际内容,不能为空字符串`;
|
||||
|
||||
// 只有配置了图床公网地址,才告诉 AI 可以发送图片
|
||||
if (imageServerBaseUrl) {
|
||||
@@ -661,315 +685,11 @@ openclaw cron add \\
|
||||
}, responseTimeout);
|
||||
});
|
||||
|
||||
// ============ 流式消息发送器 ============
|
||||
// ============ 消息发送目标 ============
|
||||
// 确定发送目标
|
||||
const targetTo = event.type === "c2c" ? event.senderId
|
||||
: event.type === "group" ? `group:${event.groupOpenid}`
|
||||
: `channel:${event.channelId}`;
|
||||
|
||||
// 判断是否支持流式(仅 c2c 支持,群聊不支持流式,且需要开关启用)
|
||||
const supportsStream = event.type === "c2c" && streamEnabled;
|
||||
log?.info(`[qqbot:${account.accountId}] Stream support: ${supportsStream} (type=${event.type}, enabled=${streamEnabled})`);
|
||||
|
||||
// 创建流式发送器
|
||||
let streamSender = supportsStream ? createStreamSender(account, targetTo, event.messageId) : null;
|
||||
let streamBuffer = ""; // 累积的全部文本(用于记录完整内容)
|
||||
let lastSentLength = 0; // 上次发送时的文本长度(用于计算增量)
|
||||
let lastSentText = ""; // 上次发送时的完整文本(用于检测新段落)
|
||||
let currentSegmentStart = 0; // 当前段落在 streamBuffer 中的起始位置
|
||||
let lastStreamSendTime = 0; // 上次流式发送时间
|
||||
let streamStarted = false; // 是否已开始流式发送
|
||||
let streamEnded = false; // 流式是否已结束
|
||||
let streamStartTime = 0; // 流式消息开始时间(用于超时检查)
|
||||
let sendingLock = false; // 发送锁,防止并发发送
|
||||
let pendingFullText = ""; // 待发送的完整文本(在锁定期间积累)
|
||||
let firstChunkWaitStart = 0; // 首个分片开始等待的时间(用于超时判断)
|
||||
let keepaliveTimer: ReturnType<typeof setTimeout> | null = null; // 心跳定时器
|
||||
let keepaliveCountSinceLastChunk = 0; // 自上次分片以来发送的状态保持消息数量
|
||||
let lastChunkSendTime = 0; // 上次分片发送时间(用于判断是否需要发送状态保持)
|
||||
|
||||
// 清理心跳定时器
|
||||
const clearKeepalive = () => {
|
||||
if (keepaliveTimer) {
|
||||
clearTimeout(keepaliveTimer);
|
||||
keepaliveTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 重置心跳定时器(每次发送后调用)
|
||||
// isContentChunk: 是否为内容分片(非状态保持消息)
|
||||
const resetKeepalive = (isContentChunk: boolean = false) => {
|
||||
clearKeepalive();
|
||||
|
||||
// 如果是内容分片,重置状态保持计数器和时间
|
||||
if (isContentChunk) {
|
||||
keepaliveCountSinceLastChunk = 0;
|
||||
lastChunkSendTime = Date.now();
|
||||
}
|
||||
|
||||
if (streamSender && streamStarted && !streamEnded) {
|
||||
// 计算下次状态保持消息的延迟时间
|
||||
// - 首次:3s(STREAM_KEEPALIVE_FIRST_DELAY)
|
||||
// - 后续:10s(STREAM_KEEPALIVE_GAP)
|
||||
const delay = keepaliveCountSinceLastChunk === 0
|
||||
? STREAM_KEEPALIVE_FIRST_DELAY
|
||||
: STREAM_KEEPALIVE_GAP;
|
||||
|
||||
keepaliveTimer = setTimeout(async () => {
|
||||
// 检查流式消息是否超时(超过 3 分钟自动结束)
|
||||
const elapsed = Date.now() - streamStartTime;
|
||||
if (elapsed >= STREAM_MAX_DURATION) {
|
||||
log?.info(`[qqbot:${account.accountId}] Stream timeout after ${Math.round(elapsed / 1000)}s, auto ending stream`);
|
||||
if (!streamEnded && !sendingLock) {
|
||||
sendingLock = true;
|
||||
try {
|
||||
// 发送结束标记
|
||||
await streamSender!.send("", true);
|
||||
streamEnded = true;
|
||||
clearKeepalive();
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Stream auto-end failed: ${err}`);
|
||||
} finally {
|
||||
sendingLock = false;
|
||||
}
|
||||
}
|
||||
return; // 超时后不再继续心跳
|
||||
}
|
||||
|
||||
// 检查是否已达到每2个分片之间的最大状态保持消息数量
|
||||
if (keepaliveCountSinceLastChunk >= STREAM_KEEPALIVE_MAX_PER_CHUNK) {
|
||||
log?.debug?.(`[qqbot:${account.accountId}] Max keepalive reached (${keepaliveCountSinceLastChunk}/${STREAM_KEEPALIVE_MAX_PER_CHUNK}), waiting for next content chunk`);
|
||||
// 不再发送状态保持,但继续监控超时
|
||||
resetKeepalive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查距上次分片是否超过 3s
|
||||
const timeSinceLastChunk = Date.now() - lastChunkSendTime;
|
||||
if (timeSinceLastChunk < STREAM_KEEPALIVE_FIRST_DELAY) {
|
||||
// 还未到发送状态保持的时机,继续等待
|
||||
resetKeepalive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送状态保持消息
|
||||
if (!streamEnded && !sendingLock) {
|
||||
log?.info(`[qqbot:${account.accountId}] Sending keepalive #${keepaliveCountSinceLastChunk + 1} (elapsed: ${Math.round(elapsed / 1000)}s, since chunk: ${Math.round(timeSinceLastChunk / 1000)}s)`);
|
||||
sendingLock = true;
|
||||
try {
|
||||
// 发送空内容
|
||||
await streamSender!.send("", false);
|
||||
lastStreamSendTime = Date.now();
|
||||
keepaliveCountSinceLastChunk++;
|
||||
resetKeepalive(false); // 继续下一个状态保持(非内容分片)
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Keepalive failed: ${err}`);
|
||||
} finally {
|
||||
sendingLock = false;
|
||||
}
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
// 流式发送函数 - 用于 onPartialReply 实时发送(增量模式)
|
||||
// 注意:不要在分片后强制添加换行符,否则会导致消息在奇怪的位置断句
|
||||
const sendStreamChunk = async (text: string, isEnd: boolean): Promise<boolean> => {
|
||||
if (!streamSender || streamEnded) return false;
|
||||
|
||||
// 直接发送文本内容,不添加任何额外换行符
|
||||
// 换行应该由 AI 生成的内容本身决定,而非强制添加
|
||||
const contentToSend = text;
|
||||
|
||||
const result = await streamSender.send(contentToSend, isEnd);
|
||||
if (result.error) {
|
||||
log?.error(`[qqbot:${account.accountId}] Stream send error: ${result.error}`);
|
||||
return false;
|
||||
} else {
|
||||
log?.debug?.(`[qqbot:${account.accountId}] Stream chunk sent, index: ${streamSender.getContext().index - 1}, isEnd: ${isEnd}, text: "${text.slice(0, 50)}..."`);
|
||||
}
|
||||
|
||||
if (isEnd) {
|
||||
streamEnded = true;
|
||||
clearKeepalive();
|
||||
} else {
|
||||
// 发送成功后重置心跳,如果是有内容的分片则重置计数器
|
||||
const isContentChunk = text.length > 0;
|
||||
resetKeepalive(isContentChunk);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 执行一次流式发送(带锁保护)
|
||||
const doStreamSend = async (fullText: string, forceEnd: boolean = false): Promise<void> => {
|
||||
// 如果正在发送,记录待发送的完整文本,稍后处理
|
||||
if (sendingLock) {
|
||||
pendingFullText = fullText;
|
||||
return;
|
||||
}
|
||||
|
||||
sendingLock = true;
|
||||
try {
|
||||
// 发送当前增量
|
||||
if (fullText.length > lastSentLength) {
|
||||
const increment = fullText.slice(lastSentLength);
|
||||
// 首次发送前,先设置流式状态和开始时间
|
||||
if (!streamStarted) {
|
||||
streamStarted = true;
|
||||
streamStartTime = Date.now();
|
||||
log?.info(`[qqbot:${account.accountId}] Stream started, max duration: ${STREAM_MAX_DURATION / 1000}s`);
|
||||
}
|
||||
const success = await sendStreamChunk(increment, forceEnd);
|
||||
if (success) {
|
||||
lastSentLength = fullText.length;
|
||||
lastSentText = fullText; // 记录完整发送文本,用于检测新段落
|
||||
lastStreamSendTime = Date.now();
|
||||
log?.info(`[qqbot:${account.accountId}] Stream partial #${streamSender!.getContext().index}, increment: ${increment.length} chars, total: ${fullText.length} chars`);
|
||||
}
|
||||
} else if (forceEnd && !streamEnded) {
|
||||
// 没有新内容但需要结束
|
||||
await sendStreamChunk("", true);
|
||||
}
|
||||
} finally {
|
||||
sendingLock = false;
|
||||
}
|
||||
|
||||
// 处理在锁定期间积累的内容
|
||||
if (pendingFullText && pendingFullText.length > lastSentLength && !streamEnded) {
|
||||
const pending = pendingFullText;
|
||||
pendingFullText = "";
|
||||
// 递归发送积累的内容(不强制结束)
|
||||
await doStreamSend(pending, false);
|
||||
}
|
||||
};
|
||||
|
||||
// onPartialReply 回调 - 实时接收 AI 生成的文本(payload.text 是累积的全文)
|
||||
// 注意:agent 在一次对话中可能产生多个回复段落(如思考、工具调用后继续回复)
|
||||
// 每个新段落的 text 会从头开始累积,需要检测并处理
|
||||
const handlePartialReply = async (payload: { text?: string }) => {
|
||||
if (!streamSender || streamEnded) {
|
||||
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply skipped: streamSender=${!!streamSender}, streamEnded=${streamEnded}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fullText = payload.text ?? "";
|
||||
if (!fullText) {
|
||||
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: empty text`);
|
||||
return;
|
||||
}
|
||||
|
||||
hasResponse = true;
|
||||
|
||||
// 检测是否是新段落:
|
||||
// 1. lastSentText 不为空(说明已经发送过内容)
|
||||
// 2. 当前文本不是以 lastSentText 开头(说明不是同一段落的增量)
|
||||
// 3. 当前文本长度小于 lastSentLength(说明文本被重置了)
|
||||
const isNewSegment = lastSentText.length > 0 &&
|
||||
(fullText.length < lastSentLength || !fullText.startsWith(lastSentText.slice(0, Math.min(10, lastSentText.length))));
|
||||
|
||||
if (isNewSegment) {
|
||||
// 新段落开始,结束当前流并创建新流
|
||||
log?.info(`[qqbot:${account.accountId}] New segment detected! lastSentLength=${lastSentLength}, newTextLength=${fullText.length}, lastSentText="${lastSentText.slice(0, 20)}...", newText="${fullText.slice(0, 20)}..."`);
|
||||
|
||||
// 保存旧的 sender 用于结束流
|
||||
const oldStreamSender = streamSender;
|
||||
const oldStreamStarted = streamStarted;
|
||||
const oldStreamEnded = streamEnded;
|
||||
|
||||
// 1. 先创建新的流式发送器并重置所有状态
|
||||
// 这样在 await 期间到达的新消息会使用新 sender
|
||||
streamSender = createStreamSender(account, targetTo, event.messageId);
|
||||
lastSentLength = 0;
|
||||
lastSentText = "";
|
||||
streamStarted = false;
|
||||
streamEnded = false;
|
||||
streamStartTime = 0;
|
||||
keepaliveCountSinceLastChunk = 0;
|
||||
lastChunkSendTime = 0;
|
||||
firstChunkWaitStart = 0; // 重置首个分片等待时间
|
||||
|
||||
// 记录当前段落在 streamBuffer 中的起始位置
|
||||
currentSegmentStart = streamBuffer.length;
|
||||
|
||||
// 追加换行分隔符(如果前面有内容且不以换行结尾)
|
||||
if (streamBuffer.length > 0 && !streamBuffer.endsWith("\n")) {
|
||||
streamBuffer += "\n\n";
|
||||
currentSegmentStart = streamBuffer.length;
|
||||
}
|
||||
|
||||
// 2. 结束旧流(如果已开始)- 使用旧的 sender
|
||||
if (oldStreamSender && oldStreamStarted && !oldStreamEnded) {
|
||||
log?.info(`[qqbot:${account.accountId}] Ending current stream before starting new segment`);
|
||||
clearKeepalive();
|
||||
sendingLock = true;
|
||||
try {
|
||||
await oldStreamSender.send("", true); // 发送结束标记
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to end stream: ${err}`);
|
||||
} finally {
|
||||
sendingLock = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新当前段落内容到 streamBuffer
|
||||
// streamBuffer = 之前的段落内容 + 当前段落的完整内容
|
||||
const beforeCurrentSegment = streamBuffer.slice(0, currentSegmentStart);
|
||||
streamBuffer = beforeCurrentSegment + fullText;
|
||||
|
||||
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: fullText.length=${fullText.length}, lastSentLength=${lastSentLength}, streamBuffer.length=${streamBuffer.length}, isNewSegment=${isNewSegment}`);
|
||||
|
||||
// 如果没有新内容,跳过
|
||||
if (fullText.length <= lastSentLength) return;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// 初始化首个分片等待开始时间(如果还没有开始)
|
||||
if (!streamStarted && !firstChunkWaitStart) {
|
||||
firstChunkWaitStart = now;
|
||||
}
|
||||
|
||||
// 控制发送频率:首次发送或间隔超过阈值
|
||||
if (!streamStarted) {
|
||||
// 首个分片:智能断句,在语义边界处发送
|
||||
const waitTime = firstChunkWaitStart ? now - firstChunkWaitStart : 0;
|
||||
const atBoundary = isAtSemanticBoundary(fullText);
|
||||
const reachedSoftLimit = fullText.length >= FIRST_CHUNK_MIN_LENGTH_SOFT;
|
||||
const reachedHardLimit = fullText.length >= FIRST_CHUNK_MIN_LENGTH_HARD;
|
||||
const timedOut = waitTime >= FIRST_CHUNK_MAX_WAIT_TIME;
|
||||
|
||||
// 发送条件(优先级从高到低):
|
||||
// 1. 达到硬下限:必须发送,避免等待太久
|
||||
// 2. 等待超时:必须发送,避免无响应
|
||||
// 3. 达到软下限 + 在语义边界:可以发送
|
||||
if (reachedHardLimit || timedOut) {
|
||||
// 硬性条件:必须发送
|
||||
if (timedOut && !reachedSoftLimit) {
|
||||
log?.info(`[qqbot:${account.accountId}] handlePartialReply: first chunk timeout, sending anyway, length=${fullText.length}, wait=${waitTime}ms`);
|
||||
} else {
|
||||
log?.info(`[qqbot:${account.accountId}] handlePartialReply: sending first chunk (hard limit), length=${fullText.length}`);
|
||||
}
|
||||
await doStreamSend(fullText, false);
|
||||
firstChunkWaitStart = 0; // 重置等待时间
|
||||
} else if (reachedSoftLimit && atBoundary) {
|
||||
// 软性条件:在语义边界处发送
|
||||
log?.info(`[qqbot:${account.accountId}] handlePartialReply: sending first chunk (at boundary), length=${fullText.length}`);
|
||||
await doStreamSend(fullText, false);
|
||||
firstChunkWaitStart = 0;
|
||||
} else {
|
||||
// 还需要等待更多内容
|
||||
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: waiting for semantic boundary, length=${fullText.length}, atBoundary=${atBoundary}, wait=${waitTime}ms`);
|
||||
pendingFullText = fullText;
|
||||
}
|
||||
} else if (now - lastStreamSendTime >= STREAM_CHUNK_INTERVAL) {
|
||||
// 后续分片:基于时间间隔发送
|
||||
log?.info(`[qqbot:${account.accountId}] handlePartialReply: sending stream chunk, length=${fullText.length}`);
|
||||
await doStreamSend(fullText, false);
|
||||
} else {
|
||||
// 不到发送时间,但记录待发送内容,确保最终会被发送
|
||||
pendingFullText = fullText;
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
@@ -987,169 +707,244 @@ openclaw cron add \\
|
||||
|
||||
let replyText = payload.text ?? "";
|
||||
|
||||
// 更新当前段落内容到 streamBuffer
|
||||
// deliver 中的 replyText 是当前段落的完整文本
|
||||
if (replyText.length > 0) {
|
||||
const beforeCurrentSegment = streamBuffer.slice(0, currentSegmentStart);
|
||||
const newStreamBuffer = beforeCurrentSegment + replyText;
|
||||
if (newStreamBuffer.length > streamBuffer.length) {
|
||||
streamBuffer = newStreamBuffer;
|
||||
log?.debug?.(`[qqbot:${account.accountId}] deliver: updated streamBuffer, replyText=${replyText.length}, total=${streamBuffer.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 收集所有图片路径
|
||||
// 收集所有图片(公网 URL 或 Base64 Data URL)
|
||||
// 注意:本地文件路径由 OpenClaw 负责转换(通过对象存储上传或读取为 Base64)
|
||||
const imageUrls: string[] = [];
|
||||
|
||||
// 处理 mediaUrls 和 mediaUrl 字段(本地文件路径)
|
||||
const mediaPaths: string[] = [];
|
||||
if (payload.mediaUrls?.length) {
|
||||
mediaPaths.push(...payload.mediaUrls);
|
||||
}
|
||||
if (payload.mediaUrl && !mediaPaths.includes(payload.mediaUrl)) {
|
||||
mediaPaths.push(payload.mediaUrl);
|
||||
}
|
||||
|
||||
for (const localPath of mediaPaths) {
|
||||
if (localPath && imageServerBaseUrl) {
|
||||
try {
|
||||
const savedUrl = saveImageFromPath(localPath);
|
||||
if (savedUrl) {
|
||||
imageUrls.push(savedUrl);
|
||||
log?.info(`[qqbot:${account.accountId}] Saved media to server: ${localPath}`);
|
||||
/**
|
||||
* 检查并收集图片 URL
|
||||
* 支持:公网 URL (http/https) 和 Base64 Data URL (data:image/...)
|
||||
*/
|
||||
const collectImageUrl = (url: string | undefined | null): boolean => {
|
||||
if (!url) return false;
|
||||
|
||||
const isHttpUrl = url.startsWith("http://") || url.startsWith("https://");
|
||||
const isDataUrl = url.startsWith("data:image/");
|
||||
|
||||
if (isHttpUrl || isDataUrl) {
|
||||
if (!imageUrls.includes(url)) {
|
||||
imageUrls.push(url);
|
||||
if (isDataUrl) {
|
||||
log?.info(`[qqbot:${account.accountId}] Collected Base64 image (length: ${url.length})`);
|
||||
} else {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to save media (not found or not image): ${localPath}`);
|
||||
log?.info(`[qqbot:${account.accountId}] Collected media URL: ${url.slice(0, 80)}...`);
|
||||
}
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to save media: ${err}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检测本地文件路径
|
||||
const isLocalPath = url.startsWith("/") ||
|
||||
/^[a-zA-Z]:[\\/]/.test(url) ||
|
||||
url.startsWith("./") ||
|
||||
url.startsWith("../");
|
||||
|
||||
if (isLocalPath) {
|
||||
log?.info(`[qqbot:${account.accountId}] Skipped local file path (OpenClaw should convert to Base64 or upload): ${url.slice(0, 80)}`);
|
||||
} else {
|
||||
log?.info(`[qqbot:${account.accountId}] Skipped unsupported media format: ${url.slice(0, 50)}`);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 处理 mediaUrls 和 mediaUrl 字段
|
||||
if (payload.mediaUrls?.length) {
|
||||
for (const url of payload.mediaUrls) {
|
||||
collectImageUrl(url);
|
||||
}
|
||||
}
|
||||
if (payload.mediaUrl) {
|
||||
collectImageUrl(payload.mediaUrl);
|
||||
}
|
||||
|
||||
// 提取文本中的各种图片格式
|
||||
// 0. 提取 MEDIA: 前缀的本地文件路径
|
||||
const mediaPathRegex = /MEDIA:([^\s\n]+)/gi;
|
||||
const mediaMatches = [...replyText.matchAll(mediaPathRegex)];
|
||||
for (const match of mediaMatches) {
|
||||
const localPath = match[1];
|
||||
if (localPath && imageServerBaseUrl) {
|
||||
try {
|
||||
const savedUrl = saveImageFromPath(localPath);
|
||||
if (savedUrl) {
|
||||
imageUrls.push(savedUrl);
|
||||
log?.info(`[qqbot:${account.accountId}] Saved local image to server: ${localPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to save local image: ${err}`);
|
||||
}
|
||||
}
|
||||
replyText = replyText.replace(match[0], "").trim();
|
||||
}
|
||||
|
||||
// 0.5. 提取本地绝对文件路径
|
||||
const localPathRegex = /(\/[^\s\n]+?(?:\.(?:png|jpg|jpeg|gif|webp)|_(?:png|jpg|jpeg|gif|webp)(?:\s|$)))/gi;
|
||||
const localPathMatches = [...replyText.matchAll(localPathRegex)];
|
||||
for (const match of localPathMatches) {
|
||||
let localPath = match[1].trim();
|
||||
if (localPath && imageServerBaseUrl) {
|
||||
localPath = localPath.replace(/_(?=(?:png|jpg|jpeg|gif|webp)$)/, ".");
|
||||
try {
|
||||
const savedUrl = saveImageFromPath(localPath);
|
||||
if (savedUrl) {
|
||||
imageUrls.push(savedUrl);
|
||||
log?.info(`[qqbot:${account.accountId}] Saved local path image to server: ${localPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to save local path image: ${err}`);
|
||||
}
|
||||
}
|
||||
replyText = replyText.replace(match[0], "").trim();
|
||||
}
|
||||
|
||||
// 1. 提取 base64 图片
|
||||
const base64ImageRegex = /!\[([^\]]*)\]\((data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)\)|(?<![(\[])(data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)/gi;
|
||||
const base64Matches = [...replyText.matchAll(base64ImageRegex)];
|
||||
for (const match of base64Matches) {
|
||||
const dataUrl = match[2] || match[3];
|
||||
if (dataUrl && imageServerBaseUrl) {
|
||||
try {
|
||||
const savedUrl = saveImage(dataUrl);
|
||||
imageUrls.push(savedUrl);
|
||||
log?.info(`[qqbot:${account.accountId}] Saved base64 image to local server`);
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to save base64 image: ${err}`);
|
||||
}
|
||||
}
|
||||
replyText = replyText.replace(match[0], "").trim();
|
||||
}
|
||||
|
||||
// 2. 提取 URL 图片
|
||||
const imageUrlRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s)]*)?)\)|(?<![(\[])(https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s]*)?)/gi;
|
||||
const urlMatches = [...replyText.matchAll(imageUrlRegex)];
|
||||
for (const match of urlMatches) {
|
||||
const url = match[2] || match[3];
|
||||
if (url) {
|
||||
// 提取文本中的图片格式
|
||||
// 1. 提取 markdown 格式的图片  或 
|
||||
const mdImageRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gi;
|
||||
const mdMatches = [...replyText.matchAll(mdImageRegex)];
|
||||
for (const match of mdMatches) {
|
||||
const url = match[2];
|
||||
if (url && !imageUrls.includes(url)) {
|
||||
imageUrls.push(url);
|
||||
log?.info(`[qqbot:${account.accountId}] Extracted image from markdown: ${url.slice(0, 80)}...`);
|
||||
}
|
||||
}
|
||||
|
||||
// 从文本中移除图片 URL
|
||||
let textWithoutImages = replyText;
|
||||
for (const match of urlMatches) {
|
||||
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
||||
}
|
||||
|
||||
// 处理剩余文本中的 URL 点号(只有在没有图片的情况下才替换)
|
||||
const hasImages = imageUrls.length > 0;
|
||||
if (!hasImages && textWithoutImages) {
|
||||
const originalText = textWithoutImages;
|
||||
textWithoutImages = textWithoutImages.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
|
||||
if (textWithoutImages !== originalText && textWithoutImages.trim()) {
|
||||
textWithoutImages += "\n\n(由于平台限制,回复中的部分符号已被替换)";
|
||||
// 2. 提取裸 URL 图片(仅在非 markdown 模式下移除)
|
||||
const bareUrlRegex = /(?<![(\["'])(https?:\/\/[^\s)"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi;
|
||||
const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)];
|
||||
for (const match of bareUrlMatches) {
|
||||
const url = match[1];
|
||||
if (url && !imageUrls.includes(url)) {
|
||||
imageUrls.push(url);
|
||||
log?.info(`[qqbot:${account.accountId}] Extracted bare image URL: ${url.slice(0, 80)}...`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 发送图片(如果有)
|
||||
for (const imageUrl of imageUrls) {
|
||||
|
||||
// 判断是否使用 markdown 模式
|
||||
const useMarkdown = account.markdownSupport === true;
|
||||
log?.info(`[qqbot:${account.accountId}] Markdown mode: ${useMarkdown}, images: ${imageUrls.length}`);
|
||||
|
||||
let textWithoutImages = replyText;
|
||||
|
||||
// 🎯 智能简化文本:当发送图片时,移除冗余的解释性文字
|
||||
// 这解决了 AI 不确定图片是否发送成功而输出大量废话的问题
|
||||
if (imageUrls.length > 0) {
|
||||
const originalLength = textWithoutImages.length;
|
||||
textWithoutImages = simplifyTextForImageSend(textWithoutImages, true);
|
||||
if (textWithoutImages.length !== originalLength) {
|
||||
log?.info(`[qqbot:${account.accountId}] Simplified text for image send: ${originalLength} -> ${textWithoutImages.length} chars`);
|
||||
}
|
||||
}
|
||||
|
||||
// 根据模式处理图片
|
||||
if (useMarkdown) {
|
||||
// ============ Markdown 模式:使用  格式 ============
|
||||
// QQBot 的 markdown 图片格式要求:
|
||||
// 需要自动获取图片尺寸,或使用默认尺寸
|
||||
|
||||
// 记录已存在于文本中的 markdown 图片 URL
|
||||
const existingMdUrls = new Set(mdMatches.map(m => m[2]));
|
||||
|
||||
// 需要追加的图片(从 mediaUrl/mediaUrls 来的)
|
||||
const imagesToAppend: string[] = [];
|
||||
|
||||
// 处理需要追加的图片:获取尺寸并格式化
|
||||
for (const url of imageUrls) {
|
||||
if (!existingMdUrls.has(url)) {
|
||||
// 这个 URL 不在文本的 markdown 格式中,需要追加
|
||||
// 尝试获取图片尺寸
|
||||
try {
|
||||
const size = await getImageSize(url);
|
||||
const mdImage = formatQQBotMarkdownImage(url, size);
|
||||
imagesToAppend.push(mdImage);
|
||||
log?.info(`[qqbot:${account.accountId}] Formatted image: ${size ? `${size.width}x${size.height}` : 'default size'} - ${url.slice(0, 60)}...`);
|
||||
} catch (err) {
|
||||
// 获取尺寸失败,使用默认尺寸
|
||||
log?.info(`[qqbot:${account.accountId}] Failed to get image size, using default: ${err}`);
|
||||
const mdImage = formatQQBotMarkdownImage(url, null);
|
||||
imagesToAppend.push(mdImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文本中已有的 markdown 图片:检查是否需要补充尺寸信息
|
||||
for (const match of mdMatches) {
|
||||
const fullMatch = match[0]; // 
|
||||
const altText = match[1]; // alt 部分
|
||||
const imgUrl = match[2]; // url 部分
|
||||
|
||||
// 检查是否已经有 QQBot 格式的尺寸 
|
||||
if (!hasQQBotImageSize(fullMatch)) {
|
||||
// 没有尺寸信息,需要补充
|
||||
try {
|
||||
const size = await getImageSize(imgUrl);
|
||||
const newMdImage = formatQQBotMarkdownImage(imgUrl, size);
|
||||
textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage);
|
||||
log?.info(`[qqbot:${account.accountId}] Updated image with size: ${size ? `${size.width}x${size.height}` : 'default'} - ${imgUrl.slice(0, 60)}...`);
|
||||
} catch (err) {
|
||||
// 获取尺寸失败,使用默认尺寸
|
||||
log?.info(`[qqbot:${account.accountId}] Failed to get image size for existing md, using default: ${err}`);
|
||||
const newMdImage = formatQQBotMarkdownImage(imgUrl, null);
|
||||
textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage);
|
||||
}
|
||||
}
|
||||
// 如果已经有尺寸信息,保留原格式
|
||||
}
|
||||
|
||||
// 从文本中移除裸 URL 图片(已转换为 markdown 格式)
|
||||
for (const match of bareUrlMatches) {
|
||||
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
||||
}
|
||||
|
||||
// 追加需要添加的图片到文本末尾
|
||||
if (imagesToAppend.length > 0) {
|
||||
textWithoutImages = textWithoutImages.trim();
|
||||
if (textWithoutImages) {
|
||||
textWithoutImages += "\n\n" + imagesToAppend.join("\n");
|
||||
} else {
|
||||
textWithoutImages = imagesToAppend.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// 发送带图片的 markdown 消息(文本+图片一起发送)
|
||||
if (textWithoutImages.trim()) {
|
||||
try {
|
||||
await sendWithTokenRetry(async (token) => {
|
||||
if (event.type === "c2c") {
|
||||
await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
|
||||
await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId);
|
||||
} else if (event.type === "group" && event.groupOpenid) {
|
||||
await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
|
||||
await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
||||
} else if (event.channelId) {
|
||||
await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
||||
}
|
||||
});
|
||||
log?.info(`[qqbot:${account.accountId}] Sent image: ${imageUrl.slice(0, 50)}...`);
|
||||
} catch (imgErr) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`);
|
||||
log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${imageUrls.length} images (${event.type})`);
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to send markdown message: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 非流式模式下,在 deliver 中发送文本
|
||||
// 流式模式下,c2c 的文本通过 onPartialReply 流式发送
|
||||
if (!supportsStream && textWithoutImages.trim()) {
|
||||
await sendWithTokenRetry(async (token) => {
|
||||
if (event.type === "c2c") {
|
||||
// c2c 非流式消息发送
|
||||
await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId);
|
||||
} else if (event.type === "group" && event.groupOpenid) {
|
||||
await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
||||
} else if (event.channelId) {
|
||||
await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
||||
}
|
||||
});
|
||||
log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type}, non-stream)`);
|
||||
} else {
|
||||
// ============ 普通文本模式:使用富媒体 API 发送图片 ============
|
||||
// 从文本中移除所有图片相关内容
|
||||
for (const match of mdMatches) {
|
||||
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
||||
}
|
||||
for (const match of bareUrlMatches) {
|
||||
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
||||
}
|
||||
|
||||
// 处理文本中的 URL 点号(防止被 QQ 解析为链接)
|
||||
if (textWithoutImages) {
|
||||
const originalText = textWithoutImages;
|
||||
textWithoutImages = textWithoutImages.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
|
||||
if (textWithoutImages !== originalText && textWithoutImages.trim()) {
|
||||
textWithoutImages += "\n\n(由于平台限制,回复中的部分符号已被替换)";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 发送图片(通过富媒体 API)
|
||||
for (const imageUrl of imageUrls) {
|
||||
try {
|
||||
await sendWithTokenRetry(async (token) => {
|
||||
if (event.type === "c2c") {
|
||||
await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
|
||||
} else if (event.type === "group" && event.groupOpenid) {
|
||||
await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
|
||||
} else if (event.channelId) {
|
||||
// 频道暂不支持富媒体,发送文本 URL
|
||||
await sendChannelMessage(token, event.channelId, imageUrl, event.messageId);
|
||||
}
|
||||
});
|
||||
log?.info(`[qqbot:${account.accountId}] Sent image via media API: ${imageUrl.slice(0, 80)}...`);
|
||||
} catch (imgErr) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
pluginRuntime.channel.activity.record({
|
||||
channel: "qqbot",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
|
||||
// 发送文本消息
|
||||
if (textWithoutImages.trim()) {
|
||||
await sendWithTokenRetry(async (token) => {
|
||||
if (event.type === "c2c") {
|
||||
await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId);
|
||||
} else if (event.type === "group" && event.groupOpenid) {
|
||||
await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
||||
} else if (event.channelId) {
|
||||
await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
||||
}
|
||||
});
|
||||
log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type})`);
|
||||
}
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
pluginRuntime.channel.activity.record({
|
||||
channel: "qqbot",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
},
|
||||
onError: async (err: unknown) => {
|
||||
log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`);
|
||||
@@ -1159,27 +954,6 @@ openclaw cron add \\
|
||||
timeoutId = null;
|
||||
}
|
||||
|
||||
// 清理心跳定时器
|
||||
clearKeepalive();
|
||||
|
||||
// 如果在流式模式中出错,发送结束标记(增量模式)
|
||||
if (streamSender && !streamEnded && streamBuffer) {
|
||||
try {
|
||||
// 等待发送锁释放
|
||||
while (sendingLock) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
// 发送剩余增量 + 错误标记
|
||||
const remainingIncrement = streamBuffer.slice(lastSentLength);
|
||||
const errorIncrement = remainingIncrement + "\n\n[生成中断]";
|
||||
await streamSender.end(errorIncrement);
|
||||
streamEnded = true;
|
||||
log?.info(`[qqbot:${account.accountId}] Stream ended due to error`);
|
||||
} catch (endErr) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to end stream: ${endErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送错误提示给用户,显示完整错误信息
|
||||
const errMsg = String(err);
|
||||
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
|
||||
@@ -1190,47 +964,13 @@ openclaw cron add \\
|
||||
}
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
// 使用 onPartialReply 实现真正的流式消息
|
||||
// 这个回调在 AI 生成过程中被实时调用
|
||||
onPartialReply: supportsStream ? handlePartialReply : undefined,
|
||||
// 禁用 block streaming,因为我们用 onPartialReply 实现更实时的流式
|
||||
disableBlockStreaming: supportsStream,
|
||||
},
|
||||
replyOptions: {},
|
||||
});
|
||||
|
||||
// 等待分发完成或超时
|
||||
try {
|
||||
await Promise.race([dispatchPromise, timeoutPromise]);
|
||||
|
||||
// 清理心跳定时器
|
||||
clearKeepalive();
|
||||
|
||||
// 分发完成后,如果使用了流式且有内容,发送结束标记
|
||||
if (streamSender && !streamEnded) {
|
||||
// 等待发送锁释放
|
||||
while (sendingLock) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
// 确保所有待发送内容都发送出去
|
||||
// 当前段落的最新完整文本
|
||||
const currentSegmentText = pendingFullText && pendingFullText.length > (streamBuffer.length - currentSegmentStart)
|
||||
? pendingFullText
|
||||
: streamBuffer.slice(currentSegmentStart);
|
||||
|
||||
// 计算当前段落剩余未发送的增量内容
|
||||
const remainingIncrement = currentSegmentText.slice(lastSentLength);
|
||||
if (remainingIncrement || streamStarted) {
|
||||
// 有剩余内容或者已开始流式,都需要发送结束标记
|
||||
await streamSender.end(remainingIncrement);
|
||||
streamEnded = true;
|
||||
log?.info(`[qqbot:${account.accountId}] Stream completed, final increment: ${remainingIncrement.length} chars, total streamBuffer: ${streamBuffer.length} chars, chunks: ${streamSender.getContext().index}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 清理心跳定时器
|
||||
clearKeepalive();
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
@@ -385,6 +385,34 @@ export function isImageServerRunning(): boolean {
|
||||
return serverInstance !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保图床服务器正在运行
|
||||
* 如果未运行,则自动启动
|
||||
* @param publicBaseUrl 公网访问的基础 URL(如 http://your-server:18765)
|
||||
* @returns 基础 URL,启动失败返回 null
|
||||
*/
|
||||
export async function ensureImageServer(publicBaseUrl?: string): Promise<string | null> {
|
||||
if (isImageServerRunning()) {
|
||||
return publicBaseUrl || currentConfig.baseUrl || `http://0.0.0.0:${currentConfig.port}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const config: Partial<ImageServerConfig> = {
|
||||
port: DEFAULT_CONFIG.port,
|
||||
storageDir: DEFAULT_CONFIG.storageDir,
|
||||
// 使用用户配置的公网地址
|
||||
baseUrl: publicBaseUrl || `http://0.0.0.0:${DEFAULT_CONFIG.port}`,
|
||||
ttlSeconds: 3600, // 1 小时过期
|
||||
};
|
||||
await startImageServer(config);
|
||||
console.log(`[image-server] Auto-started on port ${config.port}, baseUrl: ${config.baseUrl}`);
|
||||
return config.baseUrl!;
|
||||
} catch (err) {
|
||||
console.error(`[image-server] Failed to auto-start: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载远程文件并保存到本地
|
||||
* @param url 远程文件 URL
|
||||
|
||||
@@ -63,7 +63,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
return {
|
||||
channel: "qqbot" as any,
|
||||
configured,
|
||||
statusLines: [`QQ Bot (Stream): ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`],
|
||||
statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`],
|
||||
selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊(流式消息)",
|
||||
quickstartScore: configured ? 1 : 20,
|
||||
};
|
||||
@@ -119,7 +119,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
"",
|
||||
"此版本支持流式消息发送!",
|
||||
].join("\n"),
|
||||
"QQ Bot (Stream) 配置",
|
||||
"QQ Bot 配置",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
330
src/outbound.ts
330
src/outbound.ts
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* QQ Bot 消息发送模块(支持流式消息)
|
||||
* QQ Bot 消息发送模块
|
||||
*/
|
||||
|
||||
import type { ResolvedQQBotAccount, StreamContext } from "./types.js";
|
||||
import { StreamState } from "./types.js";
|
||||
import {
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
import {
|
||||
getAccessToken,
|
||||
sendC2CMessage,
|
||||
sendChannelMessage,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
sendProactiveGroupMessage,
|
||||
sendC2CImageMessage,
|
||||
sendGroupImageMessage,
|
||||
type StreamMessageResponse,
|
||||
} from "./api.js";
|
||||
|
||||
// ============ 消息回复限流器 ============
|
||||
@@ -160,155 +160,6 @@ export interface OutboundResult {
|
||||
messageId?: string;
|
||||
timestamp?: string | number;
|
||||
error?: string;
|
||||
/** 流式消息ID,用于后续分片 */
|
||||
streamId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式消息发送器
|
||||
* 用于管理一个完整的流式消息会话
|
||||
*/
|
||||
export class StreamSender {
|
||||
private context: StreamContext;
|
||||
private accessToken: string | null = null;
|
||||
private targetType: "c2c" | "group" | "channel";
|
||||
private targetId: string;
|
||||
private msgId?: string;
|
||||
private account: ResolvedQQBotAccount;
|
||||
|
||||
constructor(
|
||||
account: ResolvedQQBotAccount,
|
||||
to: string,
|
||||
replyToId?: string | null
|
||||
) {
|
||||
this.account = account;
|
||||
this.msgId = replyToId ?? undefined;
|
||||
this.context = {
|
||||
index: 0,
|
||||
streamId: "",
|
||||
ended: false,
|
||||
};
|
||||
|
||||
// 解析目标地址
|
||||
const target = parseTarget(to);
|
||||
this.targetType = target.type;
|
||||
this.targetId = target.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送流式消息分片
|
||||
* @param text 分片内容
|
||||
* @param isEnd 是否是最后一个分片
|
||||
* @returns 发送结果
|
||||
*/
|
||||
async send(text: string, isEnd = false): Promise<OutboundResult> {
|
||||
if (this.context.ended) {
|
||||
return { channel: "qqbot", error: "Stream already ended" };
|
||||
}
|
||||
|
||||
if (!this.account.appId || !this.account.clientSecret) {
|
||||
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取或复用 accessToken
|
||||
if (!this.accessToken) {
|
||||
this.accessToken = await getAccessToken(this.account.appId, this.account.clientSecret);
|
||||
}
|
||||
|
||||
const streamConfig = {
|
||||
state: isEnd ? StreamState.END : StreamState.STREAMING,
|
||||
index: this.context.index,
|
||||
id: this.context.streamId,
|
||||
};
|
||||
|
||||
let result: StreamMessageResponse;
|
||||
|
||||
if (this.targetType === "c2c") {
|
||||
result = await sendC2CMessage(
|
||||
this.accessToken,
|
||||
this.targetId,
|
||||
text,
|
||||
this.msgId,
|
||||
streamConfig
|
||||
);
|
||||
} else if (this.targetType === "group") {
|
||||
// 群聊不支持流式,直接发送普通消息
|
||||
const groupResult = await sendGroupMessage(
|
||||
this.accessToken,
|
||||
this.targetId,
|
||||
text,
|
||||
this.msgId
|
||||
// 不传 streamConfig
|
||||
);
|
||||
return {
|
||||
channel: "qqbot",
|
||||
messageId: groupResult.id,
|
||||
timestamp: groupResult.timestamp
|
||||
};
|
||||
} else {
|
||||
// 频道不支持流式,直接发送普通消息
|
||||
const channelResult = await sendChannelMessage(
|
||||
this.accessToken,
|
||||
this.targetId,
|
||||
text,
|
||||
this.msgId
|
||||
);
|
||||
return {
|
||||
channel: "qqbot",
|
||||
messageId: channelResult.id,
|
||||
timestamp: channelResult.timestamp
|
||||
};
|
||||
}
|
||||
|
||||
// 更新流式上下文
|
||||
// 第一次发送后,服务端会返回 stream_id(或在 id 字段中),后续需要带上
|
||||
if (this.context.index === 0 && result.stream_id) {
|
||||
this.context.streamId = result.stream_id;
|
||||
} else if (this.context.index === 0 && result.id && !this.context.streamId) {
|
||||
// 某些情况下 stream_id 可能在 id 字段返回
|
||||
this.context.streamId = result.id;
|
||||
}
|
||||
|
||||
this.context.index++;
|
||||
|
||||
if (isEnd) {
|
||||
this.context.ended = true;
|
||||
}
|
||||
|
||||
return {
|
||||
channel: "qqbot",
|
||||
messageId: result.id,
|
||||
timestamp: result.timestamp,
|
||||
streamId: this.context.streamId,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { channel: "qqbot", error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束流式消息
|
||||
* @param text 最后一个分片的内容(可选)
|
||||
*/
|
||||
async end(text?: string): Promise<OutboundResult> {
|
||||
return this.send(text ?? "", true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前流式上下文状态
|
||||
*/
|
||||
getContext(): Readonly<StreamContext> {
|
||||
return { ...this.context };
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已结束
|
||||
*/
|
||||
isEnded(): boolean {
|
||||
return this.context.ended;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,69 +291,6 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式发送文本消息
|
||||
*
|
||||
* @param ctx 发送上下文
|
||||
* @param textGenerator 异步文本生成器,每次 yield 一个分片
|
||||
* @returns 最终发送结果
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* async function* generateText() {
|
||||
* yield "Hello, ";
|
||||
* yield "this is ";
|
||||
* yield "a streaming ";
|
||||
* yield "message!";
|
||||
* }
|
||||
*
|
||||
* const result = await sendTextStream(ctx, generateText());
|
||||
* ```
|
||||
*/
|
||||
export async function sendTextStream(
|
||||
ctx: OutboundContext,
|
||||
textGenerator: AsyncIterable<string>
|
||||
): Promise<OutboundResult> {
|
||||
const { to, replyToId, account } = ctx;
|
||||
|
||||
const sender = new StreamSender(account, to, replyToId);
|
||||
let lastResult: OutboundResult = { channel: "qqbot" };
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
for await (const chunk of textGenerator) {
|
||||
buffer += chunk;
|
||||
|
||||
// 发送当前分片
|
||||
lastResult = await sender.send(buffer, false);
|
||||
|
||||
if (lastResult.error) {
|
||||
return lastResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 发送结束标记
|
||||
lastResult = await sender.end(buffer);
|
||||
|
||||
return lastResult;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { channel: "qqbot", error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建流式消息发送器
|
||||
* 提供更细粒度的控制
|
||||
*/
|
||||
export function createStreamSender(
|
||||
account: ResolvedQQBotAccount,
|
||||
to: string,
|
||||
replyToId?: string | null
|
||||
): StreamSender {
|
||||
return new StreamSender(account, to, replyToId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动发送消息(不需要 replyToId,有配额限制:每月 4 条/用户/群)
|
||||
*
|
||||
@@ -543,11 +331,17 @@ export async function sendProactiveMessage(
|
||||
/**
|
||||
* 发送富媒体消息(图片)
|
||||
*
|
||||
* 支持以下 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: "这是图片说明",
|
||||
@@ -555,10 +349,29 @@ export async function sendProactiveMessage(
|
||||
* 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, mediaUrl, replyToId, account } = ctx;
|
||||
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)" };
|
||||
@@ -568,17 +381,87 @@ export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResu
|
||||
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,
|
||||
mediaUrl,
|
||||
processedMediaUrl,
|
||||
replyToId ?? undefined,
|
||||
undefined // content 参数,图片消息不支持同时带文本
|
||||
);
|
||||
@@ -586,13 +469,14 @@ export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResu
|
||||
imageResult = await sendGroupImageMessage(
|
||||
accessToken,
|
||||
target.id,
|
||||
mediaUrl,
|
||||
processedMediaUrl,
|
||||
replyToId ?? undefined,
|
||||
undefined
|
||||
);
|
||||
} else {
|
||||
// 频道暂不支持富媒体消息,只发送文本 + URL
|
||||
const textWithUrl = text ? `${text}\n${mediaUrl}` : mediaUrl;
|
||||
// 频道暂不支持富媒体消息,只发送文本 + 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 };
|
||||
}
|
||||
|
||||
42
src/types.ts
42
src/types.ts
@@ -21,10 +21,8 @@ export interface ResolvedQQBotAccount {
|
||||
systemPrompt?: string;
|
||||
/** 图床服务器公网地址 */
|
||||
imageServerBaseUrl?: string;
|
||||
/** 是否支持 markdown 消息 */
|
||||
/** 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用) */
|
||||
markdownSupport?: boolean;
|
||||
/** 是否启用流式消息(仅 c2c 私聊支持),默认 true */
|
||||
streamEnabled?: boolean;
|
||||
config: QQBotAccountConfig;
|
||||
}
|
||||
|
||||
@@ -43,10 +41,8 @@ export interface QQBotAccountConfig {
|
||||
systemPrompt?: string;
|
||||
/** 图床服务器公网地址,用于发送图片,例如 http://your-ip:18765 */
|
||||
imageServerBaseUrl?: string;
|
||||
/** 是否支持 markdown 消息,默认 true */
|
||||
/** 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用) */
|
||||
markdownSupport?: boolean;
|
||||
/** 是否启用流式消息,默认 true(仅 c2c 私聊支持) */
|
||||
streamEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,37 +121,3 @@ export interface WSPayload {
|
||||
s?: number;
|
||||
t?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式消息状态
|
||||
*/
|
||||
export enum StreamState {
|
||||
/** 流式消息开始/进行中 */
|
||||
STREAMING = 1,
|
||||
/** 流式消息结束 */
|
||||
END = 10,
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式消息配置
|
||||
*/
|
||||
export interface StreamConfig {
|
||||
/** 流式状态: 1=开始/进行中, 10=结束 */
|
||||
state: StreamState;
|
||||
/** 分片索引,从0开始 */
|
||||
index: number;
|
||||
/** 流式消息ID,第一次发送为空,后续需要带上服务端返回的ID */
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式消息发送上下文
|
||||
*/
|
||||
export interface StreamContext {
|
||||
/** 当前分片索引 */
|
||||
index: number;
|
||||
/** 流式消息ID(首次发送后由服务端返回) */
|
||||
streamId: string;
|
||||
/** 是否已结束 */
|
||||
ended: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user