import { createWriteStream } from 'node:fs' import { unlink, mkdir } from 'node:fs/promises' import { Readable, Transform } from 'node:stream' import { pipeline } from 'node:stream/promises' import { tmpdir } from 'node:os' import { basename, resolve as pathResolve, sep as pathSep, join as pathJoin } from 'node:path' import type { ReadableStream as NodeWebReadableStream } from 'node:stream/web' import { getMediaDir, kindFromMime, type MediaKind } from 'openclaw/plugin-sdk/media-runtime' import { fetch, type Dispatcher, type Response } from 'undici' import { getDefaultDispatcher } from './default-dispatcher' import { EnvelopeType, FileReadyState, TrueConfChatType } from './types' import type { TrueConfRequest, Envelope, InboundDispatchFn, Logger, TrueConfChannelConfig, InboundMessage, InboundEnvelopeHint, FileInfo, ResolvedChatKind, AttachmentContent, } from './types' import { WsClient, hostPort } from './ws-client' import { fetchQuotedContext } from './reply-context' import { sendText, sendTextToChat, isReconnectableSendError } from './outbound' import { resolveAccount } from './config' import { PerChatSendQueue } from './send-queue' import type { OutboundQueue } from './outbound-queue' const DEFAULT_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 export const MAX_FILE_SIZE_HARD_LIMIT_BYTES = 2 * 1024 * 1024 * 1024 const COALESCE_WINDOW_MS = 300 const RECENT_BOT_MSG_PER_CHAT_CAP = 50 const bytesToMB = (n: number): number => (n > 0 ? Math.ceil(n / (1024 * 1024)) : 0) // TrueConf renders @-mentions as Name // inside parseMode='html' content. The href may be followed by a slash+instance // suffix (/abc123) or query params (&do=profile, ?k=v). We stop the userId // capture at any of /, &, ? — whichever separator appears first. const MENTION_RE = /]*href="trueconf:([^"/&?]+)[^"]*"/gi export function extractMentionedUserIds(text: string, parseMode: string | undefined): string[] { if (parseMode !== 'html') return [] const ids: string[] = [] for (const match of text.matchAll(MENTION_RE)) { if (match[1]) ids.push(match[1]) } return ids } export function isReplyToBot( replyMessageId: string | undefined, recent: Set | undefined, ): boolean { return !!replyMessageId && !!recent?.has(replyMessageId) } // TrueConf html mode supports only , , , , ; tag stripping is // enough to pass clean text to the LLM. export function stripHtmlTags(text: string): string { return text.replace(/<\/?[^>]+(>|$)/g, '').trim() } export function rememberBotMessage( map: Map>, chatId: string, messageId: string, ): void { let set = map.get(chatId) if (!set) { set = new Set() map.set(chatId, set) } set.add(messageId) if (set.size > RECENT_BOT_MSG_PER_CHAT_CAP) { const oldest = set.values().next().value if (oldest !== undefined) set.delete(oldest) } } // On any failure path (errorCode, unrecognized chatType, exception) we return // 'unknown' WITHOUT caching, so the next message in this chat retries the // lookup. Caller MUST drop unknown — downgrading to 'p2p' would skip the // group mention/reply gate during transient failures and cause unsolicited // replies in groups/channels. export async function resolveChatType(params: { wsClient: WsClient chatId: string cache: Map inflight: Map> logger: Logger }): Promise { const cached = params.cache.get(params.chatId) if (cached) return cached const existing = params.inflight.get(params.chatId) if (existing) return existing const lookup = (async (): Promise => { try { const resp = await params.wsClient.sendRequest('getChatByID', { chatId: params.chatId }) const errorCode = resp.payload?.errorCode if (typeof errorCode === 'number' && errorCode !== 0) { params.logger.warn( `[trueconf] getChatByID errorCode ${errorCode} for chatId=${params.chatId}; treating as unknown (will retry on next message)`, ) return 'unknown' } const chatType = resp.payload?.chatType if (chatType === TrueConfChatType.GROUP) { params.cache.set(params.chatId, 'group'); return 'group' } if (chatType === TrueConfChatType.CHANNEL) { params.cache.set(params.chatId, 'channel'); return 'channel' } if (chatType === TrueConfChatType.P2P) { params.cache.set(params.chatId, 'p2p'); return 'p2p' } params.logger.warn( `[trueconf] getChatByID returned unrecognized chatType ${JSON.stringify(chatType)} for chatId=${params.chatId}; treating as unknown`, ) return 'unknown' } catch (err) { params.logger.warn( `[trueconf] getChatByID failed for chatId=${params.chatId}: ${err instanceof Error ? err.message : String(err)}; treating as unknown (will retry on next message)`, ) return 'unknown' } })() params.inflight.set(params.chatId, lookup) try { return await lookup } finally { params.inflight.delete(params.chatId) } } // TrueConf delivers text-with-file as two separate envelopes (type 200 then 202) // with no correlationId. We buffer a lone text envelope for COALESCE_WINDOW_MS so // that a following attachment from the same (account, chat, peer) can be merged // into a single dispatch with the real caption preserved. If no attachment // arrives within the window, the timer flushes the text alone. interface PendingTextInbound { base: Omit baseText: string // F2: resolves to the quoted-context prefix (or null). The fetch is started // before buffering but awaited only at flush/dispatch, so the gate→buffer // transition stays synchronous and a fast-following attachment for the same // key always observes this entry instead of racing the network round-trip. replyPrefix: Promise | null timer: ReturnType dispatch: InboundDispatchFn logger: Logger } const pendingTextInbounds = new Map() function coalesceKey(accountId: string, chatId: string, peerId: string): string { return `${accountId}\u0000${chatId}\u0000${peerId}` } // Prepend the resolved F2 quoted-context prefix to the buffered text, if any. async function resolvePendingText(pending: PendingTextInbound): Promise { if (!pending.replyPrefix) return pending.baseText const prefix = await pending.replyPrefix return prefix ? `${prefix}\n\n${pending.baseText}` : pending.baseText } // Buffer a text-bearing envelope (PLAIN/FORWARDED/LOCATION/SURVEY) for the // coalesce window. The map entry is set SYNCHRONOUSLY (no await before this) so // a trailing attachment for the same key always finds it; the quoted prefix is // folded in later at flush/dispatch via resolvePendingText. function bufferPendingText( key: string, base: Omit, baseText: string, replyPrefix: Promise | null, ctx: InboundContext, ): void { if (pendingTextInbounds.has(key)) flushPendingText(key) const timer = setTimeout(() => flushPendingText(key), COALESCE_WINDOW_MS) timer.unref?.() pendingTextInbounds.set(key, { base, baseText, replyPrefix, timer, dispatch: ctx.dispatch, logger: ctx.logger }) } function flushPendingText(key: string): void { const pending = pendingTextInbounds.get(key) if (!pending) return clearTimeout(pending.timer) pendingTextInbounds.delete(key) void resolvePendingText(pending).then((text) => { dispatchWithFence(pending.dispatch, { ...pending.base, text }, pending.logger) }) } export function __resetCoalesceBufferForTesting(): void { for (const pending of pendingTextInbounds.values()) { clearTimeout(pending.timer) } pendingTextInbounds.clear() } const ERR_NOT_AVAILABLE = 'Файл недоступен на сервере — возможно, он был удалён. Отправьте ещё раз.' const ERR_GENERIC = 'Не удалось обработать файл — попробуйте ещё раз.' export function normalizeForCompare(value: string | null | undefined): string { return (value ?? '').replace(/\/.*$/, '').trim().toLowerCase() } function normalizeForRouting(value: string | null | undefined): string { return (value ?? '').replace(/\/.*$/, '').trim() } function requireNonEmpty(kind: string, value: string): string { if (!value) throw new Error(`${kind}: must not be empty`) if (value.includes('\u0000')) throw new Error(`${kind}: must not contain NUL byte`) return value } export interface InboundContext { wsClient: WsClient botIdentityCandidates: string[] accountId: string dispatch: InboundDispatchFn logger: Logger directChats: Map chatTypes: Map inflightChatTypes: Map> recentBotMsgIds: Map> isAlwaysRespond: (chatId: string) => boolean matchesNickname: (text: string) => boolean } export async function handleInboundMessage( msg: TrueConfRequest, ctx: InboundContext, ): Promise { // Auto-ack happens in WsClient.connect's ws.on('message') handler — see // ws-client.ts. handleInboundMessage only routes sendMessage envelopes. if (msg.method !== 'sendMessage') return const envelope = msg.payload as unknown as Envelope | undefined if (!envelope || !envelope.author || !envelope.content) return let rawAuthorId: string let stableUserId: string let chatId: string try { rawAuthorId = requireNonEmpty('RawAuthorId', envelope.author.id) stableUserId = requireNonEmpty('StableUserId', normalizeForRouting(rawAuthorId)) chatId = requireNonEmpty('ChatId', envelope.chatId.trim()) } catch (err) { ctx.logger.warn( `[trueconf] Dropping inbound message with invalid routing ids: ${err instanceof Error ? err.message : String(err)} author.id=${JSON.stringify(envelope.author.id)} chatId=${JSON.stringify(envelope.chatId)}`, ) return } const normalizedAuthor = normalizeForCompare(rawAuthorId) if (normalizedAuthor && ctx.botIdentityCandidates.some((c) => normalizeForCompare(c) === normalizedAuthor)) return if (envelope.type < 200) return const kind = await resolveChatType({ wsClient: ctx.wsClient, chatId, cache: ctx.chatTypes, inflight: ctx.inflightChatTypes, logger: ctx.logger, }) if (kind === 'channel') { ctx.logger.info(`[trueconf] dropping channel message chatId=${chatId}`) return } // Fail-closed: never downgrade unknown to direct, that path skips the gate. if (kind === 'unknown') { ctx.logger.warn(`[trueconf] dropping inbound from chatId=${chatId}: chat type unknown`) return } const key = coalesceKey(ctx.accountId, chatId, stableUserId) // Pre-validate envelope shape and build synthetic text + extraContext for // non-PLAIN types. Validation runs BEFORE the group gate so malformed // envelopes are dropped via logger.warn regardless of chat kind. let plainText: string | null = null let plainParseMode: 'text' | 'markdown' | 'html' | undefined let syntheticText: string | null = null let extraContext: InboundEnvelopeHint | undefined let attachment: AttachmentContent | null = null if (envelope.type === EnvelopeType.PLAIN_MESSAGE) { const content = envelope.content as { text: string; parseMode?: string } const parseMode = content.parseMode as 'text' | 'markdown' | 'html' | undefined plainText = parseMode === 'html' ? stripHtmlTags(content.text) : content.text plainParseMode = parseMode } else if (envelope.type === EnvelopeType.FORWARDED_MESSAGE) { const c = envelope.content as { text?: unknown; parseMode?: unknown } | undefined if (!c || typeof c.text !== 'string') { ctx.logger.warn('[trueconf] FORWARDED_MESSAGE missing text; dropping') return } const parseMode = typeof c.parseMode === 'string' ? (c.parseMode as 'text' | 'markdown' | 'html') : undefined syntheticText = parseMode === 'html' ? stripHtmlTags(c.text) : c.text plainParseMode = parseMode extraContext = { TrueConfEnvelopeType: 'forwarded' } } else if (envelope.type === EnvelopeType.ATTACHMENT) { const a = envelope.content as AttachmentContent | undefined if (!a || typeof a.fileId !== 'string' || typeof a.name !== 'string') { ctx.logger.warn('[trueconf] ATTACHMENT envelope missing required fields') return } attachment = a } else if (envelope.type === EnvelopeType.LOCATION) { const loc = envelope.content as | { latitude?: unknown; longitude?: unknown; description?: unknown } | undefined if (!loc || typeof loc.latitude !== 'number' || typeof loc.longitude !== 'number') { ctx.logger.warn('[trueconf] LOCATION missing lat/lng; dropping') return } const description = typeof loc.description === 'string' && loc.description.length > 0 ? loc.description : null syntheticText = description !== null ? `[Локация: lat=${loc.latitude}, lng=${loc.longitude}, описание: ${description}]` : `[Локация: lat=${loc.latitude}, lng=${loc.longitude}]` extraContext = { TrueConfEnvelopeType: 'location', location: { latitude: loc.latitude, longitude: loc.longitude, description }, } } else if (envelope.type === EnvelopeType.SURVEY) { const survey = envelope.content as { title?: unknown } | undefined if (!survey || typeof survey.title !== 'string') { ctx.logger.warn('[trueconf] SURVEY missing title; dropping') return } syntheticText = `[Опрос: «${survey.title}»]` extraContext = { TrueConfEnvelopeType: 'survey', survey: envelope.content } } else { ctx.logger.info(`[trueconf] unsupported envelope type ${envelope.type}; dropping`) return } // Group activation gate (skipped when isAlwaysRespond(chatId) is true): // bot must be @-mentioned (html) or the message must reply to a recent bot // message. ATTACHMENT/LOCATION/SURVEY envelopes still pass when a preceding // gated text is waiting in the coalescer (caption + media as one turn). if (kind === 'group' && !ctx.isAlwaysRespond(chatId)) { let activated = false if (envelope.type === EnvelopeType.PLAIN_MESSAGE) { const content = envelope.content as { text: string; parseMode?: string } const botUserId = ctx.wsClient.botUserId ?? '' const normalizedBot = normalizeForCompare(botUserId) const mentioned = normalizedBot.length > 0 && extractMentionedUserIds(content.text, content.parseMode).some( (id) => normalizeForCompare(id) === normalizedBot, ) const replied = isReplyToBot(envelope.replyMessageId, ctx.recentBotMsgIds.get(chatId)) activated = mentioned || replied || ctx.matchesNickname(plainText ?? content.text) } else if (envelope.type === EnvelopeType.FORWARDED_MESSAGE) { // Forwards don't carry the bot's mention markup, so reply-to-bot is the // only direct activation path. A preceding gated text in the coalescer // also activates (forward as a follow-up to a captioned turn). const replied = isReplyToBot(envelope.replyMessageId, ctx.recentBotMsgIds.get(chatId)) activated = replied || pendingTextInbounds.has(key) } else if ( envelope.type === EnvelopeType.ATTACHMENT || envelope.type === EnvelopeType.LOCATION || envelope.type === EnvelopeType.SURVEY ) { const replied = isReplyToBot(envelope.replyMessageId, ctx.recentBotMsgIds.get(chatId)) activated = replied || pendingTextInbounds.has(key) } if (!activated) { ctx.logger.info(`[trueconf] group ${chatId}: no mention/reply for type ${envelope.type}, dropping`) return } } // Only register a direct-chat mapping for true P2P chats; for groups the // chatId is shared and reusing it as a per-user key would misroute outbound. if (kind === 'p2p') { ctx.directChats.set(`${ctx.accountId}\u0000${stableUserId}`, chatId) } // Group routing: peerId = chatId so the LLM sees one conversation per group // (senderId is preserved on the dispatched message for attribution). const peerId = kind === 'group' ? chatId : stableUserId const base: Omit = { channel: 'trueconf', accountId: ctx.accountId, peerId, chatId, senderId: stableUserId, messageId: envelope.messageId, timestamp: envelope.timestamp, isGroup: kind === 'group', senderName: stableUserId, replyMessageId: envelope.replyMessageId, ...(plainParseMode ? { parseMode: plainParseMode } : {}), ...(extraContext ? { extraContext } : {}), } // F2: if this message replies to ANOTHER user's message (not the bot's own), // fetch that quoted text+author and prepend it so the agent sees the context. // The fetch is started here but NOT awaited — its promise rides on the pending // entry (or the attachment dispatch below) and is awaited at flush/dispatch. // This keeps the gate→buffer transition synchronous so a fast-following // attachment for the same (account,chat,peer) coalesces into one turn instead // of racing the round-trip (up to its 5s timeout). The author resolver is a // deliberate placeholder returning the raw id; a display-name lookup is a // follow-up. const replyPrefix: Promise | null = envelope.replyMessageId && !isReplyToBot(envelope.replyMessageId, ctx.recentBotMsgIds.get(chatId)) ? fetchQuotedContext(ctx.wsClient, envelope.replyMessageId, (id) => id || 'участника', ctx.logger) : null const baseText = plainText ?? syntheticText ?? '' if (envelope.type === EnvelopeType.PLAIN_MESSAGE) { bufferPendingText(key, base, baseText, replyPrefix, ctx) return } if (envelope.type === EnvelopeType.ATTACHMENT) { const pending = pendingTextInbounds.get(key) let text: string if (pending) { // A buffered caption (carrying its own quoted prefix) coalesces with this // file. Claim the entry synchronously, then fold in the caption's prefix. clearTimeout(pending.timer) pendingTextInbounds.delete(key) text = await resolvePendingText(pending) } else { // Bare file reply: carry this envelope's own quoted prefix onto the // placeholder so an image-only reply still gives the agent the context. const fileLine = `[File: ${sanitizeAttachmentName(attachment!.name)}]` const prefix = replyPrefix ? await replyPrefix : null text = prefix ? `${prefix}\n\n${fileLine}` : fileLine } dispatchWithFence(ctx.dispatch, { ...base, text, attachmentContent: attachment! }, ctx.logger) return } // FORWARDED, LOCATION, SURVEY all go through the same coalescer-buffered path // as PLAIN. A trailing attachment in the coalesce window coalesces with the // synthetic text built here. bufferPendingText(key, base, baseText, replyPrefix, ctx) } function dispatchWithFence(dispatch: InboundDispatchFn, inbound: InboundMessage, logger: Logger): void { const ctx = `peer=${inbound.peerId} chatId=${inbound.chatId} messageId=${inbound.messageId}` try { Promise.resolve(dispatch(inbound)).catch((err: unknown) => { logger.error(`[trueconf] Dispatch failed (${ctx}): ${err instanceof Error ? err.message : String(err)}`) }) } catch (err) { logger.error(`[trueconf] Dispatch failed (${ctx}): ${err instanceof Error ? err.message : String(err)}`) } } function sanitizeAttachmentName(rawName: string): string { const lastSegment = rawName.split(/[/\\]/).pop() ?? '' const cleaned = lastSegment.replace(/\p{Cc}/gu, '') if (!cleaned || cleaned === '.' || cleaned === '..') return 'unnamed' return cleaned } export function resolveAttachmentDir(logger?: Logger): string { try { const mediaDir = getMediaDir() if (mediaDir) return pathJoin(mediaDir, 'trueconf-inbound') } catch (err) { logger?.warn( `[trueconf] getMediaDir threw: ${err instanceof Error ? err.message : String(err)} — using tmpdir`, ) } return tmpdir() } async function ensureAttachmentDirExists(logger: Logger): Promise { const dir = resolveAttachmentDir(logger) try { await mkdir(dir, { recursive: true }) } catch (err) { logger.warn( `[trueconf] ensureAttachmentDirExists: mkdir(${dir}) failed: ${err instanceof Error ? err.message : String(err)}`, ) } } export function buildSanitizedTempPath(originalName: string): { tempPath: string sanitizedOriginalName: string } { let safe = basename(originalName).replace(/\p{Cc}/gu, '') if (!safe || safe === '.' || safe === '..') safe = 'unnamed' const attachmentDir = resolveAttachmentDir() const candidate = `${attachmentDir}${pathSep}trueconf-${crypto.randomUUID()}-${safe}` const resolvedCandidate = pathResolve(candidate) const resolvedDir = pathResolve(attachmentDir) if (!resolvedCandidate.startsWith(resolvedDir + pathSep) && resolvedCandidate !== resolvedDir) { throw new Error(`Path traversal detected: ${candidate} escapes ${resolvedDir}`) } return { tempPath: resolvedCandidate, sanitizedOriginalName: safe } } export async function downloadFile( downloadUrl: string, destPath: string, maxBytes: number, logger: Logger, dispatcher?: Dispatcher, ): Promise<{ ok: true } | { ok: false; error: string }> { let parsedUrl: URL try { parsedUrl = new URL(downloadUrl) } catch { return { ok: false, error: 'invalid_url_scheme' } } if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { return { ok: false, error: 'invalid_url_scheme' } } let response: Response try { logger.info(`[trueconf] downloadFile: fetching ${downloadUrl}`) // The account-level dispatcher carries the same TLS trust the operator // configured for OAuth and WS (caPath / tlsVerify:false). Without it, // self-signed-server deployments connect over OAuth/WS but fail on // inbound media — see TLS-trust invariant in AGENTS.md. response = await fetch(downloadUrl, { dispatcher: dispatcher ?? getDefaultDispatcher() }) } catch (err) { const cause = err instanceof Error ? (err as Error & { cause?: unknown }).cause : undefined const causeMsg = cause instanceof Error ? cause.message : cause != null ? String(cause) : '' return { ok: false, error: `fetch failed: ${err instanceof Error ? err.message : String(err)}${causeMsg ? ' | cause: ' + causeMsg : ''}`, } } if (!response.ok) return { ok: false, error: `HTTP ${response.status} ${response.statusText}` } if (!response.body) return { ok: false, error: 'Response body is null' } const contentLengthHeader = response.headers.get('content-length') if (contentLengthHeader) { const contentLength = Number.parseInt(contentLengthHeader, 10) if (Number.isFinite(contentLength) && contentLength > maxBytes) { return { ok: false, error: `content-length ${contentLength} exceeds max ${maxBytes}` } } } // Streaming cap covers servers that omit content-length. let bytesWritten = 0 const boundedCounter = new Transform({ transform(chunk: Buffer, _encoding, callback) { bytesWritten += chunk.length if (bytesWritten > maxBytes) { callback(new Error(`streamed bytes ${bytesWritten} exceeds max ${maxBytes}`)) return } callback(null, chunk) }, }) try { await pipeline( Readable.fromWeb(response.body as unknown as NodeWebReadableStream), boundedCounter, createWriteStream(destPath), ) return { ok: true } } catch (err) { try { await unlink(destPath) } catch (unlinkErr) { logger.warn( `[trueconf] Failed to unlink partial download ${destPath}: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}`, ) } return { ok: false, error: `stream pipeline failed: ${err instanceof Error ? err.message : String(err)}` } } } export function getMaxFileSize(cfg: TrueConfChannelConfig): number { const raw = (cfg as unknown as { maxFileSize?: unknown }).maxFileSize if ( typeof raw === 'number' && Number.isFinite(raw) && raw > 0 && raw <= MAX_FILE_SIZE_HARD_LIMIT_BYTES ) { return raw } return DEFAULT_MAX_FILE_SIZE_BYTES } // Wait for the server to finish receiving the file bytes. Subscribes via // subscribeFileProgress and listens for uploadFileProgress events (note: docs // call this method `uploadingProgress`, but TrueConf Server actually sends // `uploadFileProgress` — verified on bots.trueconf.com). For federated // senders the progress event fires once the inter-server BitTorrent transfer // completes. async function waitUploadComplete( wsClient: WsClient, fileId: string, expectedSize: number, timeoutMs: number, logger: Logger, ): Promise<{ ok: true } | { ok: false; reason: 'timeout' | 'subscribe_failed' | 'reconnect' }> { type Result = { ok: true } | { ok: false; reason: 'timeout' | 'subscribe_failed' | 'reconnect' } let resolve!: (v: Result) => void const promise = new Promise((r) => { resolve = r }) let settled = false const settle = (v: Result): void => { if (settled) return settled = true clearTimeout(timer) wsClient.offFileProgress(fileId) resolve(v) } // Register handler + timer BEFORE subscribing: the server sometimes pushes // the uploadFileProgress event ahead of the subscribe response. const timer = setTimeout(() => settle({ ok: false, reason: 'timeout' }), timeoutMs) wsClient.onFileProgress(fileId, (progress) => { logger.info(`[trueconf] uploadFileProgress fileId=${fileId} progress=${progress} expected=${expectedSize}`) if (progress >= expectedSize) settle({ ok: true }) }) try { const subResp = await wsClient.sendRequest('subscribeFileProgress', { fileId }) const errorCode = typeof subResp.payload?.errorCode === 'number' ? subResp.payload.errorCode : undefined if (errorCode !== undefined && errorCode !== 0) { logger.warn(`[trueconf] subscribeFileProgress errorCode ${errorCode} (fileId=${fileId})`) settle({ ok: false, reason: 'subscribe_failed' }) } } catch (err) { logger.warn(`[trueconf] subscribeFileProgress failed (fileId=${fileId}): ${err instanceof Error ? err.message : String(err)}`) settle({ ok: false, reason: isReconnectableSendError(err) ? 'reconnect' : 'subscribe_failed' }) } const result = await promise if (result.ok) { // Best-effort: keep the server-side subscription table tidy. If this // fails the server drops the subscription on its own when the socket // closes or during idle sweeps. wsClient.sendRequest('unsubscribeFileProgress', { fileId }).catch(() => {}) } return result } // Poll getFileInfo briefly after uploadFileProgress signalled completion. // The server can lag the readyState flip by a few hundred ms, so we retry // for a short window to get a fresh downloadUrl with READY state. async function pollForReady( wsClient: WsClient, fileId: string, timeoutMs: number, logger: Logger, ): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { let resp: Awaited> try { resp = await wsClient.sendRequest('getFileInfo', { fileId }) } catch (err) { logger.warn(`[trueconf] pollForReady: getFileInfo failed (fileId=${fileId}): ${err instanceof Error ? err.message : String(err)}`) return null } const info = resp.payload as unknown as FileInfo if (info.readyState === FileReadyState.READY && info.downloadUrl != null) return info if (info.readyState === FileReadyState.NOT_AVAILABLE) return null await new Promise((r) => setTimeout(r, 500)) } logger.warn(`[trueconf] pollForReady: timed out waiting for READY (fileId=${fileId})`) return null } export interface InboundAttachmentReady { ok: true tempPath: string sanitizedName: string mimeType: string kindLabel: string size: number } // Download the inbound attachment and prepare extraContext for dispatch. Error // replies are sent here; on failure returns { ok: false } and the caller // skips dispatch. On success the caller must dispatch AND is responsible for // unlinking `tempPath` on any dispatch failure path: sync throw, // onRecordError, or onDispatchError. export async function prepareInboundAttachment(params: { inboundMsg: InboundMessage wsClient: WsClient accountId: string store: { directChatsByStableUserId: Map } channelConfig: TrueConfChannelConfig logger: Logger // Per-account send queue threaded by channel.ts. Error-reply paths in this // function call sendText/sendTextToChat, which require a queue to serialize // chunks per chatId. Owning the queue at the account level keeps replies // from one account from blocking on another's outbound burst. sendQueue: PerChatSendQueue // Per-account at-least-once outbound queue. sendText/sendTextToChat in the // error-reply path route `sendMessage` through it so a reconnect-class // failure parks the chunk until auth re-establishes. outboundQueue: OutboundQueue // Per-account undici dispatcher built from the operator's CA / tlsVerify. // OAuth and WS already use it; we thread it into the inbound download path // so caPath / tlsVerify:false deployments can also fetch inbound media. dispatcher?: Dispatcher }): Promise { const { inboundMsg, wsClient, accountId, store, channelConfig, logger, sendQueue, outboundQueue, dispatcher } = params const attachment = inboundMsg.attachmentContent if (!attachment) { logger.error('[trueconf] prepareInboundAttachment called without attachmentContent') return { ok: false } } // For groups inboundMsg.peerId === chatId; sendText would (mis)treat it as // a userId and try to createP2PChat. Route group error replies straight to // the chatId so the user actually sees the failure feedback. const replyOn = async (text: string) => { try { const result = inboundMsg.isGroup ? await sendTextToChat(outboundQueue, inboundMsg.chatId, text, logger, sendQueue) : await sendText(inboundMsg.peerId, text, logger, { fallbackUserId: inboundMsg.peerId, directChatStore: store, accountId, sendQueue, outboundQueue, }) if (!result.ok) logger.warn('[trueconf] replyErrorText: send returned ok=false') } catch (err) { logger.error(`[trueconf] replyErrorText: send threw: ${err instanceof Error ? err.message : String(err)}`) } } const maxBytes = getMaxFileSize(channelConfig) const sizeErr = (bytes: number) => `Файл слишком большой (лимит: ${bytesToMB(maxBytes)} МБ, ваш файл: ${bytesToMB(bytes)} МБ).` const fail = async (text: string): Promise<{ ok: false }> => { await replyOn(text) return { ok: false } } if (attachment.size > maxBytes) { logger.info(`[trueconf] Attachment size ${attachment.size} > cap ${maxBytes} (envelope)`) return fail(sizeErr(attachment.size)) } if (attachment.readyState === FileReadyState.NOT_AVAILABLE) { logger.info(`[trueconf] Attachment ${attachment.fileId} NOT_AVAILABLE (envelope)`) return fail(ERR_NOT_AVAILABLE) } try { let infoResp: Awaited> try { infoResp = await wsClient.sendRequest('getFileInfo', { fileId: attachment.fileId }) } catch (err) { logger.warn(`[trueconf] getFileInfo failed: ${err instanceof Error ? err.message : String(err)}`) return fail(ERR_GENERIC) } const errorCode = typeof infoResp.payload?.errorCode === 'number' ? infoResp.payload.errorCode : undefined if (errorCode !== undefined && errorCode !== 0) { logger.warn(`[trueconf] getFileInfo errorCode ${errorCode}`) return fail(ERR_GENERIC) } const initialInfo = infoResp.payload as unknown as FileInfo logger.info( `[trueconf] getFileInfo: readyState=${initialInfo.readyState} size=${initialInfo.size} downloadUrl=${initialInfo.downloadUrl ? 'present' : 'null'}`, ) if (typeof initialInfo.size === 'number' && initialInfo.size > maxBytes) { logger.info(`[trueconf] Attachment size ${initialInfo.size} > cap ${maxBytes} (FileInfo)`) return fail(sizeErr(initialInfo.size ?? attachment.size)) } if (initialInfo.readyState === FileReadyState.NOT_AVAILABLE) return fail(ERR_NOT_AVAILABLE) const rewriteUrlForAccount = (url: string): string => { try { const account = resolveAccount(channelConfig, accountId) if (account.serverUrl) { const parsed = new URL(url) parsed.protocol = account.useTls ? 'https:' : 'http:' parsed.host = hostPort({ serverUrl: account.serverUrl, useTls: account.useTls ?? false, port: account.port }) return parsed.toString() } } catch (err) { logger.warn( `[trueconf] rewriteDownloadUrl: ${err instanceof Error ? err.message : String(err)} — using original URL`, ) } return url } let finalInfo: FileInfo = initialInfo if (initialInfo.readyState !== FileReadyState.READY || initialInfo.downloadUrl == null) { const expectedSize = typeof initialInfo.size === 'number' && initialInfo.size > 0 ? initialInfo.size : attachment.size const wait = await waitUploadComplete(wsClient, attachment.fileId, expectedSize, 60_000, logger) if (!wait.ok) { return fail( wait.reason === 'timeout' ? 'Файл не успел загрузиться за 60 секунд. Попробуйте ещё раз.' : ERR_GENERIC, ) } const ready = await pollForReady(wsClient, attachment.fileId, 10_000, logger) if (!ready) { logger.warn(`[trueconf] upload complete but file never reached READY (fileId=${attachment.fileId})`) return fail(ERR_GENERIC) } finalInfo = ready } if (finalInfo.downloadUrl == null) { logger.warn('[trueconf] final FileInfo.downloadUrl is null') return fail(ERR_GENERIC) } const { tempPath, sanitizedOriginalName } = buildSanitizedTempPath(attachment.name) logger.info(`[trueconf] attachment tempPath: ${tempPath}`) await ensureAttachmentDirExists(logger) const dlResult = await downloadFile( rewriteUrlForAccount(finalInfo.downloadUrl), tempPath, maxBytes, logger, dispatcher, ) if (!dlResult.ok) { logger.warn(`[trueconf] downloadFile failed: ${dlResult.error}`) return fail( dlResult.error === 'invalid_url_scheme' ? 'Недопустимый URL файла — попробуйте ещё раз.' : 'Не удалось скачать файл — попробуйте ещё раз.', ) } const finalMimeType = finalInfo.mimeType || attachment.mimeType || 'application/octet-stream' const kind: MediaKind = kindFromMime(finalMimeType) ?? 'document' const kindLabel = kind.charAt(0).toUpperCase() + kind.slice(1) return { ok: true, tempPath, sanitizedName: sanitizedOriginalName, mimeType: finalMimeType, kindLabel, size: finalInfo.size, } } catch (err) { logger.error(`[trueconf] prepareInboundAttachment: unexpected error: ${err instanceof Error ? err.message : String(err)}`) await replyOn(ERR_GENERIC) return { ok: false } } } export async function unlinkTempFile(tempPath: string, logger: Logger): Promise { try { await unlink(tempPath) } catch (err) { logger.warn( `[trueconf] Failed to unlink temp file ${tempPath}: ${err instanceof Error ? err.message : String(err)}`, ) } }