Files
Nova/src/services.ts
T
arsam 28148fc681 Import source: src/services
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:14:54 +03:30

1129 lines
39 KiB
TypeScript

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<string> {
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<GeminiResponse> {
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<string, unknown> = {
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<string, unknown> };
}>;
};
}>;
};
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<string | null> {
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 <!DOCTYPE html>\n- Embed ALL CSS in <style> and ALL JS in <script>\n- Beautiful modern UI\n- 100% working functionality\n\nReturn ONLY the raw HTML code, nothing else.`
: `Write COMPLETE, production-ready ${options?.extension ?? ""} code for: ${description}\n\nReturn ONLY the raw code, no markdown, no explanation.`;
for (const key of cfg.GEMINI_KEYS) {
try {
const result = await withTimeout(
callGemini([{ text: prompt }], FALLBACK_MODEL, key, []),
30_000,
"Gemini fallback timeout",
);
if (result && result.length > 50) {
logger.info(`✅ Gemini fallback code succeeded (${result.length} chars)`);
return stripCodeFences(result);
}
} catch (e) {
logger.warn(`Gemini fallback code gen failed: ${e instanceof Error ? e.message.slice(0, 60) : e}`);
}
}
return null;
}
// ─────────────────────────────────────────────
// SECTION: ENGINE HANDLER (with retry logic)
// ─────────────────────────────────────────────
async function handleGeminiRequest(
session: ChatSession,
user: TgUser,
parts: Part[],
isGroup: boolean,
env: Env,
): Promise<GeminiResponse> {
if (!cfg.GEMINI_KEYS.length) throw new Error("Gemini keys not configured");
const engine = session.engines.gemini;
const systemPromptText = getActivePrompt(session, user, isGroup);
let history: HistoryItem[] = [];
if (isGroup) {
const uHist = engine.userHistories.get(user.id);
history = (uHist && uHist.length > 0) ? uHist : [];
} else {
history = engine.history.slice(2);
}
const isAdmin = isOwner(user.id);
const maxAttempts = Math.min(cfg.GEMINI_KEYS.length, 5);
let lastError: Error = new Error("quota exceeded");
const startIdx = engine.apiKeyIndex;
for (let i = 0; i < maxAttempts; i++) {
const idx = (startIdx + i) % cfg.GEMINI_KEYS.length;
const key = cfg.GEMINI_KEYS[idx];
if (await isKeyDisabled(key, env)) {
logger.info(`Key[${idx}] disabled, skipping`);
continue;
}
try {
const currentRole = detectRole(parts);
const res = await withTimeout(
callGeminiWithTools(parts, cfg.GEMINI_MODEL, key, history, isAdmin, systemPromptText, currentRole),
22_000,
"timeout",
);
engine.apiKeyIndex = idx;
engine.consecutiveErrors = 0;
return res;
} catch (e) {
lastError = e instanceof Error ? e : new Error(String(e));
const msg = lastError.message.toLowerCase();
logger.warn(`Key[${idx}] failed: ${msg.slice(0, 50)}`);
if (msg.includes("blocked")) throw lastError;
if (msg.includes("quota") || msg.includes("rate limit") || msg.includes("resource_exhausted")) {
disableApiKey(key, env);
}
if (msg.includes("auth") || msg.includes("401") || msg.includes("403")) {
disableApiKey(key, env, true);
}
}
}
engine.consecutiveErrors++;
throw lastError;
}
// ─────────────────────────────────────────────
// SECTION: TRANSLATION
// ─────────────────────────────────────────────
async function translateToEnglish(text: string, env: Env): Promise<string> {
if (!/[\u0600-\u06FF]/.test(text)) return text;
const hasPersian = (s: string) => /[\u0600-\u06FF]/.test(s);
const sys = "Translate Persian text to a concise English image prompt. Max 30 words. Output ONLY English.";
if (cfg.GEMINI_KEYS.length) {
try {
const res = await callGemini([{ text: `${sys}\n\nText: ${text}` }], cfg.GEMINI_MODEL, cfg.GEMINI_KEYS[0], []);
if (res && !hasPersian(res) && res.length > 5) return res.trim();
} catch (e) {
logger.warn('translateToEnglish: Gemini translation failed, falling back to strip', e);
}
}
const clean = text.replace(/[\u0600-\u06FF]/g, "").trim();
return clean.length > 3 ? clean : "A high-quality detailed artistic image";
}
// ─────────────────────────────────────────────
// SECTION: CLOUDFLARE AI IMAGE
// ─────────────────────────────────────────────
async function generateImageCF(
prompt: string,
model: string,
env: Env,
): Promise<Uint8Array | null> {
if (!cfg.CF_ACCOUNTS.length) {
logger.warn("No Cloudflare accounts configured");
return null;
}
const maxAttempts = cfg.CF_ACCOUNTS.length;
const errors: string[] = [];
for (let i = 0; i < maxAttempts; i++) {
const idx = (_cfAccountIndex + i) % cfg.CF_ACCOUNTS.length;
const account = cfg.CF_ACCOUNTS[idx];
const url = `https://api.cloudflare.com/client/v4/accounts/${account.id}/ai/run/${model}`;
try {
const formData = new FormData();
formData.append("prompt", prompt);
const res = await fetchWithTimeout(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${account.token}`,
},
body: formData,
}, 30_000);
if (!res.ok) {
const errText = await res.text().catch((e: unknown) => {
logger.warn('generateImageCF: failed to read error body', e);
return "";
});
logger.warn(`CF account[${idx}] error ${res.status}: ${errText}`);
try {
const parsed = JSON.parse(errText);
const hasFlagged = parsed.errors?.some((e: any) =>
e.code === 3030 ||
String(e.message).toLowerCase().includes("flagged") ||
String(e.message).toLowerCase().includes("sensitive") ||
String(e.message).toLowerCase().includes("safety")
);
if (hasFlagged) {
throw new Error("FLAGGED_PROMPT: Content flagged by safety filter.");
}
} catch (jsonErr) {
if (jsonErr instanceof Error && jsonErr.message.startsWith("FLAGGED_PROMPT")) {
throw jsonErr;
}
}
throw new Error(`CF_API [HTTP ${res.status}]: ${errText || "No body response"}`);
}
_cfAccountIndex = idx;
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("image/")) {
return new Uint8Array(await res.arrayBuffer());
}
const json = await res.json() as any;
if (json?.errors?.length) {
const hasFlagged = json.errors?.some((e: any) =>
e.code === 3030 ||
String(e.message).toLowerCase().includes("flagged") ||
String(e.message).toLowerCase().includes("sensitive")
);
if (hasFlagged) {
throw new Error("FLAGGED_PROMPT: Content flagged by safety filter.");
}
throw new Error(`CF_Error: ${json.errors[0]?.message || "API Error"}`);
}
const b64 = json?.result?.image ?? json?.image ?? json?.result ?? json?.data?.[0]?.b64_json;
if (!b64) {
throw new Error("No image data in JSON response");
}
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let j = 0; j < bin.length; j++) bytes[j] = bin.charCodeAt(j);
return bytes;
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
logger.warn(`CF account[${idx}] exception: ${errMsg}`);
if (errMsg.includes("FLAGGED_PROMPT")) {
throw e;
}
errors.push(`Account[${idx}] (${account.id?.slice(0, 6)}...): ${errMsg}`);
continue;
}
}
throw new Error(`All CF accounts failed for image generation:\n${errors.join("\n")}`);
}
function getShortModelName(path: string): string {
const map: Record<string, string> = {
"@cf/black-forest-labs/flux-2-klein-4b": "Flux 2 Klein 4B",
"@cf/black-forest-labs/flux-1-schnell": "Flux 1 Schnell",
"@cf/leonardo/phoenix-1.0": "Phoenix 1.0",
};
return map[path] ?? path.split("/").pop() ?? path;
}
// ─────────────────────────────────────────────
// SECTION: ELEVENLABS TTS
// ─────────────────────────────────────────────
async function synthesizeVoice(
text: string,
voiceId?: string,
): Promise<Uint8Array | null> {
if (!cfg.ELEVENLABS_KEYS.length) return null;
const vid = voiceId ?? cfg.ELEVENLABS_VOICE_ID;
const maxAttempts = cfg.ELEVENLABS_KEYS.length;
for (let i = 0; i < maxAttempts; i++) {
const idx = (_elevenLabsKeyIndex + i) % cfg.ELEVENLABS_KEYS.length;
const key = cfg.ELEVENLABS_KEYS[idx];
try {
const url = `https://api.elevenlabs.io/v1/text-to-speech/${vid}?output_format=mp3_44100_128`;
const res = await fetchWithTimeout(
url,
{
method: "POST",
headers: {
"xi-api-key": key,
"Content-Type": "application/json",
Accept: "audio/mpeg",
},
body: JSON.stringify({
text: text.slice(0, 2500),
model_id: "eleven_multilingual_v2",
voice_settings: {
stability: 0.5,
similarity_boost: 0.75,
style: 0.0,
use_speaker_boost: true,
},
}),
},
30_000,
);
if (res.status === 429) {
logger.warn(`ElevenLabs key[${idx}] rate limited (429), trying next`);
_elevenLabsKeyIndex = (idx + 1) % cfg.ELEVENLABS_KEYS.length;
continue;
}
if (res.status === 401 || res.status === 403) {
logger.warn(`ElevenLabs key[${idx}] unauthorized (${res.status}) — key likely expired`);
_elevenLabsKeyIndex = (idx + 1) % cfg.ELEVENLABS_KEYS.length;
continue;
}
if (!res.ok) {
const errBody = await res.text().catch((e: unknown) => {
logger.warn('synthesizeVoice: failed to read error body', e);
return "";
});
logger.warn(`ElevenLabs key[${idx}] error ${res.status}: ${errBody.slice(0, 100)}`);
continue;
}
_elevenLabsKeyIndex = idx;
const buf = await res.arrayBuffer();
if (buf.byteLength < 100) {
logger.warn(`ElevenLabs key[${idx}]: received empty audio`);
continue;
}
return new Uint8Array(buf);
} catch (e) {
logger.warn(`ElevenLabs key[${idx}]: ${e instanceof Error ? e.message : e}`);
continue;
}
}
logger.error("All ElevenLabs keys failed");
return null;
}
async function sendVoiceResponse(
chatId: number,
replyTo: number,
text: string,
): Promise<boolean> {
const audio = await synthesizeVoice(text);
if (!audio) return false;
const form = new FormData();
form.append("chat_id", String(chatId));
form.append(
"voice",
new Blob([bytesToArrayBuffer(audio)], { type: "audio/mpeg" }),
"nova_voice.mp3",
);
form.append("reply_to_message_id", String(replyTo));
try {
const res = await fetchWithTimeout(
`${API_URL}/sendVoice`,
{ method: "POST", body: form },
20_000,
);
const json = await res.json() as { ok: boolean };
return json.ok;
} catch (e) {
logger.warn('sendVoiceResponse: failed to send voice message', e);
return false;
}
}
// ─────────────────────────────────────────────
// SECTION: VOICE TRANSCRIPTION
// ─────────────────────────────────────────────
async function transcribeVoice(audioUrl: string): Promise<string> {
for (const key of cfg.GEMINI_KEYS) {
try {
const audioRes = await fetchWithTimeout(audioUrl, {}, 25_000);
const buf = await audioRes.arrayBuffer();
const b64 = arrayBufferToBase64(buf);
const url = `https://generativelanguage.googleapis.com/v1beta/models/${cfg.GEMINI_MODEL}:generateContent?key=${key}`;
const res = await fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [
{ text: "Transcribe this audio accurately to Persian or the language spoken:" },
{ inline_data: { mime_type: "audio/ogg", data: b64 } },
] }],
}),
}, 30_000);
const data = await res.json() as {
error?: { code?: number };
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
};
if (res.ok && data.candidates?.[0]?.content?.parts?.[0]?.text) {
return data.candidates[0].content.parts[0].text.trim();
}
if (data.error?.code === 429) continue;
throw new Error("Transcription failed");
} catch (e) {
logger.warn('transcribeVoice: attempt failed, trying next key', e);
continue;
}
}
throw new Error("All transcription attempts failed");
}
// ─────────────────────────────────────────────
// SECTION: GOOGLE IMAGE SEARCH
// ─────────────────────────────────────────────
async function searchGoogleImages(query: string, count = 5): Promise<string[]> {
if (!cfg.GOOGLE_SEARCH_API_KEY || !cfg.GOOGLE_SEARCH_ENGINE_ID) {
throw new Error("Google Search not configured");
}
const isGif = /gif|گیف/.test(query.toLowerCase());
const results: string[] = [];
for (let attempt = 0; attempt < 3 && results.length < count; attempt++) {
const url = new URL("https://www.googleapis.com/customsearch/v1");
Object.entries({
key: cfg.GOOGLE_SEARCH_API_KEY, cx: cfg.GOOGLE_SEARCH_ENGINE_ID,
q: query, searchType: "image", num: "10", safe: "off",
imgSize: "xxlarge", fileType: isGif ? "gif" : "jpg",
start: String(Math.min((Math.abs(query.charCodeAt(0) + Date.now()) % 90) + 1 + attempt * 10, 91)),
}).forEach(([k, v]) => url.searchParams.set(k, v));
try {
const res = await fetchWithTimeout(url.toString(), {}, 15_000);
const data = await res.json() as {
error?: { message: string; code?: number };
items?: Array<{ link: string }>;
};
if (data.error) {
if (res.status === 403 || data.error.code === 429) throw new Error(data.error.message);
continue;
}
const links = (data.items ?? []).map(i => i.link).filter(l => l && !results.includes(l));
results.push(...links);
if (results.length >= count) break;
await sleep(1000);
} catch (e) {
const msg = e instanceof Error ? e.message : "";
if (msg.includes("quota") || msg.includes("blocked") || msg.includes("403")) throw e;
}
}
if (!results.length) throw new Error("NO_RESULTS");
return results.slice(0, count);
}
async function searchGoogleWeb(query: string): Promise<string> {
if (!cfg.GOOGLE_SEARCH_API_KEY || !cfg.GOOGLE_SEARCH_ENGINE_ID) {
throw new Error("Google Search not configured");
}
const url = new URL("https://www.googleapis.com/customsearch/v1");
url.searchParams.set("key", cfg.GOOGLE_SEARCH_API_KEY);
url.searchParams.set("cx", cfg.GOOGLE_SEARCH_ENGINE_ID);
url.searchParams.set("q", query);
url.searchParams.set("num", "6");
url.searchParams.set("gl", "ir");
try {
const res = await fetchWithTimeout(url.toString(), {}, 10_000);
const data = await res.json() as {
items?: Array<{ title: string; link: string; snippet: string; pagemap?: { metatags?: Array<{ "og:description"?: string }> } }>;
error?: { message: string };
searchInformation?: { totalResults: string };
};
if (data.error) throw new Error(data.error.message);
if (!data.items?.length) return "No results found for this query.";
const results = data.items.map((item, idx) => {
const desc = item.pagemap?.metatags?.[0]?.["og:description"] ?? item.snippet ?? "";
return `[نتیجه ${idx + 1}]
عنوان: ${item.title}
لینک: ${item.link}
خلاصه: ${desc.slice(0, 200)}`;
}).join("\n\n---\n\n");
const total = data.searchInformation?.totalResults ?? "?";
return `کل نتایج: ${total}\n\n${results}`;
} catch (e) {
logger.error("Web search failed", e);
throw e;
}
}
// ─────────────────────────────────────────────
// SECTION: PDF GENERATION
// ─────────────────────────────────────────────
function wrapInHTMLTemplate(content: string): string {
const escaped = content
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
let formatted = escaped
.replace(/^#\s+(.+)$/gm, "<h1>$1</h1>")
.replace(/^##\s+(.+)$/gm, "<h2>$1</h2>")
.replace(/^###\s+(.+)$/gm, "<h3>$1</h3>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(/```([\s\S]+?)```/g, "<pre>$1</pre>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/^\s*[\-\*]\s+(.+)$/gm, "<li>$1</li>");
formatted = formatted.replace(/(<li>.+?<\/li>)+/gs, "<ul>$&</ul>");
formatted = formatted.split(/\n\n+/).map(p => {
if (p.trim().startsWith("<h") || p.trim().startsWith("<ul") || p.trim().startsWith("<pre>")) {
return p;
}
return `<p>${p.replace(/\n/g, "<br>")}</p>`;
}).join("\n");
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
padding: 40px;
}
h1 {
color: #1a1a2e;
border-bottom: 2px solid #0f3460;
padding-bottom: 10px;
font-size: 24px;
}
h2 {
color: #0f3460;
margin-top: 20px;
font-size: 18px;
}
pre {
background: #f4f4f8;
padding: 15px;
border-radius: 5px;
border-left: 4px solid #e94560;
font-family: monospace;
white-space: pre-wrap;
font-size: 13px;
}
code {
background: #f4f4f8;
padding: 2px 5px;
border-radius: 3px;
font-family: monospace;
}
ul {
padding-left: 20px;
}
li {
margin-bottom: 5px;
}
p {
margin-bottom: 15px;
}
.footer {
margin-top: 50px;
font-size: 12px;
color: #777;
text-align: center;
border-top: 1px solid #ddd;
padding-top: 10px;
}
</style>
</head>
<body>
<div>
${formatted}
</div>
<div class="footer">
Generated by Nova AI Assistant
</div>
</body>
</html>`;
}
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(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<\/(h1|h2|h3|p|li|pre|div)>/gi, "\n")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<li>/gi, "• ")
.replace(/<[^>]+>/g, "")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, "\"")
.replace(/&#039;/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<Uint8Array | null> {
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<void> {
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<void> {
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<string> {
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,
};