From 28148fc681b9c384bf2289e490d1eab0ba28a9dd Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:14:54 +0330 Subject: [PATCH] Import source: src/services Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality. --- src/services.ts | 1128 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1128 insertions(+) create mode 100644 src/services.ts diff --git a/src/services.ts b/src/services.ts new file mode 100644 index 0000000..ad4436c --- /dev/null +++ b/src/services.ts @@ -0,0 +1,1128 @@ +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, +};