import { EventEmitter } from "node:events";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import fs from "node:fs";
import { mkdir, stat } from "node:fs/promises";
import TelegramBot from "node-telegram-bot-api";
import { logger } from "../logger.js";
import { loadTelegramAccessConfig, saveTelegramAccessConfig, loadOwnerConfig, saveConfig } from "../config/index.js";
import { t, resolveLocale } from "../i18n/index.js";
import { DEFAULT_CLAUDE_MODEL, GPT_MODEL } from "../config/models.js";
import { isClaudeModel } from "../agent/engines/engine-factory.js";
import { buildTelegramPreview, buildTelegramTitle, createTelegramAccessEntry, formatTelegramAccessSummary, getTelegramChatKind, serializeTelegramMessage, } from "./telegram-access.js";
import { syncBotCommands } from "../onboarding/bot-profile.js";
import { OnboardingServiceClient } from "../onboarding/onboarding-service-client.js";
import { transcribeAudioFile } from "../tools/transcribe-audio.js";
import { BILLING_CALLBACK_PREFIX, BILLING_PLAN_CALLBACK_PREFIX, BILLING_BUNDLE_CALLBACK_PREFIX, BUNDLE_ALLOWED_AMOUNTS, } from "../billing/types.js";
// Enable proper fileOptions (filename, contentType) handling in node-telegram-bot-api.
// Without this, the library ignores the fileOptions argument and emits a deprecation warning.
// See: https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#file-options-metadata
process.env.NTBA_FIX_350 = "1";
import path from "node:path";
import { getPackageRoot } from "../utils/version-check.js";
/** Pricing image bundled with the agent — uploaded as a buffer to bypass Telegram's URL caching. */
const PRICING_IMAGE_PATH = path.join(getPackageRoot(), "assets", "visionclaw_pricing.jpeg");
function getTelegramReplyTarget(meta) {
    const raw = meta?.telegramReplyTarget;
    if (!raw || typeof raw !== "object")
        return null;
    const target = raw;
    const chatId = target.chatId;
    if (typeof chatId !== "number" || !Number.isFinite(chatId))
        return null;
    const replyTarget = { chatId };
    const messageThreadId = target.messageThreadId;
    if (typeof messageThreadId === "number" && Number.isFinite(messageThreadId)) {
        replyTarget.messageThreadId = messageThreadId;
    }
    const replyToMessageId = target.replyToMessageId;
    if (typeof replyToMessageId === "number" && Number.isFinite(replyToMessageId)) {
        replyTarget.replyToMessageId = replyToMessageId;
    }
    const chatType = target.chatType;
    if (typeof chatType === "string") {
        replyTarget.chatType = chatType;
    }
    return replyTarget;
}
/**
 * Telegram channel adapter.
 * Supports multimodal inbound (photos, documents, voice) and
 * outbound (photos, documents) messages.
 */
