import type { Part, HistoryItem, MessageRole, ChatSession, Language, TgUser, GeminiFunctionCall, GeminiResponse, Env, } from './types'; import { cfg, API_URL, logger, } from './state'; import type { LogEntry } from './types'; import { fetchWithTimeout, withTimeout, sleep, stripCodeFences, sanitizeHistoryForGemini, detectRole, isOwner, isKeyDisabled, disableApiKey, saveSession, arrayBufferToBase64, bytesToArrayBuffer, sendPhoto, sendAnimation, sendMessage, getActivePrompt, t, } from './core'; // eslint-disable-next-line @typescript-eslint/no-explicit-any let _toolsCache: any = { user: null, admin: null }; let _cfAccountIndex = 0; let _elevenLabsKeyIndex = 0; // ───────────────────────────────────────────── // SECTION: GEMINI FUNCTION DECLARATIONS // ───────────────────────────────────────────── const NOVA_TOOL_DECLARATIONS = [ { name: "generate_image", description: "Generates a high-quality image from a detailed English prompt. Use only when the user explicitly requests to create, draw, generate, or paint an image.", parameters: { type: "OBJECT", properties: { prompt: { type: "STRING", description: "Highly detailed description of the image in English." }, }, required: ["prompt"], }, }, { name: "set_persona", description: "Changes Nova's personality, behavior, or custom prompt/system prompt for this conversation.", parameters: { type: "OBJECT", properties: { persona: { type: "STRING", description: "Detailed description of the new persona or system prompt." }, }, required: ["persona"], }, }, { name: "search_images", description: "Searches Google for images. Use only when the user wants to search, find, or get images/photos from the web.", parameters: { type: "OBJECT", properties: { query: { type: "STRING", description: "Search query for images." }, }, required: ["query"], }, }, { name: "web_search", description: "Searches Google for web results, latest news, weather, or real-time text information.", parameters: { type: "OBJECT", properties: { query: { type: "STRING", description: "Search query for Google Web Search." }, }, required: ["query"], }, }, { name: "download_file", description: "Downloads songs, music, audio files, or documents from the web. Use ONLY when the user asks to download, find, or play a file, music track, or document.", parameters: { type: "OBJECT", properties: { type: { type: "STRING", enum: ["audio", "document"], description: "Type of file: 'audio' for music/songs, 'document' for other files like pdf, zip." }, query: { type: "STRING", description: "The name of the file or song to search and download." }, }, required: ["type", "query"], }, }, { name: "create_pdf", description: "Generates and sends a PDF document to the user. Use only when the user explicitly requests a PDF file.", parameters: { type: "OBJECT", properties: { content: { type: "STRING", description: "The text content to put into the PDF document." }, }, required: ["content"], }, }, { name: "create_code_file", description: "Creates and sends a download link for a code file (e.g., Python, HTML, TypeScript, CSS) with the exact code.", parameters: { type: "OBJECT", properties: { extension: { type: "STRING", description: "File extension (e.g., py, ts, html, css, js, txt)." }, code: { type: "STRING", description: "Full complete runnable source code with no extra markdown or comments outside code blocks." }, filename: { type: "STRING", description: "File name without extension." }, }, required: ["extension", "code", "filename"], }, }, { name: "voice_response", description: "Generates high-quality speech and responds with a Telegram voice message. Use when the user sends a voice message or explicitly requests a voice/spoken response.", parameters: { type: "OBJECT", properties: { text: { type: "STRING", description: "The text content to convert to voice." }, }, required: ["text"], }, }, { name: "read_web_page", description: "Reads and retrieves the text content from a given URL or webpage.", parameters: { type: "OBJECT", properties: { url: { type: "STRING", description: "The complete web page URL to read." }, }, required: ["url"], }, }, { name: "set_reminder", description: "Sets a reminder at a specific time in the future.", parameters: { type: "OBJECT", properties: { timestamp_ms: { type: "NUMBER", description: "UNIX timestamp in milliseconds when the reminder should trigger." }, text: { type: "STRING", description: "The reminder message." }, }, required: ["timestamp_ms", "text"], }, }, { name: "list_schedules", description: "Lists all currently scheduled reminders and tasks.", parameters: { type: "OBJECT", properties: {}, required: [] }, }, { name: "delete_schedule", description: "Deletes a reminder or schedule by key or title.", parameters: { type: "OBJECT", properties: { key_or_title: { type: "STRING", description: "The exact key or title of the reminder to delete." }, }, required: ["key_or_title"], }, }, ]; const ADMIN_TOOL_DECLARATIONS = [ { name: "send_message_to_user", description: "پیام مستقیم به یک کاربر خاص می‌فرستد.", parameters: { type: "OBJECT", properties: { user_id: { type: "NUMBER", description: "شناسه عددی کاربر" }, message: { type: "STRING", description: "متن پیام" }, }, required: ["user_id", "message"], }, }, { name: "set_vip", description: "وضعیت VIP یک کاربر را تغییر می‌دهد.", parameters: { type: "OBJECT", properties: { user_id: { type: "NUMBER" }, vip: { type: "BOOLEAN" }, }, required: ["user_id", "vip"], }, }, { name: "show_logs", description: "لاگ‌های سیستم را نشان می‌دهد.", parameters: { type: "OBJECT", properties: {}, required: [] }, }, { name: "show_admin_panel", description: "پنل مدیریت را باز می‌کند.", parameters: { type: "OBJECT", properties: {}, required: [] }, }, ]; // ───────────────────────────────────────────── // SECTION: GEMINI API // ───────────────────────────────────────────── async function callGemini(parts: Part[], model: string, apiKey: string, history: HistoryItem[]): Promise { const res = await callGeminiWithTools(parts, model, apiKey, history, false); return res.text; } async function callGeminiWithTools( parts: Part[], model: string, apiKey: string, history: HistoryItem[], isAdmin: boolean, systemInstruction?: string, currentRole: MessageRole = "user", ): Promise { const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`; const historyToSend = history; const expectsResponse = parts.some(p => p.functionResponse); const sanitizedHistory = sanitizeHistoryForGemini(historyToSend, expectsResponse); const contents = [...sanitizedHistory, { role: currentRole, parts }]; if (!_toolsCache.user) _toolsCache.user = [{ functionDeclarations: NOVA_TOOL_DECLARATIONS }]; if (!_toolsCache.admin) _toolsCache.admin = [{ functionDeclarations: [...NOVA_TOOL_DECLARATIONS, ...ADMIN_TOOL_DECLARATIONS] }]; const tools = isAdmin ? _toolsCache.admin : _toolsCache.user; const body: Record = { contents, tools, tool_config: { function_calling_config: { mode: "AUTO" } }, generationConfig: { temperature: 0.4, topK: 40, topP: 0.95, maxOutputTokens: 8192, }, safetySettings: [ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }, { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" }, { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE" }, { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE" }, ], }; if (systemInstruction) { body.system_instruction = { parts: [{ text: systemInstruction }] }; } const res = await fetchWithTimeout(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, 18_000); const data = await res.json() as { error?: { message: string; code?: number }; candidates?: Array<{ finishReason?: string; content?: { parts?: Array<{ text?: string; thoughtSignature?: string; functionCall?: { name: string; args: Record }; }>; }; }>; }; if (data.error) { const msg = data.error.message.toLowerCase(); if (msg.includes("quota") || msg.includes("429") || msg.includes("resource_exhausted")) { throw new Error("quota exceeded"); } if (msg.includes("expired") || msg.includes("leaked")) throw new Error("auth: key expired/leaked"); if (msg.includes("safety") || msg.includes("blocked")) throw new Error("blocked by safety filter"); if (msg.includes("401") || msg.includes("unauthorized")) throw new Error("auth: unauthorized"); throw new Error(`Gemini: ${data.error.message}`); } const candidate = data.candidates?.[0]; if (candidate?.finishReason === "SAFETY") throw new Error("blocked by safety filter"); const responseParts = candidate?.content?.parts ?? []; const texts: string[] = []; const functionCalls: GeminiFunctionCall[] = []; const modelPartsToSave: Part[] = []; for (const part of responseParts) { const savedPart: Part = {}; if (part.text) { texts.push(part.text.trim()); savedPart.text = part.text; } if (part.thoughtSignature) { savedPart.thoughtSignature = part.thoughtSignature; } if (part.functionCall) { savedPart.functionCall = { name: part.functionCall.name, args: part.functionCall.args ?? {}, }; functionCalls.push(savedPart.functionCall); } if (savedPart.text || savedPart.functionCall) { modelPartsToSave.push(savedPart); } } const combinedText = texts.join("\n").trim(); if (!combinedText && functionCalls.length === 0) throw new Error("empty response"); return { text: combinedText, functionCalls, modelParts: modelPartsToSave }; } // ───────────────────────────────────────────── // SECTION: GEMINI FLASH FALLBACK FOR CODE // ───────────────────────────────────────────── async function generateCodeWithGeminiFallback( description: string, type: "webapp" | "codefile", options?: { extension?: string }, ): Promise { const FALLBACK_MODEL = (cfg as any).GEMINI_FALLBACK_MODEL ?? "gemini-flash-latest"; const prompt = type === "webapp" ? `Write a COMPLETE, stunning, fully functional single-file HTML web application for: ${description}\n\nRequirements:\n- Start with \n- Embed ALL CSS in
${formatted}
`; } function escapePdfText(text: string): string { return text .replace(/\\/g, "\\\\") .replace(/\(/g, "\\(") .replace(/\)/g, "\\)") .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); } function stripHTMLForPdf(input: string): string { return input .replace(//gi, "") .replace(//gi, "") .replace(/<\/(h1|h2|h3|p|li|pre|div)>/gi, "\n") .replace(//gi, "\n") .replace(/
  • /gi, "• ") .replace(/<[^>]+>/g, "") .replace(/</g, "<") .replace(/>/g, ">") .replace(/&/g, "&") .replace(/"/g, "\"") .replace(/'/g, "'") .replace(/\n{3,}/g, "\n\n") .trim(); } function wrapPdfLines(text: string, maxChars = 92): string[] { const lines: string[] = []; for (const raw of text.replace(/\r\n/g, "\n").split("\n")) { const paragraph = raw.trimEnd(); if (!paragraph) { lines.push(""); continue; } const words = paragraph.split(/\s+/); let line = ""; for (const word of words) { if (!line) { line = word; } else if (`${line} ${word}`.length <= maxChars) { line += ` ${word}`; } else { lines.push(line); line = word; } while (line.length > maxChars) { lines.push(line.slice(0, maxChars)); line = line.slice(maxChars); } } lines.push(line); } return lines; } function generateSimplePdf(textContent: string): Uint8Array { const normalizedText = stripHTMLForPdf(textContent) || "Nova PDF document"; const allLines = wrapPdfLines(normalizedText); const linesPerPage = 45; const pages: string[][] = []; for (let i = 0; i < allLines.length; i += linesPerPage) { pages.push(allLines.slice(i, i + linesPerPage)); } if (!pages.length) pages.push(["Nova PDF document"]); const objects: string[] = []; const fontId = 3 + pages.length * 2; objects.push("<< /Type /Catalog /Pages 2 0 R >>"); objects.push(`<< /Type /Pages /Kids [${pages.map((_, i) => `${3 + i * 2} 0 R`).join(" ")}] /Count ${pages.length} >>`); pages.forEach((pageLines, index) => { const pageId = 3 + index * 2; const contentId = pageId + 1; const streamLines = [ "BT", "/F1 11 Tf", "50 750 Td", "15 TL", ...pageLines.map(line => line ? `(${escapePdfText(line)}) Tj T*` : "T*"), "ET", ]; const stream = streamLines.join("\n"); objects.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 ${fontId} 0 R >> >> /Contents ${contentId} 0 R >>`); objects.push(`<< /Length ${new TextEncoder().encode(stream).length} >>\nstream\n${stream}\nendstream`); }); objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"); const encoder = new TextEncoder(); let pdf = "%PDF-1.4\n%Nova\n"; const offsets = [0]; objects.forEach((obj, index) => { offsets.push(encoder.encode(pdf).length); pdf += `${index + 1} 0 obj\n${obj}\nendobj\n`; }); const xrefOffset = encoder.encode(pdf).length; pdf += `xref\n0 ${objects.length + 1}\n`; pdf += "0000000000 65535 f \n"; for (let i = 1; i < offsets.length; i++) { pdf += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`; } pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; return encoder.encode(pdf); } async function generatePDFFromHTML(htmlContent: string): Promise { try { return generateSimplePdf(htmlContent); } catch (e) { logger.error("PDF generation failed", e); return null; } } // ───────────────────────────────────────────── // SECTION: IMAGE RESULTS SENDING // ───────────────────────────────────────────── async function sendImageResults(chatId: number, replyTo: number, images: string[], caption: string, lang: Language): Promise { for (let i = 0; i < Math.min(images.length, 5); i++) { const img = images[i]; const isGif = img.toLowerCase().includes(".gif"); try { if (i === 0) { const fullCaption = t(lang, "search_results", { caption, count: String(images.length) }); if (isGif) await sendAnimation(chatId, img, fullCaption, { reply_to_message_id: replyTo }); else await sendPhoto(chatId, img, fullCaption, { reply_to_message_id: replyTo }); } else { if (isGif) await sendAnimation(chatId, img); else await sendPhoto(chatId, img); } await sleep(800); } catch (e) { logger.warn('sendImageResults: failed to send image, falling back to link', e); if (i === 0) { await sendMessage(chatId, t(lang, "search_link_fallback", { link: img, count: String(images.length) }), { reply_to_message_id: replyTo }); } } } } // ───────────────────────────────────────────── // SECTION: USER MEMORY BACKGROUND // ───────────────────────────────────────────── async function updateUserMemoryBackground(session: ChatSession, userId: number, chatHistory: HistoryItem[], env: Env): Promise { const recentHistory = chatHistory.slice(-6); if (recentHistory.length < 3) return; const chatSummary = recentHistory.map(h => `${h.role}: ${h.parts[0]?.text ?? ""}`).join("\n"); const currentMem = session.userMemories.get(userId); if (!currentMem) return; const analysisPrompt = `Analyze the recent chat history between the AI and the user to update the user's permanent profile. Current Profile: - Personality: "${currentMem.personality}" - Preferences: ${JSON.stringify(currentMem.preferences)} - Topics of interest: ${JSON.stringify(currentMem.topics)} Recent Conversation: ${chatSummary} Task: Update the profile with new insights (if any). Be highly conservative. ONLY output a clean JSON with the keys "personality", "preferences" (array of strings, max 8), and "topics" (array of strings, max 5). Do not include markdown codeblocks or explanation.`; try { const rawRes = await callGemini([{ text: analysisPrompt }], cfg.GEMINI_MODEL, cfg.GEMINI_KEYS[0], []); const cleanJson = rawRes.replace(/```json/g, "").replace(/```/g, "").trim(); const parsed = JSON.parse(cleanJson) as { personality?: string; preferences?: string[]; topics?: string[] }; let changed = false; if (parsed.personality && parsed.personality !== currentMem.personality) { currentMem.personality = parsed.personality; changed = true; } if (Array.isArray(parsed.preferences)) { currentMem.preferences = Array.from(new Set([...currentMem.preferences, ...parsed.preferences])).slice(0, 8); changed = true; } if (Array.isArray(parsed.topics)) { currentMem.topics = Array.from(new Set([...currentMem.topics, ...parsed.topics])).slice(0, 5); changed = true; } if (changed) { currentMem.lastSeen = Date.now(); session.userMemories.set(userId, currentMem); await saveSession(session, env); logger.info(`🧠 Long-term memory updated for user: ${userId}`); } } catch (e) { logger.warn("Memory background update skipped", e); } } // ───────────────────────────────────────────── // SECTION: PROCESS MEDIA WITH GEMINI // ───────────────────────────────────────────── async function processMediaWithGemini(parts: Part[]): Promise { let lastErr: Error = new Error("No keys"); for (const key of cfg.GEMINI_KEYS) { try { return await withTimeout(callGemini(parts, cfg.GEMINI_MODEL, key, []), 55_000, "timeout"); } catch (e) { lastErr = e instanceof Error ? e : new Error(String(e)); if (lastErr.message.includes("blocked")) break; await sleep(1000); } } throw lastErr; } export { NOVA_TOOL_DECLARATIONS, ADMIN_TOOL_DECLARATIONS, callGemini, callGeminiWithTools, generateCodeWithGeminiFallback, handleGeminiRequest, translateToEnglish, generateImageCF, getShortModelName, synthesizeVoice, sendVoiceResponse, transcribeVoice, searchGoogleImages, searchGoogleWeb, wrapInHTMLTemplate, escapePdfText, stripHTMLForPdf, wrapPdfLines, generateSimplePdf, generatePDFFromHTML, sendImageResults, updateUserMemoryBackground, processMediaWithGemini, };