import { Bot, InputFile } from "grammy";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { AsyncLocalStorage } from "node:async_hooks";
import { NoChatContext } from "../agent/agent-types.js";
import { ExecutionLogger } from "../logging/logger.js";
const TELEGRAM_MAX_TEXT = 4096;
const MIN_INITIAL_CHARS = 20;
const THROTTLE_MS = 1000;
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
const MAX_DOCUMENT_BYTES = 20 * 1024 * 1024;
const TEXTISH_EXTENSIONS = new Set([
    ".md", ".markdown", ".txt", ".log",
    ".yaml", ".yml", ".toml", ".ini", ".csv", ".tsv",
    ".json", ".jsonl", ".xml", ".html", ".htm",
    ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
    ".py", ".rb", ".go", ".rs", ".java", ".c", ".h", ".cpp", ".hpp",
    ".sh", ".bash", ".zsh", ".fish",
    ".sql", ".env", ".conf",
]);
/** MIME types the LLM provider API accepts for inline image blocks. */
const INLINABLE_IMAGE_MIMES = new Set([
    "image/png",
    "image/jpeg",
    "image/gif",
    "image/webp",
]);
const INLINABLE_IMAGE_EXTENSIONS = {
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".gif": "image/gif",
    ".webp": "image/webp",
};
function isTextishMime(mime, filename) {
    if (mime) {
        if (mime.startsWith("text/"))
            return true;
        if (mime === "application/json" || mime === "application/xml")
            return true;
        if (mime.startsWith("application/x-sh"))
            return true;
    }
    const ext = path.extname(filename).toLowerCase();
    return TEXTISH_EXTENSIONS.has(ext);
}
/**
 * Resolve an inlinable image MIME type from the document's declared MIME plus
 * its extension. Returns null if the image cannot be safely inlined (HEIC,
 * BMP, TIFF, SVG, etc. — the LLM API only accepts png/jpeg/gif/webp).
 */
