// Telegram Bot API Adapter using grammy
// Implements MessengerAdapter interface with native typing indicator support
import { Bot } from 'grammy';
import { markdownToTelegramHtml } from './markdown-to-html.js';
import { cleanupOldMedia, downloadToMediaRoot, pickExtension, } from './media-download.js';
import { splitMessage } from '../../../utils/message-split.js';
import { Backoff } from '../../../utils/backoff.js';
import { logger as rootLogger } from '../../../core/logger.js';
import { transcribe, detectProvider, TranscribeError } from '../../../core/transcribe.js';
import { setLocationContext } from '../../../core/location-context.js';
import { normalizeIncomingCoords } from '../../../core/coord-systems.js';
import { getUserCoordPref } from '../../../core/user-coord-prefs.js';
const log = rootLogger.child({ component: 'telegram' });
export class TelegramAdapter {
    name = 'telegram';
    bot = null;
    config = null;
    messageHandler;
    buttonHandler;
    isRunning = false;
    typingIntervals = new Map();
    // grammy 的 bot.start() 长轮询偶尔会被一次网络抖动 wedge —— 既不报错也不
    // resolve，看起来还在跑但不再 fetch updates，TG 那边 pending_update_count
    // 一路涨。watchdog 周期性 ping getMe；连续多次失败就强制 stop+start，让
    // 卡死的 polling loop 重置。
    watchdogTimer;
    consecutivePingFailures = 0;
    static WATCHDOG_INTERVAL_MS = 60_000;
    static WATCHDOG_FAILURE_THRESHOLD = 3; // ~3min 无响应就重启 polling
    mediaCleanupTimer;
    static MEDIA_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // hourly
    async start() {
        // Load config
        const { readFile } = await import('node:fs/promises');
        const { join } = await import('node:path');
        const { AGIM_HOME } = await import('../../../core/agim-paths.js');
        const configPath = join(AGIM_HOME, 'config.json');
        try {
            const data = await readFile(configPath, 'utf-8');
            const config = JSON.parse(data);
            this.config = config.telegram;
        }
        catch {
            throw new Error('Telegram config not found. Run "agim config telegram" first.');
        }
        if (!this.config?.botToken) {
            throw new Error('Telegram bot token not configured. Run "agim config telegram" first.');
        }
        // Initialize bot
        this.bot = new Bot(this.config.botToken);
        // Set up message handler.
        //
        // CRITICAL: do NOT await messageHandler here. grammy processes updates
        // sequentially per chat — if the handler awaits an agent run that's
        // waiting on a separate IM-side approval reply, polling stalls and the
        // user's approval reply piles up in TG's pending_update_count, never
        // reaching us. Fire-and-forget mirrors how the WeChat ilink adapter
        // dispatches (ilink-adapter.ts:258).
        this.bot.on('message:text', (ctx) => {
            // Ignore messages from bots
            if (ctx.message.from.is_bot)
                return;
            if (!this.messageHandler)
                return;
            // Batch 3 (#8) — quoted-message recovery. If the user replied to
            // an earlier message in this chat, prepend a compact citation
            // block so the agent can refer back to it. Limited to 800 chars
            // to keep groups with long quote chains from blowing the prompt
            // (a /memo or explicit follow-up can fetch the rest).
            const replyTo = ctx.message.reply_to_message;
            const quoted = replyTo?.text || replyTo?.caption || '';
            const quotedAuthor = replyTo?.from?.username
                ?? replyTo?.from?.first_name
                ?? 'someone';
            const quotedBlock = quoted
                ? `[reply to @${quotedAuthor}: "${quoted.length > 800 ? `${quoted.slice(0, 797)}…` : quoted}"]\n\n`
                : '';
            const text = `${quotedBlock}${ctx.message.text || ''}`;
            const message = {
                id: ctx.message.message_id.toString(),
                threadId: ctx.chat.id.toString(),
                userId: ctx.message.from?.id?.toString() || 'unknown',
                text,
                timestamp: new Date(ctx.message.date * 1000),
                channelId: this.config?.channelId || 'default',
            };
            const msgCtx = {
                message,
                platform: 'telegram',
                channelId: this.config?.channelId || 'default',
            };
            this.messageHandler(msgCtx).catch((err) => {
                const errMsg = err instanceof Error ? err.message : String(err);
                const stack = err instanceof Error ? err.stack : undefined;
                log.error({ err: errMsg, stack, threadId: message.threadId }, 'Error in message handler');
            });
        });
        // Media handlers — TG photos and image documents. We await the download
        // inside the handler (it's bounded; typical TG photo is < 1 s, hard cap
        // 20 MB) so the resulting Message reflects the image being on disk before
        // we kick off the agent. messageHandler itself is fire-and-forget for the
        // same reasons as message:text above. Order across photo / text within a
        // chat is preserved because grammy serializes updates per chat.
        this.bot.on('message:photo', (ctx) => {
            if (ctx.message.from?.is_bot)
                return;
            if (!this.messageHandler)
                return;
            // Largest size is the last entry — TG ships scaled-down siblings for
            // bandwidth-conscious clients which we ignore.
            const photo = ctx.message.photo[ctx.message.photo.length - 1];
            void this.handleMediaUpload(ctx, photo.file_id, undefined, ctx.message.caption ?? '');
        });
        this.bot.on('message:document', (ctx) => {
            if (ctx.message.from?.is_bot)
                return;
            if (!this.messageHandler)
                return;
            const doc = ctx.message.document;
            // Image documents → media upload path. Audio documents → voice path.
            // Everything else (PDF / Word / Excel / zip / txt / arbitrary binaries)
            // → generic file path so the agent at least sees the attachment.
            if (doc.mime_type?.startsWith('image/')) {
                void this.handleMediaUpload(ctx, doc.file_id, doc.mime_type, ctx.message.caption ?? '');
                return;
            }
            if (doc.mime_type?.startsWith('audio/')) {
                // Document type has no `duration` field even when MIME is audio/*;
                // only message:voice / message:audio carry it. Pass undefined.
                void this.handleVoiceUpload(ctx, doc.file_id, doc.mime_type, ctx.message.caption ?? '', undefined);
                return;
            }
            void this.handleFileUpload(ctx, doc.file_id, doc.mime_type, ctx.message.caption ?? '', doc.file_name);
        });
        // Voice messages (the mic-button "press and hold" recording, OGG OPUS).
        // Caption is rare on voice but TG allows it.
        this.bot.on('message:voice', (ctx) => {
            if (ctx.message.from?.is_bot)
                return;
            if (!this.messageHandler)
                return;
            const v = ctx.message.voice;
            void this.handleVoiceUpload(ctx, v.file_id, v.mime_type, ctx.message.caption ?? '', v.duration);
        });
        // Audio messages (a music file or the "Audio" attachment button).
        this.bot.on('message:audio', (ctx) => {
            if (ctx.message.from?.is_bot)
                return;
            if (!this.messageHandler)
                return;
            const a = ctx.message.audio;
            void this.handleVoiceUpload(ctx, a.file_id, a.mime_type, ctx.message.caption ?? '', a.duration);
        });
        // Video sent via "Video" attachment (auto-compressed). Sending "as file"
        // arrives as message:document and goes through handleFileUpload instead.
        this.bot.on('message:video', (ctx) => {
            if (ctx.message.from?.is_bot)
                return;
            if (!this.messageHandler)
                return;
            const v = ctx.message.video;
            void this.handleVideoUpload(ctx, v.file_id, v.mime_type, ctx.message.caption ?? '', v.duration);
        });
        // Round video notes (40s max, recorded via the round-camera button).
        this.bot.on('message:video_note', (ctx) => {
            if (ctx.message.from?.is_bot)
                return;
            if (!this.messageHandler)
                return;
            const v = ctx.message.video_note;
            void this.handleVideoUpload(ctx, v.file_id, undefined, '', v.duration);
        });
        // Animations (silent looping MP4 / GIF). Same surface as video.
        this.bot.on('message:animation', (ctx) => {
            if (ctx.message.from?.is_bot)
                return;
            if (!this.messageHandler)
                return;
            const a = ctx.message.animation;
            void this.handleVideoUpload(ctx, a.file_id, a.mime_type, ctx.message.caption ?? '', a.duration);
        });
        // Location pins (📎 → Location → Send My Current Location / Choose on Map).
        //
        // Architecture (agent-driven, matches WeChat's H5 flow):
        //   The adapter does NOT make any decisions about the user's intent.
        //   It just stashes the coords in core/location-context (TTL 5 min)
        //   and sends a quiet ack. The user's NEXT text message in this thread
        //   gets prefixed with `[user just shared location: lat=…, lng=…, …]`
        //   by cli.ts before dispatching to the agent. The agent then decides
        //   whether to save (with what label) or just acknowledge.
        //
        //   Why no "记下来 / 跳过 / 回名字" prompt anymore: those forced the
        //   adapter into a deterministic state machine and ran a brittle
        //   heuristic noun extractor on the reply text. Several edge cases
        //   produced wrong `what` values ("记下", "一个好吃的日", etc.). The
        //   agent has LLM judgment + full conversation context — let it decide.
        //
        // Coordinate-system handling lives in core/coord-systems.ts.
        // Telegram default: pass-through (WGS-84). Most senders are Telegram
        // Desktop / Android-without-China-GMS / users outside China — all of
        // which deliver raw WGS-84. iOS Core Location in China applies the
        // GCJ offset; users hitting that case set IMHUB_TELEGRAM_COORDS_GCJ02=1.
        this.bot.on('message:location', (botCtx) => {
            if (botCtx.message.from?.is_bot)
                return;
            const loc = botCtx.message.location;
            const threadId = botCtx.chat.id.toString();
            const threadKey = `telegram:default:${threadId}`;
            const userId = botCtx.message.from?.id.toString() ?? threadId;
            // Plan C: look up the user's stored coord-system preference; if
            // present, force normalize that way. Otherwise the default
            // heuristic (in-China → GCJ-02 for telegram-native) kicks in,
            // and the user can correct it with one `#wgs` / `#gcj` / `#bd` reply.
            const pref = getUserCoordPref('telegram', userId);
            const norm = normalizeIncomingCoords('telegram-native', loc.latitude, loc.longitude, pref ? { force: pref } : undefined);
            setLocationContext(threadKey, {
                lat: norm.lat,
                lng: norm.lng,
                accuracy: typeof loc.horizontal_accuracy === 'number' ? loc.horizontal_accuracy : undefined,
            });
            const accuracyHint = typeof loc.horizontal_accuracy === 'number'
                ? `（精度 ±${Math.round(loc.horizontal_accuracy)}m）`
                : '';
            void botCtx.reply(`📍 位置已收到 (${norm.lat.toFixed(5)}, ${norm.lng.toFixed(5)})${accuracyHint}。告诉我要做什么。`).catch((err) => {
                log.warn({ err: err instanceof Error ? err.message : String(err) }, 'failed to ack location share');
            });
            log.info({
                event: 'telegram.location.received',
                threadId,
                raw_lat: loc.latitude, raw_lng: loc.longitude,
                wgs_lat: norm.lat, wgs_lng: norm.lng,
                applied: norm.applied,
            });
        });
        // Venues — same coord handling, plus the venue title + address ride
        // through as structured fields so the agent sees them in the annotation.
        this.bot.on('message:venue', (botCtx) => {
            if (botCtx.message.from?.is_bot)
                return;
            const v = botCtx.message.venue;
            const threadId = botCtx.chat.id.toString();
            const threadKey = `telegram:default:${threadId}`;
            const userId = botCtx.message.from?.id.toString() ?? threadId;
            const pref = getUserCoordPref('telegram', userId);
            const norm = normalizeIncomingCoords('telegram-native', v.location.latitude, v.location.longitude, pref ? { force: pref } : undefined);
            setLocationContext(threadKey, {
                lat: norm.lat,
                lng: norm.lng,
                venueLabel: v.title,
                venueAddress: v.address,
            });
            const addrLine = v.address ? `\n   ${v.address}` : '';
            void botCtx.reply(`📍 「${v.title}」 已收到${addrLine}\n告诉我要做什么。`).catch((err) => {
                log.warn({ err: err instanceof Error ? err.message : String(err) }, 'failed to ack venue share');
            });
            log.info({ event: 'telegram.venue.received', threadId, title: v.title, applied: norm.applied });
        });
        // Inline-button taps (approval cards). Same fire-and-forget discipline
        // as message:text — buttonHandler may resolve a pending approval which
        // calls back into editApprovalCard; we don't want that to block grammy's
        // sequential update queue.
        //
        // Telegram requires answerCallbackQuery within ~1s or the client shows
        // a spinner. We wrap ack() so the handler can call it explicitly when
        // it has a meaningful toast; if it doesn't, we send an empty ack at the
        // end as a safety net (idempotent — TG ignores the second call).
        this.bot.on('callback_query:data', (ctx) => {
            if (!this.buttonHandler) {
                void ctx.answerCallbackQuery({ text: '系统未就绪' }).catch(() => { });
                return;
            }
            const data = ctx.callbackQuery.data;
            const from = ctx.callbackQuery.from;
            const msg = ctx.callbackQuery.message;
            if (!msg) {
                void ctx.answerCallbackQuery({ text: '消息已不可用' }).catch(() => { });
                return;
            }
            // Optional allowlist gate. Empty / missing list = allow anyone (matches
            // the text-reply path). When configured, refuse with a toast — the
            // refusal does NOT resolve the pending, so an authorized user can
            // still click later.
            const allowlist = this.config?.approvalAllowlist;
            if (allowlist && allowlist.length > 0) {
                const fromIdStr = from.id.toString();
                if (!allowlist.includes(fromIdStr)) {
                    log.warn({
                        event: 'telegram.approval.unauthorized_click',
                        userId: fromIdStr,
                        username: from.username,
                        chatType: msg.chat.type,
                        chatId: msg.chat.id,
                    }, 'Unauthorized button click rejected by allowlist');
                    void ctx.answerCallbackQuery({ text: '无权审批此请求' }).catch(() => { });
                    return;
                }
            }
            let acked = false;
            const cb = {
                data,
                threadId: msg.chat.id.toString(),
                userId: from.id.toString(),
                userDisplay: from.username ? `@${from.username}` : (from.first_name || from.id.toString()),
                messageId: msg.message_id.toString(),
                ack: async (text) => {
                    if (acked)
                        return;
                    acked = true;
                    try {
                        await ctx.answerCallbackQuery(text ? { text } : undefined);
                    }
                    catch (err) {
                        log.warn({ err: String(err) }, 'answerCallbackQuery failed');
                    }
                },
            };
            this.buttonHandler(cb)
                .catch((err) => {
                log.error({ err: String(err), data }, 'Error in button handler');
            })
                .finally(() => {
                if (!acked) {
                    // Safety net so the user's TG client doesn't keep spinning when
                    // the handler forgot to ack.
                    void ctx.answerCallbackQuery().catch(() => { });
                }
            });
        });
        // Start bot in background. grammy's start() uses long polling and resolves
        // only when polling stops. We wrap it in a self-healing loop so that:
        //   1. an unexpected resolve while still isRunning → restart polling
        //   2. a thrown error → log it and retry after a short backoff
        // Combined with the watchdog (below), this defends against the silent
        // wedge mode where bot.start() neither resolves nor rejects but stops
        // fetching updates.
        log.info('Starting bot with long polling');
        this.isRunning = true;
        void this.runPollingLoop();
        this.startWatchdog();
        this.startMediaCleanup();
        log.info('Telegram adapter started');
    }
    /** Run media cleanup once now (so a long-running agim doesn't accumulate
     *  files indefinitely when restarts are infrequent) and then hourly. The
     *  hourly cadence matches the typical 7-day TTL with plenty of slack. */
    startMediaCleanup() {
        if (this.mediaCleanupTimer)
            clearInterval(this.mediaCleanupTimer);
        void cleanupOldMedia().catch((err) => {
            log.warn({ err: String(err), event: 'telegram.media.cleanup_failed' }, 'startup media cleanup failed');
        });
        this.mediaCleanupTimer = setInterval(() => {
            void cleanupOldMedia().catch((err) => {
                log.warn({ err: String(err), event: 'telegram.media.cleanup_failed' }, 'periodic media cleanup failed');
            });
        }, TelegramAdapter.MEDIA_CLEANUP_INTERVAL_MS);
    }
    async runPollingLoop() {
        // M9: exponential backoff with jitter replaces the previous fixed 2s
        // (unexpected resolve) / 5s (error) delays. Auth-revoked states no
        // longer hammer the API at a fixed cadence, and fleet-wide network
        // recovery doesn't lock-step its reconnects.
        //
        // baseMs = 2_000 preserves the previous "first error → ~2s" feel so
        // operators eyeballing logs during normal restarts see the same
        // ballpark; capMs = 60_000 prevents indefinite drift past 1 minute.
        const backoff = new Backoff({ baseMs: 2_000, capMs: 60_000, jitter: 0.5 });
        /** Reset the backoff if bot.start() ran healthy for at least this long
         *  before erroring. Without this, a single transient error at hour 12
         *  of operation would still queue the same exponential tail as a
         *  startup-time crash loop. 30 s is comfortably longer than any
         *  realistic handshake / first-fetch round-trip. */
        const HEALTHY_RUN_THRESHOLD_MS = 30_000;
        while (this.isRunning && this.bot) {
            const startedAt = Date.now();
            try {
                await this.bot.start();
                if (!this.isRunning) {
                    log.info('Bot stopped gracefully');
                    return;
                }
                // bot.start() resolved while we still want to be running — that's
                // grammy's silent-wedge mode (or an internal early return). Treat
                // it as a fault and back off before restarting.
                if (Date.now() - startedAt >= HEALTHY_RUN_THRESHOLD_MS)
                    backoff.reset();
                const delayMs = backoff.nextDelayMs();
                log.warn({
                    event: 'telegram.polling.unexpected_stop',
                    attempt: backoff.currentAttempt(),
                    delayMs,
                }, 'bot.start() resolved while still running; restarting after backoff');
                await new Promise((r) => setTimeout(r, delayMs));
            }
            catch (err) {
                if (!this.isRunning)
                    return;
                if (Date.now() - startedAt >= HEALTHY_RUN_THRESHOLD_MS)
                    backoff.reset();
                const delayMs = backoff.nextDelayMs();
                log.error({
                    err: err instanceof Error ? err.message : String(err),
                    event: 'telegram.polling.error',
                    attempt: backoff.currentAttempt(),
                    delayMs,
                }, 'Bot polling error; restarting after backoff');
                await new Promise((r) => setTimeout(r, delayMs));
            }
        }
    }
    startWatchdog() {
        if (this.watchdogTimer)
            clearInterval(this.watchdogTimer);
        this.consecutivePingFailures = 0;
        this.watchdogTimer = setInterval(async () => {
            if (!this.isRunning || !this.bot)
                return;
            try {
                await this.bot.api.getMe();
                if (this.consecutivePingFailures > 0) {
                    log.info({ event: 'telegram.watchdog.recovered' }, 'getMe ping recovered');
                }
                this.consecutivePingFailures = 0;
            }
            catch (err) {
                this.consecutivePingFailures += 1;
                log.warn({
                    event: 'telegram.watchdog.ping_failed',
                    consecutive: this.consecutivePingFailures,
                    err: err instanceof Error ? err.message : String(err),
                }, 'Watchdog getMe ping failed');
                if (this.consecutivePingFailures >= TelegramAdapter.WATCHDOG_FAILURE_THRESHOLD) {
                    log.error({ event: 'telegram.watchdog.restarting_polling' }, 'Polling appears wedged; forcing bot.stop() to trigger restart');
                    this.consecutivePingFailures = 0;
                    try {
                        await this.bot.stop();
                    }
                    catch { /* ignore */ }
                    // runPollingLoop will see bot.start() resolve and restart it.
                }
            }
        }, TelegramAdapter.WATCHDOG_INTERVAL_MS);
    }
    async stop() {
        this.isRunning = false;
        if (this.watchdogTimer) {
            clearInterval(this.watchdogTimer);
            this.watchdogTimer = undefined;
        }
        if (this.mediaCleanupTimer) {
            clearInterval(this.mediaCleanupTimer);
            this.mediaCleanupTimer = undefined;
        }
        // Clean up all typing intervals
        for (const interval of this.typingIntervals.values()) {
            clearInterval(interval);
        }
        this.typingIntervals.clear();
        if (this.bot) {
            await this.bot.stop();
            this.bot = null;
        }
        log.info('Telegram adapter stopped');
    }
    onMessage(handler) {
        this.messageHandler = handler;
    }
    /**
     * Download a TG photo / image-document, save it under MEDIA_ROOT, and surface
     * the result to messageHandler as a Message whose `text` includes the
     * caption (if any) plus a "[图片附件：/path/x.jpg]" marker — claude-code
     * picks that up and uses Read to view it.
     *
     * On download failure we still call messageHandler with a "[图片下载失败]"
     * marker so the user's interaction isn't silently dropped — they can resend
     * or be told what went wrong.
     */
    async handleMediaUpload(ctx, fileId, mime, caption) {
        if (!this.bot || !ctx.chat || !ctx.message)
            return;
        const chatId = ctx.chat.id;
        const msgId = ctx.message.message_id;
        let attachmentLine;
        const botToken = this.config?.botToken;
        // Scrub the bot token from any string before it reaches a log line or a
        // user-facing chat reply. The token rides inside the file URL we hand to
        // curl, and curl's stderr can echo the URL on TLS / DNS failures.
        const scrub = (s) => botToken ? s.split(botToken).join('[REDACTED]') : s;
        try {
            const file = await this.bot.api.getFile(fileId);
            if (!file.file_path)
                throw new Error('TG returned no file_path');
            const ext = pickExtension(file.file_path, mime);
            const url = `https://api.telegram.org/file/bot${botToken}/${file.file_path}`;
            const { path } = await downloadToMediaRoot({
                url,
                subdir: `telegram/${chatId}`,
                filename: `${msgId}.${ext}`,
            });
            attachmentLine = `[图片附件：${path}]`;
        }
        catch (err) {
            const msg = scrub(err instanceof Error ? err.message : String(err));
            log.warn({ event: 'telegram.media.download_failed', err: msg, chatId, msgId }, 'media download failed');
            attachmentLine = `[图片附件下载失败：${msg}]`;
        }
        const text = caption ? `${caption}\n\n${attachmentLine}` : attachmentLine;
        const message = {
            id: msgId.toString(),
            threadId: chatId.toString(),
            userId: ctx.message.from?.id?.toString() || 'unknown',
            text,
            timestamp: new Date(ctx.message.date * 1000),
            channelId: this.config?.channelId || 'default',
        };
        const msgCtx = {
            message,
            platform: 'telegram',
            channelId: this.config?.channelId || 'default',
        };
        if (!this.messageHandler)
            return;
        this.messageHandler(msgCtx).catch((err) => {
            log.error({
                err: err instanceof Error ? err.message : String(err),
                threadId: message.threadId,
            }, 'Error in media message handler');
        });
    }
    /**
     * Download a TG voice / audio message, transcribe it via whichever provider
     * is configured (OpenAI Whisper or whisper.cpp), and surface the transcript
     * to messageHandler as Message.text. The downloaded audio file path is
     * also included so the agent can reference it (e.g. send it back, replay).
     *
     * Failures are surfaced as text markers, not silent drops:
     *   - download failure → "[语音附件下载失败：…]"
     *   - no provider configured → "[语音附件未转写：未配置 OPENAI_API_KEY 或 IMHUB_WHISPERCPP_BIN]"
     *   - transcribe error → "[语音转写失败（${provider}）：…]"
     *
     * Since transcription can take 5-30s on a slow CPU + whisper.cpp medium,
     * we fire-and-forget the entire operation so grammy's update queue keeps
     * draining for other chats. Within this chat, ordering is still serialized
     * by grammy.
     */
    async handleVoiceUpload(ctx, fileId, mime, caption, durationSec) {
        if (!this.bot || !ctx.chat || !ctx.message)
            return;
        const chatId = ctx.chat.id;
        const msgId = ctx.message.message_id;
        let savedPath = null;
        let downloadErr = null;
        const botToken = this.config?.botToken;
        const scrub = (s) => botToken ? s.split(botToken).join('[REDACTED]') : s;
        try {
            const file = await this.bot.api.getFile(fileId);
            if (!file.file_path)
                throw new Error('TG returned no file_path');
            const ext = pickExtension(file.file_path, mime);
            const url = `https://api.telegram.org/file/bot${botToken}/${file.file_path}`;
            const { path } = await downloadToMediaRoot({
                url,
                subdir: `telegram/${chatId}`,
                filename: `${msgId}.${ext}`,
            });
            savedPath = path;
        }
        catch (err) {
            downloadErr = scrub(err instanceof Error ? err.message : String(err));
            log.warn({ event: 'telegram.voice.download_failed', err: downloadErr, chatId, msgId }, 'voice download failed');
        }
        let voiceLine;
        if (!savedPath) {
            voiceLine = `[语音附件下载失败：${downloadErr}]`;
        }
        else if (detectProvider() === 'none') {
            voiceLine = `[语音附件未转写（未配置 OPENAI_API_KEY 或 IMHUB_WHISPERCPP_BIN）：${savedPath}]`;
        }
        else {
            try {
                const result = await transcribe(savedPath, { language: 'zh' });
                const dur = durationSec != null ? `${durationSec}s, ` : '';
                voiceLine = [
                    `[语音转写（${dur}provider=${result.provider}, ${result.elapsedMs}ms）：`,
                    result.text || '（空）',
                    `源文件：${savedPath}]`,
                ].join('\n');
            }
            catch (err) {
                const reason = err instanceof TranscribeError
                    ? `${err.provider}: ${err.reason}`
                    : err instanceof Error ? err.message : String(err);
                voiceLine = `[语音转写失败（${reason}）\n源文件：${savedPath}]`;
            }
        }
        const text = caption ? `${caption}\n\n${voiceLine}` : voiceLine;
        const message = {
            id: msgId.toString(),
            threadId: chatId.toString(),
            userId: ctx.message.from?.id?.toString() || 'unknown',
            text,
            timestamp: new Date(ctx.message.date * 1000),
            channelId: this.config?.channelId || 'default',
        };
        const msgCtx = {
            message,
            platform: 'telegram',
            channelId: this.config?.channelId || 'default',
        };
        if (!this.messageHandler)
            return;
        this.messageHandler(msgCtx).catch((err) => {
            log.error({
                err: err instanceof Error ? err.message : String(err),
                threadId: message.threadId,
            }, 'Error in voice message handler');
        });
    }
    /**
     * Download an arbitrary file (PDF / Word / archive / binary) and surface
     * `[文件附件：${path} (${displayName}, ${size})]` to the agent. Same
     * download infra as media/voice; differs only in marker format and
     * filename handling — we keep the user's `file_name` (sanitised) so the
     * on-disk file stays recognisable, falling back to `${msgId}.${ext}` when
     * Telegram doesn't supply one.
     */
    async handleFileUpload(ctx, fileId, mime, caption, fileName) {
        if (!this.bot || !ctx.chat || !ctx.message)
            return;
        const chatId = ctx.chat.id;
        const msgId = ctx.message.message_id;
        const displayName = fileName || '未命名文件';
        let attachmentLine;
        const botToken = this.config?.botToken;
        const scrub = (s) => botToken ? s.split(botToken).join('[REDACTED]') : s;
        try {
            const file = await this.bot.api.getFile(fileId);
            if (!file.file_path)
                throw new Error('TG returned no file_path');
            const ext = pickExtension(file.file_path, mime);
            // Sanitise the user-supplied file_name. downloadToMediaRoot also
            // rejects '/', '\\', '..' but we replace defensively so the disk name
            // stays meaningful instead of erroring out on weird names.
            const safeName = fileName
                ? fileName.replace(/[/\\]/g, '_').replace(/\.\.+/g, '.').slice(0, 200)
                : `${msgId}.${ext}`;
            // Prefix with msgId so two uploads of the same name in a chat don't
            // collide. Underscore separator stays human-readable.
            const onDiskName = `${msgId}_${safeName}`;
            const url = `https://api.telegram.org/file/bot${botToken}/${file.file_path}`;
            const { path, bytes } = await downloadToMediaRoot({
                url,
                subdir: `telegram/${chatId}`,
                filename: onDiskName,
            });
            const sizeStr = bytes >= 1024 * 1024
                ? `${(bytes / (1024 * 1024)).toFixed(1)} MB`
                : `${(bytes / 1024).toFixed(0)} KB`;
            attachmentLine = `[文件附件：${path} (${displayName}, ${sizeStr})]`;
        }
        catch (err) {
            const msg = scrub(err instanceof Error ? err.message : String(err));
            log.warn({ event: 'telegram.file.download_failed', err: msg, chatId, msgId }, 'file download failed');
            attachmentLine = `[文件附件下载失败：${msg} (${displayName})]`;
        }
        const text = caption ? `${caption}\n\n${attachmentLine}` : attachmentLine;
        const message = {
            id: msgId.toString(),
            threadId: chatId.toString(),
            userId: ctx.message.from?.id?.toString() || 'unknown',
            text,
            timestamp: new Date(ctx.message.date * 1000),
            channelId: this.config?.channelId || 'default',
        };
        const msgCtx = {
            message,
            platform: 'telegram',
            channelId: this.config?.channelId || 'default',
        };
        if (!this.messageHandler)
            return;
        this.messageHandler(msgCtx).catch((err) => {
            log.error({
                err: err instanceof Error ? err.message : String(err),
                threadId: message.threadId,
            }, 'Error in file message handler');
        });
    }
    /**
     * Download a video / video_note / animation and surface
     * `[视频附件：${path} (${dur}s, ${size})]` to the agent. Same caveats as
     * handleFileUpload; we don't attempt frame extraction or transcription.
     */
    async handleVideoUpload(ctx, fileId, mime, caption, durationSec) {
        if (!this.bot || !ctx.chat || !ctx.message)
            return;
        const chatId = ctx.chat.id;
        const msgId = ctx.message.message_id;
        let attachmentLine;
        const botToken = this.config?.botToken;
        const scrub = (s) => botToken ? s.split(botToken).join('[REDACTED]') : s;
        try {
            const file = await this.bot.api.getFile(fileId);
            if (!file.file_path)
                throw new Error('TG returned no file_path');
            const ext = pickExtension(file.file_path, mime) || 'mp4';
            const url = `https://api.telegram.org/file/bot${botToken}/${file.file_path}`;
            const { path, bytes } = await downloadToMediaRoot({
                url,
                subdir: `telegram/${chatId}`,
                filename: `${msgId}.${ext}`,
            });
            const dur = durationSec != null ? `${durationSec}s, ` : '';
            const sizeStr = bytes >= 1024 * 1024
                ? `${(bytes / (1024 * 1024)).toFixed(1)} MB`
                : `${(bytes / 1024).toFixed(0)} KB`;
            attachmentLine = `[视频附件：${path} (${dur}${sizeStr})]`;
        }
        catch (err) {
            const msg = scrub(err instanceof Error ? err.message : String(err));
            log.warn({ event: 'telegram.video.download_failed', err: msg, chatId, msgId }, 'video download failed');
            attachmentLine = `[视频附件下载失败：${msg}]`;
        }
        const text = caption ? `${caption}\n\n${attachmentLine}` : attachmentLine;
        const message = {
            id: msgId.toString(),
            threadId: chatId.toString(),
            userId: ctx.message.from?.id?.toString() || 'unknown',
            text,
            timestamp: new Date(ctx.message.date * 1000),
            channelId: this.config?.channelId || 'default',
        };
        const msgCtx = {
            message,
            platform: 'telegram',
            channelId: this.config?.channelId || 'default',
        };
        if (!this.messageHandler)
            return;
        this.messageHandler(msgCtx).catch((err) => {
            log.error({
                err: err instanceof Error ? err.message : String(err),
                threadId: message.threadId,
            }, 'Error in video message handler');
        });
    }
    async sendMessage(threadId, text) {
        if (!this.bot) {
            throw new Error('Telegram adapter not started');
        }
        const htmlText = markdownToTelegramHtml(text);
        const chunks = splitMessage(htmlText, { maxLength: 4000, addContinuationMarker: false });
        for (const chunk of chunks) {
            await this.bot.api.sendMessage(threadId, chunk, { parse_mode: 'HTML' });
        }
    }
    onButtonCallback(handler) {
        this.buttonHandler = handler;
    }
    async sendApprovalCard(threadId, prompt) {
        if (!this.bot)
            throw new Error('Telegram adapter not started');
        const text = renderApprovalCardHtml(prompt);
        const reply_markup = renderApprovalKeyboard(prompt);
        const sent = await this.bot.api.sendMessage(threadId, text, {
            parse_mode: 'HTML',
            reply_markup,
        });
        return { messageId: sent.message_id.toString() };
    }
    async editApprovalCard(threadId, messageId, outcome) {
        if (!this.bot)
            return;
        const numericId = Number.parseInt(messageId, 10);
        if (!Number.isFinite(numericId)) {
            log.warn({ messageId }, 'editApprovalCard: non-numeric messageId');
            return;
        }
        const text = renderApprovalOutcomeHtml(outcome);
        try {
            await this.bot.api.editMessageText(threadId, numericId, text, {
                parse_mode: 'HTML',
                // Omit reply_markup → buttons stay. We want to drop them, so pass
                // an empty inline_keyboard to clear.
                reply_markup: { inline_keyboard: [] },
            });
        }
        catch (err) {
            // Common: "message is not modified", "message can't be edited" (>48h),
            // "message to edit not found". All non-fatal — bus already resolved.
            log.warn({ err: String(err), messageId }, 'editApprovalCard failed (non-fatal)');
        }
    }
    async sendChoiceCard(threadId, prompt) {
        if (!this.bot)
            throw new Error('Telegram adapter not started');
        const title = prompt.title ? `<b>${escapeHtml(prompt.title)}</b>\n` : '';
        const text = `${title}${escapeHtml(prompt.body)}`;
        // Layout: 1 button → single row; 2 → two on one row; 3+ → each on its own row.
        const rows = prompt.choices.length <= 2
            ? [prompt.choices.map((c) => ({ text: c.label, callback_data: c.data }))]
            : prompt.choices.map((c) => [{ text: c.label, callback_data: c.data }]);
        const sent = await this.bot.api.sendMessage(threadId, text, {
            parse_mode: 'HTML',
            reply_markup: { inline_keyboard: rows },
        });
        return { messageId: sent.message_id.toString() };
    }
    async editChoiceCard(threadId, messageId, finalText) {
        if (!this.bot)
            return;
        const numericId = Number.parseInt(messageId, 10);
        if (!Number.isFinite(numericId)) {
            log.warn({ messageId }, 'editChoiceCard: non-numeric messageId');
            return;
        }
        // Try editing both text + reply_markup in one call. Several Telegram
        // errors are routine and don't indicate a real problem — classify them
        // so log noise stays low:
        //   - "message is not modified" → text matches; still strip buttons
        //   - "message to edit not found" / "MESSAGE_ID_INVALID" → user
        //     deleted it or it's too old; skip silently
        //   - "message can't be edited" → past edit window (>48h); skip silently
        //   - "Too Many Requests" / 429 → rate limit; skip + brief log
        //   - everything else → warn
        const bot = this.bot;
        const classify = (raw) => {
            if (/message is not modified/i.test(raw))
                return 'not-modified';
            if (/message to edit not found|MESSAGE_ID_INVALID/i.test(raw))
                return 'gone';
            if (/message can't be edited|message_too_long/i.test(raw))
                return 'too-old';
            if (/Too Many Requests|429/i.test(raw))
                return 'rate-limit';
            return 'other';
        };
        try {
            await bot.api.editMessageText(threadId, numericId, escapeHtml(finalText), {
                parse_mode: 'HTML',
                reply_markup: { inline_keyboard: [] },
            });
            return;
        }
        catch (err) {
            const msg = String(err);
            const kind = classify(msg);
            if (kind === 'gone' || kind === 'too-old') {
                log.debug({ messageId, kind }, 'editChoiceCard: card no longer editable, skipping');
                return;
            }
            if (kind === 'rate-limit') {
                log.info({ messageId, err: msg }, 'editChoiceCard: rate-limited, skipping (buttons remain live)');
                return;
            }
            log.warn({
                err: msg, messageId, kind,
            }, 'editChoiceCard editMessageText failed');
            // Only fall through to button-only strip when text matched.
            if (kind !== 'not-modified')
                return;
        }
        try {
            await bot.api.editMessageReplyMarkup(threadId, numericId, {
                reply_markup: { inline_keyboard: [] },
            });
            log.info({ messageId }, 'editChoiceCard: stripped buttons after not-modified');
        }
        catch (err) {
            const msg = String(err);
            const kind = classify(msg);
            if (kind === 'gone' || kind === 'too-old' || kind === 'rate-limit') {
                log.debug({ messageId, kind }, 'editChoiceCard reply-markup strip skipped');
                return;
            }
            log.warn({ err: msg, messageId, kind }, 'editChoiceCard editMessageReplyMarkup failed');
        }
    }
    async sendTyping(threadId, isTyping) {
        if (!this.bot) {
            return;
        }
        if (isTyping) {
            // Send initial typing action
            try {
                await this.bot.api.sendChatAction(threadId, 'typing');
            }
            catch {
                // Ignore errors during typing
            }
            // Clear any existing interval
            const existing = this.typingIntervals.get(threadId);
            if (existing) {
                clearInterval(existing);
            }
            // Set up periodic refresh every 4 seconds (Telegram expires after ~5s)
            const interval = setInterval(async () => {
                try {
                    await this.bot?.api.sendChatAction(threadId, 'typing');
                }
                catch {
                    // Ignore errors during typing refresh
                }
            }, 4000);
            this.typingIntervals.set(threadId, interval);
        }
        else {
            // Clear the refresh interval
            const interval = this.typingIntervals.get(threadId);
            if (interval) {
                clearInterval(interval);
                this.typingIntervals.delete(threadId);
            }
            // Note: Telegram has no "cancel" action - typing just expires
        }
    }
}
function escapeHtml(s) {
    // M12: cover quote characters too. Most usages place the value in a text
    // node where `'` and `"` are harmless, but the approval-card template
    // builds attributes like <a href="..."> with user-derived values, where
    // an unescaped quote could close the attribute and inject markup.
    return s.replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
}
function formatHm(d) {
    const hh = d.getHours().toString().padStart(2, '0');
    const mm = d.getMinutes().toString().padStart(2, '0');
    return `${hh}:${mm}`;
}
function renderApprovalCardHtml(p) {
    const tool = escapeHtml(p.toolName);
    const input = escapeHtml(p.inputJson);
    const reqShort = escapeHtml(p.reqId.slice(0, 8));
    if (p.mode === 'auto-allow') {
        const sec = p.graceSeconds ?? 5;
        return [
            `⏱ <b>自动放行中</b>（${sec}s 后执行）`,
            `工具：<b>${tool}</b>`,
            `入参：<pre>${input}</pre>`,
            `点 ❌ 拒绝可同时撤销该工具的自动放行规则`,
            `<i>req: ${reqShort}</i>`,
        ].join('\n');
    }
    return [
        `🔐 <b>工具调用审批</b>`,
        `工具：<b>${tool}</b>`,
        `入参：<pre>${input}</pre>`,
        `<i>req: ${reqShort}</i>`,
    ].join('\n');
}
function renderApprovalKeyboard(p) {
    const r = p.reqId;
    if (p.mode === 'auto-allow') {
        return {
            inline_keyboard: [[
                    { text: '❌ 拒绝（撤销规则）', callback_data: `apv:${r}:n` },
                ]],
        };
    }
    return {
        inline_keyboard: [
            [
                { text: '✅ 同意', callback_data: `apv:${r}:y` },
                { text: '❌ 拒绝', callback_data: `apv:${r}:n` },
            ],
            [
                { text: '🛡 本会话自动放行同类', callback_data: `apv:${r}:a` },
            ],
        ],
    };
}
function renderApprovalOutcomeHtml(o) {
    const t = formatHm(o.atDate);
    const by = o.byUserDisplay ? ` · by ${escapeHtml(o.byUserDisplay)}` : '';
    switch (o.decision) {
        case 'allowed': return `✅ <b>已批准</b> · ${t}${by}`;
        case 'allowed-pinned': return `🛡 <b>已批准并加入自动放行</b> · ${t}${by}`;
        case 'denied': return `❌ <b>已拒绝</b> · ${t}${by}`;
        case 'denied-revoked': return `❌ <b>已拒绝并撤销自动放行</b> · ${t}${by}`;
        case 'expired': return `⏱ <b>已过期</b> · ${t}`;
    }
}
export const telegramAdapter = new TelegramAdapter();
//# sourceMappingURL=telegram-adapter.js.map