mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 17:43:05 +00:00
695 lines
22 KiB
TypeScript
695 lines
22 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
|
import {
|
|
getAccessToken,
|
|
sendC2CMessage,
|
|
sendChannelMessage,
|
|
sendDmMessage,
|
|
sendGroupMessage,
|
|
clearTokenCache,
|
|
sendC2CImageMessage,
|
|
sendGroupImageMessage,
|
|
sendC2CVoiceMessage,
|
|
sendGroupVoiceMessage,
|
|
sendC2CVideoMessage,
|
|
sendGroupVideoMessage,
|
|
sendC2CFileMessage,
|
|
sendGroupFileMessage,
|
|
} from "./api.js";
|
|
import { getQQBotRuntime } from "./runtime.js";
|
|
import type { ResolvedQQBotAccount } from "./types.js";
|
|
import {
|
|
isGlobalTTSAvailable,
|
|
resolveTTSConfig,
|
|
textToSilk,
|
|
audioFileToSilkBase64,
|
|
formatDuration,
|
|
} from "./utils/audio-convert.js";
|
|
import { MAX_UPLOAD_SIZE, formatFileSize } from "./utils/file-utils.js";
|
|
import {
|
|
parseQQBotPayload,
|
|
encodePayloadForCron,
|
|
isCronReminderPayload,
|
|
isMediaPayload,
|
|
type MediaPayload,
|
|
} from "./utils/payload.js";
|
|
import {
|
|
getQQBotDataDir,
|
|
normalizePath,
|
|
resolveQQBotPayloadLocalFilePath,
|
|
sanitizeFileName,
|
|
} from "./utils/platform.js";
|
|
|
|
export interface MessageTarget {
|
|
type: "c2c" | "guild" | "dm" | "group";
|
|
senderId: string;
|
|
messageId: string;
|
|
channelId?: string;
|
|
guildId?: string;
|
|
groupOpenid?: string;
|
|
}
|
|
|
|
export interface ReplyContext {
|
|
target: MessageTarget;
|
|
account: ResolvedQQBotAccount;
|
|
cfg: unknown;
|
|
log?: {
|
|
info: (msg: string) => void;
|
|
error: (msg: string) => void;
|
|
debug?: (msg: string) => void;
|
|
};
|
|
}
|
|
|
|
/** Send a message and retry once if the token appears to have expired. */
|
|
export async function sendWithTokenRetry<T>(
|
|
appId: string,
|
|
clientSecret: string,
|
|
sendFn: (token: string) => Promise<T>,
|
|
log?: ReplyContext["log"],
|
|
accountId?: string,
|
|
): Promise<T> {
|
|
try {
|
|
const token = await getAccessToken(appId, clientSecret);
|
|
return await sendFn(token);
|
|
} catch (err) {
|
|
const errMsg = String(err);
|
|
if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) {
|
|
log?.info(`[qqbot:${accountId}] Token may be expired, refreshing...`);
|
|
clearTokenCache(appId);
|
|
const newToken = await getAccessToken(appId, clientSecret);
|
|
return await sendFn(newToken);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Route a text message to the correct QQ target type. */
|
|
export async function sendTextToTarget(
|
|
ctx: ReplyContext,
|
|
text: string,
|
|
refIdx?: string,
|
|
): Promise<void> {
|
|
const { target, account } = ctx;
|
|
await sendWithTokenRetry(
|
|
account.appId,
|
|
account.clientSecret,
|
|
async (token) => {
|
|
if (target.type === "c2c") {
|
|
await sendC2CMessage(account.appId, token, target.senderId, text, target.messageId, refIdx);
|
|
} else if (target.type === "group" && target.groupOpenid) {
|
|
await sendGroupMessage(account.appId, token, target.groupOpenid, text, target.messageId);
|
|
} else if (target.channelId) {
|
|
await sendChannelMessage(token, target.channelId, text, target.messageId);
|
|
} else if (target.type === "dm" && target.guildId) {
|
|
await sendDmMessage(token, target.guildId, text, target.messageId);
|
|
}
|
|
},
|
|
ctx.log,
|
|
account.accountId,
|
|
);
|
|
}
|
|
|
|
/** Best-effort delivery for error text back to the user. */
|
|
export async function sendErrorToTarget(ctx: ReplyContext, errorText: string): Promise<void> {
|
|
try {
|
|
await sendTextToTarget(ctx, errorText);
|
|
} catch (sendErr) {
|
|
ctx.log?.error(
|
|
`[qqbot:${ctx.account.accountId}] Failed to send error message: ${String(sendErr)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a structured payload prefixed with `QQBOT_PAYLOAD:`.
|
|
* Returns true when the reply was handled here, otherwise false.
|
|
*/
|
|
export async function handleStructuredPayload(
|
|
ctx: ReplyContext,
|
|
replyText: string,
|
|
recordActivity: () => void,
|
|
): Promise<boolean> {
|
|
const { account, log } = ctx;
|
|
const payloadResult = parseQQBotPayload(replyText);
|
|
|
|
if (!payloadResult.isPayload) {
|
|
return false;
|
|
}
|
|
|
|
if (payloadResult.error) {
|
|
log?.error(`[qqbot:${account.accountId}] Payload parse error: ${payloadResult.error}`);
|
|
return true;
|
|
}
|
|
|
|
if (!payloadResult.payload) {
|
|
return true;
|
|
}
|
|
|
|
const parsedPayload = payloadResult.payload;
|
|
const unknownPayload = payloadResult.payload as unknown;
|
|
log?.info(
|
|
`[qqbot:${account.accountId}] Detected structured payload, type: ${parsedPayload.type}`,
|
|
);
|
|
|
|
if (isCronReminderPayload(parsedPayload)) {
|
|
log?.info(`[qqbot:${account.accountId}] Processing cron_reminder payload`);
|
|
const cronMessage = encodePayloadForCron(parsedPayload);
|
|
const confirmText = `⏰ Reminder scheduled. It will be sent at the configured time: "${parsedPayload.content}"`;
|
|
try {
|
|
await sendTextToTarget(ctx, confirmText);
|
|
log?.info(
|
|
`[qqbot:${account.accountId}] Cron reminder confirmation sent, cronMessage: ${cronMessage}`,
|
|
);
|
|
} catch (err) {
|
|
log?.error(
|
|
`[qqbot:${account.accountId}] Failed to send cron confirmation: ${
|
|
err instanceof Error ? err.message : JSON.stringify(err)
|
|
}`,
|
|
);
|
|
}
|
|
recordActivity();
|
|
return true;
|
|
}
|
|
|
|
if (isMediaPayload(parsedPayload)) {
|
|
log?.info(
|
|
`[qqbot:${account.accountId}] Processing media payload, mediaType: ${parsedPayload.mediaType}`,
|
|
);
|
|
|
|
if (parsedPayload.mediaType === "image") {
|
|
await handleImagePayload(ctx, parsedPayload);
|
|
} else if (parsedPayload.mediaType === "audio") {
|
|
await handleAudioPayload(ctx, parsedPayload);
|
|
} else if (parsedPayload.mediaType === "video") {
|
|
await handleVideoPayload(ctx, parsedPayload);
|
|
} else if (parsedPayload.mediaType === "file") {
|
|
await handleFilePayload(ctx, parsedPayload);
|
|
} else {
|
|
log?.error(
|
|
`[qqbot:${account.accountId}] Unknown media type: ${JSON.stringify(parsedPayload.mediaType)}`,
|
|
);
|
|
}
|
|
recordActivity();
|
|
return true;
|
|
}
|
|
|
|
const payloadType =
|
|
typeof unknownPayload === "object" &&
|
|
unknownPayload !== null &&
|
|
"type" in unknownPayload &&
|
|
typeof unknownPayload.type === "string"
|
|
? unknownPayload.type
|
|
: "unknown";
|
|
log?.error(`[qqbot:${account.accountId}] Unknown payload type: ${payloadType}`);
|
|
return true;
|
|
}
|
|
|
|
// Media payload handlers.
|
|
|
|
function validateStructuredPayloadLocalPath(
|
|
ctx: ReplyContext,
|
|
payloadPath: string,
|
|
mediaType: "image" | "video" | "file",
|
|
): string | null {
|
|
const allowedPath = resolveQQBotPayloadLocalFilePath(payloadPath);
|
|
if (allowedPath) {
|
|
return allowedPath;
|
|
}
|
|
|
|
ctx.log?.error(
|
|
`[qqbot:${ctx.account.accountId}] Blocked ${mediaType} payload local path outside QQ Bot media storage`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
function isRemoteHttpUrl(p: string): boolean {
|
|
return p.startsWith("http://") || p.startsWith("https://");
|
|
}
|
|
|
|
function isInlineImageDataUrl(p: string): boolean {
|
|
return /^data:image\/[^;]+;base64,/i.test(p);
|
|
}
|
|
|
|
function sanitizeForLog(value: string, maxLen = 200): string {
|
|
return value
|
|
.replace(/[\r\n\t]/g, " ")
|
|
.replaceAll("\0", " ")
|
|
.slice(0, maxLen);
|
|
}
|
|
|
|
function describeMediaTargetForLog(pathValue: string, isHttpUrl: boolean): string {
|
|
if (!isHttpUrl) {
|
|
return "<local-file>";
|
|
}
|
|
|
|
try {
|
|
const url = new URL(pathValue);
|
|
url.username = "";
|
|
url.password = "";
|
|
const urlId = crypto.createHash("sha256").update(url.toString()).digest("hex").slice(0, 12);
|
|
return sanitizeForLog(`${url.protocol}//${url.host}#${urlId}`);
|
|
} catch {
|
|
return "<invalid-url>";
|
|
}
|
|
}
|
|
|
|
async function readStructuredPayloadLocalFile(filePath: string): Promise<Buffer> {
|
|
const openFlags =
|
|
fs.constants.O_RDONLY | ("O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0);
|
|
const handle = await fs.promises.open(filePath, openFlags);
|
|
try {
|
|
const stat = await handle.stat();
|
|
if (!stat.isFile()) {
|
|
throw new Error("Path is not a regular file");
|
|
}
|
|
if (stat.size > MAX_UPLOAD_SIZE) {
|
|
throw new Error(
|
|
`File is too large (${formatFileSize(stat.size)}); QQ Bot API limit is ${formatFileSize(MAX_UPLOAD_SIZE)}`,
|
|
);
|
|
}
|
|
return handle.readFile();
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
async function handleImagePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
|
const { target, account, log } = ctx;
|
|
const normalizedPath = normalizePath(payload.path);
|
|
let imageUrl: string | null;
|
|
if (payload.source === "file") {
|
|
imageUrl = validateStructuredPayloadLocalPath(ctx, normalizedPath, "image");
|
|
} else if (isRemoteHttpUrl(normalizedPath) || isInlineImageDataUrl(normalizedPath)) {
|
|
imageUrl = normalizedPath;
|
|
} else {
|
|
log?.error(
|
|
`[qqbot:${account.accountId}] Image payload URL must use http(s) or data:image/: ${sanitizeForLog(payload.path)}`,
|
|
);
|
|
return;
|
|
}
|
|
if (!imageUrl) {
|
|
return;
|
|
}
|
|
const originalImagePath = payload.source === "file" ? imageUrl : undefined;
|
|
|
|
if (payload.source === "file") {
|
|
try {
|
|
const fileBuffer = await readStructuredPayloadLocalFile(imageUrl);
|
|
const base64Data = fileBuffer.toString("base64");
|
|
const ext = path.extname(imageUrl).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) {
|
|
log?.error(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`);
|
|
return;
|
|
}
|
|
imageUrl = `data:${mimeType};base64,${base64Data}`;
|
|
log?.info(
|
|
`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`,
|
|
);
|
|
} catch (readErr) {
|
|
log?.error(
|
|
`[qqbot:${account.accountId}] Failed to read local image: ${
|
|
readErr instanceof Error ? readErr.message : JSON.stringify(readErr)
|
|
}`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await sendWithTokenRetry(
|
|
account.appId,
|
|
account.clientSecret,
|
|
async (token) => {
|
|
if (target.type === "c2c") {
|
|
await sendC2CImageMessage(
|
|
account.appId,
|
|
token,
|
|
target.senderId,
|
|
imageUrl,
|
|
target.messageId,
|
|
undefined,
|
|
originalImagePath,
|
|
);
|
|
} else if (target.type === "group" && target.groupOpenid) {
|
|
await sendGroupImageMessage(
|
|
account.appId,
|
|
token,
|
|
target.groupOpenid,
|
|
imageUrl,
|
|
target.messageId,
|
|
);
|
|
} else if (target.type === "dm" && target.guildId) {
|
|
// By design: DM only supports text/markdown; use markdown image syntax with the
|
|
// original path so the QQ client can attempt to render it.
|
|
await sendDmMessage(token, target.guildId, ``, target.messageId);
|
|
} else if (target.channelId) {
|
|
// By design: channel messages only support text/markdown, same approach as DM above.
|
|
await sendChannelMessage(
|
|
token,
|
|
target.channelId,
|
|
``,
|
|
target.messageId,
|
|
);
|
|
}
|
|
},
|
|
log,
|
|
account.accountId,
|
|
);
|
|
log?.info(`[qqbot:${account.accountId}] Sent image via media payload`);
|
|
|
|
if (payload.caption) {
|
|
await sendTextToTarget(ctx, payload.caption);
|
|
}
|
|
} catch (err) {
|
|
log?.error(
|
|
`[qqbot:${account.accountId}] Failed to send image: ${
|
|
err instanceof Error ? err.message : JSON.stringify(err)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handleAudioPayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
|
const { target, account, cfg, log } = ctx;
|
|
try {
|
|
const ttsText = payload.caption || payload.path;
|
|
if (!ttsText?.trim()) {
|
|
log?.error(`[qqbot:${account.accountId}] Voice missing text`);
|
|
return;
|
|
}
|
|
|
|
let silkBase64: string | undefined;
|
|
let silkPath: string | undefined;
|
|
let duration: number | undefined;
|
|
let providerLabel: string | undefined;
|
|
|
|
// Strategy 1: Plugin-specific TTS (OpenAI-compatible /audio/speech API).
|
|
const ttsCfg = resolveTTSConfig(cfg as Record<string, unknown>);
|
|
if (ttsCfg) {
|
|
log?.info(
|
|
`[qqbot:${account.accountId}] TTS (plugin): "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`,
|
|
);
|
|
const ttsDir = getQQBotDataDir("tts");
|
|
const result = await textToSilk(ttsText, ttsCfg, ttsDir);
|
|
silkBase64 = result.silkBase64;
|
|
silkPath = result.silkPath;
|
|
duration = result.duration;
|
|
providerLabel = ttsCfg.model;
|
|
} else {
|
|
// Strategy 2: Fall back to global TTS provider registry (e.g. Edge TTS).
|
|
if (!isGlobalTTSAvailable(cfg as OpenClawConfig)) {
|
|
log?.error(
|
|
`[qqbot:${account.accountId}] TTS not configured (neither plugin channels.qqbot.tts nor global messages.tts)`,
|
|
);
|
|
return;
|
|
}
|
|
log?.info(`[qqbot:${account.accountId}] TTS (global fallback): "${ttsText.slice(0, 50)}..."`);
|
|
const globalResult = await getQQBotRuntime().tts.textToSpeech({
|
|
text: ttsText,
|
|
cfg: cfg as OpenClawConfig,
|
|
channel: "qqbot",
|
|
});
|
|
if (!globalResult.success || !globalResult.audioPath) {
|
|
log?.error(
|
|
`[qqbot:${account.accountId}] Global TTS failed: ${globalResult.error ?? "unknown"}`,
|
|
);
|
|
return;
|
|
}
|
|
log?.info(
|
|
`[qqbot:${account.accountId}] Global TTS returned: provider=${globalResult.provider}, format=${globalResult.outputFormat}, path=${globalResult.audioPath}`,
|
|
);
|
|
providerLabel = globalResult.provider ?? "global";
|
|
|
|
// Convert the global TTS audio file to SILK for QQ upload.
|
|
const base64 = await audioFileToSilkBase64(globalResult.audioPath);
|
|
if (!base64) {
|
|
log?.error(`[qqbot:${account.accountId}] Failed to convert global TTS audio to SILK`);
|
|
return;
|
|
}
|
|
silkBase64 = base64;
|
|
silkPath = globalResult.audioPath;
|
|
duration = 0; // Duration unknown from global TTS; use 0 as fallback.
|
|
}
|
|
|
|
if (!silkBase64) {
|
|
log?.error(`[qqbot:${account.accountId}] TTS produced no audio output`);
|
|
return;
|
|
}
|
|
|
|
log?.info(
|
|
`[qqbot:${account.accountId}] TTS done (${providerLabel}): ${duration ? formatDuration(duration) : "N/A"}, file: ${silkPath ?? "N/A"}`,
|
|
);
|
|
|
|
await sendWithTokenRetry(
|
|
account.appId,
|
|
account.clientSecret,
|
|
async (token) => {
|
|
if (target.type === "c2c") {
|
|
await sendC2CVoiceMessage(
|
|
account.appId,
|
|
token,
|
|
target.senderId,
|
|
silkBase64,
|
|
undefined,
|
|
target.messageId,
|
|
ttsText,
|
|
silkPath,
|
|
);
|
|
} else if (target.type === "group" && target.groupOpenid) {
|
|
await sendGroupVoiceMessage(
|
|
account.appId,
|
|
token,
|
|
target.groupOpenid,
|
|
silkBase64,
|
|
undefined,
|
|
target.messageId,
|
|
);
|
|
} else if (target.type === "dm" && target.guildId) {
|
|
log?.error(
|
|
`[qqbot:${account.accountId}] Voice not supported in DM, sending text fallback`,
|
|
);
|
|
await sendDmMessage(token, target.guildId, ttsText, target.messageId);
|
|
} else if (target.channelId) {
|
|
log?.error(
|
|
`[qqbot:${account.accountId}] Voice not supported in channel, sending text fallback`,
|
|
);
|
|
await sendChannelMessage(token, target.channelId, ttsText, target.messageId);
|
|
}
|
|
},
|
|
log,
|
|
account.accountId,
|
|
);
|
|
log?.info(`[qqbot:${account.accountId}] Voice message sent`);
|
|
} catch (err) {
|
|
log?.error(
|
|
`[qqbot:${account.accountId}] TTS/voice send failed: ${
|
|
err instanceof Error ? err.message : JSON.stringify(err)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
|
const { target, account, log } = ctx;
|
|
try {
|
|
const originalPath = payload.path ?? "";
|
|
const normalizedPath = normalizePath(originalPath);
|
|
const isHttpUrl = isRemoteHttpUrl(normalizedPath);
|
|
const videoPath = isHttpUrl
|
|
? normalizedPath
|
|
: validateStructuredPayloadLocalPath(ctx, originalPath, "video");
|
|
if (!videoPath) {
|
|
return;
|
|
}
|
|
if (!videoPath.trim()) {
|
|
log?.error(`[qqbot:${account.accountId}] Video missing path`);
|
|
return;
|
|
}
|
|
|
|
log?.info(
|
|
`[qqbot:${account.accountId}] Video send: ${describeMediaTargetForLog(videoPath, isHttpUrl)}`,
|
|
);
|
|
|
|
await sendWithTokenRetry(
|
|
account.appId,
|
|
account.clientSecret,
|
|
async (token) => {
|
|
if (isHttpUrl) {
|
|
if (target.type === "c2c") {
|
|
await sendC2CVideoMessage(
|
|
account.appId,
|
|
token,
|
|
target.senderId,
|
|
videoPath,
|
|
undefined,
|
|
target.messageId,
|
|
);
|
|
} else if (target.type === "group" && target.groupOpenid) {
|
|
await sendGroupVideoMessage(
|
|
account.appId,
|
|
token,
|
|
target.groupOpenid,
|
|
videoPath,
|
|
undefined,
|
|
target.messageId,
|
|
);
|
|
} else if (target.type === "dm") {
|
|
log?.error(`[qqbot:${account.accountId}] Video not supported in DM`);
|
|
} else if (target.channelId) {
|
|
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
|
|
}
|
|
} else {
|
|
const fileBuffer = await readStructuredPayloadLocalFile(videoPath);
|
|
const videoBase64 = fileBuffer.toString("base64");
|
|
log?.info(
|
|
`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${describeMediaTargetForLog(videoPath, false)}`,
|
|
);
|
|
|
|
if (target.type === "c2c") {
|
|
await sendC2CVideoMessage(
|
|
account.appId,
|
|
token,
|
|
target.senderId,
|
|
undefined,
|
|
videoBase64,
|
|
target.messageId,
|
|
undefined,
|
|
videoPath,
|
|
);
|
|
} else if (target.type === "group" && target.groupOpenid) {
|
|
await sendGroupVideoMessage(
|
|
account.appId,
|
|
token,
|
|
target.groupOpenid,
|
|
undefined,
|
|
videoBase64,
|
|
target.messageId,
|
|
);
|
|
} else if (target.type === "dm") {
|
|
log?.error(`[qqbot:${account.accountId}] Video not supported in DM`);
|
|
} else if (target.channelId) {
|
|
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
|
|
}
|
|
}
|
|
},
|
|
log,
|
|
account.accountId,
|
|
);
|
|
log?.info(`[qqbot:${account.accountId}] Video message sent`);
|
|
|
|
if (payload.caption) {
|
|
await sendTextToTarget(ctx, payload.caption);
|
|
}
|
|
} catch (err) {
|
|
const errMsg =
|
|
err instanceof Error ? err.message : typeof err === "string" ? err : JSON.stringify(err);
|
|
log?.error(`[qqbot:${account.accountId}] Video send failed: ${errMsg}`);
|
|
}
|
|
}
|
|
|
|
async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
|
const { target, account, log } = ctx;
|
|
try {
|
|
const originalPath = payload.path ?? "";
|
|
const normalizedPath = normalizePath(originalPath);
|
|
const isHttpUrl = isRemoteHttpUrl(normalizedPath);
|
|
const filePath = isHttpUrl
|
|
? normalizedPath
|
|
: validateStructuredPayloadLocalPath(ctx, originalPath, "file");
|
|
if (!filePath) {
|
|
return;
|
|
}
|
|
if (!filePath.trim()) {
|
|
log?.error(`[qqbot:${account.accountId}] File missing path`);
|
|
return;
|
|
}
|
|
|
|
const fileName = sanitizeFileName(path.basename(filePath));
|
|
log?.info(
|
|
`[qqbot:${account.accountId}] File send: ${describeMediaTargetForLog(filePath, isHttpUrl)} (${isHttpUrl ? "URL" : "local"})`,
|
|
);
|
|
|
|
await sendWithTokenRetry(
|
|
account.appId,
|
|
account.clientSecret,
|
|
async (token) => {
|
|
if (isHttpUrl) {
|
|
if (target.type === "c2c") {
|
|
await sendC2CFileMessage(
|
|
account.appId,
|
|
token,
|
|
target.senderId,
|
|
undefined,
|
|
filePath,
|
|
target.messageId,
|
|
fileName,
|
|
);
|
|
} else if (target.type === "group" && target.groupOpenid) {
|
|
await sendGroupFileMessage(
|
|
account.appId,
|
|
token,
|
|
target.groupOpenid,
|
|
undefined,
|
|
filePath,
|
|
target.messageId,
|
|
fileName,
|
|
);
|
|
} else if (target.type === "dm") {
|
|
log?.error(`[qqbot:${account.accountId}] File not supported in DM`);
|
|
} else if (target.channelId) {
|
|
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
|
|
}
|
|
} else {
|
|
const fileBuffer = await readStructuredPayloadLocalFile(filePath);
|
|
const fileBase64 = fileBuffer.toString("base64");
|
|
if (target.type === "c2c") {
|
|
await sendC2CFileMessage(
|
|
account.appId,
|
|
token,
|
|
target.senderId,
|
|
fileBase64,
|
|
undefined,
|
|
target.messageId,
|
|
fileName,
|
|
filePath,
|
|
);
|
|
} else if (target.type === "group" && target.groupOpenid) {
|
|
await sendGroupFileMessage(
|
|
account.appId,
|
|
token,
|
|
target.groupOpenid,
|
|
fileBase64,
|
|
undefined,
|
|
target.messageId,
|
|
fileName,
|
|
);
|
|
} else if (target.type === "dm") {
|
|
log?.error(`[qqbot:${account.accountId}] File not supported in DM`);
|
|
} else if (target.channelId) {
|
|
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
|
|
}
|
|
}
|
|
},
|
|
log,
|
|
account.accountId,
|
|
);
|
|
log?.info(`[qqbot:${account.accountId}] File message sent`);
|
|
} catch (err) {
|
|
const errMsg =
|
|
err instanceof Error ? err.message : typeof err === "string" ? err : JSON.stringify(err);
|
|
log?.error(`[qqbot:${account.accountId}] File send failed: ${errMsg}`);
|
|
}
|
|
}
|