function resolveInlinableImageMime(declared, filename) {
    if (declared && INLINABLE_IMAGE_MIMES.has(declared)) {
        return declared;
    }
    const ext = path.extname(filename).toLowerCase();
    return INLINABLE_IMAGE_EXTENSIONS[ext] ?? null;
}
function isAnyImageMime(mime, filename) {
    if (mime?.startsWith("image/"))
        return true;
    const ext = path.extname(filename).toLowerCase();
    return ext in INLINABLE_IMAGE_EXTENSIONS || [".bmp", ".tiff", ".heic", ".heif", ".svg"].includes(ext);
}
function sanitizeFilename(name) {
    return name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
}
export class TelegramBot {
    bot;
    token;
    agent;
    allowedUsers;
    attachmentsDir;
    /** Per-session chat context. Each incoming message runs its agent loop inside
     *  .run({chatId}) so the attachment sender can pin deliveries to the
     *  originating chat even under concurrent conversations. */
    chatContext = new AsyncLocalStorage();
    constructor(token, agent, allowedUsers) {
        this.bot = new Bot(token);
        this.token = token;
        this.agent = agent;
        this.allowedUsers = new Set(allowedUsers);
        this.attachmentsDir = path.join(agent.getWorkspace(), "attachments");
        fs.mkdirSync(this.attachmentsDir, { recursive: true });
        this.agent.registerFrontend("telegram", {
            // Both callbacks are scoped to the chatContext ALS — they throw
            // NoChatContext when called outside a Telegram update handler so the
            // Agent multiplexer can fall through to another frontend (e.g. center)
            // that does have a chat scope on the stack. Pre-multiplex behavior
            // (broadcasting to all allowed users from scheduled-task scope) is
            // intentionally dropped: in center mode, scheduled-task attachments are
            // delivered via R2 + task_run.attachments[], not Telegram broadcast.
            sender: async (payload) => {
                const store = this.chatContext.getStore();
                if (!store) {
                    throw new NoChatContext("Telegram sender called outside a chat update handler");
                }
                await this.sendAttachment(store.chatId, payload);
            },
            notifier: async (text) => {
                const store = this.chatContext.getStore();
                if (!store) {
                    throw new NoChatContext("Telegram notifier called outside a chat update handler");
                }
                try {
                    await this.bot.api.sendMessage(store.chatId, text);
                }
                catch {
                    // Notifications are best-effort; don't propagate.
                }
            },
        });
        this.setupHandlers();
    }
    isAllowed(ctx) {
        if (this.allowedUsers.size === 0)
            return true; // No restriction if empty
        const userId = ctx.from?.id;
        return userId !== undefined && this.allowedUsers.has(userId);
    }
    setupHandlers() {
        // Auth middleware
        this.bot.use(async (ctx, next) => {
            if (!this.isAllowed(ctx)) {
                await ctx.reply("⛔ Unauthorized. Your user ID: " + ctx.from?.id);
                return;
            }
            await next();
        });
        // Commands
        this.bot.command("start", async (ctx) => {
            await ctx.reply(`Hi, I'm ${this.agent.getName()}. Send me a message to get started.`);
        });
        this.bot.command("new", async (ctx) => {
            await this.agent.clearChat();
            await ctx.reply("🔄 New session started. Chat history cleared.");
        });
        this.bot.command("tasks", async (ctx) => {
            await ctx.reply(this.agent.listTasks() || "(no tasks)");
        });
        this.bot.command("add", async (ctx) => {
            const text = ctx.match;
            if (!text) {
                await ctx.reply("Usage: /add <name> | <cron>[@<timezone>] | <prompt>");
                return;
            }
            const parts = text.split("|").map((s) => s.trim());
            if (parts.length < 3) {
                await ctx.reply("Usage: /add <name> | <cron>[@<timezone>] | <prompt>");
                return;
            }
            const [name, cronField, ...promptParts] = parts;
            const prompt = promptParts.join("|");
            const atIdx = cronField.lastIndexOf("@");
            const cron = atIdx > 0 ? cronField.slice(0, atIdx).trim() : cronField;
            const timezone = atIdx > 0 ? cronField.slice(atIdx + 1).trim() : undefined;
            try {
                const result = this.agent.addTask(name, cron, prompt, timezone);
                await ctx.reply(result);
            }
            catch (err) {
                await ctx.reply(`Error: ${err instanceof Error ? err.message : String(err)}`);
            }
        });
        this.bot.command("remove", async (ctx) => {
            const id = ctx.match?.trim();
            if (!id) {
                await ctx.reply("Usage: /remove <task-id>");
                return;
            }
            await ctx.reply(this.agent.removeTask(id));
        });
        this.bot.command("enable", async (ctx) => {
            const id = ctx.match?.trim();
            if (!id) {
                await ctx.reply("Usage: /enable <task-id>");
                return;
            }
            await ctx.reply(this.agent.toggleTask(id, true));
        });
        this.bot.command("disable", async (ctx) => {
            const id = ctx.match?.trim();
            if (!id) {
                await ctx.reply("Usage: /disable <task-id>");
                return;
            }
            await ctx.reply(this.agent.toggleTask(id, false));
        });
        this.bot.command("run", async (ctx) => {
            const id = ctx.match?.trim();
            if (!id) {
                await ctx.reply("Usage: /run <task-id>");
                return;
            }
            await ctx.reply("⏳ Running task...");
            try {
                const result = await this.agent.runTaskById(id);
                // Split long messages (Telegram 4096 char limit)
                const chunks = this.splitMessage(result);
                for (const chunk of chunks) {
                    await ctx.reply(chunk);
                }
            }
            catch (err) {
                await ctx.reply(`❌ Error: ${err instanceof Error ? err.message : String(err)}`);
            }
        });
        this.bot.command("logs", async (ctx) => {
            const logsDir = path.join(this.agent.getWorkspace(), "logs");
            const logs = ExecutionLogger.listLogs(logsDir);
            if (logs.length === 0) {
                await ctx.reply("(no logs found)");
                return;
            }
            await ctx.reply("Recent logs:\n" + logs.slice(0, 20).map((l) => `  ${l}`).join("\n"));
        });
        this.bot.command("skills", async (ctx) => {
            await ctx.reply(this.agent.listSkills() || "(no skills)");
        });
        this.bot.command("genskill", async (ctx) => {
            const logFile = ctx.match?.trim();
            if (!logFile) {
                await ctx.reply("Usage: /genskill <log-filename>");
                return;
            }
            await ctx.reply("⏳ Generating skill...");
            try {
                const result = await this.agent.generateSkill(logFile);
                await ctx.reply(result);
            }
            catch (err) {
                await ctx.reply(`❌ Error: ${err instanceof Error ? err.message : String(err)}`);
            }
        });
        this.bot.command("memory", async (ctx) => {
            await ctx.reply(this.agent.listMemories() || "(no memories)");
        });
        // Regular messages → chat with agent (streaming)
        this.bot.on("message:text", async (ctx) => {
            try {
                await this.handleStreamingChat(ctx, ctx.message.text);
            }
            catch (err) {
                await ctx.reply(`❌ Error: ${err instanceof Error ? err.message : String(err)}`);
            }
        });
        // Photo uploads (Telegram compresses to multiple PhotoSize — pick largest)
        this.bot.on("message:photo", async (ctx) => {
            try {
                const photos = ctx.message.photo;
                const largest = photos[photos.length - 1];
                if (largest.file_size && largest.file_size > MAX_IMAGE_BYTES) {
                    await ctx.reply(`Image is too large (${largest.file_size} bytes). Limit: ${MAX_IMAGE_BYTES} bytes.`);
                    return;
                }
                const saved = await this.downloadToAttachments(ctx, largest.file_id, `photo-${largest.file_unique_id}.jpg`);
                const caption = ctx.message.caption || "";
                const blocks = await this.buildImageBlocks(saved, caption, "image/jpeg");
                await this.handleStreamingChat(ctx, blocks);
            }
            catch (err) {
                await ctx.reply(`❌ Error handling photo: ${err instanceof Error ? err.message : String(err)}`);
            }
        });
        // Document uploads (files, images-as-documents, PDFs, etc.)
        this.bot.on("message:document", async (ctx) => {
            try {
                const doc = ctx.message.document;
                if (doc.file_size && doc.file_size > MAX_DOCUMENT_BYTES) {
                    await ctx.reply(`File is too large (${doc.file_size} bytes). Limit: ${MAX_DOCUMENT_BYTES} bytes.`);
                    return;
                }
                const saved = await this.downloadToAttachments(ctx, doc.file_id, doc.file_name || `doc-${doc.file_unique_id}`);
                const caption = ctx.message.caption || "";
                const mime = doc.mime_type;
                const inlineMime = resolveInlinableImageMime(mime, saved.filename);
                if (inlineMime) {
                    if (saved.size > MAX_IMAGE_BYTES) {
                        await ctx.reply(`Image is too large to inline (${saved.size} bytes). Saved as attachments/${saved.filename} but not sent to the model.`);
                        return;
                    }
                    const blocks = await this.buildImageBlocks(saved, caption, inlineMime);
                    await this.handleStreamingChat(ctx, blocks);
                    return;
                }
                // Image-like MIME but not inlinable (HEIC/BMP/TIFF/SVG/...) — save and
                // tell the LLM it's there but can't be shown.
                if (isAnyImageMime(mime, saved.filename)) {
                    const preamble = `[user uploaded image: attachments/${saved.filename}, MIME: ${mime || "unknown"}, size: ${saved.size} bytes] (format ${mime || path.extname(saved.filename)} cannot be inlined for viewing; file is saved for reference)`;
                    const text = caption ? `${preamble}\n${caption}` : preamble;
                    await this.handleStreamingChat(ctx, text);
                    return;
                }
                const textish = isTextishMime(mime, saved.filename);
                const preamble = textish
                    ? `[user uploaded file: attachments/${saved.filename}, MIME: ${mime || "unknown"}, size: ${saved.size} bytes]`
                    : `[user uploaded file: attachments/${saved.filename}, MIME: ${mime || "unknown"}, size: ${saved.size} bytes] (binary; cannot be read as text)`;
                const text = caption ? `${preamble}\n${caption}` : preamble;
                await this.handleStreamingChat(ctx, text);
            }
            catch (err) {
                await ctx.reply(`❌ Error handling document: ${err instanceof Error ? err.message : String(err)}`);
            }
        });
        // Voice / video / audio — save file but do not route to model
        const mediaTypes = [
            "message:voice",
            "message:video",
            "message:audio",
            "message:video_note",
        ];
        for (const mt of mediaTypes) {
            this.bot.on(mt, async (ctx) => {
                try {
                    const msg = ctx.message;
                    const media = msg.voice || msg.video || msg.audio || msg.video_note;
                    if (!media)
                        return;
                    if (media.file_size && media.file_size > MAX_DOCUMENT_BYTES) {
                        await ctx.reply(`Media too large (${media.file_size} bytes). Skipped.`);
                        return;
                    }
                    const hint = mt.replace("message:", "");
                    const saved = await this.downloadToAttachments(ctx, media.file_id, `${hint}-${media.file_unique_id}`);
                    await ctx.reply(`📎 Saved as attachments/${saved.filename}. I don't process ${hint} messages directly yet — you can ask me to do something with the file.`);
                }
                catch (err) {
                    await ctx.reply(`❌ Error saving media: ${err instanceof Error ? err.message : String(err)}`);
                }
            });
        }
    }
    async start() {
        console.log("📱 Telegram bot starting...");
        // Register command menu so "/" shows autocomplete hints
        await this.bot.api.setMyCommands([
            { command: "new", description: "Start a new session (clear chat history)" },
            { command: "tasks", description: "List scheduled tasks" },
            { command: "remove", description: "Remove a task: /remove <id>" },
            { command: "enable", description: "Enable a task: /enable <id>" },
            { command: "disable", description: "Disable a task: /disable <id>" },
            { command: "run", description: "Run a task now: /run <id>" },
            { command: "logs", description: "View execution logs" },
            { command: "skills", description: "List available skills" },
            { command: "memory", description: "View saved memories" },
        ]);
        this.bot.start({
            onStart: (info) => {
                console.log(`📱 Telegram bot started: @${info.username}`);
            },
        });
    }
    stop() {
        this.agent.unregisterFrontend("telegram");
        this.bot.stop();
    }
    /** Send a notification message to allowed users */
    async notify(message) {
        for (const userId of this.allowedUsers) {
            try {
                await this.bot.api.sendMessage(userId, message);
            }
            catch {
                // User may not have started the bot yet
            }
        }
    }
    async sendAttachment(chatId, payload) {
        if (!fs.existsSync(payload.path)) {
            throw new Error(`file does not exist: ${payload.path}`);
        }
        const targets = chatId !== null ? [chatId] : Array.from(this.allowedUsers);
        if (targets.length === 0) {
            throw new Error("no chat to reply to and no allowed users configured");
        }
        const errors = [];
        for (const userId of targets) {
            const input = new InputFile(payload.path);
            try {
                if (payload.asImage) {
                    await this.bot.api.sendPhoto(userId, input, {
                        caption: payload.caption,
                    });
                }
                else {
                    await this.bot.api.sendDocument(userId, input, {
                        caption: payload.caption,
                    });
                }
            }
            catch (err) {
                errors.push(`${userId}: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
        if (errors.length === targets.length) {
            throw new Error(`delivery failed — ${errors.join("; ")}`);
        }
        if (errors.length > 0) {
            console.warn(`Partial attachment delivery failure: ${errors.join("; ")}`);
        }
    }
    /**
     * Fetch a Telegram file by file_id, save into attachments/ with a
     * collision-safe name, and return metadata.
     */
    async downloadToAttachments(ctx, fileId, originalName) {
        const meta = await ctx.api.getFile(fileId);
        if (!meta.file_path) {
            throw new Error("Telegram did not return a file_path");
        }
        const url = `https://api.telegram.org/file/bot${this.token}/${meta.file_path}`;
        const resp = await fetch(url);
        if (!resp.ok) {
            throw new Error(`Telegram file download failed: ${resp.status} ${resp.statusText}`);
        }
        const buf = Buffer.from(await resp.arrayBuffer());
        const safe = sanitizeFilename(originalName) || "file";
        const filename = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}-${safe}`;
        const absPath = path.join(this.attachmentsDir, filename);
        fs.writeFileSync(absPath, buf);
        return { absPath, filename, size: buf.length };
    }
    async buildImageBlocks(saved, caption, mediaType) {
        const buf = fs.readFileSync(saved.absPath);
        const preamble = `[user uploaded image: attachments/${saved.filename}, size: ${saved.size} bytes]`;
        const text = caption ? `${preamble}\n${caption}` : preamble;
        return [
            { type: "text", text },
            { type: "image", imageData: buf.toString("base64"), mediaType },
        ];
    }
    async handleStreamingChat(ctx, input) {
        const chatId = ctx.chat.id;
        // Show typing indicator
        await ctx.api.sendChatAction(chatId, "typing").catch(() => { });
        let accumulated = "";
        let messageId = null;
        let lastEditedText = "";
        let lastEditAt = 0;
        let editTimer = null;
        let finalizing = false;
        let editQueue = Promise.resolve();
        const performEdit = async (content) => {
            if (!messageId || content === lastEditedText)
                return;
            // Telegram max message length
            const truncated = content.length > TELEGRAM_MAX_TEXT
                ? content.slice(0, TELEGRAM_MAX_TEXT - 4) + " ..."
                : content;
            try {
                await ctx.api.editMessageText(chatId, messageId, truncated);
                lastEditedText = truncated;
                lastEditAt = Date.now();
            }
            catch (err) {
                // Ignore "message is not modified" errors
                const msg = err instanceof Error ? err.message : String(err);
                if (!msg.includes("message is not modified")) {
                    throw err;
                }
            }
        };
        const queueEdit = (content, swallowErrors = false) => {
            const next = editQueue.catch(() => { }).then(() => performEdit(content));
            editQueue = next;
            if (swallowErrors)
                next.catch(() => { });
            return next;
        };
        const scheduleEdit = () => {
            if (editTimer || finalizing)
                return;
            const elapsed = Date.now() - lastEditAt;
            const delay = Math.max(0, THROTTLE_MS - elapsed);
            editTimer = setTimeout(async () => {
                editTimer = null;
                if (!finalizing) {
                    await queueEdit(accumulated, true);
                }
            }, delay);
        };
        await this.chatContext.run({ chatId }, async () => {
            try {
                for await (const delta of this.agent.chatStream(input)) {
                    accumulated += delta;
                    if (!messageId && accumulated.trim().length >= MIN_INITIAL_CHARS) {
                        // Send initial message
                        const sent = await ctx.reply(accumulated);
                        messageId = sent.message_id;
                        lastEditedText = accumulated;
                        lastEditAt = Date.now();
                    }
                    else if (messageId) {
                        scheduleEdit();
                    }
                    // Refresh typing indicator periodically
                    if (Date.now() - lastEditAt > 4000) {
                        await ctx.api.sendChatAction(chatId, "typing").catch(() => { });
                    }
                }
                // Clear any pending timer and do final edit
                finalizing = true;
                if (editTimer) {
                    clearTimeout(editTimer);
                    editTimer = null;
                }
                if (!messageId) {
                    // Stream ended before we sent anything
                    const finalText = accumulated.trim() || "(no response)";
                    await ctx.reply(finalText);
                }
                else {
                    // Final edit with complete text
                    if (accumulated.length > TELEGRAM_MAX_TEXT) {
                        // Edit existing message with first chunk, send remainder as new messages
                        await queueEdit(accumulated.slice(0, TELEGRAM_MAX_TEXT));
                        const remainder = accumulated.slice(TELEGRAM_MAX_TEXT);
                        for (const chunk of this.splitMessage(remainder, TELEGRAM_MAX_TEXT)) {
                            await ctx.reply(chunk);
                        }
                    }
                    else {
                        await queueEdit(accumulated);
                    }
                }
            }
            catch (err) {
                finalizing = true;
                if (editTimer)
                    clearTimeout(editTimer);
                throw err;
            }
        });
    }
    splitMessage(text, maxLen = 4000) {
        if (text.length <= maxLen)
            return [text];
        const chunks = [];
        for (let i = 0; i < text.length; i += maxLen) {
            chunks.push(text.slice(i, i + maxLen));
        }
        return chunks;
    }
}
//# sourceMappingURL=bot.js.map