Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
1129 lines
39 KiB
TypeScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
|
|
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(/</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<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,
|
|
};
|