export class TelegramAdapter extends EventEmitter {
    name = "telegram";
    config;
    bot = null;
    telegramAccess = loadTelegramAccessConfig();
    /** The owner's Telegram user ID (equals telegramChatId for a DM). Used to
     *  authorize admin commands and always trust the owner's direct chat. */
    _ownerUserId = null;
    /** Cached plans from the most recent payment prompt, used by the callback handler */
    _cachedPlans = null;
    constructor(config) {
        super();
        this.config = config;
    }
    /**
     * Set the owner's Telegram user ID so that admin commands are only accepted
     * from the owner, even in group chats.
     */
    setOwnerUserId(id) {
        this._ownerUserId = id;
    }
    /** Best-effort read of the owner's language preference for i18n. */
    getOwnerLocale() {
        try {
            return loadOwnerConfig().language;
        }
        catch {
            return undefined;
        }
    }
    /** Best-effort read of the owner's timezone preference for settings UI. */
    getOwnerTimezone() {
        try {
            return loadOwnerConfig().timezone;
        }
        catch {
            return undefined;
        }
    }
    /**
     * Drop all Telegram access-control state captured before the owner was
     * established (pending entries + held messages). Called once from the
     * onboarding flow so pre-owner traffic does not leak into the newly
     * onboarded agent.
     */
    clearPreOwnerAccessState() {
        const pendingCount = this.telegramAccess.entries.filter((e) => e.status === "pending").length;
        const heldCount = this.telegramAccess.heldMessages.length;
        if (pendingCount === 0 && heldCount === 0)
            return;
        this.telegramAccess.entries = this.telegramAccess.entries.filter((e) => e.status !== "pending");
        this.telegramAccess.heldMessages = [];
        this.persistTelegramAccess();
        logger.log("info", "telegram", `Cleared pre-owner Telegram state: ${pendingCount} pending entr${pendingCount === 1 ? "y" : "ies"}, ${heldCount} held message${heldCount === 1 ? "" : "s"}`);
    }
    start() {
        const botToken = this.config.channels.telegram?.botToken;
        if (!botToken) {
            throw new Error("Telegram bot token not configured");
        }
        this.bot = new TelegramBot(botToken, { polling: true });
        this.bot.on("my_chat_member", (update) => {
            void this.handleChatMemberUpdate(update);
        });
        this.bot.on("message", (msg) => {
            void this.handleIncomingMessage(msg);
        });
        this.bot.on("callback_query", (query) => {
            void this.handleCallbackQuery(query);
        });
        this.bot.on("polling_error", (err) => {
            this.emit("error", err);
        });
        // Sync bot command menu in the background (non-blocking)
        void syncBotCommands(botToken);
        return Promise.resolve();
    }
    async handleIncomingMessage(msg) {
        try {
            if (await this.handleOwnerAdminCommand(msg)) {
                return;
            }
            if (!this.shouldEnforceAccessControl()) {
                await this.processInboundMessage(msg);
                return;
            }
            if (this.isOwnerDirectMessage(msg)) {
                if (this.handleGeneralCommand(msg)) {
                    return;
                }
                await this.processInboundMessage(msg);
                return;
            }
            const accessEntry = this.findEntryByChatId(msg.chat.id);
            if (!accessEntry) {
                await this.capturePendingMessage(msg);
                return;
            }
            if (accessEntry.status === "approved") {
                if (await this.rejectNonOwnerCommand(msg)) {
                    return;
                }
                await this.processInboundMessage(msg);
                return;
            }
            if (accessEntry.status === "blocked") {
                logger.debug(`Telegram: ignoring blocked chat ${msg.chat.id}`);
                return;
            }
            await this.capturePendingMessage(msg, accessEntry);
        }
        catch (err) {
            logger.warn(`Telegram: Error handling inbound message: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    async handleChatMemberUpdate(update) {
        const { chat, new_chat_member, from } = update;
        const isAdded = new_chat_member.status === "member" || new_chat_member.status === "administrator";
        if (!isAdded)
            return;
        const chatKind = getTelegramChatKind(chat.type);
        if (chatKind !== "group" && chatKind !== "supergroup")
            return;
        const existingEntry = this.findEntryByChatId(chat.id);
        if (existingEntry?.status === "approved" || existingEntry?.status === "blocked")
            return;
        const addedBy = [from.first_name, from.last_name].filter(Boolean).join(" ") || String(from.id);
        const now = new Date().toISOString();
        const entry = existingEntry ?? {
            id: `tg_group_${Math.abs(chat.id).toString(36)}`,
            chatId: chat.id,
            kind: chatKind,
            title: chat.title ?? `${chat.type} ${chat.id}`,
            status: "pending",
            firstSeenAt: now,
            lastSeenAt: now,
            pendingMessageCount: 0,
            lastMessagePreview: `Bot added by ${addedBy}`,
        };
        if (!existingEntry) {
            this.telegramAccess.entries.push(entry);
        }
        else {
            existingEntry.lastSeenAt = now;
            existingEntry.lastMessagePreview = `Bot added by ${addedBy}`;
        }
        this.persistTelegramAccess();
        logger.log("info", "telegram", `Bot added to group "${entry.title}" (${chat.id}) by ${addedBy}`);
        await this.notifyOwnerAboutPendingEntry(entry, `Added by ${addedBy}`);
    }
    async handleOwnerAdminCommand(msg) {
        if (!this._ownerUserId || msg.from?.id !== this._ownerUserId || !this.bot) {
            return false;
        }
        if (msg.text?.match(/^\/requests(?:@\S+)?$/)) {
            await this.sendAdminReply(msg.chat.id, this.buildPendingRequestsReply());
            return true;
        }
        if (msg.text?.match(/^\/chats(?:@\S+)?$/)) {
            await this.sendAdminReply(msg.chat.id, this.buildChatsReply());
            return true;
        }
        const engineMatch = msg.text?.match(/^\/engine(?:@\S+)?\s+(gpt|claude)$/i);
        if (engineMatch) {
            await this.handleEngineCommand(engineMatch[1].toLowerCase(), msg);
            return true;
        }
        const approveMatch = msg.text?.match(/^\/approve(?:@\S+)?\s+([a-z0-9_-]+)$/i);
        if (approveMatch) {
            await this.approveEntry(approveMatch[1], msg.chat.id);
            return true;
        }
        const blockMatch = msg.text?.match(/^\/block(?:@\S+)?\s+([a-z0-9_-]+)$/i);
        if (blockMatch) {
            await this.blockEntry(blockMatch[1], msg.chat.id);
            return true;
        }
        return false;
    }
    async handleEngineCommand(modelFamily, msg) {
        const currentFamily = isClaudeModel(this.config.model) ? "claude" : "gpt";
        if (currentFamily === modelFamily) {
            logger.log("info", "telegram", `Engine command requested current engine ${modelFamily}; no-op`);
            await this.sendAdminReply(msg.chat.id, `Already using ${modelFamily} engine.`);
            return;
        }
        const targetModel = modelFamily === "gpt" ? GPT_MODEL : DEFAULT_CLAUDE_MODEL;
        const targetProvider = "9router";
        this.config = { ...this.config, model: targetModel, provider: targetProvider };
        saveConfig(this.config);
        await this.sendAdminReply(msg.chat.id, `Switching to ${modelFamily} engine. Restarting agent...`);
        const sender = this.getSenderDisplay(msg);
        logger.system(`Engine switch to ${modelFamily} (${targetModel}, provider=${targetProvider}) requested by ${sender} via telegram`);
        this.emit("restart_request", { channel: "telegram", sender });
    }
    async handleCallbackQuery(query) {
        if (!this.bot)
            return;
        const callbackId = query.id;
        const data = query.data;
        if (!callbackId || !data)
            return;
        // Route billing callbacks separately from access-control callbacks
        if (data.startsWith(BILLING_CALLBACK_PREFIX)) {
            await this.handleBillingCallback(query);
            return;
        }
        // Route /settings callbacks (language, timezone)
        if (data.startsWith("set:")) {
            await this.handleSetCallback(query);
            return;
        }
        if (data.startsWith("usage_pdf:")) {
            await this.handleUsagePdfCallback(query);
            return;
        }
        if (!data.startsWith("telegram_access:")) {
            return;
        }
        if (!this._ownerUserId || query.from.id !== this._ownerUserId) {
            await this.answerCallbackQuery(callbackId, "Only the owner can use these buttons.");
            return;
        }
        const messageChatId = query.message?.chat.id;
        if (typeof messageChatId !== "number") {
            await this.answerCallbackQuery(callbackId, "This action is no longer available.");
            return;
        }
        const match = /^telegram_access:(approve|block):([a-z0-9_-]+)$/i.exec(data);
        if (!match) {
            await this.answerCallbackQuery(callbackId, "Unknown action.");
            return;
        }
        const action = match[1];
        const entryId = match[2];
        if (action === "approve") {
            const result = await this.approveEntry(entryId, messageChatId, { suppressReply: true });
            await this.answerCallbackQuery(callbackId, result);
            await this.clearCallbackButtons(query);
            await this.sendAdminReply(messageChatId, result);
            return;
        }
        const result = await this.blockEntry(entryId, messageChatId, { suppressReply: true });
        await this.answerCallbackQuery(callbackId, result);
        await this.clearCallbackButtons(query);
        await this.sendAdminReply(messageChatId, result);
    }
    /** All recognised bot command patterns (general + admin). */
    static KNOWN_COMMAND_PATTERN = /^\/(?:stop|sleep|console|restart|tunnel|help|status|tasks|backup|silent|weixin|wechat|usage|screenshot|upgrade_beta|upgrade_dev|subscribe|android[-_]?use|refresh|requests|chats|approve|block|engine|rdp|settings|e2e)(?:@\S+)?(?:\s|$)/i;
    /**
     * Returns true if the message text starts with a known bot command.
     */
    isKnownCommand(msg) {
        return !!msg.text && TelegramAdapter.KNOWN_COMMAND_PATTERN.test(msg.text);
    }
    /**
     * If the message is a known command from a non-owner chat, reply with
     * a rejection notice and return true so the caller can short-circuit.
     */
    async rejectNonOwnerCommand(msg) {
        if (!this.isKnownCommand(msg))
            return false;
        logger.debug(`Telegram: rejecting command from non-owner chat ${msg.chat.id}`);
        await this.sendAdminReply(msg.chat.id, "Sorry, commands can only be used by the owner.");
        return true;
    }
    handleGeneralCommand(msg) {
        if (msg.text?.match(/^\/stop(?:@\S+)?$/)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("stop_request", { channel: "telegram", sender });
            return true;
        }
        const sleepMatch = msg.text?.match(/^\/sleep(?:@\S+)?\s+(\d+)$/);
        if (sleepMatch) {
            const minutes = Number(sleepMatch[1]);
            const sender = this.getSenderDisplay(msg);
            this.emit("sleep_request", { channel: "telegram", sender, minutes });
            return true;
        }
        if (msg.text?.match(/^\/console(?:@\S+)?$/)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("console_request", { channel: "telegram", sender });
            return true;
        }
        if (msg.text?.match(/^\/restart(?:@\S+)?$/)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("restart_request", { channel: "telegram", sender });
            return true;
        }
        if (msg.text?.match(/^\/tunnel(?:@\S+)?\s+restart$/)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("tunnel_request", { channel: "telegram", sender });
            return true;
        }
        if (msg.text?.match(/^\/help(?:@\S+)?$/)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("help_request", { channel: "telegram", sender });
            return true;
        }
        const statusMatch = msg.text?.match(/^\/status(?:@\S+)?(?:\s+(refresh))?$/);
        if (statusMatch) {
            const sender = this.getSenderDisplay(msg);
            this.emit("status_request", {
                channel: "telegram",
                sender,
                forceRefresh: statusMatch[1] === "refresh",
                chatId: msg.chat.id,
            });
            return true;
        }
        if (msg.text?.match(/^\/backup(?:@\S+)?$/)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("backup_request", { channel: "telegram", sender });
            return true;
        }
        const silentMatch = msg.text?.match(/^\/silent(?:@\S+)?(?:\s+(on|off))?$/i);
        if (silentMatch) {
            const sender = this.getSenderDisplay(msg);
            const arg = silentMatch[1]?.toLowerCase();
            const enabled = arg !== "off";
            this.emit("silent_mode_request", { channel: "telegram", sender, enabled });
            return true;
        }
        if (msg.text?.match(/^\/(?:weixin|wechat)(?:@\S+)?$/)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("weixin_login_request", { channel: "telegram", sender });
            return true;
        }
        if (msg.text?.match(/^\/usage(?:@\S+)?$/)) {
            void this.sendUsagePeriodPrompt(msg.chat.id);
            return true;
        }
        if (msg.text?.match(/^\/screenshot(?:@\S+)?$/)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("screenshot_request", { channel: "telegram", sender });
            return true;
        }
        if (msg.text?.match(/^\/upgrade_beta(?:@\S+)?$/)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("upgrade_beta_request", { channel: "telegram", sender });
            return true;
        }
        const upgradeDevMatch = msg.text?.match(/^\/upgrade_dev(?:@\S+)?\s+(\S+)$/);
        if (upgradeDevMatch) {
            const sender = this.getSenderDisplay(msg);
            this.emit("upgrade_dev_request", { channel: "telegram", sender, tag: upgradeDevMatch[1] });
            return true;
        }
        if (msg.text?.match(/^\/tasks(?:@\S+)?$/)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("tasks_request", { channel: "telegram", sender, chatId: msg.chat.id });
            return true;
        }
        if (msg.text?.match(/^\/subscribe(?:@\S+)?$/)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("subscribe_request", { channel: "telegram", sender });
            return true;
        }
        if (msg.text?.match(/^\/refresh(?:@\S+)?$/)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("refresh_request", { channel: "telegram", sender });
            return true;
        }
        const androidUseMatch = msg.text?.match(/^\/android[-_]?use(?:@\S+)?\s+(on|off)$/i);
        if (androidUseMatch) {
            const sender = this.getSenderDisplay(msg);
            const enabled = androidUseMatch[1].toLowerCase() === "on";
            this.emit("android_use_request", { channel: "telegram", sender, enabled });
            return true;
        }
        const chatMonitorMatch = msg.text?.match(/^\/chat[-_]?monitor(?:@\S+)?\s+(on|off)$/i);
        if (chatMonitorMatch) {
            const sender = this.getSenderDisplay(msg);
            const enabled = chatMonitorMatch[1].toLowerCase() === "on";
            this.emit("chat_monitor_request", { channel: "telegram", sender, enabled });
            return true;
        }
        // /rdp [<ttl_minutes>] — generate a temporary RDP (H.264 desktop stream) link
        const rdpMatch = msg.text?.match(/^\/rdp(?:@\S+)?(?:\s+(\d+))?\s*$/i);
        if (rdpMatch) {
            const sender = this.getSenderDisplay(msg);
            const ttlMinutes = rdpMatch[1] ? parseInt(rdpMatch[1], 10) : undefined;
            this.emit("remote_desktop_request", { channel: "telegram", sender, ttlMinutes });
            return true;
        }
        if (msg.text?.match(/^\/realtime(?:@\S+)?$/i)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("realtime_request", { channel: "telegram", sender, chatId: msg.chat.id });
            return true;
        }
        if (msg.text?.match(/^\/settings(?:@\S+)?$/)) {
            const sender = this.getSenderDisplay(msg);
            this.emit("set_request", { channel: "telegram", sender, chatId: msg.chat.id });
            return true;
        }
        const e2eMatch = msg.text?.match(/^\/e2e(?:@\S+)?(?:\s+(.*))?$/i);
        if (e2eMatch) {
            const sender = this.getSenderDisplay(msg);
            this.emit("e2e_request", { channel: "telegram", sender, rawText: msg.text ?? "/e2e" });
            return true;
        }
        // Unknown slash command — absorb it with a short admin reply instead of
        // letting the LLM improvise (which has led to cases like `/vnc` being
        // silently serviced by the `remote_desktop` tool as if it were `/rdp`).
        // Natural-language messages are unaffected because they don't start with `/`.
        if (msg.text?.match(/^\/[A-Za-z][\w-]*(?:@\S+)?(?:\s|$)/)) {
            const chatId = msg.chat.id;
            void this.sendAdminReply(chatId, "Unknown command. Send /help to see available commands.").catch(() => {
                /* best-effort reply */
            });
            return true;
        }
        return false;
    }
    getSenderDisplay(msg) {
        const fromName = [msg.from?.first_name, msg.from?.last_name]
            .filter(Boolean)
            .join(" ");
        return fromName ? `${fromName} (${msg.chat.id})` : String(msg.chat.id);
    }
    /**
     * Build a reply/quote context prefix when a message is replying to another message.
     * Returns empty string if no reply context is present.
     */
    getReplyContext(msg) {
        if (!("reply_to_message" in msg) || !msg.reply_to_message)
            return "";
        const reply = msg.reply_to_message;
        const name = [reply.from?.first_name, reply.from?.last_name]
            .filter(Boolean)
            .join(" ") || "Unknown";
        const text = reply.text ?? reply.caption ?? "[media]";
        const truncated = text.length > 100 ? text.slice(0, 100) + "\u2026" : text;
        return `[replying to ${name}: "${truncated}"]`;
    }
    shouldEnforceAccessControl() {
        return this._ownerUserId !== null;
    }
    isOwnerDirectMessage(msg) {
        return msg.chat.type === "private" && this._ownerUserId !== null && msg.chat.id === this._ownerUserId;
    }
    findEntryByChatId(chatId) {
        return this.telegramAccess.entries.find((entry) => entry.chatId === chatId);
    }
    findEntryById(entryId) {
        return this.telegramAccess.entries.find((entry) => entry.id === entryId);
    }
    persistTelegramAccess() {
        saveTelegramAccessConfig(this.telegramAccess);
    }
    countHeldMessages(entryId) {
        return this.telegramAccess.heldMessages.filter((message) => message.entryId === entryId).length;
    }
    syncPendingMessageCount(entry) {
        entry.pendingMessageCount = this.countHeldMessages(entry.id);
    }
    async capturePendingMessage(msg, existingEntry) {
        const chatKind = getTelegramChatKind(msg.chat.type);
        if (!chatKind) {
            await this.processInboundMessage(msg);
            return;
        }
        const supportedMessage = msg;
        const entry = existingEntry ?? createTelegramAccessEntry(supportedMessage);
        if (!existingEntry) {
            this.telegramAccess.entries.push(entry);
        }
        entry.title = buildTelegramTitle(supportedMessage);
        entry.username = msg.from?.username ?? msg.chat.username;
        entry.userId = msg.from?.id;
        entry.lastSeenAt = new Date(msg.date * 1000).toISOString();
        entry.lastMessagePreview = buildTelegramPreview(supportedMessage);
        const isDuplicate = this.telegramAccess.heldMessages.some((heldMessage) => heldMessage.entryId === entry.id && heldMessage.message_id === msg.message_id);
        if (!isDuplicate) {
            this.telegramAccess.heldMessages.push(serializeTelegramMessage(supportedMessage, entry.id));
        }
        this.syncPendingMessageCount(entry);
        this.persistTelegramAccess();
        await this.notifyOwnerAboutPendingEntry(entry);
        await this.maybeReplyPendingApproval(msg, entry);
    }
    async notifyOwnerAboutPendingEntry(entry, detailOverride) {
        if (!this.bot || !this._ownerUserId)
            return;
        const nowMs = Date.now();
        const lastNotifiedAtMs = entry.lastOwnerNotifiedAt
            ? Date.parse(entry.lastOwnerNotifiedAt)
            : Number.NaN;
        if (!Number.isNaN(lastNotifiedAtMs) && nowMs - lastNotifiedAtMs < 5 * 60 * 1000) {
            return;
        }
        const detail = detailOverride ?? entry.lastMessagePreview ?? "(no preview)";
        const message = [
            `Pending Telegram request`,
            `${entry.id}  ${entry.kind.toUpperCase()}  ${entry.title}${entry.username ? ` (@${entry.username})` : ""}`,
            `Held messages: ${entry.pendingMessageCount}`,
            `Latest: ${detail}`,
            `Approve: /approve ${entry.id}`,
            `Block: /block ${entry.id}`,
        ].join("\n");
        try {
            await this.sendText(this._ownerUserId, message, {
                reply_markup: {
                    inline_keyboard: [[
                            { text: "Approve", callback_data: `telegram_access:approve:${entry.id}` },
                            { text: "Block", callback_data: `telegram_access:block:${entry.id}` },
                        ]],
                },
            });
            entry.lastOwnerNotifiedAt = new Date(nowMs).toISOString();
            this.persistTelegramAccess();
        }
        catch (err) {
            logger.warn(`Failed to notify owner of pending Telegram request: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    async maybeReplyPendingApproval(msg, entry) {
        if (!this.bot || msg.chat.type !== "private" || entry.autoReplySentAt)
            return;
        try {
            await this.sendText(msg.chat.id, "Your message was received and is waiting for owner approval.");
            entry.autoReplySentAt = new Date().toISOString();
            this.persistTelegramAccess();
        }
        catch (err) {
            logger.warn(`Failed to send pending approval reply: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    buildPendingRequestsReply() {
        const pendingEntries = this.telegramAccess.entries
            .filter((entry) => entry.status === "pending")
            .sort((a, b) => Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt));
        if (pendingEntries.length === 0) {
            return "No pending Telegram requests.";
        }
        return [
            "Pending Telegram requests:",
            ...pendingEntries.map((entry) => formatTelegramAccessSummary(entry)),
        ].join("\n");
    }
    buildChatsReply() {
        const approvedEntries = this.telegramAccess.entries
            .filter((entry) => entry.status === "approved")
            .sort((a, b) => a.title.localeCompare(b.title));
        const blockedEntries = this.telegramAccess.entries
            .filter((entry) => entry.status === "blocked")
            .sort((a, b) => a.title.localeCompare(b.title));
        const lines = [];
        lines.push("Telegram chat access:");
        lines.push("");
        lines.push("Approved:");
        if (approvedEntries.length === 0) {
            lines.push("(none)");
        }
        else {
            lines.push(...approvedEntries.map((entry) => formatTelegramAccessSummary(entry)));
        }
        lines.push("");
        lines.push("Blocked:");
        if (blockedEntries.length === 0) {
            lines.push("(none)");
        }
        else {
            lines.push(...blockedEntries.map((entry) => formatTelegramAccessSummary(entry)));
        }
        return lines.join("\n");
    }
    async approveEntry(entryId, replyChatId, options) {
        const entry = this.findEntryById(entryId);
        if (!entry) {
            const message = `Unknown Telegram request: ${entryId}`;
            if (!options?.suppressReply) {
                await this.sendAdminReply(replyChatId, message);
            }
            return message;
        }
        entry.status = "approved";
        const releasedCount = await this.releaseHeldMessages(entry);
        this.syncPendingMessageCount(entry);
        this.persistTelegramAccess();
        const releasedText = releasedCount > 0 ? ` Released ${releasedCount} held message${releasedCount === 1 ? "" : "s"}.` : "";
        const message = `Approved ${entry.id} (${entry.title}).${releasedText}`;
        if (!options?.suppressReply) {
            await this.sendAdminReply(replyChatId, message);
        }
        return message;
    }
    async blockEntry(entryId, replyChatId, options) {
        const entry = this.findEntryById(entryId);
        if (!entry) {
            const message = `Unknown Telegram request: ${entryId}`;
            if (!options?.suppressReply) {
                await this.sendAdminReply(replyChatId, message);
            }
            return message;
        }
        entry.status = "blocked";
        this.telegramAccess.heldMessages = this.telegramAccess.heldMessages.filter((message) => message.entryId !== entry.id);
        this.syncPendingMessageCount(entry);
        this.persistTelegramAccess();
        const message = `Blocked ${entry.id} (${entry.title}).`;
        if (!options?.suppressReply) {
            await this.sendAdminReply(replyChatId, message);
        }
        return message;
    }
    async releaseHeldMessages(entry) {
        const heldMessages = this.telegramAccess.heldMessages
            .filter((message) => message.entryId === entry.id)
            .sort((a, b) => a.date - b.date || a.message_id - b.message_id);
        this.telegramAccess.heldMessages = this.telegramAccess.heldMessages.filter((message) => message.entryId !== entry.id);
        for (const heldMessage of heldMessages) {
            await this.processInboundMessage(heldMessage);
        }
        return heldMessages.length;
    }
    async sendAdminReply(chatId, message) {
        try {
            await this.sendText(chatId, message);
        }
        catch (err) {
            logger.warn(`Failed to send Telegram admin reply: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    async answerCallbackQuery(callbackQueryId, text) {
        if (!this.bot)
            return;
        try {
            await this.bot.answerCallbackQuery(callbackQueryId, { text });
        }
        catch (err) {
            logger.warn(`Failed to answer Telegram callback query: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    async clearCallbackButtons(query) {
        if (!this.bot || !query.message)
            return;
        try {
            await this.bot.editMessageReplyMarkup({ inline_keyboard: [] }, {
                chat_id: query.message.chat.id,
                message_id: query.message.message_id,
            });
        }
        catch (err) {
            logger.warn(`Failed to clear Telegram callback buttons: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    getUsageClient() {
        if (!this.config.onboardingServiceUrl || !this.config.visionclawServiceApiKey || !this.config.agentName) {
            return null;
        }
        return new OnboardingServiceClient(this.config.onboardingServiceUrl, this.config.agentName, this.config.visionclawServiceApiKey);
    }
    formatUsageSummary(period) {
        const label = period.period === "previous" ? "Previous" : "Current";
        const start = period.summary.periodStart?.slice(0, 10) ?? "n/a";
        const end = period.summary.periodEnd?.slice(0, 10) ?? "n/a";
        // wuUsed / availableWu are stored in mWU (1 WU = 1000 mWU). Display in WU.
        // The onboarding service's mwuToWu/formatWu live in a separate package we
        // can't import from here, so the /1000 conversion is inlined.
        const formatWu = (mwu) => (mwu / 1000).toLocaleString("en-US", { maximumFractionDigits: 2 });
        const limit = period.summary.availableWu === null ? "unlimited" : formatWu(period.summary.availableWu);
        return `${label}: ${formatWu(period.summary.wuUsed)}/${limit} WU, ${period.summary.meteredCycleCount} wake cycle(s), ${start} to ${end}`;
    }
    /** In-memory cache of period availability between /usage prompt and button click. */
    _usagePeriodCache = null;
    async loadUsagePeriods() {
        const client = this.getUsageClient();
        if (!client)
            return null;
        const cached = this._usagePeriodCache;
        if (cached && Date.now() - cached.fetchedAt < 60 * 1000) {
            return cached.periods;
        }
        const availability = await client.getUsagePeriods();
        if (!availability.success)
            return null;
        this._usagePeriodCache = { fetchedAt: Date.now(), periods: availability.periods };
        return availability.periods;
    }
    async sendUsagePeriodPrompt(chatId) {
        if (!this.bot)
            return;
        const client = this.getUsageClient();
        if (!client) {
            await this.sendText(chatId, "Usage reports are not configured on this agent yet.");
            return;
        }
        const periods = await this.loadUsagePeriods();
        if (!periods) {
            await this.sendText(chatId, "Usage reports are temporarily unavailable. Please try again later.");
            return;
        }
        const availablePeriods = periods.filter((period) => period.available);
        if (availablePeriods.length === 0) {
            await this.sendText(chatId, "No usage report is available yet. Please try again after the agent has uploaded usage data.");
            return;
        }
        const summaryLines = periods.map((period) => this.formatUsageSummary(period));
        const buttons = availablePeriods.map((period) => [{
                text: period.period === "previous" ? "Previous period PDF" : "Current period PDF",
                callback_data: `usage_pdf:${period.period}`,
            }]);
        await this.bot.sendMessage(chatId, [
            "Usage report is ready.",
            "",
            ...summaryLines,
            "",
            "Choose a PDF to download:",
        ].join("\n"), {
            reply_markup: { inline_keyboard: buttons },
        });
    }
    /**
     * Local PDF cache filename. Includes the resolved period range so a roll-over
     * (e.g. month flip) invalidates the previous cache and a fresh PDF is fetched.
     */
    async usagePdfCachePath(period, periodStart, periodEnd) {
        const cacheDir = path.join(tmpdir(), "visionclaw-usage-pdfs");
        await mkdir(cacheDir, { recursive: true });
        const range = periodStart && periodEnd
            ? `${periodStart.slice(0, 10)}-${periodEnd.slice(0, 10)}`
            : period;
        const safeAgent = this.config.agentName.replace(/[^a-zA-Z0-9_.-]/g, "_");
        const safeRange = range.replace(/[^a-zA-Z0-9_.-]/g, "_");
        return path.join(cacheDir, `${safeAgent}-${period}-${safeRange}.pdf`);
    }
    async sendUsagePdf(chatId, period) {
        if (!this.bot)
            return;
        const client = this.getUsageClient();
        if (!client) {
            await this.sendText(chatId, "Usage reports are not configured on this agent yet.");
            return;
        }
        const periods = await this.loadUsagePeriods();
        const summary = periods?.find((entry) => entry.period === period)?.summary;
        const localPath = await this.usagePdfCachePath(period, summary?.periodStart ?? null, summary?.periodEnd ?? null);
        let useLocal = false;
        try {
            const cached = await stat(localPath);
            useLocal = Date.now() - cached.mtimeMs < 30 * 60 * 1000;
        }
        catch {
            useLocal = false;
        }
        if (!useLocal) {
            const download = await client.downloadUsagePdf(localPath, period);
            if (!download.success) {
                await this.sendText(chatId, "No usage PDF is available for that period yet.");
                return;
            }
        }
        const rangeKey = summary?.periodStart?.slice(0, 10) ?? period;
        await this.bot.sendDocument(chatId, localPath, { caption: "Here is your usage report. There may be up to a 30 minute delay in this report." }, { filename: `visionclaw-usage-${period}-${rangeKey}.pdf`, contentType: "application/pdf" });
    }
    async handleUsagePdfCallback(query) {
        const callbackId = query.id;
        const data = query.data;
        if (!callbackId || !data)
            return;
        if (!this._ownerUserId || query.from.id !== this._ownerUserId) {
            await this.answerCallbackQuery(callbackId, "Only the owner can use these buttons.");
            return;
        }
        const messageChatId = query.message?.chat.id;
        if (typeof messageChatId !== "number") {
            await this.answerCallbackQuery(callbackId, "This action is no longer available.");
            return;
        }
        const match = /^usage_pdf:(current|previous)$/u.exec(data);
        if (!match) {
            await this.answerCallbackQuery(callbackId, "Unknown usage period.");
            return;
        }
        const period = match[1];
        await this.answerCallbackQuery(callbackId, `Preparing ${period} usage PDF...`);
        await this.sendUsagePdf(messageChatId, period);
    }
    async stop() {
        if (this.bot) {
            await this.bot.stopPolling({ cancel: true });
            this.bot = null;
        }
    }
    async sendMessage(to, message, attachments, replyTo) {
        if (!this.bot) {
            throw new Error("Telegram bot not started");
        }
        const replyTarget = getTelegramReplyTarget(replyTo?.meta);
        // Extract numeric chat ID from sender string like "Name (12345)" or plain "12345"
        // only when no structured Telegram reply target is available.
        const match = replyTarget ? null : (/\((-?\d+)\)/.exec(to) ?? /^(-?\d+)$/.exec(to));
        const chatId = replyTarget?.chatId ?? (match ? Number(match[1]) : NaN);
        if (isNaN(chatId)) {
            throw new Error(`Invalid Telegram chat ID: ${to}`);
        }
        const sendOptions = {};
        if (replyTarget?.messageThreadId !== undefined) {
            sendOptions.message_thread_id = replyTarget.messageThreadId;
        }
        if (replyTarget?.replyToMessageId !== undefined) {
            sendOptions.reply_to_message_id = replyTarget.replyToMessageId;
            sendOptions.allow_sending_without_reply = true;
        }
        // Send attachments first
        if (attachments && attachments.length > 0) {
            for (const att of attachments) {
                // Resolve the source: prefer local file path, then URL
                let source;
                if (att.localPath) {
                    source = att.localPath;
                }
                else if (att.url) {
                    // Validate URL has a proper host before sending to Telegram
                    try {
                        const parsed = new URL(att.url);
                        if (!parsed.host) {
                            logger.warn(`Telegram: Skipping attachment with empty URL host: ${att.url}`);
                            continue;
                        }
                        source = att.url;
                    }
                    catch {
                        // Not a valid URL — treat as local file path if it looks like one
                        if (att.url.startsWith("/")) {
                            source = att.url;
                        }
                        else {
                            logger.warn(`Telegram: Skipping attachment with invalid URL: ${att.url}`);
                            continue;
                        }
                    }
                }
                if (!source)
                    continue;
                // For local files, use a read stream so node-telegram-bot-api
                // sends the file content directly with the correct content type
                // (avoids the deprecated auto-detect behaviour).
                const isLocal = source.startsWith("/");
                const fileSource = isLocal ? fs.createReadStream(source) : source;
                const fileOpts = {
                    filename: att.filename ?? (isLocal ? source.split("/").pop() : undefined),
                    contentType: att.mimeType ?? "application/octet-stream",
                };
                try {
                    switch (att.type) {
                        case "image":
                            await this.bot.sendPhoto(chatId, fileSource, { ...sendOptions, caption: att.filename }, fileOpts);
                            break;
                        case "audio":
                            await this.bot.sendVoice(chatId, fileSource, sendOptions, fileOpts);
                            break;
                        case "video":
                            await this.bot.sendVideo(chatId, fileSource, { ...sendOptions, caption: att.filename }, fileOpts);
                            break;
                        case "file":
                        case "document":
                            await this.bot.sendDocument(chatId, fileSource, { ...sendOptions, caption: att.filename }, fileOpts);
                            break;
                    }
                }
                catch (err) {
                    logger.warn(`Telegram: Failed to send ${att.type}: ${err instanceof Error ? err.message : String(err)}`);
                }
            }
        }
        // Send text message
        await this.sendText(chatId, message, Object.keys(sendOptions).length > 0 ? sendOptions : undefined);
    }
    async sendTypingIndicator(to) {
        if (!this.bot)
            return;
        const match = /\((-?\d+)\)/.exec(to) ?? /^(-?\d+)$/.exec(to);
        const chatId = match ? Number(match[1]) : NaN;
        if (isNaN(chatId))
            return;
        try {
            await this.bot.sendChatAction(chatId, "typing");
        }
        catch {
            // best-effort
        }
    }
    /**
     * Process an inbound Telegram message, resolving media file links
     * before emitting the CommandMessage.
     */
    async processInboundMessage(msg) {
        try {
            const senderDisplay = this.getSenderDisplay(msg);
            const replyContext = this.getReplyContext(msg);
            const messageText = msg.text ?? msg.caption ?? "";
            const commandMessage = {
                id: `telegram-${msg.message_id}-${msg.chat.id}`,
                channel: "telegram",
                sender: senderDisplay,
                text: replyContext ? `${replyContext}\n${messageText}` : messageText,
                attachments: [],
                timestamp: new Date(msg.date * 1000).toISOString(),
                meta: {
                    chatId: msg.chat.id,
                    messageId: msg.message_id,
                    from: msg.from,
                    chatType: msg.chat.type,
                    username: msg.from?.username,
                    telegramReplyTarget: {
                        chatId: msg.chat.id,
                        messageThreadId: "message_thread_id" in msg ? msg.message_thread_id : undefined,
                        replyToMessageId: msg.message_id,
                        chatType: msg.chat.type,
                    },
                },
            };
            // Handle photos
            if (msg.photo && msg.photo.length > 0) {
                const largestPhoto = msg.photo[msg.photo.length - 1];
                const url = await this.resolveFileLink(largestPhoto.file_id);
                commandMessage.attachments.push({
                    type: "image",
                    url: url ?? undefined,
                    mimeType: "image/jpeg",
                });
            }
            // Handle documents
            if (msg.document) {
                const filename = msg.document.file_name ?? "document";
                const mimeType = msg.document.mime_type ?? "application/octet-stream";
                const url = await this.resolveFileLink(msg.document.file_id);
                commandMessage.attachments.push({
                    type: mimeType.startsWith("image/") ? "image" : "file",
                    url: url ?? undefined,
                    filename,
                    mimeType,
                });
            }
            // Handle voice messages — pre-transcribe and pass both audio + text
            if (msg.voice) {
                const mimeType = msg.voice.mime_type ?? "audio/ogg";
                const url = await this.resolveFileLink(msg.voice.file_id);
                commandMessage.attachments.push({
                    type: "audio",
                    url: url ?? undefined,
                    mimeType,
                });
                if (url) {
                    try {
                        const transcript = await transcribeAudioFile(url);
                        const label = "[Voice message transcription]";
                        commandMessage.text = commandMessage.text
                            ? `${label}: ${transcript}\n${commandMessage.text}`
                            : `${label}: ${transcript}`;
                    }
                    catch (err) {
                        logger.warn(`Telegram: Voice pre-transcription failed: ${err instanceof Error ? err.message : String(err)}`);
                    }
                }
            }
            // Handle video
            if (msg.video) {
                const mimeType = msg.video.mime_type ?? "video/mp4";
                const url = await this.resolveFileLink(msg.video.file_id);
                commandMessage.attachments.push({
                    type: "video",
                    url: url ?? undefined,
                    mimeType,
                });
            }
            // Handle stickers as images
            if (msg.sticker) {
                const url = await this.resolveFileLink(msg.sticker.file_id);
                commandMessage.attachments.push({
                    type: "image",
                    url: url ?? undefined,
                    mimeType: "image/webp",
                });
            }
            if (commandMessage.text || commandMessage.attachments.length > 0) {
                this.emit("message", commandMessage);
            }
        }
        catch (err) {
            logger.warn(`Telegram: Error processing message: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    /**
     * Resolve a Telegram file_id to a temporary download URL.
     */
    async resolveFileLink(fileId) {
        if (!this.bot)
            return null;
        try {
            return await this.bot.getFileLink(fileId);
        }
        catch (err) {
            logger.warn(`Telegram: Failed to resolve file link: ${err instanceof Error ? err.message : String(err)}`);
            return null;
        }
    }
    async sendText(chatId, message, options) {
        if (!this.bot || !message.trim())
            return;
        const maxLength = 4096;
        if (message.length <= maxLength) {
            await this.bot.sendMessage(chatId, message, options);
            return;
        }
        for (let i = 0; i < message.length; i += maxLength) {
            await this.bot.sendMessage(chatId, message.substring(i, i + maxLength), options);
        }
    }
    // ─── /settings command (language + timezone) ─────────────────────────
    /** Common timezone options for the inline keyboard. */
    static TIMEZONE_OPTIONS = [
        { label: "UTC-8 Los Angeles", value: "America/Los_Angeles" },
        { label: "UTC-6 Chicago", value: "America/Chicago" },
        { label: "UTC-5 New York", value: "America/New_York" },
        { label: "UTC+0 London", value: "Europe/London" },
        { label: "UTC+1 Paris", value: "Europe/Paris" },
        { label: "UTC+3 Moscow", value: "Europe/Moscow" },
        { label: "UTC+5:30 Mumbai", value: "Asia/Kolkata" },
        { label: "UTC+7 Bangkok", value: "Asia/Bangkok" },
        { label: "UTC+8 Singapore", value: "Asia/Singapore" },
        { label: "UTC+8 Shanghai", value: "Asia/Shanghai" },
        { label: "UTC+9 Tokyo", value: "Asia/Tokyo" },
        { label: "UTC+10 Sydney", value: "Australia/Sydney" },
    ];
    /** Language options for the inline keyboard. */
    static LANGUAGE_OPTIONS = [
        { label: "English", value: "en" },
        { label: "中文", value: "zh" },
    ];
    /**
     * Send a message with a Telegram Mini App (Web App) button.
     */
    async sendMiniAppButton(chatId, text, buttonLabel, webAppUrl) {
        if (!this.bot)
            return;
        const buttons = [
            [{ text: buttonLabel, web_app: { url: webAppUrl } }],
        ];
        try {
            await this.bot.sendMessage(chatId, text, {
                reply_markup: { inline_keyboard: buttons },
            });
        }
        catch (err) {
            logger.warn(`Telegram: Failed to send Mini App button: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    /**
     * Send the /settings top-level menu with "Language" and "Timezone" buttons.
     */
    async sendSetMenu(chatId) {
        if (!this.bot)
            return;
        const locale = resolveLocale(this.getOwnerLocale());
        const currentLanguage = locale;
        const currentTimezone = this.getOwnerTimezone();
        const currentLanguageLabel = TelegramAdapter.LANGUAGE_OPTIONS.find((opt) => opt.value === currentLanguage)?.label ?? "English";
        const currentTimezoneLabel = TelegramAdapter.TIMEZONE_OPTIONS.find((opt) => opt.value === currentTimezone)?.label
            ?? currentTimezone
            ?? "Not set";
        const buttons = [
            [
                {
                    text: `${t("cmd.set.language_label", locale)}: ${currentLanguageLabel}`,
                    callback_data: "set:menu:language",
                },
                {
                    text: `${t("cmd.set.timezone_label", locale)}: ${currentTimezoneLabel}`,
                    callback_data: "set:menu:timezone",
                },
            ],
        ];
        try {
            await this.bot.sendMessage(chatId, t("cmd.set.menu", locale), {
                reply_markup: { inline_keyboard: buttons },
            });
        }
        catch (err) {
            logger.warn(`Telegram: Failed to send /settings menu: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    /**
     * Send the language selection inline keyboard.
     */
    async sendLanguageMenu(chatId) {
        if (!this.bot)
            return;
        const locale = resolveLocale(this.getOwnerLocale());
        const currentLanguage = locale;
        const buttons = TelegramAdapter.LANGUAGE_OPTIONS.map((opt) => [{
                text: opt.value === currentLanguage ? `✓ ${opt.label}` : opt.label,
                callback_data: `set:lang:${opt.value}`,
            }]);
        try {
            await this.bot.sendMessage(chatId, t("cmd.set.choose_language", locale), {
                reply_markup: { inline_keyboard: buttons },
            });
        }
        catch (err) {
            logger.warn(`Telegram: Failed to send language menu: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    /**
     * Send the timezone selection inline keyboard.
     */
    async sendTimezoneMenu(chatId) {
        if (!this.bot)
            return;
        const locale = resolveLocale(this.getOwnerLocale());
        const currentTimezone = this.getOwnerTimezone();
        // Two buttons per row for compactness
        const buttons = [];
        for (let i = 0; i < TelegramAdapter.TIMEZONE_OPTIONS.length; i += 2) {
            const row = [];
            row.push({
                text: TelegramAdapter.TIMEZONE_OPTIONS[i].value === currentTimezone
                    ? `✓ ${TelegramAdapter.TIMEZONE_OPTIONS[i].label}`
                    : TelegramAdapter.TIMEZONE_OPTIONS[i].label,
                callback_data: `set:tz:${TelegramAdapter.TIMEZONE_OPTIONS[i].value}`,
            });
            if (i + 1 < TelegramAdapter.TIMEZONE_OPTIONS.length) {
                row.push({
                    text: TelegramAdapter.TIMEZONE_OPTIONS[i + 1].value === currentTimezone
                        ? `✓ ${TelegramAdapter.TIMEZONE_OPTIONS[i + 1].label}`
                        : TelegramAdapter.TIMEZONE_OPTIONS[i + 1].label,
                    callback_data: `set:tz:${TelegramAdapter.TIMEZONE_OPTIONS[i + 1].value}`,
                });
            }
            buttons.push(row);
        }
        try {
            await this.bot.sendMessage(chatId, t("cmd.set.choose_timezone", locale), {
                reply_markup: { inline_keyboard: buttons },
            });
        }
        catch (err) {
            logger.warn(`Telegram: Failed to send timezone menu: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    /**
     * Handle /settings-related callback queries (language and timezone selection).
     */
    async handleSetCallback(query) {
        if (!this.bot)
            return;
        const callbackId = query.id;
        const data = query.data;
        const chatId = query.message?.chat.id;
        if (!callbackId || !data || typeof chatId !== "number")
            return;
        // Only the owner can use /settings buttons
        if (!this._ownerUserId || query.from.id !== this._ownerUserId) {
            await this.answerCallbackQuery(callbackId, "Only the owner can use these buttons.");
            return;
        }
        // Sub-menu selection: show language or timezone picker
        if (data === "set:menu:language") {
            await this.answerCallbackQuery(callbackId, "");
            await this.sendLanguageMenu(chatId);
            return;
        }
        if (data === "set:menu:timezone") {
            await this.answerCallbackQuery(callbackId, "");
            await this.sendTimezoneMenu(chatId);
            return;
        }
        // Language selected: set:lang:<code>
        const langMatch = /^set:lang:([a-z]{2})$/.exec(data);
        if (langMatch) {
            await this.answerCallbackQuery(callbackId, "");
            await this.clearCallbackButtons(query);
            this.emit("set_language_selected", { chatId, language: langMatch[1] });
            return;
        }
        // Timezone selected: set:tz:<IANA name>
        const tzMatch = /^set:tz:(.+)$/.exec(data);
        if (tzMatch) {
            await this.answerCallbackQuery(callbackId, "");
            await this.clearCallbackButtons(query);
            this.emit("set_timezone_selected", { chatId, timezone: tzMatch[1] });
            return;
        }
        await this.answerCallbackQuery(callbackId, "Unknown setting.");
    }
    // ─── Billing / Telegram Payments ─────────────────────────────────────
    /**
     * Send a plan selection prompt with inline keyboard buttons.
     * Called by the subscription gate when the subscription expires.
     * Plans come from the heartbeat response, not hardcoded.
     * User taps a plan → billing_plan_selected event is emitted.
     */
    async sendPaymentPrompt(chatId, payload) {
        if (!this.bot)
            return;
        const locale = resolveLocale(this.getOwnerLocale());
        const plans = payload.plans;
        if (plans.length === 0) {
            await this.sendText(chatId, t("billing.no_plans", locale));
            return;
        }
        const buttons = plans.map((plan) => [
            {
                // Include a short description so the button is self-contained.
                // Truncate to 60 chars to stay within Telegram's button text limit.
                text: `${plan.name} — $${plan.amount}/mo · ${plan.description}`.slice(0, 60),
                callback_data: `${BILLING_PLAN_CALLBACK_PREFIX}${plan.id}`,
            },
        ]);
        const billingMessage = payload.message ?? "\ud83d\udcb3 Subscription Required";
        // Cache plans so the callback handler can look up plan names
        this._cachedPlans = plans;
        const replyMarkup = { inline_keyboard: buttons };
        try {
            // The image is decorative, so don't let upload failures block the
            // billing text, pricing link, or plan buttons.
            if (fs.existsSync(PRICING_IMAGE_PATH)) {
                try {
                    const imgBuffer = fs.readFileSync(PRICING_IMAGE_PATH);
                    // Include a short content hash in the filename so Telegram treats
                    // an updated image as a new file instead of serving a cached copy.
                    const hash = createHash("md5").update(imgBuffer).digest("hex").slice(0, 8);
                    await this.bot.sendPhoto(chatId, imgBuffer, {}, { filename: `visionclaw_pricing_${hash}.jpeg`, contentType: "image/jpeg" });
                }
                catch (err) {
                    logger.warn(`Telegram: Failed to send pricing image: ${err instanceof Error ? err.message : String(err)}`);
                }
            }
            // Billing message as standalone text (no length limit).
            await this.bot.sendMessage(chatId, billingMessage);
            // Plan selection buttons.
            await this.bot.sendMessage(chatId, t("billing.select_plan", locale), {
                reply_markup: replyMarkup,
            });
            logger.log("info", "telegram", `Sent plan selection prompt to chat ${chatId}`);
        }
        catch (err) {
            logger.warn(`Telegram: Failed to send payment prompt: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    /**
     * Send a Stripe Checkout link as a URL button.
     * Called after the payment handler obtains a checkout URL from the backend.
     */
    async sendCheckoutLink(chatId, planName, checkoutUrl) {
        if (!this.bot)
            return;
        const locale = resolveLocale(this.getOwnerLocale());
        const message = [
            t("billing.subscribe_to", locale, planName),
            ``,
            t("billing.complete_subscription", locale),
        ].join("\n");
        const buttons = [
            [{ text: t("billing.subscribe_now", locale), url: checkoutUrl }],
        ];
        try {
            await this.bot.sendMessage(chatId, message, {
                reply_markup: { inline_keyboard: buttons },
            });
            logger.log("info", "telegram", `Sent checkout link to chat ${chatId}`);
        }
        catch (err) {
            logger.warn(`Telegram: Failed to send checkout link: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    /**
     * Send a bundle purchase (one-time top-up) amount selection prompt.
     * Called by the billing handler when the admin dispatches bundle_purchase.
     * User taps an amount → billing_bundle_selected event is emitted.
     */
    async sendBundlePrompt(chatId, payload) {
        if (!this.bot)
            return;
        const locale = resolveLocale(this.getOwnerLocale());
        const amounts = payload.amounts;
        if (amounts.length === 0) {
            await this.sendText(chatId, t("billing.no_bundles", locale));
            return;
        }
        // Build 2-per-row button layout
        const buttons = [];
        for (let i = 0; i < amounts.length; i += 2) {
            const row = amounts.slice(i, i + 2).map((amount) => ({
                text: `$${amount}`,
                callback_data: `${BILLING_BUNDLE_CALLBACK_PREFIX}${amount}`,
            }));
            buttons.push(row);
        }
        const message = t("billing.bundle_topup", locale);
        try {
            await this.bot.sendMessage(chatId, message, {
                reply_markup: { inline_keyboard: buttons },
            });
            logger.log("info", "telegram", `Sent bundle amount prompt to chat ${chatId}`);
        }
        catch (err) {
            logger.warn(`Telegram: Failed to send bundle prompt: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    /**
     * Handle billing-related callback queries (user tapped a plan or bundle button).
     * Emits a `billing_plan_selected` or `billing_bundle_selected` event.
     */
    async handleBillingCallback(query) {
        if (!this.bot)
            return;
        const callbackId = query.id;
        const data = query.data;
        const chatId = query.message?.chat.id;
        if (!callbackId || !data || typeof chatId !== "number") {
            return;
        }
        // Only the owner can interact with billing buttons
        if (!this._ownerUserId || query.from.id !== this._ownerUserId) {
            await this.answerCallbackQuery(callbackId, "Only the owner can use payment buttons.");
            return;
        }
        // Plan selection: billing:plan:<id> → emit event for payment handler
        if (data.startsWith(BILLING_PLAN_CALLBACK_PREFIX)) {
            const planId = data.slice(BILLING_PLAN_CALLBACK_PREFIX.length);
            const plan = this._cachedPlans?.find((p) => p.id === planId);
            const planName = plan?.name ?? planId;
            await this.answerCallbackQuery(callbackId, `Setting up payment for ${planName}...`);
            // Keep the plan buttons so the user can change their mind and pick
            // a different plan before completing payment.
            this.emit("billing_plan_selected", {
                chatId,
                planId,
                planName,
            });
            return;
        }
        // Bundle amount selection: billing:bundle:<amount> → emit event for billing handler
        if (data.startsWith(BILLING_BUNDLE_CALLBACK_PREFIX)) {
            const amount = parseInt(data.slice(BILLING_BUNDLE_CALLBACK_PREFIX.length), 10);
            if (isNaN(amount) || amount <= 0 || !BUNDLE_ALLOWED_AMOUNTS.includes(amount)) {
                await this.answerCallbackQuery(callbackId, "Invalid amount.");
                return;
            }
            await this.answerCallbackQuery(callbackId, `Setting up $${amount} bundle purchase...`);
            this.emit("billing_bundle_selected", {
                chatId,
                amount,
            });
            return;
        }
        await this.answerCallbackQuery(callbackId, "Unknown billing action.");
    }
}
//# sourceMappingURL=telegram.js.map