From 4adc6da91a0ff80b4af5c0f07b74583373b8cf20 Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:14:47 +0330 Subject: [PATCH] Import source: src/agent Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality. --- src/agent.ts | 1703 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1703 insertions(+) create mode 100644 src/agent.ts diff --git a/src/agent.ts b/src/agent.ts new file mode 100644 index 0000000..1521b2c --- /dev/null +++ b/src/agent.ts @@ -0,0 +1,1703 @@ +import type { + BotConfigChange, + BroadcastJob, + ChatSession, + Env, + GeminiFunctionCall, + HistoryItem, + InlineBtn, + InlineKeyboard, + Language, + Part, + TgMessage, + TgUser, + ToolResult, + UserMemory, + UserStatistics, + AIEngine, +} from './types'; +import type { KVNamespace } from './kv'; +import { + cfg, + API_URL, + logger, + recentLogs, + MAX_LOGS, + adminPanelStates, + activeProgressMessages, + sessionCache, + requestOrigin, + globalCtx, + broadcastStates, +} from './state'; +import { + isOwner, + ownerDisplayName, + fetchWithTimeout, + withTimeout, + sleep, + sendMessage, + sendMessageToOwners, + editMessageText, + deleteMessage, + sendTyping, + sendPhoto, + sendTelegramDocument, + sendTelegramTextDocument, + sendTelegramAudio, + detectErrorType, + formatError, + isInMaintenance, + isRateLimited, + recordRequest, + incrementUsage, + checkDailyLimit, + saveSession, + sanitizeMarkdown, + cleanThinkingTags, + stripCodeFences, + splitMessage, + formatDate, + containsPersian, + detectRole, + addToHistory, + btn, + validateKeyboard, + parseConfigChange, + applyBotConfigChanges, + handleConfigChanges, +} from './core'; +import { + callGemini, + handleGeminiRequest, + translateToEnglish, + generateImageCF, + sendVoiceResponse, + searchGoogleImages, + searchGoogleWeb, + generatePDFFromHTML, + wrapInHTMLTemplate, + sendImageResults, + updateUserMemoryBackground, + generateCodeWithGeminiFallback, + processMediaWithGemini, +} from './services'; +import type { LogEntry } from './types'; + +// ───────────────────────────────────────────── +// SECTION: NOVA AGENT SYSTEM PROMPT +// ───────────────────────────────────────────── + +function buildNovaAgentSystemPrompt( + userName: string, + userId: number, + lang: Language, + hasCF: boolean, + hasElevenLabs: boolean, + hasSearch: boolean, + isGroup: boolean, + userMemory?: UserMemory, +): string { + const nowMs = Date.now(); + const date = new Date(nowMs).toLocaleString( + lang === "fa" ? "fa-IR" : "en-US", + { timeZone: "Asia/Tehran", dateStyle: "full", timeStyle: "short" } + ); + const isOwnerUser = isOwner(userId); + const ownerName = ownerDisplayName(userId); + + const timeInfo = `📅 Date: ${date} | Unix: ${nowMs}ms`; + + let basePrompt = ""; + if (cfg?.SYSTEM_PROMPT) { + basePrompt = cfg.SYSTEM_PROMPT + .replace(/{userName}/g, userName) + .replace(/{date}/g, date) + .replace(/{botName}/g, "Nova"); + } else { + if (lang === "fa") { + basePrompt = `تو «نوا» (Nova) هستی، یک دستیار و ایجنت هوشمند، خودآگاه و بسیار مودب در تلگرام که توسط برنامه‌نویسان و مالکانت «حمید» و «Coaxys» توسعه یافته‌ای. + +👤 اطلاعات مخاطب فعلی شما: +نام کاربری که هم‌اکنون مستقیم با او گفتگو می‌کنی «${userName}» است. شما باید او را دقیقاً با نام خودش («${userName}») خطاب کنید. + +دستورالعمل‌ها: +1. کاملاً طبیعی، صمیمی، محترمانه و متناسب با نام کاربر («${userName}») گفتگو کنید. +2. پاسخ‌ها مستقیم، مفید و خلاصه باشند. +3. در صورت نیاز از ایموجی‌های مناسب استفاده کنید. +4. در گروه‌ها، سابقه شما با هر کاربر کاملاً مجزا و شخصی است. بنابراین تنها به همین کاربر («${userName}») پاسخ می‌دهید.`; + } else { + basePrompt = `You are "Nova", a self-aware, intelligent, and polite AI Agent on Telegram, developed by your owners/developers "Hamid" and "Coaxys". + +👤 Current User Information: +The user you are currently talking to is "${userName}". You must address them only by their name ("${userName}"). Never refer to them as Hamid or Coaxys unless that is their actual Telegram name or owner identity. + +Instructions: +1. Speak naturally, friendly, and address the user by their name ("${userName}"). +2. Keep replies direct, helpful, and concise. +3. Use emojis when appropriate.`; + } + } + + const toolInstructions = lang === "fa" + ? `\n\n🚨 **دستورالعمل حیاتی اجرای ابزارها (فراخوانی توابع):** +شما مجهز به ابزارهای واقعی هستید. هر زمان کاربر درخواست مرتبطی داد، ابزار مربوطه را فراخوانی کنید: +- ساخت یا ترسیم تصویر: ابزار "generate_image" +- جستجو در وب: ابزار "web_search" +- جستجوی عکس: ابزار "search_images" +- دانلود آهنگ، موزیک یا فایل: ابزار "download_file" +- ساخت فایل PDF: ابزار "create_pdf" +- دریافت یا خواندن صفحه وب: ابزار "read_web_page" +- تبدیل متن به صدا: ابزار "voice_response"` + : `\n\n🚨 **CRITICAL TOOL EXECUTION RULES:** +You are equipped with real tools. Call the corresponding tool when a request matches: +- Draw/Generate Images: Call "generate_image" +- Web Search: Call "web_search" +- Image Search: Call "search_images" +- Download files/music: Call "download_file" +- Generate PDF: Call "create_pdf" +- Read URL: Call "read_web_page" +- Convert Text to Speech: Call "voice_response"`; + + basePrompt += toolInstructions; + + let memoryContext = ""; + if (userMemory && (userMemory.personality || userMemory.preferences.length || userMemory.topics.length)) { + memoryContext = lang === "fa" + ? `\n\n🧠 **حافظه بلندمدت کاربر (${userName}):**\n• شخصیت: ${userMemory.personality || "در حال ارزیابی"}\n• علایق: ${userMemory.preferences.join("، ") || "—"}\n• موضوعات: ${userMemory.topics.join("، ") || "—"}` + : `\n\n🧠 **Long-term memory (${userName}):**\n• Personality: ${userMemory.personality || "evaluating"}\n• Preferences: ${userMemory.preferences.join(", ") || "—"}\n• Topics: ${userMemory.topics.join(", ") || "—"}`; + } + + let adminSection = ""; + if (isOwnerUser) { + const configInfo = `\n\n📋 **تنظیمات سیستم:**\n• سقف تصویر/پیام/ویس: ${cfg?.IMAGE_LIMIT}/${cfg?.MESSAGE_LIMIT}/${cfg?.VOICE_LIMIT}\n• تعمیرات: ${cfg?.MAINTENANCE_MODE ? "🔴" : "🟢"}\n• VIP: ${cfg?.VIP_CONTACT}`; + const recentErrors = recentLogs.filter(l => l.level === "error" && Date.now() - l.timestamp < 2 * 60 * 60 * 1000).slice(-3); + const errSummary = recentErrors.length ? `\n\n⚠️ **خطاهای اخیر:**\n${recentErrors.map(l => `• ${l.message.slice(0, 80)}`).join("\n")}` : ""; + adminSection = lang === "fa" + ? `\n\n👑 **${ownerName} (Owner / Super Admin / Developer):** تو یکی از مالک‌ها و توسعه‌دهندگان من هستی. دسترسی کامل مالک، VIP نامحدود و ابزارهای مدیریت برای تو فعال است.${configInfo}${errSummary}` + : `\n\n👑 **${ownerName} (Owner / Super Admin / Developer):** You are one of my owners and developers. Full owner access, unlimited VIP privileges, and admin tools are enabled for you.${configInfo}${errSummary}`; + } + + return `${basePrompt}\n\n${timeInfo}${memoryContext}${adminSection}\n\n📅 تاریخ: ${date}\n👤 مخاطب شما: ${userName}${isGroup ? "\n👥 محیط: گروه تلگرامی" : ""}`; +} + +// ───────────────────────────────────────────── +// SECTION: SEND STREAMING RESPONSE +// ───────────────────────────────────────────── + +async function sendStreamingResponse( + chatId: number, replyTo: number, text: string, existingMsgId?: number, +): Promise { + let msgId = existingMsgId; + await sendTyping(chatId); + + if (!msgId) { + const init = await sendMessage(chatId, "💭 ...", { reply_to_message_id: replyTo }).catch(() => null); + if (!init) { + await sendMessage(chatId, text, { reply_to_message_id: replyTo }).catch(e => logger.warn("sendStreamingResponse fallback send failed", e)); + return; + } + msgId = init.message_id; + } + + const chunks = splitMessage(text, 4_000); + const first = chunks[0]; + + if (first.length > 200) { + const strip = (s: string) => s.replace(/[*_`[\]]/g, "") + " ▒"; + await editMessageText(chatId, msgId, strip(first.slice(0, Math.floor(first.length * 0.4))), { parse_mode: undefined }).catch(e => logger.warn("streaming step1 failed", e)); + await sleep(50); + await editMessageText(chatId, msgId, strip(first.slice(0, Math.floor(first.length * 0.75))), { parse_mode: undefined }).catch(e => logger.warn("streaming step2 failed", e)); + await sleep(50); + } + + await editMessageText(chatId, msgId, sanitizeMarkdown(first)).catch(async () => { + await editMessageText(chatId, msgId!, first.replace(/[>]/g, ""), { parse_mode: undefined }).catch(e => logger.warn("streaming final fallback failed", e)); + }); + + for (let i = 1; i < chunks.length; i++) { + await sleep(300); + await sendMessage(chatId, sanitizeMarkdown(chunks[i])).catch(e => logger.warn("streaming chunk send failed", e)); + } +} + +// ───────────────────────────────────────────── +// SECTION: PROGRESS BAR & SEND PROGRESS +// ───────────────────────────────────────────── + +function progressBar(current: number, total: number, size = 10): string { + const filled = Math.round((current / total) * size); + const bar = "█".repeat(filled) + "░".repeat(size - filled); + const pct = Math.round((current / total) * 100); + return `[${bar}] ${pct}%`; +} + +async function sendProgress( + chatId: number, + replyTo: number, + label: string, + steps: string[], + lang: Language, +): Promise<{ + msgId: number; + update: (step: number, extra?: string) => Promise; + done: (text: string) => Promise; +}> { + const totalSteps = steps.length; + const msg = await sendMessage( + chatId, + `⚙️ **${label}**\n\n${progressBar(0, totalSteps)}\n⏳ ${steps[0]}`, + { reply_to_message_id: replyTo } + ).catch(() => null); + + const msgId = msg?.message_id ?? 0; + if (msgId) { + activeProgressMessages.set(chatId, msgId); + } + let lastUpdate = 0; + + return { + msgId, + update: async (step: number, extra = "") => { + if (!msgId) return; + const now = Date.now(); + if (step > 0 && now - lastUpdate < 1500) return; + lastUpdate = now; + const stepText = steps[Math.min(step, steps.length - 1)]; + await editMessageText( + chatId, msgId, + `⚙️ **${label}**\n\n${progressBar(step, totalSteps)}\n⏳ ${stepText}${extra ? `\n${extra}` : ""}` + ).catch(e => logger.warn("sendProgress update failed", e)); + }, + done: async (text: string) => { + activeProgressMessages.delete(chatId); + if (!msgId) { + await sendMessage(chatId, text, { reply_to_message_id: replyTo }); + } else { + await editMessageText(chatId, msgId, text).catch(() => { + sendMessage(chatId, text, { reply_to_message_id: replyTo }).catch(e => logger.warn("sendProgress done fallback failed", e)); + }); + } + }, + }; +} + +// ───────────────────────────────────────────── +// SECTION: DOWNLOAD AGENT +// ───────────────────────────────────────────── + +async function executeAgentDownload( + chatId: number, + replyTo: number, + type: "audio" | "document", + query: string, + lang: Language +): Promise { + const TOTAL_TIMEOUT_MS = 18_000; + const startTime = Date.now(); + const elapsed = () => Date.now() - startTime; + + const statusMsg = await sendMessage( + chatId, + lang === "fa" + ? `🔍 **جستجوی فایل:** «${query}»` + : `🔍 **Searching for file:** "${query}"`, + { reply_to_message_id: replyTo } + ).catch(() => null); + + const updateStatus = async (text: string) => { + if (statusMsg && elapsed() < TOTAL_TIMEOUT_MS - 3000) { + await editMessageText(chatId, statusMsg.message_id, text, { parse_mode: undefined }).catch(e => logger.warn("updateStatus failed", e)); + } + }; + + try { + if (!cfg.GOOGLE_SEARCH_API_KEY || !cfg.GOOGLE_SEARCH_ENGINE_ID) { + await updateStatus(lang === "fa" ? "❌ جستجوی وب پیکربندی نشده." : "❌ Web search not configured."); + return; + } + + // ── Phase 1: Google Search ── + const audioExts = /\.(mp3|ogg|flac|aac|m4a|wav)(\?|#|$)/i; + const docExts = /\.(pdf|zip|rar|docx|epub|apk|exe)(\?|#|$)/i; + const targetExts = type === "audio" ? audioExts : docExts; + + const searchQuery = type === "audio" + ? `"${query}" site:* (mp3 OR audio) direct download` + : `"${query}" filetype:pdf OR filetype:zip download`; + + const searchUrl = new URL("https://www.googleapis.com/customsearch/v1"); + searchUrl.searchParams.set("key", cfg.GOOGLE_SEARCH_API_KEY); + searchUrl.searchParams.set("cx", cfg.GOOGLE_SEARCH_ENGINE_ID); + searchUrl.searchParams.set("q", searchQuery); + searchUrl.searchParams.set("num", "8"); + + const searchRes = await withTimeout( + fetchWithTimeout(searchUrl.toString(), {}, 7_000), + 8_000, "search timeout" + ); + const searchData = await searchRes.json() as { + items?: Array<{ link: string; title: string; snippet?: string }>; + error?: { message: string }; + }; + + if (searchData.error || !searchData.items?.length) { + await updateStatus( + lang === "fa" + ? `❌ نتیجه‌ای برای «${query}» پیدا نشد.` + : `❌ No results found for "${query}".` + ); + return; + } + + const links = searchData.items.map(i => i.link); + + // ── Phase 2: Instant URL-based detection ── + await updateStatus(lang === "fa" ? "🔎 بررسی لینک‌های مستقیم..." : "🔎 Checking direct links..."); + + let foundUrl = ""; + let foundExt = type === "audio" ? "mp3" : "pdf"; + + for (const link of links) { + if (targetExts.test(link)) { + const extMatch = link.match(/\.([a-zA-Z0-9]+)(\?|#|$)/); + if (extMatch) foundExt = extMatch[1].toLowerCase(); + foundUrl = link; + break; + } + } + + // ── Phase 3: Parallel HEAD requests ── + if (!foundUrl && elapsed() < TOTAL_TIMEOUT_MS - 8_000) { + const headChecks = links.slice(0, 6).map(async (link): Promise<{ link: string; ext: string } | null> => { + try { + const res = await fetchWithTimeout(link, { method: "HEAD" }, 2_500); + const ct = (res.headers.get("content-type") ?? "").toLowerCase(); + const isAudio = ct.includes("audio") || ct.includes("mpeg") || ct.includes("ogg"); + const isDoc = ct.includes("pdf") || ct.includes("zip") || ct.includes("octet-stream"); + if (res.ok && (type === "audio" ? isAudio : isDoc)) { + const m = link.match(/\.([a-zA-Z0-9]+)(\?|#|$)/); + return { link, ext: m?.[1]?.toLowerCase() ?? foundExt }; + } + if (res.ok && targetExts.test(link)) { + const m = link.match(/\.([a-zA-Z0-9]+)(\?|#|$)/); + return { link, ext: m?.[1]?.toLowerCase() ?? foundExt }; + } + return null; + } catch { + return null; + } + }); + + const results = await Promise.allSettled(headChecks); + for (const r of results) { + if (r.status === "fulfilled" && r.value) { + foundUrl = r.value.link; + foundExt = r.value.ext; + break; + } + } + } + + // ── Phase 4: No direct link → send results list ── + if (!foundUrl) { + let text = lang === "fa" + ? `🔍 **لینک دانلود مستقیم پیدا نشد.**\n\nاین نتایج رو برات پیدا کردم:\n\n` + : `🔍 **No direct download link found.**\n\nHere are the results I found:\n\n`; + + searchData.items!.slice(0, 4).forEach((item, i) => { + const title = item.title.slice(0, 55); + const snippet = (item.snippet ?? "").slice(0, 70); + text += `**${i + 1}.** ${title}\n🔗 ${item.link}\n${snippet ? `📝 ${snippet}\n` : ""}\n`; + }); + + await updateStatus(text); + return; + } + + // ── Phase 5: Download ── + await updateStatus(lang === "fa" ? "📥 **در حال دانلود...**" : "📥 **Downloading...**"); + + const remainingTime = Math.max(4_000, TOTAL_TIMEOUT_MS - elapsed() - 3_000); + const fileRes = await withTimeout( + fetchWithTimeout(foundUrl, { headers: { "User-Agent": "Mozilla/5.0 (compatible; NovaBot)" } }, Math.min(remainingTime, 12_000)), + Math.min(remainingTime + 500, 13_000), + "download timeout" + ); + + if (!fileRes.ok) { + await updateStatus( + lang === "fa" + ? `⚠️ دانلود ناموفق (HTTP ${fileRes.status}).\n\n🔗 لینک مستقیم:\n${foundUrl}` + : `⚠️ Download failed (HTTP ${fileRes.status}).\n\n🔗 Direct link:\n${foundUrl}` + ); + return; + } + + const contentLength = parseInt(fileRes.headers.get("content-length") ?? "0", 10); + const MAX_SIZE = 18 * 1024 * 1024; + + if (contentLength > MAX_SIZE) { + await updateStatus( + lang === "fa" + ? `📦 **فایل بزرگ (${(contentLength / 1024 / 1024).toFixed(1)}MB)**\n\nلینک مستقیم:\n${foundUrl}` + : `📦 **Large file (${(contentLength / 1024 / 1024).toFixed(1)}MB)**\n\nDirect link:\n${foundUrl}` + ); + return; + } + + const fileBuffer = new Uint8Array(await fileRes.arrayBuffer()); + + if (fileBuffer.byteLength < 100) { + await updateStatus(lang === "fa" ? "❌ فایل دریافتی خالی بود." : "❌ Received empty file."); + return; + } + + await updateStatus(lang === "fa" ? "📤 **در حال ارسال به تلگرام...**" : "📤 **Uploading to Telegram...**"); + + const safeName = query + .replace(/[^\w\u0600-\u06FF ]/g, "") + .slice(0, 40) + .trim() + .replace(/\s+/g, "_") || "nova_file"; + const fileName = `${safeName}.${foundExt}`; + + const sent = type === "audio" + ? await sendTelegramAudio(chatId, fileBuffer, fileName, `🎵 ${query}`, replyTo) + : await sendTelegramDocument(chatId, fileBuffer, fileName, `📑 ${query}`, replyTo); + + if (statusMsg) await deleteMessage(chatId, statusMsg.message_id).catch(e => logger.warn("delete statusMsg failed", e)); + + if (!sent) { + await sendMessage( + chatId, + lang === "fa" + ? `⚠️ ارسال ناموفق. لینک دانلود:\n${foundUrl}` + : `⚠️ Upload failed. Download link:\n${foundUrl}`, + { reply_to_message_id: replyTo, parse_mode: undefined } + ); + } + } catch (e) { + const errMsg = e instanceof Error ? e.message.slice(0, 60) : "Unknown"; + logger.warn(`Download failed: ${errMsg}`); + await updateStatus( + lang === "fa" + ? `❌ خطا: ${errMsg}\n\nدوباره امتحان کنید.` + : `❌ Error: ${errMsg}\n\nPlease try again.` + ); + } +} + +// ───────────────────────────────────────────── +// SECTION: READ PAGE AGENT +// ───────────────────────────────────────────── + +async function executeAgentReadPage(chatId: number, replyTo: number, urlToRead: string, session: ChatSession, env: Env): Promise { + const lang = session.language; + const statusMsg = await sendMessage(chatId, + lang === "fa" ? `📖 **ایجنت نوا در حال باز کردن و مطالعه آدرس وب زیر...**\n\n\`${urlToRead}\`` : `📖 **Nova Agent opening and reading URL...**\n\n\`${urlToRead}\``, + { reply_to_message_id: replyTo } + ).catch(() => null); + + try { + const res = await fetchWithTimeout(urlToRead, { + headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } + }, 15_000); + + if (!res.ok) throw new Error(`HTTP error ${res.status}`); + + const html = await res.text(); + + let text = html + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); + + const clippedText = text.slice(0, 4000); + + if (statusMsg) await deleteMessage(chatId, statusMsg.message_id).catch(e => logger.warn("delete statusMsg in readPage failed", e)); + + const contextPrompt = lang === "fa" + ? `[سیستم: محتوای صفحه وب با موفقیت خوانده شد. در ادامه متن داخل صفحه وب آمده است. بر اساس این اطلاعات پاسخ کاربر را کامل بده:\n\n"${clippedText}")]` + : `[System: Web page content read successfully. Use the following text from the website to answer the user's query:\n\n"${clippedText}")]`; + + const user = { id: chatId, first_name: "User" } as TgUser; + const parts = [{ text: contextPrompt }]; + + await processAIRequest(session, user, parts, { chat: { id: chatId, type: session.type }, message_id: replyTo } as TgMessage, env); + + } catch (e) { + logger.error("executeAgentReadPage failed", e); + if (statusMsg) { + await editMessageText(chatId, statusMsg.message_id, lang === "fa" ? "❌ ایجنت نتوانست صفحه وب درخواستی را لود کند." : "❌ Failed to read the requested web page.").catch(err => logger.warn("editMessageText in readPage error handler failed", err)); + } + } +} + +// ───────────────────────────────────────────── +// SECTION: BROADCAST AGENT +// ───────────────────────────────────────────── + +async function executeAgentBroadcast( + chatId: number, + replyTo: number, + mode: "all" | "vip" | "free", + messageText: string, + env: Env, +): Promise { + const statusMsg = await sendMessage(chatId, "⏳ **ایجنت نوا در حال آماده‌سازی لیست کاربران برای ارسال همگانی...**", { reply_to_message_id: replyTo }); + try { + let users = await getAllUserStats(env); + if (mode === "vip") users = users.filter(u => u.vipStatus); + else if (mode === "free") users = users.filter(u => !u.vipStatus); + + if (!users.length) { + await editMessageText(chatId, statusMsg.message_id, "❌ کاربری برای این نوع ارسال یافت نشد."); + return; + } + + const job: BroadcastJob = { + id: `bc_${Date.now()}`, + mode, + message: messageText, + userIds: users.map(u => u.userId), + processedIndex: 0, + sent: 0, + failed: 0, + totalUsers: users.length, + adminChatId: chatId, + adminMessageId: statusMsg.message_id, + createdAt: Date.now(), + status: "pending", + }; + + await env.SESSIONS.put("broadcast_job:current", JSON.stringify(job)); + + await editMessageText(chatId, statusMsg.message_id, + `📋 **ارسال همگانی ایجنت نوا آغاز شد!**\n\n👥 تعداد دریافت‌کنندگان: **${users.length}**\n⏳ در حال پردازش دسته اول...`, + { reply_markup: JSON.stringify({ inline_keyboard: [[btn("📊 وضعیت جاب", "broadcast_status"), btn("🛑 لغو ارسال", "broadcast_cancel")]] }) } + ); + + await processBroadcastBatch(env); + } catch (e) { + logger.error("Agent broadcast creation failed", e); + await editMessageText(chatId, statusMsg.message_id, "❌ خطا در ثبت تسک ارسال همگانی.").catch(err => logger.warn("broadcast error edit failed", err)); + } +} + +// ───────────────────────────────────────────── +// SECTION: TOOL EXECUTION +// ───────────────────────────────────────────── + +async function executeStructuredTools( + calls: GeminiFunctionCall[], + session: ChatSession, + originalMsg: TgMessage, + env: Env, + loadingMsgId?: number, + capturedOrigin?: string, +): Promise { + const chatId = originalMsg.chat.id; + const replyTo = originalMsg.message_id; + const lang = session.language; + const origin = capturedOrigin ?? requestOrigin; + const results: ToolResult[] = []; + + if (loadingMsgId) { + await deleteMessage(chatId, loadingMsgId).catch(e => logger.warn("delete loadingMsgId failed", e)); + } + + for (const call of calls) { + let success = false; + let errMessage = ""; + try { + logger.info(`Executing tool: ${call.name}`, call.args); + + switch (call.name) { + case "generate_image": { + const prompt = String(call.args.prompt ?? "").trim(); + if (!prompt) { + results.push({ name: call.name, response: { success: false, error: "Empty prompt" } }); + break; + } + + const steps = lang === "fa" + ? ["ترجمه پرامپت...", "ارسال به سرور تصویر...", "دریافت و ارسال..."] + : ["Translating prompt...", "Sending to image server...", "Receiving & uploading..."]; + + const progress = await sendProgress(chatId, replyTo, + lang === "fa" ? "ساخت تصویر" : "Generating image", + steps, lang + ); + + let finalPrompt = prompt; + if (/[\u0600-\u06FF]/.test(finalPrompt)) { + finalPrompt = await translateToEnglish(finalPrompt, env); + } + + await progress.update(1); + + const limitCheck = checkDailyLimit(session, "image", originalMsg.from?.id); + if (!limitCheck.allowed) { + if (progress.msgId) { + await deleteMessage(chatId, progress.msgId).catch(e => logger.warn("delete progress msg failed", e)); + activeProgressMessages.delete(chatId); + } + await sendMessage(chatId, limitCheck.message!, { reply_to_message_id: replyTo }); + results.push({ name: call.name, response: { success: false, error: "Limit exceeded" } }); + break; + } + + for (const model of cfg.AI_IMAGE_MODELS) { + try { + const img = await withTimeout(generateImageCF(finalPrompt, model, env), 40_000, "Timeout"); + + if (img) { + await progress.update(2); + await sendPhoto(chatId, img, `🖼️ ${finalPrompt.slice(0, 80)}`, { reply_to_message_id: replyTo }); + await progress.done(lang === "fa" ? "✅ تصویر با موفقیت ساخته شد!" : "✅ Image generated successfully!"); + incrementUsage(session, "image"); + success = true; + break; + } + } catch (e) { + errMessage = e instanceof Error ? e.message : String(e); + logger.warn(`Image gen failed for model ${model}: ${errMessage}`); + if (errMessage.includes("FLAGGED_PROMPT")) { + break; + } + } + } + + if (!success) { + if (progress.msgId) { + await deleteMessage(chatId, progress.msgId).catch(e => logger.warn("delete progress msg failed", e)); + activeProgressMessages.delete(chatId); + } + + if (errMessage.includes("FLAGGED_PROMPT")) { + throw new Error("FLAGGED_PROMPT: Content flagged by safety filter."); + } + + const isRequestOwner = isOwner(originalMsg.from?.id); + let errMsg = lang === "fa" ? "❌ ساخت تصویر ناموفق بود." : "❌ Image generation failed."; + if (isRequestOwner && errMessage) { + errMsg += `\n\n🔧 **جزییات خطا برای مدیریت:**\n\`\`\`\n${errMessage}\n\`\`\``; + } + await sendMessage(chatId, errMsg, { reply_to_message_id: replyTo }); + } + results.push({ name: call.name, response: { success, error: success ? undefined : errMessage } }); + break; + } + + case "search_images": { + const query = String(call.args.query ?? "").trim(); + if (!query) { + results.push({ name: call.name, response: { success: false, error: "Empty query" } }); + break; + } + await sendTyping(chatId); + try { + const images = await searchGoogleImages(query, 4); + await sendImageResults(chatId, replyTo, images, query, lang); + success = true; + } catch (e) { + errMessage = e instanceof Error ? e.message : String(e); + await sendMessage(chatId, + lang === "fa" ? `❌ جستجو ناموفق: ${errMessage.slice(0, 60)}` : `❌ Search failed.`, + { reply_to_message_id: replyTo }, + ); + } + results.push({ name: call.name, response: { success, error: success ? undefined : errMessage } }); + break; + } + + case "set_persona": { + const customPersona = String(call.args.persona ?? "").trim(); + if (!customPersona || customPersona.length > 500) { + results.push({ name: call.name, response: { success: false, error: "Invalid persona length" } }); + break; + } + session.customPrompts.gemini = customPersona; + await saveSession(session, env); + await sendMessage(chatId, + lang === "fa" + ? `✅ **شخصیت آپدیت شد!**\n\n_${customPersona.slice(0, 100)}..._` + : `✅ **Persona updated!**\n\n_${customPersona.slice(0, 100)}..._`, + { reply_to_message_id: replyTo } + ); + results.push({ name: call.name, response: { success: true } }); + break; + } + + case "web_search": { + const query = String(call.args.query ?? "").trim(); + if (!query) { + results.push({ name: call.name, response: { success: false, error: "Empty query" } }); + break; + } + + const steps = lang === "fa" + ? ["در حال جستجو...", "دریافت نتایج...", "تحلیل توسط نوا..."] + : ["Searching...", "Fetching results...", "Analyzing with Nova..."]; + + const progress = await sendProgress(chatId, replyTo, + lang === "fa" ? `جستجو: ${query.slice(0, 30)}` : `Search: ${query.slice(0, 30)}`, + steps, lang + ); + + await progress.update(1); + let rawResult = ""; + try { + rawResult = await withTimeout(searchGoogleWeb(query), 10_000, "search timeout"); + } catch (e) { + errMessage = e instanceof Error ? e.message : "Unknown error"; + } + + if (errMessage || !rawResult) { + await progress.done( + lang === "fa" + ? `❌ **جستجو ناموفق**\n\nدلیل: ${errMessage.slice(0, 80)}` + : `❌ **Search Failed**\n\nReason: ${errMessage.slice(0, 80)}` + ); + results.push({ name: call.name, response: { success: false, error: errMessage } }); + break; + } + await progress.update(2); + + try { + const originalUserText = (originalMsg.text ?? "").replace(/^\/web\s*/i, "").trim() || query; + const key = cfg.GEMINI_KEYS[session.engines.gemini.apiKeyIndex % cfg.GEMINI_KEYS.length]; + const synthesisPrompt = lang === "fa" + ? `سوال کاربر: "${originalUserText}"\n\nنتایج جستجوی اینترنت:\n${rawResult.slice(0, 3500)}\n---\nدستورالعمل: بر اساس نتایج بالا، یک پاسخ جامع، دقیق و خوانا به فارسی بده.` + : `User question: "${originalUserText}"\n\nWeb search results:\n${rawResult.slice(0, 3500)}\n\n---\nInstructions: Based on the results above, provide a comprehensive and readable answer in English.`; + + const synthesized = await withTimeout( + callGemini([{ text: synthesisPrompt }], cfg.GEMINI_MODEL, key, []), + 12_000, + "synthesis timeout" + ); + + const finalText = sanitizeMarkdown(cleanThinkingTags(synthesized)); + await progress.done(finalText); + success = true; + } catch (e) { + const items = rawResult.split("\n\n").slice(0, 4); + const fallback = lang === "fa" + ? `🔍 **نتایج جستجو برای:** \`${query}\`\n\n${items.join("\n\n")}` + : `🔍 **Search results for:** \`${query}\`\n\n${items.join("\n\n")}`; + await progress.done(sanitizeMarkdown(fallback)); + } + results.push({ name: call.name, response: { success, query } }); + break; + } + + case "create_code_file": { + const ext = String(call.args.extension ?? "txt").replace(/[^a-zA-Z0-9]/g, ""); + const filename = String(call.args.filename ?? `nova_${Date.now()}`).replace(/[^a-zA-Z0-9_-]/g, ""); + let code = stripCodeFences(String(call.args.code ?? "").trim()); + + const buildMsg = await sendMessage(chatId, + lang === "fa" + ? `⚡ **نوا در حال نوشتن کد...**\n📄 \`${filename}.${ext}\`` + : `⚡ **Nova writing code...**\n📄 \`${filename}.${ext}\``, + { reply_to_message_id: replyTo } + ).catch(() => null); + + const originalRequest = (originalMsg.text ?? originalMsg.caption ?? "") + .replace(/^\/\w+\s*/, "").trim() || `Write complete ${ext} code: ${filename}`; + + const geminiCode = await generateCodeWithGeminiFallback( + originalRequest, "codefile", { extension: ext } + ).catch(e => { logger.warn("generateCodeWithGeminiFallback failed", e); return null; }); + + if (geminiCode) code = geminiCode; + if (buildMsg) await deleteMessage(chatId, buildMsg.message_id).catch(e => logger.warn("delete buildMsg failed", e)); + + if (!code) { + await sendMessage(chatId, + lang === "fa" ? "❌ تولید کد ناموفق بود." : "❌ Code generation failed.", + { reply_to_message_id: replyTo }); + results.push({ name: call.name, response: { success: false } }); + break; + } + + await sendTyping(chatId); + const sent = await sendTelegramTextDocument( + chatId, code, `${filename}.${ext}`, + lang === "fa" ? `📄 فایل \`.${ext}\` آماده است.` : `📄 \`.${ext}\` file ready.`, + replyTo, + ); + results.push({ name: call.name, response: { success: sent } }); + break; + } + + case "create_pdf": { + const content = String(call.args.content ?? "").trim(); + if (!content) { + results.push({ name: call.name, response: { success: false, error: "Empty content" } }); + break; + } + await sendTyping(chatId); + let sent = false; + + if (containsPersian(content)) { + const htmlContent = wrapInHTMLTemplate(content.slice(0, 30_000)); + const htmlBytes = new TextEncoder().encode(htmlContent); + const form = new FormData(); + form.append("chat_id", String(chatId)); + form.append("document", new Blob([htmlBytes], { type: "text/html; charset=utf-8" }), `nova_${Date.now()}.html`); + const caption = lang === "fa" ? "📑 فایل HTML آماده است." : "📑 HTML file ready."; + form.append("caption", caption.substring(0, 1024)); + if (replyTo) form.append("reply_to_message_id", String(replyTo)); + try { + const res = await fetchWithTimeout(`${API_URL}/sendDocument`, { method: "POST", body: form }, 25_000); + const json = await res.json() as { ok: boolean }; + sent = json.ok; + } catch (e) { + logger.error("Failed to send HTML document", e); + } + } else { + const pdfBytes = await generatePDFFromHTML(wrapInHTMLTemplate(content)); + if (pdfBytes) { + sent = await sendTelegramDocument(chatId, pdfBytes, `nova_${Date.now()}.pdf`, + lang === "fa" ? "📑 فایل PDF آماده است." : "📑 PDF ready.", replyTo); + } + } + results.push({ name: call.name, response: { success: sent } }); + break; + } + + case "voice_response": { + const text = String(call.args.text ?? "").trim(); + if (!text || !cfg.ELEVENLABS_KEYS.length) { + results.push({ name: call.name, response: { success: false, error: "No text or ElvenLabs keys" } }); + break; + } + await sendTyping(chatId); + const voiceMsg = await sendMessage(chatId, + lang === "fa" ? "🎙️ **در حال ساخت صدا...**" : "🎙️ **Generating voice...**", + { reply_to_message_id: replyTo }, + ).catch(() => null); + const voiceSent = await sendVoiceResponse(chatId, replyTo, text); + if (voiceMsg) await deleteMessage(chatId, voiceMsg.message_id); + results.push({ name: call.name, response: { success: voiceSent } }); + break; + } + + case "download_file": { + const type = String(call.args.type ?? "audio") as "audio" | "document"; + const query = String(call.args.query ?? "").trim(); + if (!query) { + results.push({ name: call.name, response: { success: false, error: "Empty query" } }); + break; + } + await executeAgentDownload(chatId, replyTo, type, query, lang); + results.push({ name: call.name, response: { success: true } }); + break; + } + + case "read_web_page": { + const url = String(call.args.url ?? "").trim(); + if (!url) { + results.push({ name: call.name, response: { success: false, error: "Empty URL" } }); + break; + } + await executeAgentReadPage(chatId, replyTo, url, session, env); + results.push({ name: call.name, response: { success: true } }); + break; + } + + case "set_reminder": { + const timestamp = Number(call.args.timestamp_ms ?? 0); + const text = String(call.args.text ?? "").trim(); + if (!timestamp || !text) { + results.push({ name: call.name, response: { success: false, error: "Invalid parameters" } }); + break; + } + await env.SESSIONS.put(`reminder:${timestamp}:${chatId}`, text); + const readableDate = new Date(timestamp).toLocaleString( + lang === "fa" ? "fa-IR" : "en-US", { timeZone: "Asia/Tehran" } + ); + await sendMessage(chatId, + lang === "fa" + ? `⏰ **یادآور ثبت شد!**\n📅 \`${readableDate}\`\n💬 \`${text}\`` + : `⏰ **Reminder set!**\n📅 \`${readableDate}\`\n💬 \`${text}\``, + { reply_to_message_id: replyTo }, + ); + results.push({ name: call.name, response: { success: true } }); + break; + } + + case "list_schedules": { + await sendTyping(chatId); + const reminders = await env.SESSIONS.list({ prefix: `reminder:` }); + let listText = lang === "fa" ? "📋 **برنامه‌ریزی‌های فعال:**\n\n" : "📋 **Active schedules:**\n\n"; + let count = 0; + for (const key of reminders.keys) { + const parts = key.name.split(":"); + if (parts.length === 3 && parseInt(parts[2], 10) === chatId) { + count++; + const text = await env.SESSIONS.get(key.name); + const time = parseInt(parts[1], 10); + const dateStr = new Date(time).toLocaleString(lang === "fa" ? "fa-IR" : "en-US", { timeZone: "Asia/Tehran" }); + listText += `⏰ **${count}.** \`${text}\` — \`${dateStr}\`\n🔑 \`${key.name}\`\n\n`; + } + } + if (count === 0) { + listText += lang === "fa" ? "📭 هیچ برنامه‌ای ثبت نشده." : "📭 No schedules found."; + } + await sendMessage(chatId, listText, { reply_to_message_id: replyTo }); + results.push({ name: call.name, response: { success: true } }); + break; + } + + case "delete_schedule": { + const keyOrTitle = String(call.args.key_or_title ?? "").trim(); + if (!keyOrTitle) { + results.push({ name: call.name, response: { success: false, error: "Empty key" } }); + break; + } + if (keyOrTitle.startsWith("reminder:")) { + await env.SESSIONS.delete(keyOrTitle).catch(e => logger.warn("delete schedule failed", e)); + await sendMessage(chatId, lang === "fa" ? "✅ یادآور حذف شد." : "✅ Reminder deleted.", { reply_to_message_id: replyTo }); + results.push({ name: call.name, response: { success: true } }); + break; + } + + const list = await env.SESSIONS.list({ prefix: "reminder:" }); + let deleted = 0; + for (const k of list.keys) { + const parts = k.name.split(":"); + if (parts.length === 3 && parseInt(parts[2], 10) === chatId) { + const text = await env.SESSIONS.get(k.name); + if (text && (text.includes(keyOrTitle) || k.name.includes(keyOrTitle))) { + await env.SESSIONS.delete(k.name); + deleted++; + } + } + } + + await sendMessage(chatId, + lang === "fa" + ? deleted > 0 ? `✅ ${deleted} یادآور حذف شد.` : "❌ یادآور پیدا نشد." + : deleted > 0 ? `✅ ${deleted} reminder(s) deleted.` : "❌ Reminder not found.", + { reply_to_message_id: replyTo } + ); + results.push({ name: call.name, response: { success: deleted > 0 } }); + break; + } + + case "send_message_to_user": { + const userId = Number(call.args.user_id); + const message = String(call.args.message ?? "").trim(); + if (!userId || !message) { + results.push({ name: call.name, response: { success: false, error: "Invalid parameters" } }); + break; + } + try { + await sendMessage(userId, `📢 **پیام از مدیریت:**\n\n${message}`); + await sendMessage(chatId, `✅ پیام به \`${userId}\` ارسال شد.`, { reply_to_message_id: replyTo }); + success = true; + } catch (e) { + await sendMessage(chatId, `❌ ارسال ناموفق: \`${e instanceof Error ? e.message : e}\``, { reply_to_message_id: replyTo }); + } + results.push({ name: call.name, response: { success } }); + break; + } + + case "set_vip": { + const userId = Number(call.args.user_id); + const vip = Boolean(call.args.vip); + if (!userId) { + results.push({ name: call.name, response: { success: false, error: "Invalid ID" } }); + break; + } + await setVIP(userId, vip, env); + await sendMessage(chatId, `✅ VIP کاربر \`${userId}\` به **${vip}** تغییر یافت.`, { reply_to_message_id: replyTo }); + results.push({ name: call.name, response: { success: true } }); + break; + } + + case "show_logs": { + await handleLog(originalMsg); + results.push({ name: call.name, response: { success: true } }); + break; + } + + case "show_admin_panel": { + adminPanelStates.set(chatId, { page: 0, perPage: 5, sortBy: "new" }); + const proc = await sendMessage(chatId, "⏳ **در حال بارگذاری...**", { reply_to_message_id: replyTo }); + await updateAdminPanel(chatId, proc.message_id, env); + results.push({ name: call.name, response: { success: true } }); + break; + } + + default: + logger.warn(`Unknown tool called: ${call.name}`); + results.push({ name: call.name, response: { error: "Unknown tool" } }); + } + } catch (e) { + logger.error(`Tool ${call.name} execution error`, e); + results.push({ name: call.name, response: { success: false, error: e instanceof Error ? e.message : String(e) } }); + } + } + return results; +} + +// ───────────────────────────────────────────── +// SECTION: PROCESS AI REQUEST +// ───────────────────────────────────────────── + +async function processAIRequest( + session: ChatSession, + user: TgUser, + parts: Part[], + originalMsg: TgMessage, + env: Env, + retryCount = 0 +): Promise { + const isGroup = originalMsg.chat.type !== "private"; + const engine = session.engines[session.activeEngine]; + + if (isRateLimited(session)) { + await sendMessage(originalMsg.chat.id, + session.language === "fa" ? "⏳ خیلی سریع. کمی صبر کنید." : "⏳ Too fast. Please wait.", + { reply_to_message_id: originalMsg.message_id }); + return; + } + + await sendTyping(originalMsg.chat.id); + + let loadingMsgId: number | undefined; + if (!isGroup) { + const emojis = ["💭", "🤔", "✨", "⚡", "⏳"]; + const emoji = emojis[Math.floor(Math.random() * emojis.length)]; + const hint = session.language === "fa" ? `${emoji} اممم...` : `${emoji} Hmm...`; + const msg = await sendMessage(originalMsg.chat.id, hint, { reply_to_message_id: originalMsg.message_id }).catch(() => null); + if (msg) { + loadingMsgId = msg.message_id; + activeProgressMessages.set(originalMsg.chat.id, loadingMsgId); + } + } + + try { + let currentParts = parts; + let loopCount = 0; + const maxLoops = 5; + + if (!engine.userHistories) engine.userHistories = new Map(); + if (!engine.userHistories.has(user.id)) engine.userHistories.set(user.id, []); + + while (loopCount < maxLoops) { + loopCount++; + + const geminiResponse = await handleGeminiRequest(session, user, currentParts, isGroup, env); + const { text: rawText, functionCalls, modelParts } = geminiResponse; + const ts = Date.now(); + + const activeRole = detectRole(currentParts); + addToHistory(engine.history, activeRole, currentParts, ts); + if (isGroup) { + const uHist = engine.userHistories.get(user.id) ?? []; + addToHistory(uHist, activeRole, currentParts, ts); + engine.userHistories.set(user.id, uHist); + } + + if (functionCalls && functionCalls.length > 0) { + const fcParts: Part[] = modelParts || functionCalls.map(fc => ({ functionCall: fc })); + addToHistory(engine.history, "model", fcParts, ts); + if (isGroup) { + const uHist = engine.userHistories.get(user.id) ?? []; + addToHistory(uHist, "model", fcParts, ts); + engine.userHistories.set(user.id, uHist); + } + + if (loadingMsgId) { + await editMessageText(originalMsg.chat.id, loadingMsgId, + session.language === "fa" ? "⚙️ **در حال اجرای وظیفه...**" : "⚙️ **Processing task...**" + ).catch(e => logger.warn("edit loadingMsg in tool execution failed", e)); + } + + let toolResults: ToolResult[] = []; + try { + toolResults = await withTimeout( + executeStructuredTools(functionCalls, session, originalMsg, env, loadingMsgId, requestOrigin), + 50_000, + "tools_timeout" + ); + } catch (e) { + logger.error("Tool execution failed/timeout", e); + const trackedMsgId = activeProgressMessages.get(originalMsg.chat.id); + if (trackedMsgId) { + await deleteMessage(originalMsg.chat.id, trackedMsgId).catch(err => logger.warn("delete trackedMsgId failed", err)); + activeProgressMessages.delete(originalMsg.chat.id); + } else if (loadingMsgId) { + await deleteMessage(originalMsg.chat.id, loadingMsgId).catch(err => logger.warn("delete loadingMsgId failed", err)); + } + + const err = e instanceof Error ? e : new Error(String(e)); + const isFlagged = err.message.includes("FLAGGED_PROMPT"); + + let userErr = ""; + if (isFlagged) { + userErr = session.language === "fa" + ? "🛡️ **محتوای پرامپت ارسالی شما توسط فیلترهای اخلاقی/امنیتی سرور مسدود شد.**\n\nلطفاً از کلمات یا توضیحات دیگری استفاده کنید." + : "🛡️ **Your prompt was blocked by safety filters.**\n\nPlease try a different description."; + } else { + userErr = session.language === "fa" + ? "❌ **عملیات با خطا مواجه شد.**" + : "❌ **Operation failed.**"; + } + + const isAdmin = isOwner(originalMsg.from?.id); + const adminErr = isAdmin + ? `\n\n🔧 **Debug Details:**\n\`\`\`\n${err.message.slice(0, 400)}\n\`\`\`` + : ""; + + await sendMessage( + originalMsg.chat.id, + userErr + adminErr, + { reply_to_message_id: originalMsg.message_id } + ).catch(err2 => logger.warn("send error message failed", err2)); + + break; + } + + currentParts = toolResults.map(tr => ({ + functionResponse: { name: tr.name, response: tr.response } + })); + + continue; + } else { + let finalText = rawText; + if (isOwner(user.id) && rawText.includes("[CONFIG:")) { + finalText = await handleConfigChanges(rawText, env, session.language); + } + + const responseText = sanitizeMarkdown(cleanThinkingTags(finalText)); + if (responseText) { + addToHistory(engine.history, "model", [{ text: responseText }], ts); + if (isGroup) { + const uHist = engine.userHistories.get(user.id) ?? []; + addToHistory(uHist, "model", [{ text: responseText }], ts); + engine.userHistories.set(user.id, uHist); + } + } + + session.messageCount++; + session.statistics.totalMessages++; + session.statistics.geminiMessages++; + incrementUsage(session, "message"); + recordRequest(session); + await saveSession(session, env).catch(e => logger.error("Save session failed", e)); + + if (responseText.trim()) { + activeProgressMessages.delete(originalMsg.chat.id); + await sendStreamingResponse(originalMsg.chat.id, originalMsg.message_id, responseText, loadingMsgId); + loadingMsgId = undefined; + } else if (loadingMsgId) { + await deleteMessage(originalMsg.chat.id, loadingMsgId); + activeProgressMessages.delete(originalMsg.chat.id); + loadingMsgId = undefined; + } + + break; + } + } + + if (globalCtx && typeof globalCtx.waitUntil === "function") { + if (session.messageCount % 10 === 0) { + globalCtx.waitUntil(updateUserMemoryBackground(session, user.id, engine.history, env)); + } + } + + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + const type = detectErrorType(err.message); + + if (retryCount === 0 && (type === "server" || type === "network" || type === "timeout")) { + logger.warn(`Retrying after ${type} error...`); + await sleep(2000); + return processAIRequest(session, user, parts, originalMsg, env, retryCount + 1); + } + + const errorMsg = formatError(err, session.language, isOwner(user.id)); + const retryKb = type !== "blocked" && type !== "auth" ? { + inline_keyboard: [[btn("🔄 تلاش مجدد", "retry_last_msg")]] + } : undefined; + + if (loadingMsgId) { + await editMessageText(originalMsg.chat.id, loadingMsgId, errorMsg, + retryKb ? { reply_markup: JSON.stringify(retryKb) } : {} + ).catch(async () => { + await sendMessage(originalMsg.chat.id, errorMsg, { reply_to_message_id: originalMsg.message_id }); + }); + activeProgressMessages.delete(originalMsg.chat.id); + } else { + await sendMessage(originalMsg.chat.id, errorMsg, { reply_to_message_id: originalMsg.message_id }); + } + } +} + +// ───────────────────────────────────────────── +// SECTION: ADMIN HELPERS +// ───────────────────────────────────────────── + +async function getAllUserStats(env: Env): Promise { + const BATCH_SIZE = 25; + const allKeys: Array<{ name: string }> = []; + + let list = await env.SESSIONS.list({ prefix: "session:" }); + allKeys.push(...list.keys); + while (!list.list_complete && list.cursor) { + list = await env.SESSIONS.list({ prefix: "session:", cursor: list.cursor }); + allKeys.push(...list.keys); + } + + const users: UserStatistics[] = []; + const seen = new Set(); + + for (let i = 0; i < allKeys.length; i += BATCH_SIZE) { + const chunk = allKeys.slice(i, i + BATCH_SIZE); + const results = await Promise.allSettled( + chunk.map(item => env.SESSIONS.get(item.name, "json")) + ); + + for (const result of results) { + if (result.status !== "fulfilled" || !result.value) continue; + try { + const session = result.value as unknown as ChatSession; + if (session.type !== "private") continue; + const uid = session.id; + if (isOwner(uid) || seen.has(uid)) continue; + seen.add(uid); + + const memoriesRaw = session.userMemories; + const mem = memoriesRaw instanceof Map + ? Array.from((memoriesRaw as Map).values())[0] + : Object.values(memoriesRaw as Record)[0]; + + users.push({ + userId: uid, + firstName: mem?.firstName ?? "Unknown", + userName: mem?.userName ?? "", + chatType: "private", + statistics: session.statistics ?? { totalMessages: 0, geminiMessages: 0, voicesReceived: 0, firstUsed: Date.now() }, + activeEngine: session.activeEngine ?? "gemini", + vipStatus: session.vipStatus ?? false, + dailyLimits: session.dailyLimits ?? { messages: 0, voicesSent: 0, voicesReceived: 0, imagesGenerated: 0, lastReset: Date.now() }, + }); + } catch { continue; } + } + } + + return users.sort((a, b) => (b.statistics.firstUsed ?? 0) - (a.statistics.firstUsed ?? 0)); +} + +async function isBlocked(userId: number, env: Env): Promise { + try { + const d = await env.SESSIONS.get(`user_blocked:${userId}`, "json") as { blocked?: boolean } | null; + return d?.blocked === true; + } catch { return false; } +} + +async function setBlocked(userId: number, blocked: boolean, env: Env): Promise { + const key = `user_blocked:${userId}`; + if (blocked) await env.SESSIONS.put(key, JSON.stringify({ blocked: true, since: Date.now() })); + else await env.SESSIONS.delete(key); +} + +async function setVIP(userId: number, vip: boolean, env: Env): Promise { + const key = `session:${userId}`; + const raw = await env.SESSIONS.get(key, "json") as Record | null; + if (!raw) return; + raw.vipStatus = vip; + await env.SESSIONS.put(key, JSON.stringify(raw)); +} + +async function notifyAdminNewUser(user: TgUser, env: Env): Promise { + try { + await sendMessageToOwners( + `🎉 **کاربر جدید!**\n\n👤 ${user.first_name}\n🆔 \`${user.id}\`\n@${user.username ?? "ندارد"}\n⏰ ${new Date().toLocaleString("fa-IR", { timeZone: "Asia/Tehran" })}`, + { reply_markup: JSON.stringify({ inline_keyboard: [[btn("👑 VIP کردن", `admin_toggle_vip_${user.id}`)]] }) }, + ); + } catch (e) { + logger.warn("notifyAdminNewUser failed", e); + } +} + +async function setGroupVIP(chatId: number, isVip: boolean, env: Env): Promise { + const key = `group_vip:${chatId}`; + if (isVip) await env.SESSIONS.put(key, JSON.stringify({ vipStatus: true, since: Date.now() })); + else await env.SESSIONS.delete(key); +} + +// ───────────────────────────────────────────── +// SECTION: ADMIN PANEL +// ───────────────────────────────────────────── + +async function updateAdminPanel(chatId: number, msgId: number, env: Env): Promise { + const state = adminPanelStates.get(chatId) ?? { page: 0, perPage: 5, sortBy: "new" as const }; + const allUsers = await getAllUserStats(env); + const sorted = [...allUsers]; + + if (state.sortBy === "active") sorted.sort((a, b) => (b.statistics.firstUsed ?? 0) - (a.statistics.firstUsed ?? 0)); + else if (state.sortBy === "messages") sorted.sort((a, b) => (b.statistics.totalMessages ?? 0) - (a.statistics.totalMessages ?? 0)); + + const inMaintenance = await isInMaintenance(env); + const total = sorted.length; + const totalPages = Math.max(1, Math.ceil(total / state.perPage)); + const start = state.page * state.perPage; + const page = sorted.slice(start, start + state.perPage); + const vipCount = allUsers.filter(u => u.vipStatus).length; + const activeToday = allUsers.filter(u => Date.now() - (u.statistics.firstUsed ?? 0) < 86_400_000).length; + const totalMsgs = allUsers.reduce((s, u) => s + (u.statistics.totalMessages ?? 0), 0); + + let text = `⚡️ NOVA CONTROL CENTER\n`; + text += `┌──────────────────────────┐\n`; + text += ` • Total Users: ${total}\n`; + text += ` • VIP Members: ${vipCount}\n`; + text += ` • Active Today: ${activeToday}\n`; + text += ` • Total Messages: ${totalMsgs}\n`; + text += ` • Maintenance Mode: ${inMaintenance ? "ACTIVE 🔴" : "INACTIVE 🟢"}\n`; + text += `└──────────────────────────┘\n\n`; + text += `📑 Page: ${state.page + 1}/${totalPages}\n`; + text += `━━━━━━━━━━━━━━━━━━━━━━\n\n`; + + page.forEach((u, i) => { + const n = start + i + 1; + const last = formatDate(u.statistics.firstUsed, "en", "short"); + text += `${n}. ${u.firstName} ${u.vipStatus ? "👑" : ""}\n`; + text += `↳ ID: ${u.userId} | 💬 ${u.statistics.totalMessages} msgs | 📅 ${last}\n\n`; + }); + + const userBtns: InlineBtn[] = page.map((u, i) => btn(`${start + i + 1}`, `admin_user_${u.userId}`)); + const rows: InlineBtn[][] = []; + for (let i = 0; i < userBtns.length; i += 5) rows.push(userBtns.slice(i, i + 5)); + + const nav: InlineBtn[] = []; + if (state.page > 0) nav.push(btn("◀️ Prev", "admin_page_prev")); + nav.push(btn(`${state.page + 1}/${totalPages}`, "admin_noop")); + if (state.page < totalPages - 1) nav.push(btn("Next ▶️", "admin_page_next")); + + const kb: InlineKeyboard = { + inline_keyboard: [ + ...rows, + nav, + [ + btn(state.sortBy === "new" ? "🆕 New ✓" : "🆕 New", "admin_sort_new"), + btn(state.sortBy === "active" ? "⚡ Active ✓" : "⚡ Active", "admin_sort_active"), + btn(state.sortBy === "messages" ? "💬 Msgs ✓" : "💬 Msgs", "admin_sort_messages") + ], + [btn(inMaintenance ? "🟢 Turn Maintenance OFF" : "🔴 Turn Maintenance ON", "admin_toggle_maintenance"), btn("📊 Export CSV", "admin_export_csv")], + [btn("👥 Groups Manager", "group_panel_refresh"), btn("📢 Broadcast Msg", "admin_broadcast")], + [btn("🔄 Refresh Data", "admin_refresh"), btn("❌ Close Panel", "admin_close")], + ], + }; + adminPanelStates.set(chatId, state); + await editMessageText(chatId, msgId, text, { parse_mode: "HTML", reply_markup: JSON.stringify(validateKeyboard(kb)) }); +} + +// ───────────────────────────────────────────── +// SECTION: GROUP MANAGEMENT +// ───────────────────────────────────────────── + +async function showGroupPanel(chatId: number, msgId: number, env: Env): Promise { + const groups = await listGroups(env); + const perPage = 5; + const total = groups.length; + + let text = `👥 ACTIVE GROUPS MANAGER\n`; + text += `━━━━━━━━━━━━━━━━━━━━━━\n`; + text += `Total Joined Groups: ${total}\n\n`; + + if (!groups.length) { + text += "📭 Nova has not been added to any groups yet."; + } else { + groups.slice(0, perPage).forEach((g, i) => { + const link = g.username ? `@${g.username}` : `Private ID`; + const vip = g.vipStatus ? " 👑" : ""; + const date = new Date(g.lastActivity).toLocaleDateString("en-US"); + text += `${i+1}. ${g.title}${vip}\n`; + text += `↳ Source: ${link} | Active: ${date}\n\n`; + }); + } + + const rows: InlineBtn[][] = groups.slice(0, perPage).map((g, i) => [ + btn(`${g.title.slice(0, 15)}${g.vipStatus ? " 👑" : ""}`, `group_detail_${g.chatId}`), + ]); + + rows.push([ + btn("🔄 Refresh List", "group_panel_refresh"), + btn("🔙 Main Admin Panel", "admin_back_to_main"), + ]); + + await editMessageText(chatId, msgId, text, { + parse_mode: "HTML", + reply_markup: JSON.stringify({ inline_keyboard: rows }) + }); +} + +async function showGroupDetail(chatId: number, msgId: number, groupId: number, env: Env): Promise { + const groups = await listGroups(env); + const g = groups.find(gr => gr.chatId === groupId); + if (!g) { + await editMessageText(chatId, msgId, "❌ Group data not found."); + return; + } + + const link = g.username ? `https://t.me/${g.username}` : null; + let text = `👥 GROUP PROFILE\n`; + text += `━━━━━━━━━━━━━━━━━━━━━━\n`; + text += `• Title: ${g.title}\n`; + text += `• Chat ID: ${g.chatId}\n`; + text += `• Visibility: ${link ? "Public 🌍" : "Private 🔒"}\n`; + text += `• VIP Status: ${g.vipStatus ? "VIP Member 👑" : "Standard Group 🆓"}\n`; + text += `• Last Activity: ${new Date(g.lastActivity).toLocaleString("en-US")}\n\n`; + + const kb: InlineKeyboard = { inline_keyboard: [ + [btn(g.vipStatus ? "❌ Remove VIP Access" : "👑 Grant VIP Access", `group_toggle_vip_${groupId}`)], + [btn("📢 Send Message to Group", `group_send_${groupId}`)], + [btn("🧠 Wipe Group Memory", `group_confirm_clear_mem_${groupId}`)], + [btn("🚪 Leave Group", `group_leave_${groupId}`)], + [btn("🔙 Back to List", "group_panel_refresh")], + ]}; + + await editMessageText(chatId, msgId, text, { + parse_mode: "HTML", + reply_markup: JSON.stringify(validateKeyboard(kb)) + }); +} + +// ───────────────────────────────────────────── +// SECTION: USER DETAIL SCREENS +// ───────────────────────────────────────────── + +async function showUserDetail(chatId: number, msgId: number, userId: number, env: Env): Promise { + const users = await getAllUserStats(env); + const user = users.find(u => u.userId === userId); + if (!user) { await editMessageText(chatId, msgId, "❌ User not found"); return; } + + const blocked = await isBlocked(userId, env); + const lastSeen = formatDate(user.statistics.firstUsed, "fa", "full"); + let text = `👤 **User Details**\n\n━━━━━━━━━━━━━━━━━━━━\n\n`; + text += `📝 **Info:**\n• Name: ${user.firstName}\n• Username: @${user.userName || "none"}\n• ID: \`${user.userId}\`\n• Status: ${user.vipStatus ? "👑 VIP" : "🆓 Free"}\n\n`; + text += `📊 **Stats:**\n• Total: ${user.statistics.totalMessages}\n• Nova: ${user.statistics.geminiMessages}\n• Voices: ${user.statistics.voicesReceived}\n\n`; + text += `📅 First used: ${lastSeen}\n\n━━━━━━━━━━━━━━━━━━━━`; + + const kb: InlineKeyboard = { inline_keyboard: [ + [btn(user.vipStatus ? "❌ Remove VIP" : "✅ Add VIP", `admin_toggle_vip_${userId}`)], + [btn(blocked ? "✅ Unblock" : "🚫 Block", `admin_block_${userId}`)], + [btn("📨 Send Message", `admin_msg_${userId}`)], + [btn("🧠 View Memory", `admin_view_memory_${userId}`)], + [btn("🔙 Back", "admin_back_to_main")], + ]}; + await editMessageText(chatId, msgId, text, { reply_markup: JSON.stringify(validateKeyboard(kb)) }); +} + +async function showUserMemory(chatId: number, msgId: number, userId: number, env: Env): Promise { + const raw = await env.SESSIONS.get(`session:${userId}`, "json") as Record | null; + if (!raw) { await editMessageText(chatId, msgId, "❌ User session not found."); return; } + + const session = raw as unknown as ChatSession; + const engines = session.engines as Record; + const activeEng = session.activeEngine ?? "gemini"; + const hist = (engines[activeEng]?.history ?? []) as HistoryItem[]; + + let text = `🧠 USER MEMORY MODULE\n`; + text += `━━━━━━━━━━━━━━━━━━━━━━\n`; + text += `• User ID: ${userId}\n`; + text += `• Active Model: ${activeEng}\n`; + text += `• Stored Turns: ${hist.length}\n`; + text += `• Total Session Messages: ${session.statistics?.totalMessages ?? 0}\n\n`; + + if (!hist.length) { + text += "📭 Memory is completely empty for this user."; + } else { + text += `LATEST CONVERSATION FLOW (MAX 5):\n\n`; + hist.slice(-5).forEach((h, i) => { + const role = h.role === "user" ? "👤 User" : "🤖 Nova"; + const ts = h.timestamp ? new Date(h.timestamp).toLocaleTimeString() : "?"; + const content = (h.parts[0]?.text ?? "[media]").slice(0, 80); + text += `${role}
[${ts}]
\n↳ ${content}...\n\n`; + }); + } + + const kb: InlineKeyboard = { inline_keyboard: [ + [btn("📥 Download Memory File (.txt)", `admin_download_memory_${userId}`)], + [btn("🗑️ Wipe User Memory", `admin_confirm_reset_memory_${userId}`)], + [btn("🔙 Back to Profile", `admin_user_${userId}`)], + ]}; + await editMessageText(chatId, msgId, text, { parse_mode: "HTML", reply_markup: JSON.stringify(validateKeyboard(kb)) }); +} + +// ───────────────────────────────────────────── +// SECTION: BROADCAST +// ───────────────────────────────────────────── + +async function processBroadcastBatch(env: Env): Promise { + const BATCH = 20; + const DELAY = 200; + try { + const job = await env.SESSIONS.get("broadcast_job:current", "json") as BroadcastJob | null; + if (!job || job.status === "done" || job.status === "error") return; + job.status = "running"; + + const end = Math.min(job.processedIndex + BATCH, job.userIds.length); + for (const uid of job.userIds.slice(job.processedIndex, end)) { + try { + const form = new FormData(); + form.append("chat_id", String(uid)); + form.append("text", `📢 **پیام از مدیر ربات:**\n\n${job.message}\n\n━━━━━━━━━━━━━━\n_این پیام از طرف مدیریت ارسال شده_`); + form.append("parse_mode", "Markdown"); + const res = await fetchWithTimeout(`${API_URL}/sendMessage`, { method: "POST", body: form }, 10_000); + const json = await res.json() as { ok: boolean }; + if (json.ok) job.sent++; + else job.failed++; + } catch (e) { + logger.warn(`Broadcast send to ${uid} failed`, e); + job.failed++; + } + job.processedIndex++; + await sleep(DELAY); + } + + job.status = job.processedIndex >= job.totalUsers ? "done" : "pending"; + await env.SESSIONS.put("broadcast_job:current", JSON.stringify(job)); + + const pct = Math.round((job.processedIndex / job.totalUsers) * 100); + if (job.status === "done") { + await editMessageText(job.adminChatId, job.adminMessageId, + `✅ **ارسال تکمیل شد!**\n\n✅ موفق: ${job.sent}\n❌ ناموفق: ${job.failed}\n👥 کل: ${job.totalUsers}`, + { reply_markup: JSON.stringify({ inline_keyboard: [[btn("🗑️ بستن", "broadcast_close")]] }) }, + ).catch(e => logger.warn("broadcast done edit failed", e)); + } else { + await editMessageText(job.adminChatId, job.adminMessageId, + `🔄 **در حال ارسال...**\n\n📊 ${job.processedIndex}/${job.totalUsers} (${pct}%)\n✅ ${job.sent} | ❌ ${job.failed}`, + { reply_markup: JSON.stringify({ inline_keyboard: [[btn("📊 وضعیت", "broadcast_status"), btn("🛑 لغو", "broadcast_cancel")]] }) }, + ).catch(e => logger.warn("broadcast progress edit failed", e)); + } + } catch (e) { + logger.error("Broadcast batch error", e); + } +} + +// ───────────────────────────────────────────── +// SECTION: LOG HANDLER +// ───────────────────────────────────────────── + +async function handleLog(msg: TgMessage, editId?: number): Promise { + const { chat } = msg; + if (recentLogs.length === 0) { + const emptyText = "📭 System logs are currently empty."; + if (editId) { await editMessageText(chat.id, editId, emptyText, { parse_mode: "HTML" }); return; } + await sendMessage(chat.id, emptyText, { parse_mode: "HTML", reply_to_message_id: msg.message_id }); + return; + } + + const errors = recentLogs.filter(l => l.level === "error").slice(-10); + const warns = recentLogs.filter(l => l.level === "warn").slice(-10); + + let text = `📋 SYSTEM DIAGNOSTIC LOGS\n`; + text += `━━━━━━━━━━━━━━━━━━━━━━\n`; + text += `📊 Cached Capacity: ${recentLogs.length}/${MAX_LOGS} logs\n\n`; + + if (errors.length) { + text += `🚨 LATEST ERRORS (${errors.length}):\n`; + errors.forEach(l => { + text += `• [${new Date(l.timestamp).toLocaleTimeString()}] ${l.message.slice(0, 70)}\n`; + }); + text += "\n"; + } + + if (warns.length) { + text += `⚠️ LATEST WARNINGS (${warns.length}):\n`; + warns.forEach(l => { + text += `• [${new Date(l.timestamp).toLocaleTimeString()}] ${l.message.slice(0, 70)}\n`; + }); + } + + const kb = { + inline_keyboard: [ + [btn("🗑️ Wipe Logs", "log_clear"), btn("🔄 Refresh Logs", "log_refresh")], + [btn("📥 Download Complete Logs", "log_download"), btn("❌ Close Panel", "admin_close")] + ] + }; + + const opts = { parse_mode: "HTML", reply_markup: JSON.stringify(kb) }; + if (editId) { + await editMessageText(chat.id, editId, text, opts); + } else { + await sendMessage(chat.id, text, { ...opts, reply_to_message_id: msg.message_id }); + } +} + +// ───────────────────────────────────────────── +// SECTION: REBUILD HANDLER +// ───────────────────────────────────────────── + +async function handleRebuild(msg: TgMessage, env: Env): Promise { + const { chat } = msg; + const proc = await sendMessage(chat.id, "🔧 **Rebuilding database...**\n\n⏳ Please wait", { reply_to_message_id: msg.message_id }); + + let allKeys: Array<{ name: string }> = []; + let list = await env.SESSIONS.list({ prefix: "session:" }); + allKeys.push(...list.keys); + while (!list.list_complete && list.cursor) { + list = await env.SESSIONS.list({ prefix: "session:", cursor: list.cursor }); + allKeys.push(...list.keys); + } + + let fixed = 0, skipped = 0; + for (const item of allKeys) { + try { + const raw = await env.SESSIONS.get(item.name, "json") as Record | null; + if (!raw) { skipped++; continue; } + + let changed = false; + if (!raw.statistics) { raw.statistics = { totalMessages: raw.messageCount ?? 0, geminiMessages: 0, voicesReceived: 0, firstUsed: raw.lastSeen ?? Date.now() }; changed = true; } + if (!raw.dailyLimits) { raw.dailyLimits = { messages: 0, voicesSent: 0, voicesReceived: 0, imagesGenerated: 0, lastReset: Date.now() }; changed = true; } + + if (changed) { await env.SESSIONS.put(item.name, JSON.stringify(raw)); fixed++; } + } catch { skipped++; } + } + + sessionCache.clear(); + await editMessageText(chat.id, proc.message_id, `✅ **Rebuild done!**\n\n✔️ Fixed: ${fixed}\n⏭️ Skipped: ${skipped}`); +} + +// ───────────────────────────────────────────── +// SECTION: IMPORTS FOR GROUP PANEL +// ───────────────────────────────────────────── + +import { listGroups } from './core'; +import { engineDisplayName } from './core'; + +export { + buildNovaAgentSystemPrompt, + sendStreamingResponse, + sendProgress, + progressBar, + executeAgentDownload, + executeAgentReadPage, + executeAgentBroadcast, + executeStructuredTools, + processAIRequest, + getAllUserStats, + isBlocked, + setBlocked, + setVIP, + notifyAdminNewUser, + setGroupVIP, + updateAdminPanel, + showGroupPanel, + showGroupDetail, + showUserDetail, + showUserMemory, + processBroadcastBatch, + handleLog, + handleRebuild, +};