mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-17 12:11:20 +00:00
1476 lines
48 KiB
TypeScript
1476 lines
48 KiB
TypeScript
import * as path from "path";
|
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
import {
|
|
getAccessToken,
|
|
sendC2CFileMessage,
|
|
sendC2CImageMessage,
|
|
sendC2CMessage,
|
|
sendC2CVideoMessage,
|
|
sendC2CVoiceMessage,
|
|
sendChannelMessage,
|
|
sendDmMessage,
|
|
sendGroupFileMessage,
|
|
sendGroupImageMessage,
|
|
sendGroupMessage,
|
|
sendGroupVideoMessage,
|
|
sendGroupVoiceMessage,
|
|
sendProactiveC2CMessage,
|
|
sendProactiveGroupMessage,
|
|
} from "./api.js";
|
|
import type { ResolvedQQBotAccount } from "./types.js";
|
|
import {
|
|
audioFileToSilkBase64,
|
|
isAudioFile,
|
|
shouldTranscodeVoice,
|
|
waitForFile,
|
|
} from "./utils/audio-convert.js";
|
|
import { debugError, debugLog, debugWarn } from "./utils/debug-log.js";
|
|
import {
|
|
checkFileSize,
|
|
downloadFile,
|
|
fileExistsAsync,
|
|
formatFileSize,
|
|
readFileAsync,
|
|
} from "./utils/file-utils.js";
|
|
import { normalizeMediaTags } from "./utils/media-tags.js";
|
|
import { decodeCronPayload } from "./utils/payload.js";
|
|
import {
|
|
getQQBotMediaDir,
|
|
isLocalPath as isLocalFilePath,
|
|
normalizePath,
|
|
resolveQQBotLocalMediaPath,
|
|
sanitizeFileName,
|
|
} from "./utils/platform.js";
|
|
|
|
// Limit passive replies per message_id within the QQ Bot reply window.
|
|
const MESSAGE_REPLY_LIMIT = 4;
|
|
const MESSAGE_REPLY_TTL = 60 * 60 * 1000;
|
|
|
|
interface MessageReplyRecord {
|
|
count: number;
|
|
firstReplyAt: number;
|
|
}
|
|
|
|
type QQMessageResult = {
|
|
ext_info?: {
|
|
ref_idx?: string;
|
|
};
|
|
};
|
|
|
|
const messageReplyTracker = new Map<string, MessageReplyRecord>();
|
|
|
|
function getRefIdx(result: QQMessageResult): string | undefined {
|
|
return result.ext_info?.ref_idx;
|
|
}
|
|
|
|
/** Result of the passive-reply limit check. */
|
|
export interface ReplyLimitResult {
|
|
allowed: boolean;
|
|
remaining: number;
|
|
shouldFallbackToProactive: boolean;
|
|
fallbackReason?: "expired" | "limit_exceeded";
|
|
message?: string;
|
|
}
|
|
|
|
/** Check whether a message can still receive a passive reply. */
|
|
export function checkMessageReplyLimit(messageId: string): ReplyLimitResult {
|
|
const now = Date.now();
|
|
const record = messageReplyTracker.get(messageId);
|
|
|
|
// Opportunistically evict expired records to keep the tracker bounded.
|
|
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,
|
|
};
|
|
}
|
|
|
|
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
|
return {
|
|
allowed: false,
|
|
remaining: 0,
|
|
shouldFallbackToProactive: true,
|
|
fallbackReason: "expired",
|
|
message: "Message is older than 1 hour; sending as a proactive message instead",
|
|
};
|
|
}
|
|
|
|
const remaining = MESSAGE_REPLY_LIMIT - record.count;
|
|
if (remaining <= 0) {
|
|
return {
|
|
allowed: false,
|
|
remaining: 0,
|
|
shouldFallbackToProactive: true,
|
|
fallbackReason: "limit_exceeded",
|
|
message: `Passive reply limit reached (${MESSAGE_REPLY_LIMIT} per hour); sending proactively instead`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
allowed: true,
|
|
remaining,
|
|
shouldFallbackToProactive: false,
|
|
};
|
|
}
|
|
|
|
/** Record one passive reply against a message. */
|
|
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++;
|
|
}
|
|
}
|
|
debugLog(
|
|
`[qqbot] recordMessageReply: ${messageId}, count=${messageReplyTracker.get(messageId)?.count}`,
|
|
);
|
|
}
|
|
|
|
/** Return reply-tracker stats for diagnostics. */
|
|
export function getMessageReplyStats(): { trackedMessages: number; totalReplies: number } {
|
|
let totalReplies = 0;
|
|
for (const record of messageReplyTracker.values()) {
|
|
totalReplies += record.count;
|
|
}
|
|
return { trackedMessages: messageReplyTracker.size, totalReplies };
|
|
}
|
|
|
|
/** Return the passive-reply configuration. */
|
|
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;
|
|
mimeType?: string;
|
|
}
|
|
|
|
export interface OutboundResult {
|
|
channel: string;
|
|
messageId?: string;
|
|
timestamp?: string | number;
|
|
error?: string;
|
|
refIdx?: string;
|
|
}
|
|
|
|
/** Parse a qqbot target into a structured delivery target. */
|
|
function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
|
|
const timestamp = new Date().toISOString();
|
|
debugLog(`[${timestamp}] [qqbot] parseTarget: input=${to}`);
|
|
|
|
let id = to.replace(/^qqbot:/i, "");
|
|
|
|
if (id.startsWith("c2c:")) {
|
|
const userId = id.slice(4);
|
|
if (!userId || userId.length === 0) {
|
|
const error = `Invalid c2c target format: ${to} - missing user ID`;
|
|
debugError(`[${timestamp}] [qqbot] parseTarget: ${error}`);
|
|
throw new Error(error);
|
|
}
|
|
debugLog(`[${timestamp}] [qqbot] parseTarget: c2c target, user ID=${userId}`);
|
|
return { type: "c2c", id: userId };
|
|
}
|
|
|
|
if (id.startsWith("group:")) {
|
|
const groupId = id.slice(6);
|
|
if (!groupId || groupId.length === 0) {
|
|
const error = `Invalid group target format: ${to} - missing group ID`;
|
|
debugError(`[${timestamp}] [qqbot] parseTarget: ${error}`);
|
|
throw new Error(error);
|
|
}
|
|
debugLog(`[${timestamp}] [qqbot] parseTarget: group target, group ID=${groupId}`);
|
|
return { type: "group", id: groupId };
|
|
}
|
|
|
|
if (id.startsWith("channel:")) {
|
|
const channelId = id.slice(8);
|
|
if (!channelId || channelId.length === 0) {
|
|
const error = `Invalid channel target format: ${to} - missing channel ID`;
|
|
debugError(`[${timestamp}] [qqbot] parseTarget: ${error}`);
|
|
throw new Error(error);
|
|
}
|
|
debugLog(`[${timestamp}] [qqbot] parseTarget: channel target, channel ID=${channelId}`);
|
|
return { type: "channel", id: channelId };
|
|
}
|
|
|
|
if (!id || id.length === 0) {
|
|
const error = `Invalid target format: ${to} - empty ID after removing qqbot: prefix`;
|
|
debugError(`[${timestamp}] [qqbot] parseTarget: ${error}`);
|
|
throw new Error(error);
|
|
}
|
|
|
|
debugLog(`[${timestamp}] [qqbot] parseTarget: default c2c target, ID=${id}`);
|
|
return { type: "c2c", id };
|
|
}
|
|
|
|
// Structured media send helpers shared by gateway delivery and sendText.
|
|
|
|
/** Normalized target information for media sends. */
|
|
export interface MediaTargetContext {
|
|
targetType: "c2c" | "group" | "channel" | "dm";
|
|
targetId: string;
|
|
account: ResolvedQQBotAccount;
|
|
replyToId?: string;
|
|
logPrefix?: string;
|
|
}
|
|
|
|
/** Build a media target from a normal outbound context. */
|
|
function buildMediaTarget(
|
|
ctx: { to: string; account: ResolvedQQBotAccount; replyToId?: string | null },
|
|
logPrefix?: string,
|
|
): MediaTargetContext {
|
|
const target = parseTarget(ctx.to);
|
|
return {
|
|
targetType: target.type,
|
|
targetId: target.id,
|
|
account: ctx.account,
|
|
replyToId: ctx.replyToId ?? undefined,
|
|
logPrefix,
|
|
};
|
|
}
|
|
|
|
/** Resolve an authenticated access token for the account. */
|
|
async function getToken(account: ResolvedQQBotAccount): Promise<string> {
|
|
if (!account.appId || !account.clientSecret) {
|
|
throw new Error("QQBot not configured (missing appId or clientSecret)");
|
|
}
|
|
return getAccessToken(account.appId, account.clientSecret);
|
|
}
|
|
|
|
/** Return true when public URLs should be passed through directly. */
|
|
function shouldDirectUploadUrl(account: ResolvedQQBotAccount): boolean {
|
|
return account.config?.urlDirectUpload !== false;
|
|
}
|
|
|
|
/**
|
|
* Send a photo from a local file, public URL, or Base64 data URL.
|
|
*/
|
|
export async function sendPhoto(
|
|
ctx: MediaTargetContext,
|
|
imagePath: string,
|
|
): Promise<OutboundResult> {
|
|
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
const mediaPath = resolveQQBotLocalMediaPath(normalizePath(imagePath));
|
|
const isLocal = isLocalFilePath(mediaPath);
|
|
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
const isData = mediaPath.startsWith("data:");
|
|
|
|
// Force a local download before upload when direct URL upload is disabled.
|
|
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
|
|
debugLog(`${prefix} sendPhoto: urlDirectUpload=false, downloading URL first...`);
|
|
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendPhoto");
|
|
if (localFile) {
|
|
return await sendPhoto(ctx, localFile);
|
|
}
|
|
return { channel: "qqbot", error: `Failed to download image: ${mediaPath.slice(0, 80)}` };
|
|
}
|
|
|
|
let imageUrl = mediaPath;
|
|
|
|
if (isLocal) {
|
|
if (!(await fileExistsAsync(mediaPath))) {
|
|
return { channel: "qqbot", error: "Image not found" };
|
|
}
|
|
const sizeCheck = checkFileSize(mediaPath);
|
|
if (!sizeCheck.ok) {
|
|
return { channel: "qqbot", error: sizeCheck.error! };
|
|
}
|
|
const fileBuffer = await readFileAsync(mediaPath);
|
|
const ext = path.extname(mediaPath).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: `Unsupported image format: ${ext}` };
|
|
}
|
|
imageUrl = `data:${mimeType};base64,${fileBuffer.toString("base64")}`;
|
|
debugLog(`${prefix} sendPhoto: local → Base64 (${formatFileSize(fileBuffer.length)})`);
|
|
} else if (!isHttp && !isData) {
|
|
return { channel: "qqbot", error: `Unsupported image source: ${mediaPath.slice(0, 50)}` };
|
|
}
|
|
|
|
try {
|
|
const token = await getToken(ctx.account);
|
|
const localPath = isLocal ? mediaPath : undefined;
|
|
|
|
if (ctx.targetType === "c2c") {
|
|
const r = await sendC2CImageMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
imageUrl,
|
|
ctx.replyToId,
|
|
undefined,
|
|
localPath,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else if (ctx.targetType === "group") {
|
|
const r = await sendGroupImageMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
imageUrl,
|
|
ctx.replyToId,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else {
|
|
// Channel messages only support public URLs through markdown.
|
|
if (isHttp) {
|
|
const r = await sendChannelMessage(token, ctx.targetId, ``, ctx.replyToId);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
}
|
|
debugLog(`${prefix} sendPhoto: channel does not support local/Base64 images`);
|
|
return { channel: "qqbot", error: "Channel does not support local/Base64 images" };
|
|
}
|
|
} catch (err) {
|
|
const msg = formatErrorMessage(err);
|
|
|
|
// Fall back to plugin-managed download + Base64 when QQ fails to fetch the URL directly.
|
|
if (isHttp && !isData) {
|
|
debugWarn(
|
|
`${prefix} sendPhoto: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
|
|
);
|
|
const retryResult = await downloadAndRetrySendPhoto(ctx, mediaPath, prefix);
|
|
if (retryResult) {
|
|
return retryResult;
|
|
}
|
|
}
|
|
|
|
debugError(`${prefix} sendPhoto failed: ${msg}`);
|
|
return { channel: "qqbot", error: msg };
|
|
}
|
|
}
|
|
|
|
/** Download a remote image locally and retry `sendPhoto` through the local-file path. */
|
|
async function downloadAndRetrySendPhoto(
|
|
ctx: MediaTargetContext,
|
|
httpUrl: string,
|
|
prefix: string,
|
|
): Promise<OutboundResult | null> {
|
|
try {
|
|
const downloadDir = getQQBotMediaDir("downloads", "url-fallback");
|
|
const localFile = await downloadFile(httpUrl, downloadDir);
|
|
if (!localFile) {
|
|
debugError(`${prefix} sendPhoto fallback: download also failed for ${httpUrl.slice(0, 80)}`);
|
|
return null;
|
|
}
|
|
|
|
debugLog(`${prefix} sendPhoto fallback: downloaded → ${localFile}, retrying as Base64`);
|
|
return await sendPhoto(ctx, localFile);
|
|
} catch (err) {
|
|
debugError(`${prefix} sendPhoto fallback error:`, err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send voice from either a local file or a public URL.
|
|
*
|
|
* URL handling respects `urlDirectUpload`, and local files are transcoded when needed.
|
|
*/
|
|
export async function sendVoice(
|
|
ctx: MediaTargetContext,
|
|
voicePath: string,
|
|
directUploadFormats?: string[],
|
|
transcodeEnabled: boolean = true,
|
|
): Promise<OutboundResult> {
|
|
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
const mediaPath = resolveQQBotLocalMediaPath(normalizePath(voicePath));
|
|
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
|
|
if (isHttp) {
|
|
if (shouldDirectUploadUrl(ctx.account)) {
|
|
try {
|
|
const token = await getToken(ctx.account);
|
|
if (ctx.targetType === "c2c") {
|
|
const r = await sendC2CVoiceMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
undefined,
|
|
mediaPath,
|
|
ctx.replyToId,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else if (ctx.targetType === "group") {
|
|
const r = await sendGroupVoiceMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
undefined,
|
|
mediaPath,
|
|
ctx.replyToId,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else {
|
|
debugLog(`${prefix} sendVoice: voice not supported in channel`);
|
|
return { channel: "qqbot", error: "Voice not supported in channel" };
|
|
}
|
|
} catch (err) {
|
|
const msg = formatErrorMessage(err);
|
|
debugWarn(
|
|
`${prefix} sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`,
|
|
);
|
|
}
|
|
} else {
|
|
debugLog(`${prefix} sendVoice: urlDirectUpload=false, downloading URL first...`);
|
|
}
|
|
|
|
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVoice");
|
|
if (localFile) {
|
|
return await sendVoiceFromLocal(
|
|
ctx,
|
|
localFile,
|
|
directUploadFormats,
|
|
transcodeEnabled,
|
|
prefix,
|
|
);
|
|
}
|
|
return { channel: "qqbot", error: `Failed to download audio: ${mediaPath.slice(0, 80)}` };
|
|
}
|
|
|
|
return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled, prefix);
|
|
}
|
|
|
|
/** Send voice from a local file. */
|
|
async function sendVoiceFromLocal(
|
|
ctx: MediaTargetContext,
|
|
mediaPath: string,
|
|
directUploadFormats: string[] | undefined,
|
|
transcodeEnabled: boolean,
|
|
prefix: string,
|
|
): Promise<OutboundResult> {
|
|
// TTS can still be flushing the file to disk, so wait for a stable file first.
|
|
const fileSize = await waitForFile(mediaPath);
|
|
if (fileSize === 0) {
|
|
return { channel: "qqbot", error: "Voice generate failed" };
|
|
}
|
|
|
|
const needsTranscode = shouldTranscodeVoice(mediaPath);
|
|
|
|
if (needsTranscode && !transcodeEnabled) {
|
|
const ext = path.extname(mediaPath).toLowerCase();
|
|
debugLog(
|
|
`${prefix} sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`,
|
|
);
|
|
return {
|
|
channel: "qqbot",
|
|
error: `Voice transcoding is disabled and format ${ext} cannot be uploaded directly`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const silkBase64 = await audioFileToSilkBase64(mediaPath, directUploadFormats);
|
|
let uploadBase64 = silkBase64;
|
|
|
|
if (!uploadBase64) {
|
|
const buf = await readFileAsync(mediaPath);
|
|
uploadBase64 = buf.toString("base64");
|
|
debugLog(
|
|
`${prefix} sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`,
|
|
);
|
|
} else {
|
|
debugLog(`${prefix} sendVoice: SILK ready (${fileSize} bytes)`);
|
|
}
|
|
|
|
const token = await getToken(ctx.account);
|
|
|
|
if (ctx.targetType === "c2c") {
|
|
const r = await sendC2CVoiceMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
uploadBase64,
|
|
undefined,
|
|
ctx.replyToId,
|
|
undefined,
|
|
mediaPath,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else if (ctx.targetType === "group") {
|
|
const r = await sendGroupVoiceMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
uploadBase64,
|
|
undefined,
|
|
ctx.replyToId,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else {
|
|
debugLog(`${prefix} sendVoice: voice not supported in channel`);
|
|
return { channel: "qqbot", error: "Voice not supported in channel" };
|
|
}
|
|
} catch (err) {
|
|
const msg = formatErrorMessage(err);
|
|
debugError(`${prefix} sendVoice (local) failed: ${msg}`);
|
|
return { channel: "qqbot", error: msg };
|
|
}
|
|
}
|
|
|
|
/** Send video from either a public URL or a local file. */
|
|
export async function sendVideoMsg(
|
|
ctx: MediaTargetContext,
|
|
videoPath: string,
|
|
): Promise<OutboundResult> {
|
|
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
const mediaPath = resolveQQBotLocalMediaPath(normalizePath(videoPath));
|
|
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
|
|
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
|
|
debugLog(`${prefix} sendVideoMsg: urlDirectUpload=false, downloading URL first...`);
|
|
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg");
|
|
if (localFile) {
|
|
return await sendVideoFromLocal(ctx, localFile, prefix);
|
|
}
|
|
return { channel: "qqbot", error: `Failed to download video: ${mediaPath.slice(0, 80)}` };
|
|
}
|
|
|
|
try {
|
|
const token = await getToken(ctx.account);
|
|
|
|
if (isHttp) {
|
|
if (ctx.targetType === "c2c") {
|
|
const r = await sendC2CVideoMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
mediaPath,
|
|
undefined,
|
|
ctx.replyToId,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else if (ctx.targetType === "group") {
|
|
const r = await sendGroupVideoMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
mediaPath,
|
|
undefined,
|
|
ctx.replyToId,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else {
|
|
debugLog(`${prefix} sendVideoMsg: video not supported in channel`);
|
|
return { channel: "qqbot", error: "Video not supported in channel" };
|
|
}
|
|
}
|
|
|
|
return await sendVideoFromLocal(ctx, mediaPath, prefix);
|
|
} catch (err) {
|
|
const msg = formatErrorMessage(err);
|
|
|
|
// If direct URL upload fails, retry through a local download path.
|
|
if (isHttp) {
|
|
debugWarn(
|
|
`${prefix} sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
|
|
);
|
|
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg");
|
|
if (localFile) {
|
|
return await sendVideoFromLocal(ctx, localFile, prefix);
|
|
}
|
|
}
|
|
|
|
debugError(`${prefix} sendVideoMsg failed: ${msg}`);
|
|
return { channel: "qqbot", error: msg };
|
|
}
|
|
}
|
|
|
|
/** Send video from a local file. */
|
|
async function sendVideoFromLocal(
|
|
ctx: MediaTargetContext,
|
|
mediaPath: string,
|
|
prefix: string,
|
|
): Promise<OutboundResult> {
|
|
if (!(await fileExistsAsync(mediaPath))) {
|
|
return { channel: "qqbot", error: "Video not found" };
|
|
}
|
|
const sizeCheck = checkFileSize(mediaPath);
|
|
if (!sizeCheck.ok) {
|
|
return { channel: "qqbot", error: sizeCheck.error! };
|
|
}
|
|
|
|
const fileBuffer = await readFileAsync(mediaPath);
|
|
const videoBase64 = fileBuffer.toString("base64");
|
|
debugLog(`${prefix} sendVideoMsg: local video (${formatFileSize(fileBuffer.length)})`);
|
|
|
|
try {
|
|
const token = await getToken(ctx.account);
|
|
if (ctx.targetType === "c2c") {
|
|
const r = await sendC2CVideoMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
undefined,
|
|
videoBase64,
|
|
ctx.replyToId,
|
|
undefined,
|
|
mediaPath,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else if (ctx.targetType === "group") {
|
|
const r = await sendGroupVideoMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
undefined,
|
|
videoBase64,
|
|
ctx.replyToId,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else {
|
|
debugLog(`${prefix} sendVideoMsg: video not supported in channel`);
|
|
return { channel: "qqbot", error: "Video not supported in channel" };
|
|
}
|
|
} catch (err) {
|
|
const msg = formatErrorMessage(err);
|
|
debugError(`${prefix} sendVideoMsg (local) failed: ${msg}`);
|
|
return { channel: "qqbot", error: msg };
|
|
}
|
|
}
|
|
|
|
/** Send a file from a local path or public URL. */
|
|
export async function sendDocument(
|
|
ctx: MediaTargetContext,
|
|
filePath: string,
|
|
): Promise<OutboundResult> {
|
|
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
const mediaPath = resolveQQBotLocalMediaPath(normalizePath(filePath));
|
|
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
const fileName = sanitizeFileName(path.basename(mediaPath));
|
|
|
|
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
|
|
debugLog(`${prefix} sendDocument: urlDirectUpload=false, downloading URL first...`);
|
|
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument");
|
|
if (localFile) {
|
|
return await sendDocumentFromLocal(ctx, localFile, prefix);
|
|
}
|
|
return { channel: "qqbot", error: `Failed to download file: ${mediaPath.slice(0, 80)}` };
|
|
}
|
|
|
|
try {
|
|
const token = await getToken(ctx.account);
|
|
|
|
if (isHttp) {
|
|
if (ctx.targetType === "c2c") {
|
|
const r = await sendC2CFileMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
undefined,
|
|
mediaPath,
|
|
ctx.replyToId,
|
|
fileName,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else if (ctx.targetType === "group") {
|
|
const r = await sendGroupFileMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
undefined,
|
|
mediaPath,
|
|
ctx.replyToId,
|
|
fileName,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else {
|
|
debugLog(`${prefix} sendDocument: file not supported in channel`);
|
|
return { channel: "qqbot", error: "File not supported in channel" };
|
|
}
|
|
}
|
|
|
|
return await sendDocumentFromLocal(ctx, mediaPath, prefix);
|
|
} catch (err) {
|
|
const msg = formatErrorMessage(err);
|
|
|
|
// If direct URL upload fails, retry through a local download path.
|
|
if (isHttp) {
|
|
debugWarn(
|
|
`${prefix} sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
|
|
);
|
|
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument");
|
|
if (localFile) {
|
|
return await sendDocumentFromLocal(ctx, localFile, prefix);
|
|
}
|
|
}
|
|
|
|
debugError(`${prefix} sendDocument failed: ${msg}`);
|
|
return { channel: "qqbot", error: msg };
|
|
}
|
|
}
|
|
|
|
/** Send a file from local storage. */
|
|
async function sendDocumentFromLocal(
|
|
ctx: MediaTargetContext,
|
|
mediaPath: string,
|
|
prefix: string,
|
|
): Promise<OutboundResult> {
|
|
const fileName = sanitizeFileName(path.basename(mediaPath));
|
|
|
|
if (!(await fileExistsAsync(mediaPath))) {
|
|
return { channel: "qqbot", error: "File not found" };
|
|
}
|
|
const sizeCheck = checkFileSize(mediaPath);
|
|
if (!sizeCheck.ok) {
|
|
return { channel: "qqbot", error: sizeCheck.error! };
|
|
}
|
|
const fileBuffer = await readFileAsync(mediaPath);
|
|
if (fileBuffer.length === 0) {
|
|
return { channel: "qqbot", error: `File is empty: ${mediaPath}` };
|
|
}
|
|
const fileBase64 = fileBuffer.toString("base64");
|
|
debugLog(`${prefix} sendDocument: local file (${formatFileSize(fileBuffer.length)})`);
|
|
|
|
try {
|
|
const token = await getToken(ctx.account);
|
|
if (ctx.targetType === "c2c") {
|
|
const r = await sendC2CFileMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
fileBase64,
|
|
undefined,
|
|
ctx.replyToId,
|
|
fileName,
|
|
mediaPath,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else if (ctx.targetType === "group") {
|
|
const r = await sendGroupFileMessage(
|
|
ctx.account.appId,
|
|
token,
|
|
ctx.targetId,
|
|
fileBase64,
|
|
undefined,
|
|
ctx.replyToId,
|
|
fileName,
|
|
);
|
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
} else {
|
|
debugLog(`${prefix} sendDocument: file not supported in channel`);
|
|
return { channel: "qqbot", error: "File not supported in channel" };
|
|
}
|
|
} catch (err) {
|
|
const msg = formatErrorMessage(err);
|
|
debugError(`${prefix} sendDocument (local) failed: ${msg}`);
|
|
return { channel: "qqbot", error: msg };
|
|
}
|
|
}
|
|
|
|
/** Download a remote file into the fallback media directory. */
|
|
async function downloadToFallbackDir(
|
|
httpUrl: string,
|
|
prefix: string,
|
|
caller: string,
|
|
): Promise<string | null> {
|
|
try {
|
|
const downloadDir = getQQBotMediaDir("downloads", "url-fallback");
|
|
const localFile = await downloadFile(httpUrl, downloadDir);
|
|
if (!localFile) {
|
|
debugError(`${prefix} ${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`);
|
|
return null;
|
|
}
|
|
debugLog(`${prefix} ${caller} fallback: downloaded → ${localFile}`);
|
|
return localFile;
|
|
} catch (err) {
|
|
debugError(`${prefix} ${caller} fallback download error:`, err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send text, optionally falling back from passive reply mode to proactive mode.
|
|
*
|
|
* Also supports inline media tags such as `<qqimg>...</qqimg>`.
|
|
*/
|
|
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|
const { to, account } = ctx;
|
|
let { text, replyToId } = ctx;
|
|
let fallbackToProactive = false;
|
|
|
|
debugLog(
|
|
"[qqbot] sendText ctx:",
|
|
JSON.stringify(
|
|
{ to, text: text?.slice(0, 50), replyToId, accountId: account.accountId },
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
if (replyToId) {
|
|
const limitCheck = checkMessageReplyLimit(replyToId);
|
|
|
|
if (!limitCheck.allowed) {
|
|
if (limitCheck.shouldFallbackToProactive) {
|
|
debugWarn(
|
|
`[qqbot] sendText: passive reply unavailable, falling back to proactive send - ${limitCheck.message}`,
|
|
);
|
|
fallbackToProactive = true;
|
|
replyToId = null;
|
|
} else {
|
|
debugError(
|
|
`[qqbot] sendText: passive reply was blocked without a fallback path - ${limitCheck.message}`,
|
|
);
|
|
return {
|
|
channel: "qqbot",
|
|
error: limitCheck.message,
|
|
};
|
|
}
|
|
} else {
|
|
debugLog(
|
|
`[qqbot] sendText: remaining passive replies for ${replyToId}: ${limitCheck.remaining}/${MESSAGE_REPLY_LIMIT}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
text = normalizeMediaTags(text);
|
|
|
|
const mediaTagRegex =
|
|
/<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
const mediaTagMatches = text.match(mediaTagRegex);
|
|
|
|
if (mediaTagMatches && mediaTagMatches.length > 0) {
|
|
debugLog(`[qqbot] sendText: Detected ${mediaTagMatches.length} media tag(s), processing...`);
|
|
|
|
// Preserve the original text/media ordering when sending mixed content.
|
|
const sendQueue: Array<{
|
|
type: "text" | "image" | "voice" | "video" | "file" | "media";
|
|
content: string;
|
|
}> = [];
|
|
|
|
let lastIndex = 0;
|
|
const mediaTagRegexWithIndex =
|
|
/<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
let match;
|
|
|
|
while ((match = mediaTagRegexWithIndex.exec(text)) !== null) {
|
|
const textBefore = text
|
|
.slice(lastIndex, match.index)
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
if (textBefore) {
|
|
sendQueue.push({ type: "text", content: textBefore });
|
|
}
|
|
|
|
const tagName = match[1].toLowerCase();
|
|
|
|
let mediaPath = match[2]?.trim() ?? "";
|
|
if (mediaPath.startsWith("MEDIA:")) {
|
|
mediaPath = mediaPath.slice("MEDIA:".length);
|
|
}
|
|
mediaPath = normalizePath(mediaPath);
|
|
|
|
// Fix paths that the model emitted with markdown-style escaping.
|
|
mediaPath = mediaPath.replace(/\\\\/g, "\\");
|
|
|
|
// Skip octal escape decoding for Windows local paths (e.g. C:\Users\1\file.txt)
|
|
// where backslash-digit sequences like \1, \2 ... \7 are directory separators,
|
|
// not octal escape sequences.
|
|
const isWinLocal = /^[a-zA-Z]:[\\/]/.test(mediaPath) || mediaPath.startsWith("\\\\");
|
|
try {
|
|
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
|
|
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
|
|
|
|
if (!isWinLocal && (hasOctal || hasNonASCII)) {
|
|
debugLog(`[qqbot] sendText: Decoding path with mixed encoding: ${mediaPath}`);
|
|
|
|
let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => {
|
|
return String.fromCharCode(parseInt(octal, 8));
|
|
});
|
|
|
|
const bytes: number[] = [];
|
|
for (let i = 0; i < decoded.length; i++) {
|
|
const code = decoded.charCodeAt(i);
|
|
if (code <= 0xff) {
|
|
bytes.push(code);
|
|
} else {
|
|
const charBytes = Buffer.from(decoded[i], "utf8");
|
|
bytes.push(...charBytes);
|
|
}
|
|
}
|
|
|
|
const buffer = Buffer.from(bytes);
|
|
const utf8Decoded = buffer.toString("utf8");
|
|
|
|
if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) {
|
|
mediaPath = utf8Decoded;
|
|
debugLog(`[qqbot] sendText: Successfully decoded path: ${mediaPath}`);
|
|
}
|
|
}
|
|
} catch (decodeErr) {
|
|
debugError(
|
|
`[qqbot] sendText: Path decode error: ${
|
|
decodeErr instanceof Error ? decodeErr.message : JSON.stringify(decodeErr)
|
|
}`,
|
|
);
|
|
}
|
|
|
|
if (mediaPath) {
|
|
if (tagName === "qqmedia") {
|
|
sendQueue.push({ type: "media", content: mediaPath });
|
|
debugLog(`[qqbot] sendText: Found auto-detect media in <qqmedia>: ${mediaPath}`);
|
|
} else if (tagName === "qqvoice") {
|
|
sendQueue.push({ type: "voice", content: mediaPath });
|
|
debugLog(`[qqbot] sendText: Found voice path in <qqvoice>: ${mediaPath}`);
|
|
} else if (tagName === "qqvideo") {
|
|
sendQueue.push({ type: "video", content: mediaPath });
|
|
debugLog(`[qqbot] sendText: Found video URL in <qqvideo>: ${mediaPath}`);
|
|
} else if (tagName === "qqfile") {
|
|
sendQueue.push({ type: "file", content: mediaPath });
|
|
debugLog(`[qqbot] sendText: Found file path in <qqfile>: ${mediaPath}`);
|
|
} else {
|
|
sendQueue.push({ type: "image", content: mediaPath });
|
|
debugLog(`[qqbot] sendText: Found image path in <qqimg>: ${mediaPath}`);
|
|
}
|
|
}
|
|
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
|
|
const textAfter = text
|
|
.slice(lastIndex)
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
if (textAfter) {
|
|
sendQueue.push({ type: "text", content: textAfter });
|
|
}
|
|
|
|
debugLog(`[qqbot] sendText: Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`);
|
|
|
|
// Send queue items in order.
|
|
const mediaTarget = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendText]");
|
|
let lastResult: OutboundResult = { channel: "qqbot" };
|
|
|
|
for (const item of sendQueue) {
|
|
try {
|
|
if (item.type === "text") {
|
|
if (replyToId) {
|
|
const accessToken = await getToken(account);
|
|
const target = parseTarget(to);
|
|
if (target.type === "c2c") {
|
|
const result = await sendC2CMessage(
|
|
account.appId,
|
|
accessToken,
|
|
target.id,
|
|
item.content,
|
|
replyToId,
|
|
);
|
|
recordMessageReply(replyToId);
|
|
lastResult = {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: result.ext_info?.ref_idx,
|
|
};
|
|
} else if (target.type === "group") {
|
|
const result = await sendGroupMessage(
|
|
account.appId,
|
|
accessToken,
|
|
target.id,
|
|
item.content,
|
|
replyToId,
|
|
);
|
|
recordMessageReply(replyToId);
|
|
lastResult = {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: result.ext_info?.ref_idx,
|
|
};
|
|
} else {
|
|
const result = await sendChannelMessage(
|
|
accessToken,
|
|
target.id,
|
|
item.content,
|
|
replyToId,
|
|
);
|
|
recordMessageReply(replyToId);
|
|
lastResult = {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: getRefIdx(result),
|
|
};
|
|
}
|
|
} else {
|
|
const accessToken = await getToken(account);
|
|
const target = parseTarget(to);
|
|
if (target.type === "c2c") {
|
|
const result = await sendProactiveC2CMessage(
|
|
account.appId,
|
|
accessToken,
|
|
target.id,
|
|
item.content,
|
|
);
|
|
lastResult = {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: getRefIdx(result),
|
|
};
|
|
} else if (target.type === "group") {
|
|
const result = await sendProactiveGroupMessage(
|
|
account.appId,
|
|
accessToken,
|
|
target.id,
|
|
item.content,
|
|
);
|
|
lastResult = {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: getRefIdx(result),
|
|
};
|
|
} else {
|
|
const result = await sendChannelMessage(accessToken, target.id, item.content);
|
|
lastResult = {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: getRefIdx(result),
|
|
};
|
|
}
|
|
}
|
|
debugLog(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`);
|
|
} else if (item.type === "image") {
|
|
lastResult = await sendPhoto(mediaTarget, item.content);
|
|
} else if (item.type === "voice") {
|
|
lastResult = await sendVoice(
|
|
mediaTarget,
|
|
item.content,
|
|
undefined,
|
|
account.config?.audioFormatPolicy?.transcodeEnabled !== false,
|
|
);
|
|
} else if (item.type === "video") {
|
|
lastResult = await sendVideoMsg(mediaTarget, item.content);
|
|
} else if (item.type === "file") {
|
|
lastResult = await sendDocument(mediaTarget, item.content);
|
|
} else if (item.type === "media") {
|
|
// Auto-route qqmedia based on the file extension.
|
|
lastResult = await sendMedia({
|
|
to,
|
|
text: "",
|
|
mediaUrl: item.content,
|
|
accountId: account.accountId,
|
|
replyToId,
|
|
account,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
const errMsg = formatErrorMessage(err);
|
|
debugError(`[qqbot] sendText: Failed to send ${item.type}: ${errMsg}`);
|
|
lastResult = { channel: "qqbot", error: errMsg };
|
|
}
|
|
}
|
|
|
|
return lastResult;
|
|
}
|
|
|
|
if (!replyToId) {
|
|
if (!text || text.trim().length === 0) {
|
|
debugError("[qqbot] sendText error: proactive message content cannot be empty");
|
|
return {
|
|
channel: "qqbot",
|
|
error: "Proactive messages require non-empty content (--message cannot be empty)",
|
|
};
|
|
}
|
|
if (fallbackToProactive) {
|
|
debugLog(
|
|
`[qqbot] sendText: [fallback] sending proactive message to ${to}, length=${text.length}`,
|
|
);
|
|
} else {
|
|
debugLog(`[qqbot] sendText: sending proactive message to ${to}, length=${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);
|
|
debugLog("[qqbot] sendText target:", JSON.stringify(target));
|
|
|
|
if (!replyToId) {
|
|
let outResult: OutboundResult;
|
|
if (target.type === "c2c") {
|
|
const result = await sendProactiveC2CMessage(account.appId, accessToken, target.id, text);
|
|
outResult = {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: getRefIdx(result),
|
|
};
|
|
} else if (target.type === "group") {
|
|
const result = await sendProactiveGroupMessage(account.appId, accessToken, target.id, text);
|
|
outResult = {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: getRefIdx(result),
|
|
};
|
|
} else {
|
|
const result = await sendChannelMessage(accessToken, target.id, text);
|
|
outResult = {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: getRefIdx(result),
|
|
};
|
|
}
|
|
return outResult;
|
|
}
|
|
|
|
if (target.type === "c2c") {
|
|
const result = await sendC2CMessage(account.appId, accessToken, target.id, text, replyToId);
|
|
recordMessageReply(replyToId);
|
|
return {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: result.ext_info?.ref_idx,
|
|
};
|
|
} else if (target.type === "group") {
|
|
const result = await sendGroupMessage(account.appId, accessToken, target.id, text, replyToId);
|
|
recordMessageReply(replyToId);
|
|
return {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: result.ext_info?.ref_idx,
|
|
};
|
|
} else {
|
|
const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
|
|
recordMessageReply(replyToId);
|
|
return {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: getRefIdx(result),
|
|
};
|
|
}
|
|
} catch (err) {
|
|
const message = formatErrorMessage(err);
|
|
return { channel: "qqbot", error: message };
|
|
}
|
|
}
|
|
|
|
/** Send a proactive message without a replyToId. */
|
|
export async function sendProactiveMessage(
|
|
account: ResolvedQQBotAccount,
|
|
to: string,
|
|
text: string,
|
|
): Promise<OutboundResult> {
|
|
const timestamp = new Date().toISOString();
|
|
|
|
if (!account.appId || !account.clientSecret) {
|
|
const errorMsg = "QQBot not configured (missing appId or clientSecret)";
|
|
debugError(`[${timestamp}] [qqbot] sendProactiveMessage: ${errorMsg}`);
|
|
return { channel: "qqbot", error: errorMsg };
|
|
}
|
|
|
|
debugLog(
|
|
`[${timestamp}] [qqbot] sendProactiveMessage: starting, to=${to}, text length=${text.length}, accountId=${account.accountId}`,
|
|
);
|
|
|
|
try {
|
|
debugLog(
|
|
`[${timestamp}] [qqbot] sendProactiveMessage: getting access token for appId=${account.appId}`,
|
|
);
|
|
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
|
|
debugLog(`[${timestamp}] [qqbot] sendProactiveMessage: parsing target=${to}`);
|
|
const target = parseTarget(to);
|
|
debugLog(
|
|
`[${timestamp}] [qqbot] sendProactiveMessage: target parsed, type=${target.type}, id=${target.id}`,
|
|
);
|
|
|
|
let outResult: OutboundResult;
|
|
if (target.type === "c2c") {
|
|
debugLog(
|
|
`[${timestamp}] [qqbot] sendProactiveMessage: sending proactive C2C message to user=${target.id}`,
|
|
);
|
|
const result = await sendProactiveC2CMessage(account.appId, accessToken, target.id, text);
|
|
debugLog(
|
|
`[${timestamp}] [qqbot] sendProactiveMessage: proactive C2C message sent successfully, messageId=${result.id}`,
|
|
);
|
|
outResult = {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: getRefIdx(result),
|
|
};
|
|
} else if (target.type === "group") {
|
|
debugLog(
|
|
`[${timestamp}] [qqbot] sendProactiveMessage: sending proactive group message to group=${target.id}`,
|
|
);
|
|
const result = await sendProactiveGroupMessage(account.appId, accessToken, target.id, text);
|
|
debugLog(
|
|
`[${timestamp}] [qqbot] sendProactiveMessage: proactive group message sent successfully, messageId=${result.id}`,
|
|
);
|
|
outResult = {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: getRefIdx(result),
|
|
};
|
|
} else {
|
|
debugLog(
|
|
`[${timestamp}] [qqbot] sendProactiveMessage: sending channel message to channel=${target.id}`,
|
|
);
|
|
const result = await sendChannelMessage(accessToken, target.id, text);
|
|
debugLog(
|
|
`[${timestamp}] [qqbot] sendProactiveMessage: channel message sent successfully, messageId=${result.id}`,
|
|
);
|
|
outResult = {
|
|
channel: "qqbot",
|
|
messageId: result.id,
|
|
timestamp: result.timestamp,
|
|
refIdx: getRefIdx(result),
|
|
};
|
|
}
|
|
return outResult;
|
|
} catch (err) {
|
|
const errorMessage = formatErrorMessage(err);
|
|
debugError(`[${timestamp}] [qqbot] sendProactiveMessage: error: ${errorMessage}`);
|
|
debugError(
|
|
`[${timestamp}] [qqbot] sendProactiveMessage: error stack: ${err instanceof Error ? err.stack : "No stack trace"}`,
|
|
);
|
|
return { channel: "qqbot", error: errorMessage };
|
|
}
|
|
}
|
|
|
|
/** Send rich media, auto-routing by media type and source. */
|
|
export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> {
|
|
const { to, text, replyToId, account, mimeType } = ctx;
|
|
const mediaUrl = resolveQQBotLocalMediaPath(normalizePath(ctx.mediaUrl));
|
|
|
|
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" };
|
|
}
|
|
|
|
const target = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendMedia]");
|
|
|
|
// Dispatch by type, preferring MIME and falling back to the file extension.
|
|
// Individual send* helpers already handle direct URL upload vs. download fallback.
|
|
if (isAudioFile(mediaUrl, mimeType)) {
|
|
const formats =
|
|
account.config?.audioFormatPolicy?.uploadDirectFormats ??
|
|
account.config?.voiceDirectUploadFormats;
|
|
const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
|
|
const result = await sendVoice(target, mediaUrl, formats, transcodeEnabled);
|
|
if (!result.error) {
|
|
if (text?.trim()) {
|
|
await sendTextAfterMedia(target, text);
|
|
}
|
|
return result;
|
|
}
|
|
// Preserve the voice error and fall back to file send.
|
|
const voiceError = result.error;
|
|
debugWarn(`[qqbot] sendMedia: sendVoice failed (${voiceError}), falling back to sendDocument`);
|
|
const fallback = await sendDocument(target, mediaUrl);
|
|
if (!fallback.error) {
|
|
if (text?.trim()) {
|
|
await sendTextAfterMedia(target, text);
|
|
}
|
|
return fallback;
|
|
}
|
|
return { channel: "qqbot", error: `voice: ${voiceError} | fallback file: ${fallback.error}` };
|
|
}
|
|
|
|
if (isVideoFile(mediaUrl, mimeType)) {
|
|
const result = await sendVideoMsg(target, mediaUrl);
|
|
if (!result.error && text?.trim()) {
|
|
await sendTextAfterMedia(target, text);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Non-image, non-audio, and non-video media fall back to file send.
|
|
if (
|
|
!isImageFile(mediaUrl, mimeType) &&
|
|
!isAudioFile(mediaUrl, mimeType) &&
|
|
!isVideoFile(mediaUrl, mimeType)
|
|
) {
|
|
const result = await sendDocument(target, mediaUrl);
|
|
if (!result.error && text?.trim()) {
|
|
await sendTextAfterMedia(target, text);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Default to image handling. sendPhoto already contains URL fallback logic.
|
|
const result = await sendPhoto(target, mediaUrl);
|
|
if (!result.error && text?.trim()) {
|
|
await sendTextAfterMedia(target, text);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Send text after media when the transport supports a follow-up text message. */
|
|
async function sendTextAfterMedia(ctx: MediaTargetContext, text: string): Promise<void> {
|
|
try {
|
|
const token = await getToken(ctx.account);
|
|
if (ctx.targetType === "c2c") {
|
|
await sendC2CMessage(ctx.account.appId, token, ctx.targetId, text, ctx.replyToId);
|
|
} else if (ctx.targetType === "group") {
|
|
await sendGroupMessage(ctx.account.appId, token, ctx.targetId, text, ctx.replyToId);
|
|
} else if (ctx.targetType === "channel") {
|
|
await sendChannelMessage(token, ctx.targetId, text, ctx.replyToId);
|
|
} else if (ctx.targetType === "dm") {
|
|
await sendDmMessage(token, ctx.targetId, text, ctx.replyToId);
|
|
}
|
|
} catch (err) {
|
|
debugError(
|
|
`[qqbot] sendTextAfterMedia failed: ${err instanceof Error ? err.message : JSON.stringify(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Extract a lowercase extension from a path or URL, ignoring query and hash segments. */
|
|
function getCleanExt(filePath: string): string {
|
|
const cleanPath = filePath.split("?")[0].split("#")[0];
|
|
return path.extname(cleanPath).toLowerCase();
|
|
}
|
|
|
|
/** Check whether a file is an image using MIME first and extension as fallback. */
|
|
function isImageFile(filePath: string, mimeType?: string): boolean {
|
|
if (mimeType) {
|
|
if (mimeType.startsWith("image/")) {
|
|
return true;
|
|
}
|
|
}
|
|
const ext = getCleanExt(filePath);
|
|
return [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext);
|
|
}
|
|
|
|
/** Check whether a file or URL is a video using MIME first and extension as fallback. */
|
|
function isVideoFile(filePath: string, mimeType?: string): boolean {
|
|
if (mimeType) {
|
|
if (mimeType.startsWith("video/")) {
|
|
return true;
|
|
}
|
|
}
|
|
const ext = getCleanExt(filePath);
|
|
return [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"].includes(ext);
|
|
}
|
|
|
|
/**
|
|
* Send a message emitted by an OpenClaw cron task.
|
|
*
|
|
* Cron output may be either:
|
|
* 1. A `QQBOT_CRON:{base64}` structured payload that includes target metadata.
|
|
* 2. Plain text that should be sent directly to the provided fallback target.
|
|
*
|
|
* @param account Resolved account configuration.
|
|
* @param to Fallback target address when the payload does not include one.
|
|
* @param message Message content, either `QQBOT_CRON:` payload or plain text.
|
|
* @returns Send result.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* // Structured payload
|
|
* const result = await sendCronMessage(
|
|
* account,
|
|
* "user_openid",
|
|
* "QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs..."
|
|
* );
|
|
*
|
|
* // Plain text
|
|
* const result = await sendCronMessage(account, "user_openid", "This is a plain reminder message.");
|
|
* ```
|
|
*/
|
|
export async function sendCronMessage(
|
|
account: ResolvedQQBotAccount,
|
|
to: string,
|
|
message: string,
|
|
): Promise<OutboundResult> {
|
|
const timestamp = new Date().toISOString();
|
|
debugLog(`[${timestamp}] [qqbot] sendCronMessage: to=${to}, message length=${message.length}`);
|
|
|
|
// Detect `QQBOT_CRON:` structured payloads first.
|
|
const cronResult = decodeCronPayload(message);
|
|
|
|
if (cronResult.isCronPayload) {
|
|
if (cronResult.error) {
|
|
debugError(
|
|
`[${timestamp}] [qqbot] sendCronMessage: cron payload decode error: ${cronResult.error}`,
|
|
);
|
|
return {
|
|
channel: "qqbot",
|
|
error: `Failed to decode cron payload: ${cronResult.error}`,
|
|
};
|
|
}
|
|
|
|
if (cronResult.payload) {
|
|
const payload = cronResult.payload;
|
|
debugLog(
|
|
`[${timestamp}] [qqbot] sendCronMessage: decoded cron payload, targetType=${payload.targetType}, targetAddress=${payload.targetAddress}, content length=${payload.content.length}`,
|
|
);
|
|
|
|
// Prefer the target encoded in the structured payload.
|
|
const targetTo =
|
|
payload.targetType === "group" ? `group:${payload.targetAddress}` : payload.targetAddress;
|
|
|
|
debugLog(
|
|
`[${timestamp}] [qqbot] sendCronMessage: sending proactive message to targetTo=${targetTo}`,
|
|
);
|
|
|
|
// Send the reminder content.
|
|
const result = await sendProactiveMessage(account, targetTo, payload.content);
|
|
|
|
if (result.error) {
|
|
debugError(
|
|
`[${timestamp}] [qqbot] sendCronMessage: proactive message failed, error=${result.error}`,
|
|
);
|
|
} else {
|
|
debugLog(`[${timestamp}] [qqbot] sendCronMessage: proactive message sent successfully`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// Fall back to plain text handling when the payload is not structured.
|
|
debugLog(`[${timestamp}] [qqbot] sendCronMessage: plain text message, sending to ${to}`);
|
|
return await sendProactiveMessage(account, to, message);
|
|
}
|