Files
openclaw/extensions/qqbot/src/outbound.ts
Sliverp bf6f506dfa Feature/add qq channel (#52986)
* feat: add QQ Bot channel extension

* fix(qqbot): add setupWizard to runtime plugin for onboard re-entry

* fix: fix review

* fix: fix review

* chore: sync lockfile and config-docs baseline for qqbot extension

* refactor: 移除图床服务器相关代码

* fix

* docs: 新增 QQ Bot 插件文档并修正链接路径

* refactor: remove credential backup functionality and update setup logic

- Deleted the credential backup module to streamline the codebase.
- Updated the setup surface to handle client secrets more robustly, allowing for configured secret inputs.
- Simplified slash commands by removing unused hot upgrade compatibility checks and related functions.
- Adjusted types to use SecretInput for client secrets in QQBot configuration.
- Modified bundled plugin metadata to allow additional properties in the config schema.

* feat: 添加本地媒体路径解析功能,修正 QQBot 媒体路径处理

* feat: 添加本地媒体路径解析功能,修正 QQBot 媒体路径处理

* feat: remove qqbot-media and qqbot-remind skills, add tests for config and setup

- Deleted the qqbot-media and qqbot-remind skills documentation files.
- Added unit tests for qqbot configuration and setup processes, ensuring proper handling of SecretRef-backed credentials and account configurations.
- Implemented tests for local media path remapping, verifying correct resolution of media file paths.
- Removed obsolete channel and remind tools, streamlining the codebase.

* feat: 更新 QQBot 配置模式,添加音频格式和账户定义

* feat: 添加 QQBot 频道管理和定时提醒技能,更新媒体路径解析功能

* fix

* feat: 添加 /bot-upgrade 指令以查看 QQBot 插件升级指引

* feat: update reminder and qq channel skills

* feat: 更新remind工具投递目标地址格式

* feat: Refactor QQBot payload handling and improve code documentation

- Simplified and clarified the structure of payload interfaces for Cron reminders and media messages.
- Enhanced the parsing function to provide clearer error messages and improved validation.
- Updated platform utility functions for better cross-platform compatibility and clearer documentation.
- Improved text parsing utilities for better readability and consistency in emoji representation.
- Optimized upload cache management with clearer comments and reduced redundancy.
- Integrated QQBot plugin into the bundled channel plugins and updated metadata for installation.

* OK apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift

> openclaw@2026.3.26 check:bundled-channel-config-metadata /Users/yuehuali/code/PR/openclaw
> node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check

[bundled-channel-config-metadata] stale generated output at src/config/bundled-channel-config-metadata.generated.ts
 ELIFECYCLE  Command failed with exit code 1.
 ELIFECYCLE  Command failed with exit code 1.

* feat: 添加 QQBot 渠道配置及相关账户设置

* fix(qqbot): resolve 14 high-priority bugs from PR #52986 review

DM routing (7 fixes):
- #1: DM slash-command replies use sendDmMessage(guildId) instead of sendC2CMessage(senderId)
- #2: DM qualifiedTarget uses qqbot:dm:${guildId} instead of qqbot:c2c:${senderId}
- #3: sendTextChunks adds DM branch
- #4: sendMarkdownReply adds DM branch for text and Base64 images
- #5: parseAndSendMediaTags maps DM to targetType:dm + guildId
- #6: sendTextToTarget DM branch uses sendDmMessage; MessageTarget adds guildId field
- #7: handleImage/Audio/Video/FilePayload add DM branches

Other high-priority fixes:
- #8: Fix sendC2CVoiceMessage/sendGroupVoiceMessage parameter misalignment
- #9: broadcastMessage uses groupOpenid instead of member_openid for group users
- #10: Unify KnownUser storage - proactive.ts delegates to known-users.ts
- #11: Remove invalid recordKnownUser calls for guild/DM users
- #12: sendGroupMessage uses sendAndNotify to trigger onMessageSent hook
- #13: sendPhoto channel unsupported returns error field
- #14: sendTextAfterMedia adds channel and dm branches

Type fixes:
- DeliverEventContext adds guildId field
- MediaTargetContext.targetType adds dm variant
- sendPlainTextReply imgMediaTarget adds DM branch

* fix(qqbot): resolve 2 blockers + 7 medium-priority bugs from PR #52986 review

Blocker-1: Remove unused dmPolicy config knob
- dmPolicy was declared in schema/types/plugin.json but never consumed at runtime
- Removed from config-schema.ts, types.ts, and openclaw.plugin.json
- allowFrom remains active (already wired into framework command-auth)

Blocker-2: Gate sensitive slash commands with allowFrom authorization
- SlashCommand interface adds requireAuth?: boolean
- SlashCommandContext adds commandAuthorized: boolean
- /bot-logs set to requireAuth: true (reads local log files)
- matchSlashCommand rejects unauthorized senders for requireAuth commands
- trySlashCommandOrEnqueue computes commandAuthorized from allowFrom config

Medium-priority fixes:
- #15: Strip non-HTTP/non-local markdown image tags to prevent path leakage
- #16: applyQQBotAccountConfig clears clientSecret when setting clientSecretFile and vice versa
- #17: getAdminMarkerFile sanitizes accountId to prevent path traversal
- #18: URGENT_COMMANDS uses exact match instead of startsWith prefix match
- #19: isCronExpression validates each token starts with a cron-valid character
- #20: --token format validation rejects malformed input without colon separator
- #21: resolveDefaultQQBotAccountId checks QQBOT_APP_ID environment variable

* test(qqbot): add focused tests for slash command authorization path

- Unauthorized sender rejected for /bot-logs (requireAuth: true)
- Authorized sender allowed for /bot-logs
- Non-requireAuth commands (/bot-ping, /bot-help, /bot-version) work for all senders
- Unknown slash commands return null (passthrough)
- Non-slash messages return null
- Usage query (/bot-logs ?) also gated by auth check

* fix(qqbot): align global TTS fallback with framework config resolution

- Extract isGlobalTTSAvailable to utils/audio-convert.ts, mirroring core
  resolveTtsConfig logic: check auto !== 'off', fall back to legacy
  enabled boolean, default to off when neither is set.
- Add pre-check in reply-dispatcher before calling globalTextToSpeech to
  avoid unnecessary TTS calls and noisy error logs when TTS is not
  configured.
- Remove inline as any casts; use OpenClawConfig type throughout.
- Refactor handleAudioPayload into flat early-return structure with
  unified send path (plugin TTS → global fallback → send).

* fix(qqbot): break ESM circular dependency causing multi-account startup crash

The bundled gateway chunk had a circular static import on the channel
chunk (gateway -> outbound-deliver -> channel, while channel dynamically
imports gateway). When two accounts start concurrently via Promise.all,
the first dynamic import triggers module graph evaluation; the circular
reference causes api exports (including runDiagnostics) to resolve as
undefined before the module finishes evaluating.

Fix: extract chunkText and TEXT_CHUNK_LIMIT from channel.ts into a new
text-utils.ts leaf module. outbound-deliver.ts now imports from
text-utils.ts, breaking the cycle. channel.ts re-exports for backward
compatibility.

* fix(qqbot): serialize gateway module import to prevent multi-account startup race

When multiple accounts start concurrently via Promise.all, each calls
await import('./gateway.js') independently. Due to ESM circular
dependencies in the bundled output, the first import can resolve
transitive exports as undefined before module evaluation completes.

Fix: cache the dynamic import promise in a module-level variable so all
concurrent startAccount calls share the same import, ensuring the
gateway module is fully evaluated before any account uses it.

* refactor(qqbot): remove startup greeting logic

Remove getStartupGreetingPlan and related startup greeting delivery:
- Delete startup-greeting.ts (greeting plan, marker persistence)
- Delete admin-resolver.ts (admin resolution, greeting dispatch)
- Remove startup greeting calls from gateway READY/RESUMED handlers
- Remove isFirstReadyGlobal flag and adminCtx

* fix(qqbot): skip octal escape decoding for Windows local paths

Windows paths like C:\Users\1\file.txt contain backslash-digit sequences
that were incorrectly matched as octal escape sequences and decoded,
corrupting the file path. Detect Windows local paths (drive letter or UNC
prefix) and skip the octal decoding step for them.

* fix bot issue

* feat: 支持 TTS 自动开关并清理配置中的 clientSecretFile

* docs: 添加 QQBot 配置和消息处理的设计说明

* rebase

* fix(qqbot): align slash-command auth with shared command-auth model

Route requireAuth:true slash commands (e.g. /bot-logs) through the
framework's api.registerCommand() so resolveCommandAuthorization()
applies commands.allowFrom.qqbot precedence and qqbot: prefix
normalization before any handler runs.

- slash-commands.ts: registerCommand() now auto-routes by requireAuth
  into two maps (commands / frameworkCommands); getFrameworkCommands()
  exports the auth-required set for framework registration; bot-help
  lists both maps
- index.ts: registerFull() iterates getFrameworkCommands() and calls
  api.registerCommand() for each; handler derives msgType from ctx.from,
  sends file attachments via sendDocument, supports multi-account via
  ctx.accountId
- gateway.ts (inbound): replace raw allowFrom string comparison with
  qqbotPlugin.config.formatAllowFrom() to strip qqbot: prefix and
  uppercase before matching event.senderId
- gateway.ts (pre-dispatch): remove stale auth computation; commandAuthorized
  is true (requireAuth:true commands never reach matchSlashCommand)
- command-auth.test.ts: add regression tests for qqbot: prefix
  normalization in the inbound commandAuthorized computation
- slash-commands.test.ts: update /bot-logs tests to expect null
  (command routed to framework, not in local registry)

* rebase and solve conflict

* fix(qqbot): preserve mixed env setup credentials

---------

Co-authored-by: yuehuali <yuehuali@tencent.com>
Co-authored-by: walli <walli@tencent.com>
Co-authored-by: WideLee <limkuan24@gmail.com>
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
2026-03-31 16:13:16 +08:00

1445 lines
48 KiB
TypeScript

import * as path from "path";
import {
getAccessToken,
sendC2CMessage,
sendChannelMessage,
sendDmMessage,
sendGroupMessage,
sendProactiveC2CMessage,
sendProactiveGroupMessage,
sendC2CImageMessage,
sendGroupImageMessage,
sendC2CVoiceMessage,
sendGroupVoiceMessage,
sendC2CVideoMessage,
sendGroupVideoMessage,
sendC2CFileMessage,
sendGroupFileMessage,
} from "./api.js";
import type { ResolvedQQBotAccount } from "./types.js";
import {
isAudioFile,
audioFileToSilkBase64,
waitForFile,
shouldTranscodeVoice,
} from "./utils/audio-convert.js";
import { debugLog, debugError, debugWarn } from "./utils/debug-log.js";
import { downloadFile } from "./utils/file-utils.js";
import {
checkFileSize,
readFileAsync,
fileExistsAsync,
isLargeFile,
formatFileSize,
} from "./utils/file-utils.js";
import { normalizeMediaTags } from "./utils/media-tags.js";
import { decodeCronPayload } from "./utils/payload.js";
import {
isLocalPath as isLocalFilePath,
normalizePath,
resolveQQBotLocalMediaPath,
sanitizeFileName,
getQQBotDataDir,
getQQBotMediaDir,
} 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;
}
const messageReplyTracker = new Map<string, MessageReplyRecord>();
/** 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, `![](${mediaPath})`, 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 = err instanceof Error ? err.message : String(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 = err instanceof Error ? err.message : String(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 = err instanceof Error ? err.message : String(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 = err instanceof Error ? err.message : String(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 = err instanceof Error ? err.message : String(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 = err instanceof Error ? err.message : String(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 = err instanceof Error ? err.message : String(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}`);
}
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: (result as any).ext_info?.ref_idx,
};
}
} 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: (result as any).ext_info?.ref_idx,
};
} 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: (result as any).ext_info?.ref_idx,
};
} else {
const result = await sendChannelMessage(accessToken, target.id, item.content);
lastResult = {
channel: "qqbot",
messageId: result.id,
timestamp: result.timestamp,
refIdx: (result as any).ext_info?.ref_idx,
};
}
}
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 = err instanceof Error ? err.message : String(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: (result as any).ext_info?.ref_idx,
};
} 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: (result as any).ext_info?.ref_idx,
};
} else {
const result = await sendChannelMessage(accessToken, target.id, text);
outResult = {
channel: "qqbot",
messageId: result.id,
timestamp: result.timestamp,
refIdx: (result as any).ext_info?.ref_idx,
};
}
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: (result as any).ext_info?.ref_idx,
};
}
} catch (err) {
const message = err instanceof Error ? err.message : String(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: (result as any).ext_info?.ref_idx,
};
} 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: (result as any).ext_info?.ref_idx,
};
} 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: (result as any).ext_info?.ref_idx,
};
}
return outResult;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(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}`);
}
}
/** 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);
}