From 4e502c9b21dec2156aa864c3ef5745d8e1b29832 Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:14:51 +0330 Subject: [PATCH] Import source: src/core Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality. --- src/core.ts | 1744 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1744 insertions(+) create mode 100644 src/core.ts diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..7eb425c --- /dev/null +++ b/src/core.ts @@ -0,0 +1,1744 @@ +import type { + BotConfig, + BotConfigChange, + ChatSession, + ChatType, + Env, + ErrorType, + HistoryItem, + InlineBtn, + InlineKeyboard, + Language, + MessageRole, + Part, + TgChat, + TgMessage, + TgUser, + UserMemory, + AIEngine, + GroupInfo, +} from './types'; +import type { KVNamespace } from './kv'; +import { + cfg, + API_URL, + BOT_INFO, + BOT_SELF_ID, + logger, + sessionCache, + activeRequests, + sessionLoadLocks, + globalDisabledKeys, + _saveDisabledKeysTimer, + lastDisabledKeysFetch, + lastTypingSent, + maintenanceCache, + recentLogs, + TokenBucket, + userBucketStore, + COAXYS_OWNER_ID, + _cachedBotConfig, + _configCacheTs, + setCfg, + setMaintenanceCache, + setGlobalDisabledKeys, + setSaveDisabledKeysTimer, + setLastDisabledKeysFetch, + setCachedBotConfig, + setConfigCacheTs, +} from './state'; + +// ───────────────────────────────────────────── +// SECTION: CONFIG & OWNER HELPERS +// ───────────────────────────────────────────── + +async function getBotConfig(env: Env): Promise { + try { + const raw = await env.SESSIONS.get("bot_config", "json") as BotConfig | null; + if (raw) { + return { + image_limit: typeof raw.image_limit === "number" ? raw.image_limit : 5, + message_limit: typeof raw.message_limit === "number" ? raw.message_limit : 50, + voice_limit: typeof raw.voice_limit === "number" ? raw.voice_limit : 10, + system_prompt: typeof raw.system_prompt === "string" ? raw.system_prompt : "", + maintenance: typeof raw.maintenance === "boolean" ? raw.maintenance : false, + vip_contact: typeof raw.vip_contact === "string" ? raw.vip_contact : "@Hacker1382", + }; + } + } catch (e) { + logger.error("getBotConfig failed", e); + } + return { + image_limit: 5, + message_limit: 50, + voice_limit: 10, + system_prompt: "", + maintenance: false, + vip_contact: "@Hacker1382", + }; +} + +function parseOwnerIds(primaryOwnerId: number, extraIds?: string): number[] { + const owners = new Set([primaryOwnerId]); + for (const raw of (extraIds ?? "").split(",")) { + const id = parseInt(raw.trim(), 10); + if (Number.isFinite(id) && id > 0) owners.add(id); + } + return [...owners]; +} + +function createConfig(env: Env, botConfig: BotConfig) { + const CF_ACCOUNTS = [ + { id: env.CF_ID_1, token: env.CF_TOKEN_1 }, + { id: env.CF_ID_2, token: env.CF_TOKEN_2 }, + { id: env.CF_ID_3, token: env.CF_TOKEN_3 }, + ].filter((a): a is { id: string; token: string } => + Boolean(a.id && a.token) + ); + + const primaryOwnerId = parseInt(env.BOT_OWNER_ID ?? "1558272627", 10); + + return { + TOKEN: env.TOKEN, + BOT_OWNER_ID: primaryOwnerId, + OWNER_IDS: parseOwnerIds(primaryOwnerId, env.ADDITIONAL_OWNER_IDS), + GEMINI_KEYS: [ + env.GEMINI_KEY_1, env.GEMINI_KEY_2, env.GEMINI_KEY_3, + env.GEMINI_KEY_4, env.GEMINI_KEY_5, + ].filter((k): k is string => Boolean(k)), + GEMINI_MODEL: "gemini-flash-lite-latest", + GEMINI_FALLBACK_MODEL: "gemini-flash-latest", + GOOGLE_SEARCH_API_KEY: env.GOOGLE_SEARCH_API_KEY ?? "", + GOOGLE_SEARCH_ENGINE_ID: env.GOOGLE_SEARCH_ENGINE_ID ?? "", + CF_ACCOUNTS, + CF_ACCOUNT_INDEX: 0, + AI_IMAGE_MODELS: ["@cf/black-forest-labs/flux-2-klein-4b"], + MAINTENANCE_MODE: botConfig.maintenance, + ELEVENLABS_KEYS: [ + env.ELEVENLABS_KEY_1?.trim(), + env.ELEVENLABS_KEY_2?.trim(), + env.ELEVENLABS_KEY_3?.trim(), + env.ELEVENLABS_KEY_4?.trim(), + env.ELEVENLABS_KEY_5?.trim() + ].filter((k): k is string => Boolean(k)), + ELEVENLABS_VOICE_ID: "21m00Tcm4TlvDq8ikWAM", + HISTORY_LIMIT: 15, + SESSION_TTL: 30 * 24 * 60 * 60 * 1000, + MAX_CONCURRENT_REQUESTS: 50, + REQUEST_TIMEOUT: 28_000, + RATE_LIMIT_WINDOW: 60 * 1000, + RATE_LIMIT_MAX_REQUESTS: 30, + MESSAGE_CHUNK_SIZE: 4_000, + MAX_MESSAGE_LENGTH: 10_000, + MAX_PROMPT_LENGTH: 5_000, + MAX_FILE_SIZE: 15 * 1024 * 1024, + ALLOWED_CHAT_TYPES: ["private", "group", "supergroup"] as const, + MODEL_CACHE_TTL: 12 * 60 * 60 * 1000, + IMAGE_LIMIT: botConfig.image_limit, + MESSAGE_LIMIT: botConfig.message_limit, + VOICE_LIMIT: botConfig.voice_limit, + SYSTEM_PROMPT: botConfig.system_prompt, + VIP_CONTACT: botConfig.vip_contact, + }; +} + +type Config = ReturnType; + +function isOwner(userId?: number | null): boolean { + return typeof userId === "number" && Boolean(cfg?.OWNER_IDS.includes(userId)); +} + +function isPrimaryOwner(userId?: number | null): boolean { + return typeof userId === "number" && userId === cfg?.BOT_OWNER_ID; +} + +function ownerDisplayName(userId: number): string { + if (userId === COAXYS_OWNER_ID) return "Coaxys"; + if (userId === cfg?.BOT_OWNER_ID) return "Hamid"; + return "Owner"; +} + +function hasPrivilegedAccess(session: ChatSession, userId?: number | null): boolean { + return session.vipStatus || isOwner(userId) || isOwner(session.id); +} + +function accountStatusLabel(session: ChatSession, lang: Language, userId?: number | null): string { + if (isOwner(userId) || isOwner(session.id)) { + return lang === "fa" ? "مالک / توسعه‌دهنده 👑" : "Owner / Developer 👑"; + } + if (session.vipStatus) return lang === "fa" ? "کاربر ویژه VIP 💎" : "VIP Member 💎"; + return lang === "fa" ? "کاربر عادی 🆓" : "Standard User 🆓"; +} + +// ───────────────────────────────────────────── +// SECTION: TRANSLATIONS +// ───────────────────────────────────────────── + +const TR = { + fa: { + engine_gemini: "نوا", + loading: "⏳ لطفاً صبر کنید...", processing: "⚙️ در حال پردازش...", + prompt_title: "✏️ **تنظیمات پرامپت شخصی**", prompt_current: "پرامپت فعلی:", + prompt_default: "پیش‌فرض", prompt_guide: "💡 برای تنظیم: `/setprompt [موتور] متن شما`", + prompt_reset: "ریست", prompt_show: "نمایش پرامپت 👁️", prompt_manage: "مدیریت پرامپت 📝", + system_prompt: "تو نوا هستی، یک دستیار هوشمند، خودآگاه، مودب و مفید. پاسخ‌های دقیق و خلاصه به فارسی بده. تاریخ: {date}", + img_limit: "⚠️ محدودیت روزانه تمام شده است.", + img_start: "🎨 **شروع ساخت تصویر...**", img_translating: "🔄 **در حال ترجمه...**", + img_processing: "⏳ در حال پردازش با {count} مدل...", + img_failed: "❌ **ساخت تصویر ناموفق بود.**", img_success: "✅ **پایان پردازش.**", + img_help: "❌ **فرمت نادرست**\n\nاستفاده: `/img [توضیح]`\nمثال: `/img یک گربه در فضا`", + btn_settings: "تنظیمات ⚙️", btn_back: "بازگشت 🔙", btn_select_model: "🤖 وضعیت نوا", + btn_prompt: "پرامپت (شخصیت) ✏️", btn_help: "راهنما 📖", btn_close: "بستن ❌", + btn_refresh: "بروزرسانی 🔄", btn_retry: "🔄 تلاش مجدد", btn_confirm: "✅ بله", + btn_cancel: "❌ لغو", btn_prev: "◀️ قبلی", btn_next: "بعدی ▶️", + err_title: "خطا", err_quota: "ظرفیت این مدل تکمیل شده. بعداً امتحان کنید.", + err_auth: "مشکل در کلیدهای دسترسی.", err_network: "مشکل در اتصال به سرور.", + err_timeout: "زمان پاسخگویی تمام شد.", err_blocked: "محتوا توسط سیستم امنیتی رد شد.", + err_empty: "پاسخی دریافت نشد. لطفاً سوال را تغییر دهید.", err_voice: "خطا در پردازش صدا.", + err_image: "ساخت تصویر با خطا مواجه شد.", err_unknown: "یک خطای غیرمنتظره رخ داد.", + err_vip_only: "⚠️ این قابلیت مخصوص کاربران VIP است.", + err_format: "❌ **فرمت نادرست**", err_empty_prompt: "❌ پرامپت نمی‌تواند خالی باشد.", + err_prompt_toolong: "❌ پرامپت خیلی طولانی است.", + err_engine_invalid: "❌ موتور نادرست. موتورها: `نوا`", + err_vip_prompt: "⚠️ **دسترسی محدود**\n\nتنظیم پرامپت فقط برای کاربران VIP امکان‌پذیر است.", + err_config_missing: "❌ تنظیمات Cloudflare انجام نشده است.", + active_model_title: "⚙️ **تنظیمات {name}**", active_model_keys: "🔑 **کلیدها:** {count}", + active_model_static_desc: "💡 {name} از یک مدل ثابت استفاده می‌کند.", + active_model_current: "🤖 **مدل فعال:** {name}", active_model_key_idx: "🔑 **کلید API:** {index}/{total}", + active_model_count: "📊 **تعداد مدل‌ها:** {count}", active_model_guide: "💡 برای تغییر مدل از دکمه زیر استفاده کنید", + model_select_title: "🤖 **انتخاب مدل {name}**", model_total_count: "📊 تعداد کل: {count} مدل", + model_last_update: "🕐 آخرین بروزرسانی: {time}", model_page_info: "📄 صفحه {page} از {total}", + model_not_found: "❌ **هیچ مدلی یافت نشد**", + search_searching: '🔍 **در حال جستجوی "{query}"...**\n\n⏳ لطفاً صبر کنید', + search_results: "🖼️ {caption}\n\n📸 {count} تصویر یافت شد", + search_failed: "❌ **خطا در جستجو**", + search_guide: "💡 راهنمایی:\n• از کلمات ساده‌تر استفاده کنید\n• به انگلیسی امتحان کنید", + search_link_fallback: "⚠️ لینک: {link}\n\n📸 {count} تصویر یافت شد", + search_no_results: "هیچ تصویری یافت نشد.", search_long_query: "❌ توضیح خیلی طولانی است.", + search_usage: "استفاده: `/search [متن]`", + welcome_private: "🚀 **سلام {name} عزیز!**\n\nخوش آمدی به **نوا (Nova)** 🤖\n\n✨ **قابلیت‌های من:**\n🧠 هوش مصنوعی هوشمند و خودآگاه\n🎨 ساخت تصویر پیشرفته\n🎤 تشخیص و تایپ صدا\n🔍 جستجوی تصاویر وب\n📑 ساخت و خلاصه کردن فایل PDF\n\n👇 از منوی زیر شروع کن:", + welcome_group: "👋 **سلام به اعضای گروه {name}!**\n\nمن **نوا** هستم 🤖. برای استفاده من رو **منشن** کنید.", + help_text: "🧭 **راهنمای کامل ربات**\n\n💬 **گفتگو:** کافیه پیامت رو بنویسی یا ویس بفرستی.\n\n🎨 **تصاویر:**\n• ساخت: `/img یک گربه فضانورد`\n• جستجو: `/search طبیعت`\n\n🔍 **وب:** `/web اخبار امروز هوش مصنوعی`\n📑 **PDF:** `/pdf متن شما`\n\n⚙️ **تنظیمات:**\n• /model - وضعیت هوش مصنوعی نوا\n• /new - مکالمه جدید\n• /prompt - تنظیم شخصیت\n• /language - تغییر زبان", + }, + en: { + engine_gemini: "Nova", + loading: "⏳ Please wait...", processing: "⚙️ Processing...", + prompt_title: "✏️ **Custom Persona Settings**", prompt_current: "Current Persona:", + prompt_default: "Default", prompt_guide: "💡 To set: `/setprompt [engine] your text`", + prompt_reset: "Reset", prompt_show: "Show Persona 👁️", prompt_manage: "Manage Persona 📝", + system_prompt: "You are Nova, a self-aware, intelligent, polite, and helpful assistant. Be concise and accurate. Date: {date}", + img_limit: "⚠️ Daily limit exceeded.", + img_start: "🎨 **Starting image generation...**", img_translating: "🔄 **Translating...**", + img_processing: "⏳ Processing with {count} models...", + img_failed: "❌ **Image generation failed.**", img_success: "✅ **Processing completed.**", + img_help: "❌ **Invalid Format**\n\nUsage: `/img [prompt]`\nExample: `/img a cat in space`", + btn_settings: "Settings ⚙️", btn_back: "Back 🔙", btn_select_model: "🤖 Nova Status", + btn_prompt: "Persona ✏️", btn_help: "Help 📖", btn_close: "Close ❌", + btn_refresh: "Refresh 🔄", btn_retry: "🔄 Retry", btn_confirm: "✅ Confirm", + btn_cancel: "❌ Cancel", btn_prev: "◀️ Previous", btn_next: "Next ▶️", + err_title: "Error", err_quota: "Quota exceeded. Please try again later.", + err_auth: "Authentication failed.", err_network: "Network connection error.", + err_timeout: "Request timed out.", err_blocked: "Content blocked by safety filters.", + err_empty: "Received empty response. Please rephrase.", err_voice: "Voice processing failed.", + err_image: "Image generation failed.", err_unknown: "An unexpected error occurred.", + err_vip_only: "⚠️ This feature is for VIP users only.", + err_format: "❌ **Invalid Format**", err_empty_prompt: "❌ Prompt cannot be empty.", + err_prompt_toolong: "❌ Prompt is too long.", + err_engine_invalid: "❌ Invalid engine. Use: `nova`", + err_vip_prompt: "⚠️ **Restricted Access**\n\nCustom personas are for VIP users only.", + err_config_missing: "❌ Cloudflare config missing.", + active_model_title: "⚙️ **{name} Settings**", active_model_keys: "🔑 **Keys:** {count}", + active_model_static_desc: "💡 {name} uses a stable static model.", + active_model_current: "🤖 **Active Model:** {name}", active_model_key_idx: "🔑 **API Key:** {index}/{total}", + active_model_count: "📊 **Model Count:** {count}", active_model_guide: "💡 Use the button below to change model", + model_select_title: "🤖 **Select {name} Model**", model_total_count: "📊 Total: {count} models", + model_last_update: "🕐 Last Update: {time}", model_page_info: "📄 Page {page} of {total}", + model_not_found: "❌ **No models found**", + search_searching: '🔍 **Searching for "{query}"...**\n\n⏳ Please wait', + search_results: "🖼️ {caption}\n\n📸 {count} images found", + search_failed: "❌ **Search Failed**", + search_guide: "💡 Tips:\n• Use simpler keywords\n• Try in English", + search_link_fallback: "⚠️ Link: {link}\n\n📸 {count} images found", + search_no_results: "No images found.", search_long_query: "❌ Query too long.", + search_usage: "Usage: `/search [query]`", + welcome_private: "🚀 **Hello {name}!**\n\nWelcome to **Nova** 🤖\n\n✨ **My Capabilities:**\n🧠 Smart self-aware AI Agent\n🎨 Advanced Image Generation\n🎤 Voice Recognition\n🔍 Web Image Search\n📑 PDF Creation & Summarization\n\n👇 Start below:", + welcome_group: "👋 **Hello {name} members!**\n\nI am **Nova** 🤖. **Mention** me to get started.", + help_text: "🧭 **Bot Guide**\n\n💬 **Chat:** Just type or send a voice note.\n\n🎨 **Images:**\n• Generate: `/img a cute cat`\n• Search: `/search nature`\n\n🔍 **Web:** `/web latest AI news`\n📑 **PDF:** `/pdf your text`\n\n⚙️ **Settings:**\n• /model - Nova AI Status\n• /new - Clear Memory\n• /prompt - Custom Persona\n• /language - Change Language", + }, +}; + +type LangKey = keyof typeof TR.fa; + +function t(lang: Language, key: LangKey, vars?: Record): string { + const translations = TR[lang] as Record; + let text = translations[key] ?? (TR.fa as Record)[key] ?? key; + if (vars) { + for (const [k, v] of Object.entries(vars)) { + text = text.replace(new RegExp(`\\{${k}\\}`, "g"), v); + } + } + return text; +} + +function tSession(session: ChatSession, key: LangKey, vars?: Record): string { + return t(session.language, key, vars); +} + +const MODEL_META = { + gemini: { emoji: "🤖", fa: "نوا", en: "Nova", badge_fa: "⚡ سریع · دقیق", badge_en: "⚡ Fast · Accurate" }, +} as const; + +function engineDisplayName(engine: AIEngine, lang: Language): string { + return lang === "fa" ? MODEL_META[engine].fa : MODEL_META[engine].en; +} + +// ───────────────────────────────────────────── +// SECTION: UTILITIES +// ───────────────────────────────────────────── + +function sanitizeInput(text: string): string { + return text.trim() + .replace(/[^\S\r\n]+/g, " ") + .replace(/[\x00-\x1f\x7f-\x9f]/g, "") + .substring(0, 10_000); +} + +function generateId(): string { + return `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +async function saveGroupInfo(chat: TgChat, env: Env): Promise { + const key = `group_info:${chat.id}`; + const existing = await env.SESSIONS.get(key, "json") as GroupInfo | null; + const info: GroupInfo = { + chatId: chat.id, + title: chat.title ?? "Unknown Group", + type: chat.type as "group" | "supergroup", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + username: (chat as any).username, + addedAt: existing?.addedAt ?? Date.now(), + lastActivity: Date.now(), + vipStatus: existing?.vipStatus ?? false, + }; + await env.SESSIONS.put(key, JSON.stringify(info)); +} + +async function listGroups(env: Env): Promise { + const groups: GroupInfo[] = []; + let list = await env.SESSIONS.list({ prefix: "group_info:" }); + const keys = [...list.keys]; + while (!list.list_complete && list.cursor) { + list = await env.SESSIONS.list({ prefix: "group_info:", cursor: list.cursor }); + keys.push(...list.keys); + } + for (const k of keys) { + const raw = await env.SESSIONS.get(k.name, "json") as GroupInfo | null; + if (raw) groups.push(raw); + } + return groups.sort((a, b) => b.lastActivity - a.lastActivity); +} + +function arrayBufferToBase64(buf: ArrayBuffer): string { + let binary = ""; + const bytes = new Uint8Array(buf); + const CHUNK = 0x2000; + for (let i = 0; i < bytes.length; i += CHUNK) { + // eslint-disable-next-line prefer-spread + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK) as unknown as number[]); + } + return btoa(binary); +} + +function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +function sanitizeMarkdown(text: string): string { + let s = text; + + const boldMatches = s.match(/\*\*/g) ?? []; + if (boldMatches.length % 2 !== 0) { + const lastIdx = s.lastIndexOf("**"); + s = s.slice(0, lastIdx) + s.slice(lastIdx + 2); + } + + const singles = (s.match(/(?/gi, "") + .replace(/ maxLen) { + let i = 0; + while (i < para.length) { chunks.push(para.slice(i, i + maxLen)); i += maxLen; } + current = ""; + } else { + current = para; + } + } + } + if (current.trim()) chunks.push(current.trim()); + return chunks.filter(Boolean); +} + +function formatDate(ts: number | undefined, lang: Language, format: "full" | "short" | "time" = "full"): string { + if (!ts || isNaN(ts) || ts === 0) return lang === "fa" ? "نامشخص" : "Unknown"; + try { + const opts: Intl.DateTimeFormatOptions = { timeZone: "Asia/Tehran" }; + if (format === "full") Object.assign(opts, { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" }); + else if (format === "short") Object.assign(opts, { year: "numeric", month: "short", day: "numeric" }); + else Object.assign(opts, { hour: "2-digit", minute: "2-digit" }); + return new Date(ts).toLocaleString(lang === "fa" ? "fa-IR" : "en-US", opts); + } catch { return lang === "fa" ? "نامشخص" : "Unknown"; } +} + +async function withTimeout(promise: Promise, ms: number, msg = "Timeout"): Promise { + let id: ReturnType; + const timeout = new Promise((_, rej) => { id = setTimeout(() => rej(new Error(msg)), ms); }); + try { + const result = await Promise.race([promise, timeout]); + clearTimeout(id!); + return result; + } catch (e) { + clearTimeout(id!); + throw e; + } +} + +async function fetchWithTimeout( + url: string, + opts: RequestInit = {}, + ms = 20_000, + retries = 0 +): Promise { + for (let attempt = 0; attempt <= retries; attempt++) { + const ctrl = new AbortController(); + const id = setTimeout(() => ctrl.abort(), ms); + try { + const res = await fetch(url, { ...opts, signal: ctrl.signal }); + clearTimeout(id); + return res; + } catch (e) { + clearTimeout(id); + const isLast = attempt === retries; + if (isLast) throw e; + await sleep(500 * (attempt + 1)); + } + } + throw new Error("fetch failed after all retries"); +} + +function sleep(ms: number): Promise { return new Promise(r => setTimeout(r, ms)); } + +function extractTopics(text: string): string[] { + return text.toLowerCase() + .replace(/[^\w\s\u0600-\u06FF]/g, " ") + .split(/\s+/) + .filter(w => w.length > 3) + .slice(0, 3); +} + +function containsPersian(text: string): boolean { + return /[\u0600-\u06FF\u200C\u200D]/.test(text); +} + +function detectRole(parts: Part[]): MessageRole { + if (parts.some(p => p.functionResponse)) return "user"; + if (parts.some(p => p.functionCall)) return "model"; + return "user"; +} + +// ───────────────────────────────────────────── +// SECTION: API KEY MANAGEMENT +// ───────────────────────────────────────────── + +async function isKeyDisabled(key: string, env: Env): Promise { + const now = Date.now(); + if (now - lastDisabledKeysFetch > 60_000) { + try { + const data = await env.SESSIONS.get("disabled_api_keys", "json"); + if (data) setGlobalDisabledKeys(data as Record); + setLastDisabledKeysFetch(now); + } catch (e) { + logger.warn("Failed to fetch disabled keys cache", e); + } + } + const unlock = globalDisabledKeys[key]; + return Boolean(unlock && now < unlock); +} + +function disableApiKey(key: string, env: Env, isDaily = false): void { + const duration = isDaily ? 3 * 60 * 60 * 1000 : 3 * 60 * 1000; + globalDisabledKeys[key] = Date.now() + duration; + saveDisabledKeysDebounced(env); + logger.warn(`🚫 API Key locked for ${isDaily ? "3h" : "3m"}`); +} + +function saveDisabledKeysDebounced(env: Env): void { + if (_saveDisabledKeysTimer) clearTimeout(_saveDisabledKeysTimer); + const timer = setTimeout(() => { + env.SESSIONS.put("disabled_api_keys", JSON.stringify(globalDisabledKeys)).catch(e => logger.error("Failed to save disabled API keys", e)); + setSaveDisabledKeysTimer(null); + }, 2000); + setSaveDisabledKeysTimer(timer); +} + +// ───────────────────────────────────────────── +// SECTION: RATE LIMITING & CONCURRENCY +// ───────────────────────────────────────────── + +function isRateLimited(session: ChatSession): boolean { + const now = Date.now(); + session.rateLimiting.requests = session.rateLimiting.requests.filter(t => now - t < cfg.RATE_LIMIT_WINDOW); + return session.rateLimiting.requests.length >= cfg.RATE_LIMIT_MAX_REQUESTS; +} + +function recordRequest(session: ChatSession): void { + session.rateLimiting.requests.push(Date.now()); +} + +function canProcessRequest(chatId: number, requestId: string): boolean { + if (!activeRequests.has(chatId)) activeRequests.set(chatId, new Set()); + const chat = activeRequests.get(chatId)!; + const now = Date.now(); + + for (const r of chat) { + if (now - r.ts > 120_000) chat.delete(r); + } + + const total = Array.from(activeRequests.values()).reduce((s, set) => s + set.size, 0); + if (total >= cfg.MAX_CONCURRENT_REQUESTS || chat.size >= 3) return false; + + chat.add({ id: requestId, ts: now }); + return true; +} + +function releaseRequest(chatId: number, requestId: string): void { + const chat = activeRequests.get(chatId); + if (!chat) return; + for (const r of chat) { + if (r.id === requestId) { chat.delete(r); break; } + } + if (chat.size === 0) activeRequests.delete(chatId); +} + +// ───────────────────────────────────────────── +// SECTION: SESSION MANAGEMENT +// ───────────────────────────────────────────── + +function createDefaultSession(chat: TgChat, user: TgUser): ChatSession { + const now = Date.now(); + const mem = new Map(); + mem.set(user.id, { + userId: user.id, userName: user.username ?? user.first_name, + firstName: user.first_name, lastSeen: now, messageCount: 0, + topics: [], personality: "", preferences: [], interactionStyle: "", + }); + return { + id: chat.id, type: chat.type, activeEngine: "gemini", + lastSeen: now, messageCount: 0, language: "fa", + userMemories: mem, groupContext: [], + userCustomPrompts: new Map(), + customPrompts: { gemini: null}, + engines: { + gemini: { history: [], userHistories: new Map(), apiKeyIndex: 0, consecutiveErrors: 0 }, + }, + rateLimiting: { requests: [] }, + settings: { + autoCleanHistory: true, typingIndicator: true, + groupResponseMode: "mention_only", personalizedResponses: true, + contextAwareness: true, languageSet: false, + }, + statistics: { + totalMessages: 0, geminiMessages: 0, voicesReceived: 0, firstUsed: now, + }, + vipStatus: false, + dailyLimits: { messages: 0, voicesSent: 0, voicesReceived: 0, imagesGenerated: 0, lastReset: now }, + }; +} + +function toMap(raw: unknown, keyFn: (k: string) => K): Map { + if (!raw) return new Map(); + if (raw instanceof Map) return raw as Map; + const map = new Map(); + if (Array.isArray(raw)) { + for (const [k, v] of raw) map.set(keyFn(String(k)), v as V); + } else if (typeof raw === "object") { + for (const [k, v] of Object.entries(raw as Record)) { + map.set(keyFn(k), v as V); + } + } + return map; +} + +function hydrateSession(raw: Record, chat: TgChat, user: TgUser): ChatSession { + const s = raw as unknown as ChatSession; + s.lastSeen = Date.now(); + s.language ??= "fa"; + + s.statistics ??= { + totalMessages: s.messageCount ?? 0, geminiMessages: 0, voicesReceived: 0, firstUsed: s.lastSeen, + }; + if (s.statistics.totalMessages === 0 && s.messageCount > 0) { + s.statistics.totalMessages = s.messageCount; + } + + s.userCustomPrompts = toMap( + (s.userCustomPrompts as unknown) ?? {}, + k => parseInt(k, 10) + ); + + s.dailyLimits ??= { messages: 0, voicesSent: 0, voicesReceived: 0, imagesGenerated: 0, lastReset: Date.now() }; + + s.userMemories = toMap(s.userMemories as unknown, k => parseInt(k, 10)); + + s.userMemories.forEach((mem) => { + mem.preferences ??= []; + mem.topics ??= []; + mem.personality ??= ""; + }); + + if (!s.userMemories.has(user.id)) { + s.userMemories.set(user.id, { + userId: user.id, userName: user.username ?? user.first_name, + firstName: user.first_name, lastSeen: Date.now(), messageCount: 0, + topics: [], personality: "", preferences: [], interactionStyle: "", + }); + } + + s.groupContext ??= []; + + s.engines ??= { + gemini: { history: [], userHistories: new Map(), apiKeyIndex: 0, consecutiveErrors: 0 }, + }; + + for (const engineKey of ["gemini"] as const) { + const eng = s.engines[engineKey]; + if (Array.isArray(eng.history)) { + eng.history = eng.history.filter(h => { + const txt = h.parts[0]?.text ?? ""; + return !txt.startsWith("[ابزار سیستم اجرا شد:") + && !txt.startsWith("[TOOL_CALL:"); + }); + } + eng.userHistories = toMap(eng.userHistories as unknown, k => parseInt(k, 10)); + if (!eng.userHistories.has(user.id)) eng.userHistories.set(user.id, []); + eng.history ??= []; + if (eng.history.length === 0) { + const isGroup = chat.type !== "private"; + const promptText = buildSystemPrompt(engineKey, user.first_name, isGroup, s.language, user.id); + const role: MessageRole = engineKey === "gemini" ? "user" : "assistant"; + eng.history = [{ role, parts: [{ text: promptText }], timestamp: Date.now() }]; + if (engineKey === "gemini") { + eng.history.push({ role: "model", parts: [{ text: "سلام! آماده کمک هستم." }], timestamp: Date.now() }); + } + } + } + + s.rateLimiting ??= { requests: [] }; + s.settings ??= { + autoCleanHistory: true, typingIndicator: true, groupResponseMode: "mention_only", + personalizedResponses: true, contextAwareness: true, languageSet: false, + }; + + s.settings.groupResponseMode ??= "mention_only"; + + return s; +} + +function mapToObj(map: Map): Record { + if (!(map instanceof Map)) return (map as Record) ?? {}; + const obj: Record = {}; + map.forEach((v, k) => { obj[String(k)] = v; }); + return obj; +} + +async function saveSession(session: ChatSession, env: Env): Promise { + const key = `session:${session.id}`; + const data: Record = { + ...session, + userMemories: mapToObj(session.userMemories as Map), + userCustomPrompts: mapToObj(session.userCustomPrompts as Map), + groupContext: session.groupContext, + engines: { + gemini: { + ...session.engines.gemini, + userHistories: mapToObj(session.engines.gemini.userHistories as Map), + }, + }, + }; + + let json = JSON.stringify(data); + + if (json.length > 2 * 1024 * 1024) { + logger.warn(`⚠️ Session ${session.id} large (${Math.round(json.length / 1024)}KB), compressing`); + const compressed = JSON.parse(json) as Record; + const engines = compressed.engines as Record; + for (const eng of Object.values(engines)) { + if (Array.isArray(eng.history)) eng.history = eng.history.slice(-20); + eng.userHistories = {}; + } + json = JSON.stringify(compressed); + } + + await env.SESSIONS.put(key, json); +} + +async function getOrCreateSession(chat: TgChat, user: TgUser, env: Env): Promise { + const cacheKey = `session:${chat.id}`; + const cached = sessionCache.get(cacheKey); + if (cached) return cached; + + if (sessionLoadLocks.has(chat.id)) { + return sessionLoadLocks.get(chat.id)!; + } + + const promise = withTimeout( (async (): Promise => { + try { + const raw = await env.SESSIONS.get(cacheKey, "json"); + let session: ChatSession; + + if (raw) { + session = hydrateSession(raw as Record, chat, user); + } else { + session = createDefaultSession(chat, user); + await saveSession(session, env); + } + + const now = Date.now(); + if (now - session.dailyLimits.lastReset > 24 * 60 * 60 * 1000) { + session.dailyLimits = { messages: 0, voicesSent: 0, voicesReceived: 0, imagesGenerated: 0, lastReset: now }; + } + + if (isOwner(user.id) && session.type === "private") { + session.vipStatus = true; + } + + const cacheTTL = session.vipStatus + ? 10 * 60 * 1000 + : 5 * 60 * 1000; + sessionCache.set(cacheKey, session, cacheTTL); + return session; + } finally { + sessionLoadLocks.delete(chat.id); + } + })(), + 10_000, + "Session load timeout" +).catch((e) => { + logger.error("Session load failed, using default", e); + sessionLoadLocks.delete(chat.id); + return createDefaultSession(chat, user); +}); + sessionLoadLocks.set(chat.id, promise); + return promise; +} + +function getUserBucket(userId: number, isVip: boolean): TokenBucket { + const entry = userBucketStore.get(userId); + if (entry) { + entry.lastUsed = Date.now(); + return entry.bucket as TokenBucket; + } + + if (userBucketStore.size >= 800) { + const sorted = [...userBucketStore.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed); + for (const [id] of sorted.slice(0, 200)) userBucketStore.delete(id); + } + + const bucket = new TokenBucket(isVip ? 50 : 20, isVip ? 10 : 2); + userBucketStore.set(userId, { bucket, lastUsed: Date.now() }); + return bucket; +} + +// ───────────────────────────────────────────── +// SECTION: SYSTEM PROMPT BUILDER +// ───────────────────────────────────────────── + +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👥 محیط: گروه تلگرامی" : ""}`; +} + +function buildSystemPrompt( + engine: AIEngine, + userName: string, + isGroup: boolean, + lang: Language, + userId: number, + userMemory?: UserMemory, +): string { + if (!cfg) { + return `You are Nova, a helpful assistant. User: ${userName}.`; + } + return buildNovaAgentSystemPrompt( + userName, + userId, + lang, + cfg.CF_ACCOUNTS.length > 0, + cfg.ELEVENLABS_KEYS.length > 0, + Boolean(cfg.GOOGLE_SEARCH_API_KEY && cfg.GOOGLE_SEARCH_ENGINE_ID), + isGroup, + userMemory, + ); +} + +function getActivePrompt(session: ChatSession, userName: string | TgUser, isGroup = false): string { + const userId = typeof userName === "object" ? userName.id : 0; + const name = typeof userName === "object" ? userName.first_name : userName; + const lang = session.language; + const userMemory = userId ? session.userMemories.get(userId) : undefined; + + const userSpecificPrompt = (isGroup && userId) + ? session.userCustomPrompts?.get(userId) + : undefined; + const custom = userSpecificPrompt ?? (!isGroup ? session.customPrompts.gemini : null); + + const basePrompt = buildSystemPrompt(session.activeEngine, name, isGroup, lang, userId, userMemory); + + if (custom) { + const date = new Date().toLocaleString(lang === "fa" ? "fa-IR" : "en-US", { timeZone: "Asia/Tehran" }); + const label = lang === "fa" ? "🎭 شخصیت سفارشی" : "🎭 Custom persona"; + return `${basePrompt}\n\n${label}:\n${custom}\n📅 ${date}`; + } + return basePrompt; +} + +let _processAIRequest: ((session: ChatSession, user: TgUser, parts: Part[], originalMsg: TgMessage, env: Env) => Promise) | null = null; + +function registerProcessAIRequest(fn: typeof _processAIRequest): void { + _processAIRequest = fn; +} + +async function handleStickerMessage(msg: TgMessage, env: Env): Promise { + const { chat, from, sticker } = msg; + if (!from || !sticker) return; + + const session = await getOrCreateSession(chat, from, env); + if (!shouldRespondInGroup(msg, session)) return; + + const mc = await checkMaintenance(env, from.id); + if (mc.blocked) return; + + const lang = session.language; + const emoji = sticker.emoji ?? "😊"; + + const prompt = lang === "fa" + ? `کاربر یک استیکر ${emoji} فرستاد. یه پاسخ کوتاه و صمیمی با ایموجی بده (حداکثر ۲ جمله)` + : `User sent a ${emoji} sticker. Reply briefly and friendly with emoji (max 2 sentences)`; + + if (_processAIRequest) { + await _processAIRequest(session, from, [{ text: prompt }], msg, env); + } +} + +function shouldRespondInGroup(message: TgMessage, session: ChatSession): boolean { + const text = message.text ?? message.caption ?? ""; + const botId = BOT_INFO?.id ?? BOT_SELF_ID; + const botUsername = BOT_INFO?.username?.toLowerCase(); + + const isReply = Boolean(botId && message.reply_to_message?.from?.id === botId); + + const hasAtMention = botUsername ? ( + text.toLowerCase().includes(`@${botUsername}`) || + Boolean(message.entities?.some(e => { + if (e.type !== "mention") return false; + const mentionText = text.slice(e.offset, e.offset + e.length).toLowerCase(); + return mentionText === `@${botUsername}`; + })) + ) : false; + + const isNameCalled = /(? { + const cleaned: Part = {}; + if (p.text) cleaned.text = p.text; + if (p.inline_data) cleaned.text = `[رسانه پیوست: ${p.inline_data.mime_type}]`; + if (p.functionCall) cleaned.functionCall = p.functionCall; + if (p.functionResponse) cleaned.functionResponse = p.functionResponse; + if (p.thoughtSignature) cleaned.thoughtSignature = p.thoughtSignature; + return cleaned; + }).filter(p => p.text || p.inline_data || p.functionCall || p.functionResponse); + + if (historyParts.length === 0) return; + + let finalRole = role; + if (historyParts.some(p => p.functionResponse)) finalRole = "user"; + else if (historyParts.some(p => p.functionCall)) finalRole = "model"; + + history.push({ role: finalRole, parts: historyParts, timestamp: ts }); + + const limit = cfg?.HISTORY_LIMIT ?? 15; + if (history.length > limit + 2) { + const preserved = history.slice(0, 2); + const recent = history.slice(-(limit - 2)); + history.splice(0, history.length, ...preserved, ...recent); + } +} + +function sanitizeHistoryForGemini(history: HistoryItem[], expectsResponse = false): Array<{ role: string; parts: Part[] }> { + const limit = cfg?.HISTORY_LIMIT ?? 15; + + let startIndex = Math.max(0, history.length - limit); + while (startIndex > 0) { + const turn = history[startIndex]; + const role = turn.role === "assistant" ? "model" : (turn.role === "system" ? "user" : turn.role); + const isStandardUser = role === "user" && !turn.parts.some(p => p.functionResponse || p.functionCall); + if (isStandardUser) { + break; + } + startIndex--; + } + const limitedHistory = history.slice(startIndex); + + const cleanedTurns = limitedHistory + .filter(h => h.parts.length > 0) + .map(h => { + let role: string = h.role; + if (role === "assistant") role = "model"; + if (role === "system") role = "user"; + + if (h.parts.some(p => p.functionResponse)) role = "user"; + else if (h.parts.some(p => p.functionCall)) role = "model"; + + return { + role, + parts: h.parts.map(p => { + const cleaned: Part = {}; + if (p.text) cleaned.text = p.text; + if (p.inline_data) cleaned.inline_data = p.inline_data; + if (p.functionCall) cleaned.functionCall = p.functionCall; + if (p.functionResponse) cleaned.functionResponse = p.functionResponse; + if (p.thoughtSignature) cleaned.thoughtSignature = p.thoughtSignature; + return cleaned; + }).filter(p => p.text || p.inline_data || p.functionCall || p.functionResponse), + }; + }) + .filter(t => t.parts.length > 0); + + const finalTurns: Array<{ role: string; parts: Part[] }> = []; + + for (const turn of cleanedTurns) { + const hasFuncCall = turn.parts.some(p => p.functionCall); + const hasFuncResp = turn.parts.some(p => p.functionResponse); + + if (finalTurns.length === 0) { + if (turn.role === "user" && !hasFuncResp && !hasFuncCall) { + finalTurns.push(turn); + } + continue; + } + + const lastTurn = finalTurns[finalTurns.length - 1]; + const lastHasFuncCall = lastTurn.parts.some(p => p.functionCall); + + if (lastHasFuncCall) { + if (hasFuncResp) { + const pureRespParts = turn.parts.filter(p => p.functionResponse); + finalTurns.push({ role: "user", parts: pureRespParts }); + } else { + finalTurns.pop(); + if (turn.role === "user" && !hasFuncResp && !hasFuncCall) { + if (finalTurns.length > 0 && finalTurns[finalTurns.length - 1].role === "user") { + finalTurns[finalTurns.length - 1].parts.push(...turn.parts); + } else { + finalTurns.push(turn); + } + } + } + continue; + } + + if (hasFuncResp) { + continue; + } + + if (lastTurn.role === turn.role) { + lastTurn.parts.push(...turn.parts); + } else { + finalTurns.push(turn); + } + } + + if (!expectsResponse && finalTurns.length > 0) { + const lastTurn = finalTurns[finalTurns.length - 1]; + if (lastTurn.parts.some(p => p.functionCall)) { + finalTurns.pop(); + } + } + + while (finalTurns.length > 0 && finalTurns[0].role !== "user") { + finalTurns.shift(); + } + + return finalTurns; +} + +// ───────────────────────────────────────────── +// SECTION: VIP & DAILY LIMITS +// ───────────────────────────────────────────── + +function checkDailyLimit(session: ChatSession, type: "message" | "voice_sent" | "image", userId?: number): { allowed: boolean; message?: string } { + if (hasPrivilegedAccess(session, userId)) return { allowed: true }; + + const limits: Record = { message: cfg.MESSAGE_LIMIT, voice_sent: cfg.VOICE_LIMIT, image: cfg.IMAGE_LIMIT }; + const usage: Record = { + message: session.dailyLimits.messages, + voice_sent: session.dailyLimits.voicesSent, + image: session.dailyLimits.imagesGenerated, + }; + const limit = limits[type]; + if (usage[type] >= limit) { + const lang = session.language; + const contact = cfg.VIP_CONTACT; + const msg = lang === "fa" + ? `⚠️ **محدودیت روزانه**\n\nامروز ${limit} ${type === "image" ? "تصویر" : type === "message" ? "پیام" : "ویس"} ارسال کرده‌اید.\n\n🌟 برای دسترسی نامحدود VIP شوید.\n👑 تماس: ${contact}` + : `⚠️ **Daily Limit**\n\nYou've used ${limit} ${type}s today.\n\n🌟 Go VIP for unlimited access.\n👑 Contact: ${contact}`; + return { allowed: false, message: msg }; + } + return { allowed: true }; +} + +function incrementUsage(session: ChatSession, type: "message" | "voice_sent" | "image"): void { + if (type === "message") session.dailyLimits.messages++; + else if (type === "voice_sent") session.dailyLimits.voicesSent++; + else if (type === "image") session.dailyLimits.imagesGenerated++; +} + +// ───────────────────────────────────────────── +// SECTION: TELEGRAM API WRAPPERS +// ───────────────────────────────────────────── + +async function tg(method: string, params: Record): Promise { + const MAX_RETRIES = 3; + let lastErr: Error = new Error("unknown"); + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const res = await fetchWithTimeout(`${API_URL}/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }, 15_000); + + const result = await res.json() as { ok: boolean; result?: unknown; description?: string; error_code?: number; parameters?: { retry_after?: number } }; + + if (!result.ok) { + if (result.description?.includes("message is not modified")) return true; + if (result.error_code === 429) { + const wait = (result.parameters?.retry_after ?? 1) * 1000; + if (attempt < MAX_RETRIES) { await sleep(wait); continue; } + } + throw new Error(`TG ${result.error_code}: ${result.description}`); + } + return result.result; + } catch (e) { + lastErr = e instanceof Error ? e : new Error(String(e)); + if (attempt < MAX_RETRIES && (lastErr.message.includes("timeout") || lastErr.message.includes("fetch"))) { + await sleep(1000 * attempt); + continue; + } + break; + } + } + if (!lastErr.message.includes("not modified") && !lastErr.message.includes("parse entities")) { + logger.error(`TG ${method} failed`, lastErr); + } else if (lastErr.message.includes("parse entities")) { + logger.warn(`TG ${method} markdown parse issue — retrying plain`); + } + throw lastErr; +} + +async function sendMessage(chatId: number, text: string, opts: Record = {}): Promise { + const params: Record = { + chat_id: chatId, + text: String(text).substring(0, 4096), + parse_mode: "Markdown", + disable_web_page_preview: true, + ...opts, + }; + try { + return await tg("sendMessage", params) as TgMessage; + } catch (e) { + if (e instanceof Error && e.message.includes("can't parse entities")) { + delete params.parse_mode; + return await tg("sendMessage", params) as TgMessage; + } + throw e; + } +} + +async function sendMessageToOwners(text: string, opts: Record = {}): Promise { + await Promise.allSettled(cfg.OWNER_IDS.map((ownerId: number) => sendMessage(ownerId, text, opts))); +} + +async function editMessageText(chatId: number, messageId: number, text: string, opts: Record = {}): Promise { + const params: Record = { + chat_id: chatId, message_id: messageId, + text: String(text).substring(0, 4096), + parse_mode: opts.parse_mode ?? "Markdown", + ...opts, + }; + try { + await tg("editMessageText", params); + } catch (e) { + const msg = e instanceof Error ? e.message : ""; + if (msg.includes("not modified") || msg.includes("exactly the same")) return; + if (msg.includes("message to edit not found") || msg.includes("MESSAGE_ID_INVALID")) return; + if (msg.includes("can't parse entities") || msg.includes("Markdown")) { + delete params.parse_mode; + await tg("editMessageText", params).catch(e => logger.warn("editMessageText retry without markdown failed", e)); + } + } +} + +async function deleteMessage(chatId: number, msgId: number): Promise { + await tg("deleteMessage", { chat_id: chatId, message_id: msgId }).catch(e => logger.warn("deleteMessage failed", e)); +} + +async function sendTyping(chatId: number): Promise { + const now = Date.now(); + const last = lastTypingSent.get(chatId) ?? 0; + if (now - last < 6000) return; + lastTypingSent.set(chatId, now); + tg("sendChatAction", { chat_id: chatId, action: "typing" }).catch(e => logger.warn("sendTyping failed", e)); +} + +async function answerCb(id: string, text?: string, alert = false): Promise { + await tg("answerCallbackQuery", { + callback_query_id: id, text: text?.substring(0, 200), show_alert: alert, + }).catch(e => logger.warn("answerCallbackQuery failed", e)); +} + +async function sendPhoto(chatId: number, photo: string | Uint8Array, caption?: string, opts: Record = {}): Promise { + if (typeof photo === "string" && (photo.startsWith("http://") || photo.startsWith("https://"))) { + const p: Record = { chat_id: chatId, photo, ...opts }; + if (caption) p.caption = caption.substring(0, 1024); + return await tg("sendPhoto", p) as TgMessage; + } + const form = new FormData(); + form.append("chat_id", String(chatId)); + if (photo instanceof Uint8Array) { + form.append("photo", new Blob([bytesToArrayBuffer(photo)], { type: "image/png" }), "image.png"); + } else { + const bin = Uint8Array.from(atob(photo), c => c.charCodeAt(0)); + form.append("photo", new Blob([bytesToArrayBuffer(bin)], { type: "image/png" }), "image.png"); + } + if (caption) form.append("caption", caption.substring(0, 1024)); + for (const [k, v] of Object.entries(opts)) { + if (!["photo", "caption", "chat_id"].includes(k)) form.append(k, String(v)); + } + const res = await fetchWithTimeout(`${API_URL}/sendPhoto`, { method: "POST", body: form }); + const json = await res.json() as { ok: boolean; result: TgMessage; description?: string }; + if (!json.ok) throw new Error(`sendPhoto: ${json.description}`); + return json.result; +} + +async function sendAnimation(chatId: number, animation: string, caption?: string, opts: Record = {}): Promise { + const p: Record = { chat_id: chatId, animation, ...opts }; + if (caption) p.caption = caption.substring(0, 1024); + return await tg("sendAnimation", p) as TgMessage; +} + +async function getFileUrl(fileId: string): Promise { + const res = await tg("getFile", { file_id: fileId }) as { file_path: string }; + return `https://api.telegram.org/file/bot${cfg.TOKEN}/${res.file_path}`; +} + +async function isUserAdmin(userId: number, chatId: number): Promise { + if (isOwner(userId)) return true; + try { + const m = await tg("getChatMember", { chat_id: chatId, user_id: userId }) as { status: string }; + return m.status === "creator" || m.status === "administrator"; + } catch { return false; } +} + +async function sendTelegramDocument(chatId: number, fileData: Uint8Array, fileName: string, caption?: string, replyTo?: number): Promise { + const form = new FormData(); + form.append("chat_id", String(chatId)); + form.append("document", new Blob([bytesToArrayBuffer(fileData)], { type: "application/pdf" }), fileName); + if (caption) 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 }; + return json.ok; + } catch (e) { + logger.error("Failed to send document in telegram", e); + return false; + } +} + +async function sendTelegramTextDocument(chatId: number, content: string, fileName: string, caption?: string, replyTo?: number): Promise { + const bytes = new TextEncoder().encode(content); + const form = new FormData(); + form.append("chat_id", String(chatId)); + form.append("document", new Blob([bytesToArrayBuffer(bytes)], { type: "text/plain; charset=utf-8" }), fileName); + if (caption) 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 }; + return json.ok; + } catch (e) { + logger.error("Failed to send text document in telegram", e); + return false; + } +} + +async function sendTelegramAudio(chatId: number, fileData: Uint8Array, fileName: string, caption?: string, replyTo?: number): Promise { + const form = new FormData(); + form.append("chat_id", String(chatId)); + form.append("audio", new Blob([bytesToArrayBuffer(fileData)], { type: "audio/mpeg" }), fileName); + if (caption) form.append("caption", caption.substring(0, 1024)); + if (replyTo) form.append("reply_to_message_id", String(replyTo)); + + try { + const res = await fetchWithTimeout(`${API_URL}/sendAudio`, { method: "POST", body: form }, 35_000); + const json = await res.json() as { ok: boolean }; + return json.ok; + } catch (e) { + logger.error("Failed to send audio in telegram", e); + return false; + } +} + +// ───────────────────────────────────────────── +// SECTION: ERROR HANDLING +// ───────────────────────────────────────────── + +function detectErrorType(msg: string): ErrorType { + const m = msg.toLowerCase(); + if (m.includes("quota") || m.includes("rate limit") || m.includes("429") || m.includes("محدودیت")) return "quota"; + if (m.includes("safety") || m.includes("blocked") || m.includes("مسدود")) return "blocked"; + if (m.includes("expired") || m.includes("leaked") || m.includes("401") || m.includes("403") || m.includes("unauthorized")) return "auth"; + if (m.includes("empty") || m.includes("خالی") || m.includes("no content")) return "empty"; + if (m.includes("timeout") || m.includes("aborted") || m.includes("زمان")) return "timeout"; + if (m.includes("500") || m.includes("502") || m.includes("503") || m.includes("overloaded")) return "server"; + if (m.includes("fetch") || m.includes("network") || m.includes("اتصال")) return "network"; + return "unknown"; +} + +function formatError(error: Error, lang: Language, isAdmin = false): string { + const type = detectErrorType(error.message); + const icons: Record = { + quota: "📊", auth: "🔑", network: "🌐", timeout: "⏱️", + blocked: "🛡️", empty: "📭", server: "🔥", unknown: "⚠️", + }; + const msgs: Record = { + quota: ["ظرفیت مدل تمام شده. مدل دیگری انتخاب کنید (/model).", "Quota exceeded. Switch models (/model)."], + auth: ["مشکل در کلید API.", "Authentication failed."], + network: ["مشکل در اتصال به سرور.", "Connection error."], + timeout: ["زمان پاسخگویی تمام شد.", "Request timed out."], + blocked: ["محتوا توسط فیلتر امنیتی مسدود شد.", "Content blocked by safety filter."], + empty: ["پاسخ خالی. سوال را تغییر دهید.", "Empty response. Please rephrase."], + server: ["مشکل موقت سرور. دوباره امتحان کنید.", "Temporary server error. Try again."], + unknown: ["خطای غیرمنتظره.", "Unexpected error."], + }; + const icon = icons[type]; + const [fa, en] = msgs[type]; + const userMsg = lang === "fa" ? fa : en; + let out = `${icon} ${userMsg}`; + + if (isAdmin) { + out += `\n\n🔧 **Raw Admin Error Log:**\n\`\`\`\n${error.message}\n\`\`\``; + } + return out; +} + +// ───────────────────────────────────────────── +// SECTION: MAINTENANCE +// ───────────────────────────────────────────── + +async function isInMaintenance(env: Env): Promise { + const now = Date.now(); + if (maintenanceCache && now - maintenanceCache.ts < 10_000) return maintenanceCache.value; + const val = await env.SESSIONS.get("maintenance_mode", "text"); + setMaintenanceCache({ value: val === "true", ts: now }); + return maintenanceCache!.value; +} + +async function checkMaintenance(env: Env, userId: number): Promise<{ blocked: boolean; message?: string }> { + if (isOwner(userId)) return { blocked: false }; + if (await isInMaintenance(env)) { + return { blocked: true, message: "🛠️ **در حال بروزرسانی**\n\nربات در حال به‌روزرسانی است. لطفاً چند دقیقه صبر کنید." }; + } + return { blocked: false }; +} + +// ───────────────────────────────────────────── +// SECTION: KEYBOARD HELPERS +// ───────────────────────────────────────────── + +function btn(text: string, data: string): InlineBtn { return { text, callback_data: data }; } +function urlBtn(text: string, url: string): InlineBtn { return { text, url }; } + +function buildModelKeyboard(session: ChatSession): InlineKeyboard { + const lang = session.language; + return { inline_keyboard: [ + [btn(lang === "fa" ? "⚙️ وضعیت نوا" : "⚙️ Nova Status", "active_model_settings"), + btn(lang === "fa" ? "✏️ شخصیت" : "✏️ Persona", "custom_prompt_menu")], + [btn(lang === "fa" ? "🔙 بازگشت" : "🔙 Back", "open_help")], + ]}; +} + +function buildModelSelectionText(session: ChatSession): string { + const lang = session.language; + if (lang === "fa") { + return `🔮 *وضعیت ایجنت نوا*\n\n🤖 ایجنت فعال: *نوا (Nova)*\n_⚡ سریع · دقیق · هوشمند_\n\n━━━━━━━━━━━━━━━━━━━━\n`; + } + return `🔮 *Nova Agent Status*\n\n🤖 Active Agent: *Nova*\n_⚡ Fast · Accurate · Smart_\n\n━━━━━━━━━━━━━━━━━━━━\n`; +} + +function getVIPKeyboard(): InlineKeyboard { + const contact = cfg.VIP_CONTACT.trim(); + const url = contact.startsWith("http://") || contact.startsWith("https://") + ? contact + : `https://t.me/${contact.replace(/^@/, "")}`; + return { inline_keyboard: [[urlBtn("👑 ارتقا به VIP", url)]] }; +} + +function validateKeyboard(kb: InlineKeyboard): InlineKeyboard { + return { + inline_keyboard: kb.inline_keyboard + .map(row => row.filter(b => b.text?.trim())) + .filter(row => row.length > 0), + }; +} + +// ───────────────────────────────────────────── +// SECTION: CONFIG CHANGES +// ───────────────────────────────────────────── + +function normalizeDigits(text: string): string { + const fa = "۰۱۲۳۴۵۶۷۸۹"; + const ar = "٠١٢٣٤٥٦٧٨٩"; + return text.replace(/[۰-۹٠-٩]/g, ch => { + const faIdx = fa.indexOf(ch); + if (faIdx >= 0) return String(faIdx); + const arIdx = ar.indexOf(ch); + return arIdx >= 0 ? String(arIdx) : ch; + }); +} + +function normalizeConfigChange(change: BotConfigChange): BotConfigChange | null { + if (change.key === "image_limit" || change.key === "message_limit" || change.key === "voice_limit") { + const num = typeof change.value === "number" ? change.value : parseInt(String(change.value), 10); + if (!Number.isFinite(num)) return null; + return { key: change.key, value: Math.max(0, Math.min(10_000, Math.floor(num))) }; + } + if (change.key === "maintenance") { + const raw = String(change.value).trim().toLowerCase(); + const enabled = change.value === true || ["true", "1", "on", "yes", "enable", "enabled", "فعال", "روشن"].includes(raw); + const disabled = change.value === false || ["false", "0", "off", "no", "disable", "disabled", "غیرفعال", "خاموش"].includes(raw); + if (!enabled && !disabled) return null; + return { key: "maintenance", value: enabled }; + } + if (change.key === "system_prompt") { + const value = String(change.value).trim().slice(0, 12_000); + return { key: "system_prompt", value }; + } + if (change.key === "vip_contact") { + const value = String(change.value).trim().slice(0, 128); + if (!value) return null; + return { key: "vip_contact", value }; + } + return null; +} + +function parseConfigChange(text: string): BotConfigChange[] { + const changes: BotConfigChange[] = []; + const matches = normalizeDigits(text).matchAll(/\[CONFIG:([a-z_]+):([\s\S]*?)\]/gi); + for (const match of matches) { + const key = match[1].trim() as keyof BotConfig; + const val = match[2].trim(); + let change: BotConfigChange | null = null; + if (key === "image_limit" || key === "message_limit" || key === "voice_limit") { + const num = parseInt(val, 10); + if (!isNaN(num)) change = { key, value: num }; + } else if (key === "maintenance") { + change = { key, value: val }; + } else if (key === "system_prompt" || key === "vip_contact") { + change = { key, value: val }; + } + const normalized = change ? normalizeConfigChange(change) : null; + if (normalized) changes.push(normalized); + } + return changes; +} + +function parseNaturalConfigChanges(text: string): BotConfigChange[] { + const normalized = normalizeDigits(text.trim()); + const changes: BotConfigChange[] = []; + + const addNumber = (key: "image_limit" | "message_limit" | "voice_limit", patterns: RegExp[]) => { + for (const pattern of patterns) { + const match = normalized.match(pattern); + if (!match) continue; + const change = normalizeConfigChange({ key, value: parseInt(match[1], 10) }); + if (change) changes.push(change); + return; + } + }; + + addNumber("image_limit", [ + /(?:image|img|photo|picture)\s*(?:daily\s*)?(?:limit|max|quota)\s*(?:=|:|to|is|be|set to)?\s*(\d{1,5})/i, + /(?:image|img|photo|picture)\s*(?:=|:)\s*(\d{1,5})/i, + /(?:limit|max|quota)\s*(?:for\s*)?(?:image|img|photo|picture)\s*(?:=|:|to|is)?\s*(\d{1,5})/i, + /(?:سقف|محدودیت).{0,24}(?:عکس|تصویر).{0,16}?(\d{1,5})/i, + /(?:عکس|تصویر).{0,24}(?:سقف|محدودیت|بکن|کن|بشه).{0,16}?(\d{1,5})/i, + ]); + addNumber("message_limit", [ + /(?:message|text|chat)\s*(?:daily\s*)?(?:limit|max|quota)\s*(?:=|:|to|is|be|set to)?\s*(\d{1,5})/i, + /(?:message|text|chat)\s*(?:=|:)\s*(\d{1,5})/i, + /(?:limit|max|quota)\s*(?:for\s*)?(?:message|text|chat)\s*(?:=|:|to|is)?\s*(\d{1,5})/i, + /(?:سقف|محدودیت).{0,24}(?:پیام|متن|چت).{0,16}?(\d{1,5})/i, + /(?:پیام|متن|چت).{0,24}(?:سقف|محدودیت|بکن|کن|بشه).{0,16}?(\d{1,5})/i, + ]); + addNumber("voice_limit", [ + /(?:voice|audio)\s*(?:daily\s*)?(?:limit|max|quota)\s*(?:=|:|to|is|be|set to)?\s*(\d{1,5})/i, + /(?:voice|audio)\s*(?:=|:)\s*(\d{1,5})/i, + /(?:limit|max|quota)\s*(?:for\s*)?(?:voice|audio)\s*(?:=|:|to|is)?\s*(\d{1,5})/i, + /(?:سقف|محدودیت).{0,24}(?:ویس|صدا|صوت).{0,16}?(\d{1,5})/i, + /(?:ویس|صدا|صوت).{0,24}(?:سقف|محدودیت|بکن|کن|بشه).{0,16}?(\d{1,5})/i, + ]); + + const systemPrompt = normalized.match(/(?:system\s*prompt|prompt\s*system)\s*[:=]\s*([\s\S]{1,12000})/i) + ?? normalized.match(/(?:سیستم\s*پرامپت|پرامپت\s*سیستم|دستورالعمل\s*سیستم)\s*[:=]\s*([\s\S]{1,12000})/i); + if (systemPrompt) { + const change = normalizeConfigChange({ key: "system_prompt", value: systemPrompt[1] }); + if (change) changes.push(change); + } + + const vipContact = normalized.match(/(?:vip[_\s-]*contact|vip\s*username|contact)\s*[:=]\s*(@?[A-Za-z0-9_]{3,64})/i) + ?? normalized.match(/(?:تماس|آیدی|ایدی).{0,12}(?:vip|وی ای پی|وی‌آی‌پی)?\s*[:=]\s*(@?[A-Za-z0-9_]{3,64})/i); + if (vipContact) { + const change = normalizeConfigChange({ key: "vip_contact", value: vipContact[1] }); + if (change) changes.push(change); + } + + const maintenance = normalized.match(/maintenance\s*(?:mode)?\s*(?:=|:|to|is)?\s*(on|off|true|false|enable|disable|enabled|disabled|1|0)/i) + ?? normalized.match(/(?:حالت\s*)?(?:تعمیرات|نگهداری).{0,16}(روشن|خاموش|فعال|غیرفعال|true|false|1|0)/i); + if (maintenance) { + const change = normalizeConfigChange({ key: "maintenance", value: maintenance[1] }); + if (change) changes.push(change); + } + return changes; +} + +async function applyBotConfigChanges(changes: BotConfigChange[], env: Env): Promise { + const cleanChanges = changes + .map(normalizeConfigChange) + .filter((change): change is BotConfigChange => Boolean(change)); + if (!cleanChanges.length) return []; + const currentConfig = await getBotConfig(env); + for (const change of cleanChanges) { + logger.info(`Applying config change: ${change.key} = ${change.value}`); + switch (change.key) { + case "image_limit": + case "message_limit": + case "voice_limit": + currentConfig[change.key] = change.value as number; + break; + case "maintenance": + currentConfig.maintenance = change.value as boolean; + break; + case "system_prompt": + currentConfig.system_prompt = change.value as string; + break; + case "vip_contact": + currentConfig.vip_contact = change.value as string; + break; + } + } + + await env.SESSIONS.put("bot_config", JSON.stringify(currentConfig)); + setCfg(createConfig(env, currentConfig)); + setCachedBotConfig(null); + setMaintenanceCache({ value: currentConfig.maintenance, ts: Date.now() }); + return cleanChanges; +} + +function formatConfigChangeSummary(changes: BotConfigChange[], lang: Language): string { + const labels: Record = lang === "fa" + ? { image_limit: "سقف تصویر", message_limit: "سقف پیام", voice_limit: "سقف ویس", system_prompt: "سیستم پرامپت", maintenance: "حالت تعمیرات", vip_contact: "تماس VIP" } + : { image_limit: "Image limit", message_limit: "Message limit", voice_limit: "Voice limit", system_prompt: "System prompt", maintenance: "Maintenance mode", vip_contact: "VIP contact" }; + const rows = changes.map(change => { + const value = change.key === "system_prompt" + ? String(change.value).slice(0, 80) + (String(change.value).length > 80 ? "..." : "") + : String(change.value); + return `• ${labels[change.key]}: \`${value}\``; + }); + return lang === "fa" + ? `✅ **تنظیمات نوا ذخیره شد.**\n\n${rows.join("\n")}` + : `✅ **Nova settings saved.**\n\n${rows.join("\n")}`; +} + +async function handleConfigChanges(text: string, env: Env, lang: Language): Promise { + const changes = await applyBotConfigChanges(parseConfigChange(text), env); + if (changes.length === 0) return text; + + const cleaned = text.replace(/\[CONFIG:([a-z_]+):([\s\S]*?)\]/gi, "").trim(); + return cleaned || formatConfigChangeSummary(changes, lang); +} + +async function getBotConfigCached(env: Env): Promise { + if (_cachedBotConfig && Date.now() - _configCacheTs < 30_000) { + return _cachedBotConfig; + } + const config = await getBotConfig(env); + setCachedBotConfig(config); + setConfigCacheTs(Date.now()); + return config; +} + +// NOTE: handleStickerMessage and shouldRespondInGroup reference processAIRequest +// which lives outside core.ts. They are exported for use by the handler that has processAIRequest. +// The circular dependency is resolved by having the caller import handleStickerMessage +// and pass it the env, while processAIRequest is called dynamically. + +export { + getBotConfig, + getBotConfigCached, + parseOwnerIds, + createConfig, + isOwner, + isPrimaryOwner, + ownerDisplayName, + hasPrivilegedAccess, + accountStatusLabel, + TR, + t, + tSession, + MODEL_META, + engineDisplayName, + sanitizeInput, + generateId, + saveGroupInfo, + listGroups, + arrayBufferToBase64, + bytesToArrayBuffer, + sanitizeMarkdown, + cleanThinkingTags, + stripCodeFences, + splitMessage, + formatDate, + withTimeout, + fetchWithTimeout, + sleep, + extractTopics, + containsPersian, + detectRole, + isKeyDisabled, + disableApiKey, + saveDisabledKeysDebounced, + isRateLimited, + recordRequest, + canProcessRequest, + releaseRequest, + createDefaultSession, + toMap, + hydrateSession, + mapToObj, + saveSession, + getOrCreateSession, + getUserBucket, + buildSystemPrompt, + buildNovaAgentSystemPrompt, + getActivePrompt, + handleStickerMessage, + shouldRespondInGroup, + addToHistory, + sanitizeHistoryForGemini, + checkDailyLimit, + incrementUsage, + tg, + sendMessage, + sendMessageToOwners, + editMessageText, + deleteMessage, + sendTyping, + answerCb, + sendPhoto, + sendAnimation, + getFileUrl, + isUserAdmin, + sendTelegramDocument, + sendTelegramTextDocument, + sendTelegramAudio, + detectErrorType, + formatError, + isInMaintenance, + checkMaintenance, + btn, + urlBtn, + buildModelKeyboard, + buildModelSelectionText, + getVIPKeyboard, + validateKeyboard, + normalizeDigits, + normalizeConfigChange, + parseConfigChange, + parseNaturalConfigChanges, + applyBotConfigChanges, + formatConfigChangeSummary, + handleConfigChanges, +}; + +export type { Config, LangKey };