import * as fs from "node:fs"; import * as path from "node:path"; import { formatErrorMessage } from "../utils/format.js"; import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../utils/string-normalize.js";
let _audioAdapter: OutboundAudioAdapter | null = null;
let _audioAdapterFactory: (() => OutboundAudioAdapter) | null = null;
/** Register the audio conversion adapter — called by gateway startup. */
export function registerOutboundAudioAdapter(adapter: OutboundAudioAdapter): void {
_audioAdapter = adapter;
}
/** Register a factory that creates the adapter on first access (lazy init). */
export function registerOutboundAudioAdapterFactory(factory: () => OutboundAudioAdapter): void {
_audioAdapterFactory = factory;
}
function getAudio(): OutboundAudioAdapter { if (!_audioAdapter && _audioAdapterFactory) {
_audioAdapter = _audioAdapterFactory();
} if (!_audioAdapter) { thrownew Error("OutboundAudioAdapter not registered");
} return _audioAdapter;
}
// Re-alias for use in the file. function audioFileToSilkBase64(p: string, f?: string[]): Promise<string | undefined> { return getAudio().audioFileToSilkBase64(p, f);
} function isAudioFile(p: string, m?: string): boolean { // Safe to return false when adapter is unavailable — this is a type-check // function called by sendMedia's dispatch logic before any audio processing. try { return getAudio().isAudioFile(p, m);
} catch { returnfalse;
}
} function shouldTranscodeVoice(p: string): boolean { return getAudio().shouldTranscodeVoice(p);
} function waitForFile(p: string, ms?: number): Promise<number> { return getAudio().waitForFile(p, ms);
} import type { GatewayAccount } from "../types.js"; import {
checkFileSize,
downloadFile,
fileExistsAsync,
formatFileSize,
readFileAsync,
} from "../utils/file-utils.js"; import { debugError, debugLog, debugWarn } from "../utils/log.js"; import { normalizeMediaTags } from "../utils/media-tags.js"; import { decodeCronPayload } from "../utils/payload.js"; import {
getQQBotDataDir,
getQQBotMediaDir,
isLocalPath as isLocalFilePath,
normalizePath,
resolveQQBotPayloadLocalFilePath,
} from "../utils/platform.js"; import { sanitizeFileName } from "../utils/string-normalize.js"; import {
isImageFile as coreIsImageFile,
isVideoFile as coreIsVideoFile,
} from "./media-type-detect.js"; // Bridge to core/ modules — use the canonical implementations from the core // package so the same logic can be shared with the standalone version. import { ReplyLimiter, type ReplyLimitResult } from "./reply-limiter.js"; import {
sendText as senderSendText,
sendImage as senderSendImage,
sendVoiceMessage as senderSendVoice,
sendVideoMessage as senderSendVideo,
sendFileMessage as senderSendFile,
initApiConfig,
accountToCreds,
type DeliveryTarget,
} from "./sender.js"; import { parseTarget as coreParseTarget } from "./target-parser.js";
// Module-level reply limiter instance (replaces the old Map-based tracker). const replyLimiter = new ReplyLimiter();
// Limit passive replies per message_id within the QQ Bot reply window. // Delegated to core/messaging/reply-limiter.ts for cross-version sharing. const MESSAGE_REPLY_LIMIT = 4;
/** Result of the passive-reply limit check. */
export type { ReplyLimitResult };
/** Check whether a message can still receive a passive reply. */
export function checkMessageReplyLimit(messageId: string): ReplyLimitResult { return replyLimiter.checkLimit(messageId);
}
/** Record one passive reply against a message. */
export function recordMessageReply(messageId: string): void {
replyLimiter.record(messageId);
debugLog(
`[qqbot] recordMessageReply: ${messageId}, count=${replyLimiter.getStats().totalReplies}`,
);
}
/** Return reply-tracker stats for diagnostics. */
export function getMessageReplyStats(): { trackedMessages: number; totalReplies: number } { return replyLimiter.getStats();
}
/** Return the passive-reply configuration. */
export function getMessageReplyConfig(): { limit: number; ttlMs: number; ttlHours: number } { return replyLimiter.getConfig();
}
/** 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}`); const parsed = coreParseTarget(to);
debugLog(`[${timestamp}] [qqbot] parseTarget: ${parsed.type} target, ID=${parsed.id}`); return parsed;
}
// 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: GatewayAccount;
replyToId?: string;
}
/** Build a media target from a normal outbound context. */ function buildMediaTarget(ctx: {
to: string;
account: GatewayAccount;
replyToId?: string | null;
}): MediaTargetContext { const target = parseTarget(ctx.to); return {
targetType: target.type,
targetId: target.id,
account: ctx.account,
replyToId: ctx.replyToId ?? undefined,
};
}
/** Return true when public URLs should be passed through directly. */ function shouldDirectUploadUrl(account: GatewayAccount): boolean { return account.config?.urlDirectUpload !== false;
}
if (options.allowMissingLocalPath) { const allowedMissingPath = resolveMissingPathWithinMediaRoot(normalizedPath); if (allowedMissingPath) { return { ok: true, mediaPath: allowedMissingPath };
}
}
debugWarn(`blocked local ${mediaKind} path outside QQ Bot media storage`); return {
ok: false,
error: `${qqBotMediaKindLabel[mediaKind]} path must be inside QQ Bot media storage`,
};
}
// Force a local download before upload when direct URL upload is disabled. if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
debugLog(`sendPhoto: urlDirectUpload=false, downloading URL first...`); const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto"); if (localFile) { return await sendPhoto(ctx, localFile);
} return { channel: "qqbot", error: `Failed to download image: ${mediaPath.slice(0, 80)}` };
}
if (target.type === "c2c" || target.type === "group") { const r = await senderSendImage(target, imageUrl, creds, {
msgId: ctx.replyToId,
content: undefined,
localPath,
}); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
} if (isHttp) { const r = await senderSendText(target, ``, creds, {
msgId: ctx.replyToId,
}); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
}
debugLog(`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(
`sendPhoto: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
); const retryResult = await downloadAndRetrySendPhoto(ctx, mediaPath); if (retryResult) { return retryResult;
}
}
/** Send voice from a local file. */
async function sendVoiceFromLocal(
ctx: MediaTargetContext,
mediaPath: string,
directUploadFormats: string[] | undefined,
transcodeEnabled: boolean,
): 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" };
}
// Re-check containment after the file appears to prevent symlink-race escapes. const safeMediaPath = resolveQQBotPayloadLocalFilePath(mediaPath); if (!safeMediaPath) {
debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`); return { channel: "qqbot", error: "Voice path must be inside QQ Bot media storage" };
}
/** Send video from either a public URL or a local file. */
export async function sendVideoMsg(
ctx: MediaTargetContext,
videoPath: string,
): Promise<OutboundResult> { const resolvedMediaPath = resolveOutboundMediaPath(videoPath, "video"); if (!resolvedMediaPath.ok) { return { channel: "qqbot", error: resolvedMediaPath.error };
} const mediaPath = resolvedMediaPath.mediaPath; const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
// If direct URL upload fails, retry through a local download path. if (isHttp) {
debugWarn(
`sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
); const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg"); if (localFile) { return await sendVideoFromLocal(ctx, localFile);
}
}
// If direct URL upload fails, retry through a local download path. if (isHttp) {
debugWarn(
`sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
); const localFile = await downloadToFallbackDir(mediaPath, "sendDocument"); if (localFile) { return await sendDocumentFromLocal(ctx, localFile);
}
}
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 });
}
/** 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;
if (!account.appId || !account.clientSecret) { return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
} if (!ctx.mediaUrl) { return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
} const resolvedMediaPath = resolveOutboundMediaPath(ctx.mediaUrl, "media", {
allowMissingLocalPath: true,
}); if (!resolvedMediaPath.ok) { return { channel: "qqbot", error: resolvedMediaPath.error };
} const mediaUrl = resolvedMediaPath.mediaPath;
const target = buildMediaTarget({ to, account, replyToId });
// 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 creds = accountToCreds(ctx.account); const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
await senderSendText(target, text, creds, { msgId: ctx.replyToId });
} catch (err) {
debugError(`[qqbot] sendTextAfterMedia failed: ${formatErrorMessage(err)}`);
}
}
// Media type detection delegated to core/outbound/media-type-detect.ts. // Re-alias for backward compatibility within this file. const isImageFile = coreIsImageFile; const isVideoFile = coreIsVideoFile;
// Fall back to plain text handling when the payload is not structured.
debugLog(`[${timestamp}] [qqbot] sendCronMessage: plain text message, sending to ${to}`); return await sendText({ account, to, text: message });
}
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.