diff --git a/src/callback_commands/cancel.ts b/src/callback_commands/cancel.ts index 89a6cad..313a35f 100644 --- a/src/callback_commands/cancel.ts +++ b/src/callback_commands/cancel.ts @@ -13,7 +13,7 @@ export class Cancel extends CallbackCommand { } static withData(data?: string): Cancel { - return new Cancel("", data); + return new Cancel(undefined, data); } async execute(): Promise { diff --git a/src/callback_commands/ollama-cancel.ts b/src/callback_commands/ollama-cancel.ts index c9daaef..9f8b756 100644 --- a/src/callback_commands/ollama-cancel.ts +++ b/src/callback_commands/ollama-cancel.ts @@ -1,7 +1,7 @@ import {CallbackCommand} from "../base/callback-command"; import {CallbackQuery} from "typescript-telegram-bot-api"; import {abortOllamaRequest, bot, getOllamaRequest} from "../index"; -import {logError} from "../util/utils"; +import {escapeMarkdownV2Text, logError} from "../util/utils"; import {MessageStore} from "../common/message-store"; import {StoredMessage} from "../model/stored-message"; import {Requirements} from "../base/requirements"; @@ -59,8 +59,8 @@ export class OllamaCancel extends CallbackCommand { await bot.editMessageText({ chat_id: chatId, message_id: messageId, - text: newText, - parse_mode: "Markdown", + text: escapeMarkdownV2Text(newText), + parse_mode: "MarkdownV2", reply_markup: {inline_keyboard: []}, }); diff --git a/src/callback_commands/try-again.ts b/src/callback_commands/try-again.ts deleted file mode 100644 index 331a424..0000000 --- a/src/callback_commands/try-again.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {CallbackCommand} from "../base/callback-command"; - -export class TryAgain extends CallbackCommand { - data = ""; - text = "🔁 Повторить"; - - constructor(text?: string, data?: string) { - super(); - - this.text = text ?? this.text; - this.data = data ?? this.data; - } - - static withData(data?: string): TryAgain { - return new TryAgain("", data); - } - - async execute(): Promise { - return Promise.resolve(); - } -} \ No newline at end of file diff --git a/src/commands/ae.ts b/src/commands/ae.ts index f9c0a9e..cb3c770 100644 --- a/src/commands/ae.ts +++ b/src/commands/ae.ts @@ -7,6 +7,8 @@ import {Requirement} from "../base/requirement"; export class Ae extends Command { argsMode = "required" as const; + command = ["ae"]; + title = "/ae"; description = "evaluation"; @@ -16,11 +18,8 @@ export class Ae extends Command { const match = params?.[3] || ""; try { - let e = eval(match); - - e = ((typeof e == "string") ? e : JSON.stringify(e)); - - await oldSendMessage(msg, e).catch(async () => await errorPlaceholder(msg)); + let result = this.executeEvaluation(match); + await oldSendMessage(msg, result).catch(async () => await errorPlaceholder(msg)); } catch (e: any) { const text = e.message.toString(); @@ -35,4 +34,25 @@ export class Ae extends Command { await oldSendMessage(msg, text).catch(logError); } } + + executeEvaluation(evaluation: string): string { + try { + let e = eval(evaluation); + + e = ((typeof e == "string") ? e : JSON.stringify(e)); + + return e; + } catch (e: any) { + const text = e.message.toString(); + + if (text.includes("is not defined")) { + return "Variable not defined"; + } + + logError(`${text} + * Stacktrace: ${e.stack}`); + + return text; + } + } } \ No newline at end of file diff --git a/src/commands/ollama-chat.ts b/src/commands/ollama-chat.ts index c5ee3db..03f7677 100644 --- a/src/commands/ollama-chat.ts +++ b/src/commands/ollama-chat.ts @@ -6,6 +6,7 @@ import { logError, oldReplyToMessage, replyToMessage, + runCommand, startIntervalEditor } from "../util/utils"; import {Environment} from "../common/environment"; @@ -15,9 +16,1741 @@ import {OllamaCancel} from "../callback_commands/ollama-cancel"; import {OllamaGetModel} from "./ollama-get-model"; import {ChatCommand} from "../base/chat-command"; import {MessagePart} from "../common/message-part"; -import {Options} from "ollama"; +import {Options, Tool, ToolCall} from "ollama"; import fs from "node:fs"; import path from "node:path"; +import axios from "axios"; + +// TODO: 03/05/2026, Danil Nikolaev: cleanup + +type ToolHandler = (args?: Record) => Promise | unknown; + +type ChatMessage = { + role: "system" | "user" | "assistant" | "tool"; + content: string; + images?: string[]; + tool_calls?: ToolCall[]; + tool_name?: string; +}; + +const MAX_TOOL_ROUNDS = 50; +const TELEGRAM_MESSAGE_LIMIT = 4096; + +const FILE_TOOLS_ROOT_DIR = path.resolve(Environment.FILE_TOOLS_ROOT_DIR ?? ""); + +const MAX_FILE_READ_BYTES = 128 * 1024 * 1024; +const MAX_FILE_WRITE_BYTES = 128 * 1024 * 1024 +const MAX_DIRECTORY_ENTRIES = 200; +const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024; +const MAX_COPY_ENTRIES = 500; + +type BraveSearchProfile = { + name?: string; + long_name?: string; + url?: string; + img?: string; +}; + +type BraveSearchMetaUrl = { + scheme?: string; + netloc?: string; + hostname?: string; + favicon?: string; + path?: string; +}; + +type BraveSearchThumbnail = { + src?: string; + original?: string; +}; + +type BraveSearchResult = { + type?: string; + title?: string; + url?: string; + description?: string; + age?: string; + page_age?: string; + language?: string; + family_friendly?: boolean; + is_source_local?: boolean; + is_source_both?: boolean; + profile?: BraveSearchProfile; + meta_url?: BraveSearchMetaUrl; + thumbnail?: BraveSearchThumbnail; + extra_snippets?: string[]; +}; + +type BraveSearchApiResponse = { + type?: string; + query?: { + original?: string; + show_strict_warning?: boolean; + is_navigational?: boolean; + is_news_breaking?: boolean; + spellcheck_off?: boolean; + country?: string; + bad_results?: boolean; + should_fallback?: boolean; + postal_code?: string; + city?: string; + header_country?: string; + more_results_available?: boolean; + state?: string; + altered?: string; + }; + + web?: { + type?: string; + results?: BraveSearchResult[]; + }; + + news?: { + type?: string; + results?: BraveSearchResult[]; + }; + + videos?: { + type?: string; + results?: BraveSearchResult[]; + }; + + discussions?: { + type?: string; + results?: BraveSearchResult[]; + }; + + faq?: unknown; + infobox?: unknown; + locations?: unknown; + mixed?: unknown; + summarizer?: unknown; +}; + +const braveSearchTool = { + type: "function", + function: { + name: "web_search", + description: + "Search the web using Brave Search API. Use this for current information, facts, documentation, news, products, recent events, source lookup, and general web search. Returns ranked web/news/video results with titles, URLs and snippets.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: + "Search query. Must be non-empty. Maximum 400 characters and 50 words.", + }, + count: { + type: "number", + description: + "Number of web results to return. Min 1, max 20. Default is 5.", + }, + offset: { + type: "number", + description: + "Zero-based page offset. Min 0, max 9. Default is 0.", + }, + country: { + type: "string", + description: + "Optional 2-letter country code for result localization, for example US, RU, DE. Default is US.", + }, + searchLang: { + type: "string", + description: + "Optional search language code, for example en, ru, de. Default is en.", + }, + uiLang: { + type: "string", + description: + "Optional UI language, usually language-country format, for example en-US, ru-RU, de-DE.", + }, + safesearch: { + type: "string", + enum: ["off", "moderate", "strict"], + description: + "Adult content filter. Default is moderate.", + }, + freshness: { + type: "string", + description: + "Optional freshness filter: pd for last 24h, pw for last 7 days, pm for last 31 days, py for last 365 days, or YYYY-MM-DDtoYYYY-MM-DD.", + }, + resultFilter: { + type: "string", + description: + "Comma-separated result types. Examples: web, news, videos, discussions, faq, infobox, locations, query, summarizer. Default is web.", + }, + extraSnippets: { + type: "boolean", + description: + "Whether to request extra snippets. Default is false.", + }, + spellcheck: { + type: "boolean", + description: + "Whether Brave may spellcheck and alter the query. Default is true.", + }, + }, + required: ["query"], + }, + }, +} satisfies Tool; + +const evaluationTool = { + type: "function", + function: { + name: "evaluation", + description: "Execute command in a shell", + parameters: { + type: "object", + properties: { + cmd: { + type: "string", + description: "Actual command to execute in a shell" + } + }, + required: ["cmd"] + } + } +} satisfies Tool; + +const getCurrentDateTimeTool = { + type: "function", + function: { + name: "get_current_datetime", + description: + "Get the real current date and time. Use this tool when the user asks about today, current time, current date, weekday, timestamp, or relative dates.", + parameters: { + type: "object", + properties: { + timeZone: { + type: "string", + description: + "Optional IANA timezone, for example Europe/Moscow, Europe/Berlin, UTC. If omitted, system timezone is used.", + }, + locale: { + type: "string", + description: + "Optional locale, for example ru-RU or en-US. If omitted, system locale/default locale is used.", + }, + }, + required: [], + }, + }, +} satisfies Tool; + +const getWeatherTool = { + type: "function", + function: { + name: "get_weather", + type: "string", + description: "Get the current temperature for a city.", + parameters: { + type: "object", + properties: { + city: { + type: "string", + description: "The name of the city." + }, + lang: { + type: "string", + description: "language code for the response/content. Must be a valid ISO 639-1 two-letter language code, for example: \"en\", \"ru\", \"de\", \"fr\".Determine the value automatically from the language the user is using to communicate with the LLM. If the user explicitly requests a specific language, use that requested language instead. Do not use language names, locales, or regional variants such as \"English\", \"ru-RU\", or \"en_US\"; return only the ISO 639-1 code." + } + }, + required: ["city", "lang"], + } + } +} satisfies Tool; + +const readFileTool = { + type: "function", + function: { + name: "read_file", + description: + "Read a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Relative file path inside the root directory, for example notes/todo.txt.", + }, + maxBytes: { + type: "number", + description: `Optional max bytes to read. Maximum allowed value is ${MAX_FILE_READ_BYTES}.`, + }, + }, + required: ["path"], + }, + }, +} satisfies Tool; + +const listDirectoryTool = { + type: "function", + function: { + name: "list_directory", + description: + "List files and directories inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Relative directory path inside the root directory. Use . for root.", + }, + }, + required: [], + }, + }, +} satisfies Tool; + +const createFileTool = { + type: "function", + function: { + name: "create_file", + description: + "Create a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Relative file path inside the root directory.", + }, + content: { + type: "string", + description: "File content.", + }, + overwrite: { + type: "boolean", + description: "Whether to overwrite the file if it already exists. Default is false.", + }, + createParents: { + type: "boolean", + description: "Whether to create parent directories automatically. Default is true.", + }, + }, + required: ["path"], + }, + }, +} satisfies Tool; + +const createDirectoryTool = { + type: "function", + function: { + name: "create_directory", + description: + "Create a directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Relative directory path inside the root directory.", + }, + recursive: { + type: "boolean", + description: "Whether to create parent directories automatically. Default is true.", + }, + }, + required: ["path"], + }, + }, +} satisfies Tool; + +const copyPathTool = { + type: "function", + function: { + name: "copy_path", + description: + "Copy a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Directory copy requires recursive=true. Symlinks are forbidden.", + parameters: { + type: "object", + properties: { + sourcePath: { + type: "string", + description: "Relative source file or directory path inside the root directory.", + }, + targetPath: { + type: "string", + description: "Relative target file or directory path inside the root directory.", + }, + recursive: { + type: "boolean", + description: "Required for copying directories. Default is false.", + }, + overwrite: { + type: "boolean", + description: "Whether to overwrite existing files. Directory merge is allowed, but existing directories are not deleted. Default is false.", + }, + createParents: { + type: "boolean", + description: "Whether to create target parent directories automatically. Default is true.", + }, + }, + required: ["sourcePath", "targetPath"], + }, + }, +} satisfies Tool; + +const updateFileTool = { + type: "function", + function: { + name: "update_file", + description: + "Update a UTF-8 text file inside the hardcoded root directory. Supports replace, append and prepend. Use only relative paths. Going up with ../ and absolute paths are forbidden.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Relative file path inside the root directory.", + }, + content: { + type: "string", + description: "Content to write.", + }, + mode: { + type: "string", + enum: ["replace", "append", "prepend"], + description: "Update mode. Default is replace.", + }, + createIfMissing: { + type: "boolean", + description: "Whether to create the file if it does not exist. Default is false.", + }, + }, + required: ["path", "content"], + }, + }, +} satisfies Tool; + +const renamePathTool = { + type: "function", + function: { + name: "rename_path", + description: + "Rename or move a file/directory inside the hardcoded root directory. This is the main directory modification tool. Use only relative paths. Going up with ../ and absolute paths are forbidden.", + parameters: { + type: "object", + properties: { + sourcePath: { + type: "string", + description: "Relative source path inside the root directory.", + }, + targetPath: { + type: "string", + description: "Relative target path inside the root directory.", + }, + overwrite: { + type: "boolean", + description: "Whether to overwrite an existing target file. Directory overwrite is not supported. Default is false.", + }, + createParents: { + type: "boolean", + description: "Whether to create target parent directories automatically. Default is false.", + }, + }, + required: ["sourcePath", "targetPath"], + }, + }, +} satisfies Tool; + +const deletePathTool = { + type: "function", + function: { + name: "delete_path", + description: + "Delete a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Recursive deletion requires recursive=true.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Relative file or directory path inside the root directory.", + }, + recursive: { + type: "boolean", + description: "Whether to delete non-empty directories recursively. Default is false.", + }, + }, + required: ["path"], + }, + }, +} satisfies Tool; + +const getTools = () => { + const tools: Tool[] = [ + evaluationTool, + + getCurrentDateTimeTool, + + readFileTool, + listDirectoryTool, + createFileTool, + createDirectoryTool, + updateFileTool, + renamePathTool, + copyPathTool, + deletePathTool, + ]; + + if (Environment.BRAVE_SEARCH_API_KEY) { + tools.unshift(braveSearchTool); + } + + if (Environment.OPEN_WEATHER_MAP_API_KEY) { + tools.unshift(getWeatherTool) + } + + return tools; +} + +const evaluationToolPrompt = [ + "Shell tool rules:", + "- You have access to the `evaluation` tool.", + "- `evaluation` executes a shell command on the server.", + "- This tool is powerful and potentially dangerous.", + "- Use this tool only when command execution is actually necessary.", + "- Prefer specialized tools when available, for example filesystem tools for reading, creating, updating, copying, moving or deleting files.", + "", + "Platform awareness:", + "- The server shell may be Linux/macOS shell, Windows CMD, or Windows PowerShell.", + "- Do not assume Bash/Linux commands are available.", + "- Do not assume Windows commands are available.", + "- If the current OS/shell is unknown, first run a safe environment inspection command.", + "- Safe OS inspection examples:", + " - Node.js: `node -p \"process.platform\"`", + " - Node.js: `node -p \"process.cwd()\"`", + " - Windows CMD: `ver`", + " - PowerShell: `$PSVersionTable.PSVersion`", + " - POSIX shell: `uname -a`", + "", + "Preferred safe commands:", + "- Prefer read-only commands.", + "- Prefer short, explicit and predictable commands.", + "- Cross-platform when Node.js is available:", + " - `node -p \"process.cwd()\"`", + " - `node -p \"process.platform\"`", + " - `node -e \"console.log(require('fs').readdirSync('.'))\"`", + "- POSIX examples:", + " - `pwd`, `ls`, `find`, `cat`, `head`, `tail`, `grep`, `sed -n`, `wc`, `stat`, `file`, `du`, `df`, `ps`.", + "- Windows CMD examples:", + " - `cd`, `dir`, `type`, `where`, `findstr`.", + "- PowerShell examples:", + " - `Get-Location`, `Get-ChildItem`, `Get-Content`, `Select-String`, `Measure-Object`, `Get-Item`, `Get-Process`.", + "", + "Filesystem restrictions:", + "- Work only inside the allowed project/root directory.", + "- Use relative paths when possible.", + "- Do not use absolute paths unless the user explicitly asks and it is safe.", + "- Do not use `..` to go to parent directories.", + "- Do not access files outside the allowed root directory.", + "- Do not follow or use symlinks to escape the allowed root directory.", + "", + "Forbidden actions unless the user explicitly asks and the action is clearly safe:", + "- Do not delete files or directories.", + "- Do not overwrite files.", + "- Do not move files.", + "- Do not change permissions.", + "- Do not change ownership.", + "- Do not install packages.", + "- Do not update the system.", + "- Do not start, stop or restart services.", + "- Do not run background processes.", + "- Do not run long-running commands.", + "- Do not run infinite loops.", + "- Do not use fork bombs.", + "- Do not use privilege escalation.", + "", + "Forbidden command examples:", + "- POSIX: `sudo`, `su`, `rm`, `rmdir`, `chmod`, `chown`, `dd`, `mkfs`, `mount`, `umount`, `kill`, `reboot`, `shutdown`.", + "- Windows CMD: `del`, `erase`, `rmdir`, `rd`, `format`, `shutdown`, `taskkill`.", + "- PowerShell: `Remove-Item`, `Move-Item`, `Set-ItemProperty`, `Stop-Process`, `Restart-Computer`, `Stop-Computer`.", + "", + "Network restrictions:", + "- Do not make network requests unless the user explicitly asks.", + "- Do not use `curl`, `wget`, `Invoke-WebRequest`, `Invoke-RestMethod`, `ssh`, `scp`, `rsync`, `nc`, `nmap` unless explicitly requested and safe.", + "", + "Secrets and privacy:", + "- Never read secrets, tokens, API keys, passwords, private keys, certificates, `.env` files, SSH keys, browser data or credential stores unless the user explicitly asks and it is necessary.", + "- If command output contains secrets, do not repeat them back to the user.", + "", + "Command construction:", + "- Do not execute untrusted user text directly as shell code.", + "- Quote paths and arguments safely.", + "- Avoid command chaining with `;`, `&&`, `||`, pipes, backticks or command substitution unless necessary.", + "- Avoid glob patterns that may affect too many files.", + "- If unsure whether a command is safe, do not run it.", +].join("\n"); + +const braveSearchToolPrompt = [ + "Brave Search tool rules:", + "- You have access to `web_search`.", + "- Use `web_search` when the user asks for current information, recent events, fresh prices, documentation lookup, source lookup, product info, news, public facts, or anything that may have changed.", + "- Use `web_search` for normal web search results.", + "- Do not use `evaluation` for web search.", + "", + "How to query:", + "- Keep search queries short and focused.", + "- Prefer the user's original language unless another language is clearly better for the topic.", + "- Use `searchLang` based on the expected language of results: `ru` for Russian, `en` for English, `de` for German.", + "- Use `country` for localization when relevant, for example `RU`, `US`, `DE`.", + "- Use `count` between 3 and 10 by default.", + "- Use `resultFilter: \"web\"` for normal search.", + "- Use `resultFilter: \"news,web\"` for recent news/events.", + "- Use `resultFilter: \"videos\"` only when the user asks for videos.", + "- Use `resultFilter: \"discussions,web\"` when forum/community opinions are useful.", + "", + "Freshness:", + "- Use `freshness: \"pd\"` for last 24 hours.", + "- Use `freshness: \"pw\"` for last 7 days.", + "- Use `freshness: \"pm\"` for last 31 days.", + "- Use `freshness: \"py\"` for last 365 days.", + "- Use a custom range like `2025-01-01to2025-12-31` only when the user asks for a specific date range.", + "", + "Answering:", + "- Treat snippets as hints, not as full source documents.", + "- Do not invent details that are not present in the search results.", + "- When giving factual claims based on search results, mention the source title or URL.", + "- If results are weak, ambiguous or empty, say that the search result was insufficient.", +].join("\n"); + + +const getToolSystemPrompt = () => { + const lines = [ + "Tool rules:", + "- You have access to get_current_datetime.", + "- Use get_current_datetime when the user asks about the current date, current time, today, weekday, timestamp, or relative dates.", + "- Do not guess the current date or time.", + "Filesystem tool rules:", + "- You have access to filesystem tools working only inside the hardcoded root directory.", + "- All filesystem paths must be relative to the root directory.", + "- You may go into child directories.", + "- You must never go up to parent directories.", + "- Do not use ../ paths.", + "- Do not use absolute paths.", + "- Do not try to access symlinks.", + "- Use read_file for reading files.", + "- Use list_directory for reading directories.", + "- Use create_file for creating files.", + "- Use create_directory for creating directories.", + "- Use update_file for replacing, appending or prepending file content.", + "- Use rename_path for renaming or moving files/directories inside the root.", + "- Use delete_path for deleting files/directories inside the root.", + "", + // TODO: 03/05/2026, Danil Nikolaev: check security moments + evaluationToolPrompt, + ] + + if (Environment.BRAVE_SEARCH_API_KEY) { + lines.push( + "", + braveSearchToolPrompt, + "" + ) + } + + return lines.join("\n"); +} + +function asIntegerInRange( + value: unknown, + fallback: number, + min: number, + max: number, +): number { + const parsed = typeof value === "number" + ? value + : typeof value === "string" + ? Number(value) + : NaN; + + if (!Number.isFinite(parsed)) return fallback; + + const int = Math.trunc(parsed); + + return Math.min(max, Math.max(min, int)); +} + +function asEnum( + value: unknown, + allowed: readonly T[], + fallback: T, +): T { + if (typeof value !== "string") return fallback; + + const normalized = value.trim(); + + return allowed.includes(normalized as T) + ? normalized as T + : fallback; +} + +function cleanSearchText(value: unknown): string | null { + if (typeof value !== "string") return null; + + return value + .replace(/<[^>]*>/g, "") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/\s+/g, " ") + .trim() || null; +} + +function normalizeBraveResultFilter(value: unknown): string { + const allowed = new Set([ + "discussions", + "faq", + "infobox", + "news", + "query", + "summarizer", + "videos", + "web", + "locations", + ]); + + const raw = asNonEmptyString(value); + + if (!raw) return "web"; + + const parts = raw + .split(",") + .map(part => part.trim().toLowerCase()) + .filter(part => allowed.has(part)); + + return parts.length ? [...new Set(parts)].join(",") : "web"; +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function normalizeToolArguments(args: unknown): Record { + if (!args) return {}; + + if (typeof args === "string") { + try { + const parsed = JSON.parse(args); + + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return { + raw: args, + }; + } + + return {}; + } + + if (typeof args === "object" && !Array.isArray(args)) { + return args as Record; + } + + return {}; +} + +async function webSearch(args?: Record) { + console.log("braveSearch()"); + + try { + const query = asNonEmptyString(args?.query); + + if (!query) { + throw new Error("query is required"); + } + + if (query.length > 400) { + throw new Error("query is too long. Max allowed length is 400 characters."); + } + + const wordCount = query.split(/\s+/).filter(Boolean).length; + + if (wordCount > 50) { + throw new Error("query has too many words. Max allowed word count is 50."); + } + + const count = asIntegerInRange(args?.count, 5, 1, 20); + const offset = asIntegerInRange(args?.offset, 0, 0, 9); + + const country = asNonEmptyString(args?.country)?.toUpperCase() ?? "US"; + const searchLang = asNonEmptyString(args?.searchLang)?.toLowerCase() ?? "en"; + const uiLang = asNonEmptyString(args?.uiLang) ?? undefined; + + const safesearch = asEnum( + args?.safesearch, + ["off", "moderate", "strict"] as const, + "moderate", + ); + + const freshness = asNonEmptyString(args?.freshness); + const resultFilter = normalizeBraveResultFilter(args?.resultFilter); + + const extraSnippets = asBoolean(args?.extraSnippets, false); + const spellcheck = asBoolean(args?.spellcheck, true); + + const response = await axios.get( + "https://api.search.brave.com/res/v1/web/search", + { + timeout: 10_000, + params: { + q: query, + count, + offset, + country, + search_lang: searchLang, + safesearch, + result_filter: resultFilter, + text_decorations: false, + spellcheck, + extra_snippets: extraSnippets, + ...(uiLang ? {ui_lang: uiLang} : {}), + ...(freshness ? {freshness} : {}), + }, + headers: { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "X-Subscription-Token": Environment.BRAVE_SEARCH_API_KEY, + "User-Agent": "TelegramBot/1.0", + }, + }, + ); + + const data = response.data; + + return { + ok: true, + query, + alteredQuery: data.query?.altered ?? null, + moreResultsAvailable: data.query?.more_results_available ?? null, + resultFilter, + count, + offset, + country, + searchLang, + safesearch, + freshness: freshness ?? null, + + web: data.web?.results?.map(mapBraveResult) ?? [], + news: data.news?.results?.map(mapBraveResult) ?? [], + videos: data.videos?.results?.map(mapBraveResult) ?? [], + discussions: data.discussions?.results?.map(mapBraveResult) ?? [], + + hasInfobox: Boolean(data.infobox), + hasFaq: Boolean(data.faq), + hasLocations: Boolean(data.locations), + hasSummarizer: Boolean(data.summarizer), + + note: "Use returned URLs as sources. Do not invent facts that are not present in the snippets/results.", + }; + } catch (e: any) { + logError(e); + + const status = e?.response?.status; + const data = e?.response?.data; + + return { + ok: false, + status: typeof status === "number" ? status : null, + error: e instanceof Error ? e.message : String(e), + response: data ?? null, + }; + } finally { + console.log("END: braveSearch()"); + } +} + +function mapBraveResult(result: BraveSearchResult) { + return { + title: cleanSearchText(result.title), + url: asNonEmptyString(result.url) ?? null, + description: cleanSearchText(result.description), + age: asNonEmptyString(result.age) ?? asNonEmptyString(result.page_age) ?? null, + language: asNonEmptyString(result.language) ?? null, + source: asNonEmptyString(result.profile?.name) + ?? asNonEmptyString(result.profile?.long_name) + ?? asNonEmptyString(result.meta_url?.hostname) + ?? null, + hostname: asNonEmptyString(result.meta_url?.hostname) ?? null, + thumbnail: asNonEmptyString(result.thumbnail?.src) + ?? asNonEmptyString(result.thumbnail?.original) + ?? null, + extraSnippets: Array.isArray(result.extra_snippets) + ? result.extra_snippets + .map(cleanSearchText) + .filter((value): value is string => Boolean(value)) + : [], + }; +} + +function getSystemTimeZone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +function asBoolean(value: unknown, defaultValue = false): boolean { + if (typeof value === "boolean") return value; + + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + + if (normalized === "true") return true; + if (normalized === "false") return false; + } + + return defaultValue; +} + +function asString(value: unknown, defaultValue = ""): string { + return typeof value === "string" ? value : defaultValue; +} + +function asPositiveInt(value: unknown, defaultValue: number, maxValue: number): number { + const n = typeof value === "number" + ? value + : typeof value === "string" + ? Number(value) + : NaN; + + if (!Number.isFinite(n) || n <= 0) return defaultValue; + + return Math.min(Math.floor(n), maxValue); +} + +async function ensureFileToolsRootExists(): Promise { + await fs.promises.mkdir(FILE_TOOLS_ROOT_DIR, {recursive: true}); + + const stat = await fs.promises.stat(FILE_TOOLS_ROOT_DIR); + + if (!stat.isDirectory()) { + throw new Error(`File tools root is not a directory: ${FILE_TOOLS_ROOT_DIR}`); + } +} + +function resolveSafeToolPath(inputPath: unknown, fallback = "."): { + absolutePath: string; + relativePath: string; +} { + const rawPath = asNonEmptyString(inputPath) ?? fallback; + + if (rawPath.includes("\0")) { + throw new Error("Path must not contain null bytes."); + } + + if ( + path.isAbsolute(rawPath) || + path.win32.isAbsolute(rawPath) || + path.posix.isAbsolute(rawPath) + ) { + throw new Error("Absolute paths are not allowed. Use only relative paths inside the root directory."); + } + + const normalizedInputPath = rawPath.replace(/[\\/]+/g, path.sep); + + const absolutePath = path.resolve(FILE_TOOLS_ROOT_DIR, normalizedInputPath); + const relativePath = path.relative(FILE_TOOLS_ROOT_DIR, absolutePath); + + if ( + relativePath.startsWith("..") || + path.isAbsolute(relativePath) + ) { + throw new Error("Path escapes the root directory. Going up is not allowed."); + } + + return { + absolutePath, + relativePath: relativePath || ".", + }; +} + +function assertTargetIsNotInsideSource(sourceAbsolutePath: string, targetAbsolutePath: string): void { + const relative = path.relative(sourceAbsolutePath, targetAbsolutePath); + + if ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ) { + throw new Error("Cannot copy a directory into itself."); + } +} + +async function assertNoSymlinkInPath( + absolutePath: string, + options?: { + allowMissingTail?: boolean; + } +): Promise { + await ensureFileToolsRootExists(); + + const relativePath = path.relative(FILE_TOOLS_ROOT_DIR, absolutePath); + + if (!relativePath || relativePath === ".") { + return; + } + + const parts = relativePath.split(path.sep).filter(Boolean); + + let currentPath = FILE_TOOLS_ROOT_DIR; + + for (const part of parts) { + currentPath = path.join(currentPath, part); + + try { + const stat = await fs.promises.lstat(currentPath); + + if (stat.isSymbolicLink()) { + throw new Error("Symlinks are not allowed in file tool paths."); + } + } catch (e: any) { + if (e?.code === "ENOENT" && options?.allowMissingTail) { + return; + } + + throw e; + } + } +} + +async function pathExists(absolutePath: string): Promise { + try { + await fs.promises.lstat(absolutePath); + return true; + } catch (e: any) { + if (e?.code === "ENOENT") return false; + throw e; + } +} + +function assertNotRoot(relativePath: string): void { + if (relativePath === ".") { + throw new Error("Operation on the root directory itself is not allowed."); + } +} + +function getEntryType(stat: fs.Stats): "file" | "directory" | "symlink" | "other" { + if (stat.isSymbolicLink()) return "symlink"; + if (stat.isFile()) return "file"; + if (stat.isDirectory()) return "directory"; + return "other"; +} + +async function evaluation(args?: Record): Promise { + const cmd = asNonEmptyString(args?.cmd); + if (!cmd) return undefined; + + const {stdout, stderr} = await runCommand(cmd); + + return stdout ?? stderr; +} + +function getCurrentDateTime(args?: Record) { + const now = new Date(); + + const systemTimeZone = getSystemTimeZone(); + const requestedTimeZone = asNonEmptyString(args?.timeZone); + const requestedLocale = asNonEmptyString(args?.locale); + + const timeZone = requestedTimeZone ?? systemTimeZone; + const locale = requestedLocale ?? undefined; + + try { + const formatted = new Intl.DateTimeFormat(locale, { + timeZone, + dateStyle: "full", + timeStyle: "long", + }).format(now); + + return { + iso: now.toISOString(), + unixMs: now.getTime(), + timeZone, + systemTimeZone, + locale: locale ?? "system-default", + formatted, + }; + } catch (error) { + const formatted = new Intl.DateTimeFormat(undefined, { + timeZone: systemTimeZone, + dateStyle: "full", + timeStyle: "long", + }).format(now); + + return { + iso: now.toISOString(), + unixMs: now.getTime(), + timeZone: systemTimeZone, + systemTimeZone, + locale: "system-default", + formatted, + warning: "Invalid locale or timezone was provided. Fallback to system locale and system timezone was used.", + requestedTimeZone: requestedTimeZone ?? null, + requestedLocale: requestedLocale ?? null, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function getWeather(args?: Record): Promise { + console.log("getWeather()"); + try { + const city = asNonEmptyString(args?.city); + const lang = asNonEmptyString(args?.lang); + + if (!city) { + return null; + } + + const apiKey = Environment.OPEN_WEATHER_MAP_API_KEY; + + const geocodeResponse = (await axios.get(`https://api.openweathermap.org/geo/1.0/direct?q=${city}&limit=1&appid=${apiKey}`)).data[0]; + console.log("GEOCODE_RESPONSE", geocodeResponse); + const lat = geocodeResponse.lat; + const lon = geocodeResponse.lon; + + const response = (await axios.get(`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&units=metric&appid=${apiKey}` + (lang ? `&lang=${lang}` : ""))).data; + console.log("RESPONSE: getWeather(lang=" + lang + "): ", response); + + const main = response.main; + const sys = response.sys; + const wind = response.wind; + const weather = response.weather[0]; + + let date = new Date(sys.sunrise * 1000); + + const sunrise = [ + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + ] + .map((v) => String(v).padStart(2, "0")) + .join(":"); + + date = new Date(sys.sunset * 1000); + + const sunset = [ + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + ] + .map((v) => String(v).padStart(2, "0")) + .join(":"); + + const weatherReport = `main: ${weather.main}; description: ${weather.description};\n` + + `temperature: ${main.temp}, max ${main.temp_max}, min ${main.temp_min}; feels like: ${main.feels_like};\n` + + `humidity: ${main.humidity}; pressure: ${main.pressure}, sea level: ${main.sea_level}, ground level: ${main.grnd_level};\n` + + `sunrise: ${sunrise} UTC; sunset: ${sunset} UTC;\n` + + `wind: degree: ${wind.deg}, speed: ${wind.speed}`; + + return weatherReport; + } catch (e: any) { + logError(e); + return null; + } finally { + console.log("END: getWeather()"); + } +} + +async function readFile(args?: Record) { + const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); + + await assertNoSymlinkInPath(absolutePath); + + const stat = await fs.promises.lstat(absolutePath); + + if (!stat.isFile()) { + throw new Error(`Path is not a file: ${relativePath}`); + } + + const maxBytes = asPositiveInt(args?.maxBytes, MAX_FILE_READ_BYTES, MAX_FILE_READ_BYTES); + + if (stat.size > maxBytes) { + throw new Error(`File is too large: ${stat.size} bytes. Max allowed: ${maxBytes} bytes.`); + } + + const buffer = await fs.promises.readFile(absolutePath); + + if (buffer.includes(0)) { + throw new Error("Binary files are not supported."); + } + + return { + ok: true, + path: relativePath, + sizeBytes: stat.size, + content: buffer.toString("utf8"), + }; +} + +async function listDirectory(args?: Record) { + const {absolutePath, relativePath} = resolveSafeToolPath(args?.path, "."); + + await assertNoSymlinkInPath(absolutePath); + + const stat = await fs.promises.lstat(absolutePath); + + if (!stat.isDirectory()) { + throw new Error(`Path is not a directory: ${relativePath}`); + } + + const dirEntries = await fs.promises.readdir(absolutePath, { + withFileTypes: true, + }); + + const limitedEntries = dirEntries.slice(0, MAX_DIRECTORY_ENTRIES); + + const entries = await Promise.all(limitedEntries.map(async entry => { + const entryAbsolutePath = path.join(absolutePath, entry.name); + const entryRelativePath = relativePath === "." + ? entry.name + : path.join(relativePath, entry.name); + + const entryStat = await fs.promises.lstat(entryAbsolutePath); + + return { + name: entry.name, + path: entryRelativePath, + type: getEntryType(entryStat), + sizeBytes: entryStat.isFile() ? entryStat.size : null, + modifiedAt: entryStat.mtime.toISOString(), + }; + })); + + return { + ok: true, + path: relativePath, + entries, + totalEntries: dirEntries.length, + returnedEntries: entries.length, + truncated: dirEntries.length > entries.length, + }; +} + +async function createFile(args?: Record) { + const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); + + assertNotRoot(relativePath); + + const content = asString(args?.content, ""); + const overwrite = asBoolean(args?.overwrite, false); + const createParents = asBoolean(args?.createParents, true); + + const contentSizeBytes = Buffer.byteLength(content, "utf8"); + + if (contentSizeBytes > MAX_FILE_WRITE_BYTES) { + throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`); + } + + const parentPath = path.dirname(absolutePath); + + if (createParents) { + await assertNoSymlinkInPath(parentPath, {allowMissingTail: true}); + await fs.promises.mkdir(parentPath, {recursive: true}); + } else { + await assertNoSymlinkInPath(parentPath); + } + + if (await pathExists(absolutePath)) { + const stat = await fs.promises.lstat(absolutePath); + + if (stat.isSymbolicLink()) { + throw new Error("Symlink targets are not allowed."); + } + + if (stat.isDirectory()) { + throw new Error(`Path is a directory, not a file: ${relativePath}`); + } + + if (!overwrite) { + throw new Error(`File already exists: ${relativePath}`); + } + } + + await fs.promises.writeFile(absolutePath, content, { + encoding: "utf8", + flag: overwrite ? "w" : "wx", + }); + + return { + ok: true, + path: relativePath, + sizeBytes: contentSizeBytes, + overwritten: overwrite, + }; +} + +type CopyPathStats = { + entries: number; + totalBytes: number; +}; + +async function copyPathRecursive(params: { + sourceAbsolutePath: string; + targetAbsolutePath: string; + overwrite: boolean; + stats: CopyPathStats; +}): Promise { + const { + sourceAbsolutePath, + targetAbsolutePath, + overwrite, + stats, + } = params; + + if (stats.entries >= MAX_COPY_ENTRIES) { + throw new Error(`Too many entries to copy. Max allowed: ${MAX_COPY_ENTRIES}.`); + } + + stats.entries++; + + const sourceStat = await fs.promises.lstat(sourceAbsolutePath); + + if (sourceStat.isSymbolicLink()) { + throw new Error("Symlinks are not allowed in copied paths."); + } + + if (sourceStat.isFile()) { + stats.totalBytes += sourceStat.size; + + if (stats.totalBytes > MAX_COPY_TOTAL_BYTES) { + throw new Error(`Copied data is too large. Max allowed: ${MAX_COPY_TOTAL_BYTES} bytes.`); + } + + if (await pathExists(targetAbsolutePath)) { + const targetStat = await fs.promises.lstat(targetAbsolutePath); + + if (targetStat.isSymbolicLink()) { + throw new Error("Symlink targets are not allowed."); + } + + if (targetStat.isDirectory()) { + throw new Error("Cannot overwrite a directory with a file."); + } + + if (!overwrite) { + throw new Error(`Target file already exists: ${path.relative(FILE_TOOLS_ROOT_DIR, targetAbsolutePath)}`); + } + } + + await fs.promises.copyFile( + sourceAbsolutePath, + targetAbsolutePath, + overwrite ? 0 : fs.constants.COPYFILE_EXCL, + ); + + return; + } + + if (sourceStat.isDirectory()) { + if (await pathExists(targetAbsolutePath)) { + const targetStat = await fs.promises.lstat(targetAbsolutePath); + + if (targetStat.isSymbolicLink()) { + throw new Error("Symlink targets are not allowed."); + } + + if (!targetStat.isDirectory()) { + throw new Error("Cannot overwrite a file with a directory."); + } + } else { + await fs.promises.mkdir(targetAbsolutePath); + } + + const entries = await fs.promises.readdir(sourceAbsolutePath); + + for (const entry of entries) { + const childSourcePath = path.join(sourceAbsolutePath, entry); + const childTargetPath = path.join(targetAbsolutePath, entry); + + await copyPathRecursive({ + sourceAbsolutePath: childSourcePath, + targetAbsolutePath: childTargetPath, + overwrite, + stats, + }); + } + + return; + } + + throw new Error("Only files and directories can be copied."); +} + +async function copyPath(args?: Record) { + const source = resolveSafeToolPath(args?.sourcePath); + const target = resolveSafeToolPath(args?.targetPath); + + assertNotRoot(source.relativePath); + assertNotRoot(target.relativePath); + + await assertNoSymlinkInPath(source.absolutePath); + + const sourceStat = await fs.promises.lstat(source.absolutePath); + + if (sourceStat.isSymbolicLink()) { + throw new Error("Symlink sources are not allowed."); + } + + const recursive = asBoolean(args?.recursive, false); + const overwrite = asBoolean(args?.overwrite, false); + const createParents = asBoolean(args?.createParents, true); + + if (sourceStat.isDirectory() && !recursive) { + throw new Error("Source is a directory. Set recursive=true to copy directories."); + } + + if (sourceStat.isDirectory()) { + assertTargetIsNotInsideSource(source.absolutePath, target.absolutePath); + } + + const targetParentPath = path.dirname(target.absolutePath); + + if (createParents) { + await assertNoSymlinkInPath(targetParentPath, { + allowMissingTail: true, + }); + + await fs.promises.mkdir(targetParentPath, { + recursive: true, + }); + + await assertNoSymlinkInPath(targetParentPath); + } else { + await assertNoSymlinkInPath(targetParentPath); + } + + const stats: CopyPathStats = { + entries: 0, + totalBytes: 0, + }; + + await copyPathRecursive({ + sourceAbsolutePath: source.absolutePath, + targetAbsolutePath: target.absolutePath, + overwrite, + stats, + }); + + return { + ok: true, + from: source.relativePath, + to: target.relativePath, + recursive, + overwrite, + entriesCopied: stats.entries, + bytesCopied: stats.totalBytes, + }; +} + +async function createDirectory(args?: Record) { + const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); + + const recursive = asBoolean(args?.recursive, true); + + await assertNoSymlinkInPath(absolutePath, { + allowMissingTail: true, + }); + + await fs.promises.mkdir(absolutePath, { + recursive, + }); + + return { + ok: true, + path: relativePath, + recursive, + }; +} + +async function updateFile(args?: Record) { + const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); + + assertNotRoot(relativePath); + + const content = asString(args?.content, ""); + const mode = (asNonEmptyString(args?.mode) ?? "replace").toLowerCase(); + const createIfMissing = asBoolean(args?.createIfMissing, false); + + if (!["replace", "append", "prepend"].includes(mode)) { + throw new Error(`Unsupported update mode: ${mode}`); + } + + const contentSizeBytes = Buffer.byteLength(content, "utf8"); + + if (contentSizeBytes > MAX_FILE_WRITE_BYTES) { + throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`); + } + + const parentPath = path.dirname(absolutePath); + + await assertNoSymlinkInPath(parentPath); + + const exists = await pathExists(absolutePath); + + if (!exists && !createIfMissing) { + throw new Error(`File does not exist: ${relativePath}`); + } + + if (exists) { + await assertNoSymlinkInPath(absolutePath); + + const stat = await fs.promises.lstat(absolutePath); + + if (!stat.isFile()) { + throw new Error(`Path is not a file: ${relativePath}`); + } + } + + if (mode === "replace") { + await fs.promises.writeFile(absolutePath, content, { + encoding: "utf8", + flag: "w", + }); + } else if (mode === "append") { + await fs.promises.appendFile(absolutePath, content, { + encoding: "utf8", + }); + } else { + const oldContent = exists + ? await fs.promises.readFile(absolutePath, "utf8") + : ""; + + const resultSizeBytes = Buffer.byteLength(content + oldContent, "utf8"); + + if (resultSizeBytes > MAX_FILE_WRITE_BYTES) { + throw new Error(`Result file content is too large: ${resultSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`); + } + + await fs.promises.writeFile(absolutePath, content + oldContent, { + encoding: "utf8", + flag: "w", + }); + } + + const newStat = await fs.promises.stat(absolutePath); + + return { + ok: true, + path: relativePath, + mode, + sizeBytes: newStat.size, + created: !exists, + }; +} + +async function renamePath(args?: Record) { + const source = resolveSafeToolPath(args?.sourcePath); + const target = resolveSafeToolPath(args?.targetPath); + + assertNotRoot(source.relativePath); + assertNotRoot(target.relativePath); + + await assertNoSymlinkInPath(source.absolutePath); + + const sourceStat = await fs.promises.lstat(source.absolutePath); + + if (sourceStat.isSymbolicLink()) { + throw new Error("Symlink targets are not allowed."); + } + + const relativeTargetInsideSource = path.relative(source.absolutePath, target.absolutePath); + + if ( + relativeTargetInsideSource === "" || + (!relativeTargetInsideSource.startsWith("..") && !path.isAbsolute(relativeTargetInsideSource)) + ) { + throw new Error("Cannot move a directory into itself."); + } + + const overwrite = asBoolean(args?.overwrite, false); + const createParents = asBoolean(args?.createParents, false); + + const targetParentPath = path.dirname(target.absolutePath); + + if (createParents) { + await assertNoSymlinkInPath(targetParentPath, {allowMissingTail: true}); + await fs.promises.mkdir(targetParentPath, {recursive: true}); + } else { + await assertNoSymlinkInPath(targetParentPath); + } + + if (await pathExists(target.absolutePath)) { + const targetStat = await fs.promises.lstat(target.absolutePath); + + if (targetStat.isSymbolicLink()) { + throw new Error("Symlink targets are not allowed."); + } + + if (!overwrite) { + throw new Error(`Target already exists: ${target.relativePath}`); + } + + if (sourceStat.isDirectory() || targetStat.isDirectory()) { + throw new Error("Overwrite for directories is not supported."); + } + + await fs.promises.rm(target.absolutePath, { + force: false, + }); + } + + await fs.promises.rename(source.absolutePath, target.absolutePath); + + return { + ok: true, + from: source.relativePath, + to: target.relativePath, + overwrite, + }; +} + +async function deletePath(args?: Record) { + const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); + + assertNotRoot(relativePath); + + await assertNoSymlinkInPath(absolutePath); + + const stat = await fs.promises.lstat(absolutePath); + + if (stat.isSymbolicLink()) { + throw new Error("Symlink targets are not allowed."); + } + + const recursive = asBoolean(args?.recursive, false); + + if (stat.isDirectory()) { + if (recursive) { + await fs.promises.rm(absolutePath, { + recursive: true, + force: false, + }); + } else { + await fs.promises.rmdir(absolutePath); + } + } else { + await fs.promises.rm(absolutePath, { + force: false, + }); + } + + return { + ok: true, + path: relativePath, + recursive, + deleted: true, + }; +} + +const getToolHandlers = () => { + let handlers: Record = { + evaluation: evaluation, + + get_current_datetime: getCurrentDateTime, + + read_file: readFile, + list_directory: listDirectory, + create_file: createFile, + create_directory: createDirectory, + update_file: updateFile, + rename_path: renamePath, + copy_path: copyPath, + delete_path: deletePath, + }; + + if (Environment.BRAVE_SEARCH_API_KEY) { + handlers = { + web_search: webSearch, + ...handlers + } + } + + if (Environment.OPEN_WEATHER_MAP_API_KEY) { + handlers = { + get_weather: getWeather, + ...handlers + } + } + + return handlers; +}; + +function stringifyToolResult(result: unknown): string { + if (typeof result === "string") return result; + return JSON.stringify(result, null, 2); +} + +async function executeToolCall( + name: string, + args?: unknown, +): Promise { + const handler = getToolHandlers()[name as keyof typeof getToolHandlers]; + + if (!handler) { + return stringifyToolResult({ + error: `Unknown tool: ${name}`, + }); + } + + try { + const result = await handler(normalizeToolArguments(args)); + return stringifyToolResult(result); + } catch (error) { + return stringifyToolResult({ + error: error instanceof Error ? error.message : String(error), + }); + } +} + +function addToolSystemPrompt(chatMessages: ChatMessage[]): void { + const systemMessage = chatMessages.find(message => message.role === "system"); + + if (systemMessage) { + systemMessage.content = `${systemMessage.content}\n\n${getToolSystemPrompt()}`; + return; + } + + chatMessages.unshift({ + role: "system", + content: getToolSystemPrompt(), + images: [], + }); +} + +function cleanupThinkingContent(content: string, isThinking: boolean): { + visibleContent: string; + isThinking: boolean; +} { + let result = content; + let nextThinking = isThinking; + + if (result.includes("")) { + nextThinking = true; + result = result.slice(result.indexOf("") + "".length); + } + + if (nextThinking) { + if (result.includes("")) { + result = result.slice(result.indexOf("") + "".length); + nextThinking = false; + } else { + result = ""; + } + } + + return { + visibleContent: result, + isThinking: nextThinking, + }; +} export class OllamaChat extends ChatCommand { command = ["ollamaThink", "ollama"]; @@ -28,7 +1761,7 @@ export class OllamaChat extends ChatCommand { async execute(msg: Message, match?: RegExpExecArray | null): Promise { console.log("match", match); - return this.executeOllama(msg, match?.[3] || "", match?.[1]?.toLowerCase()?.startsWith("ollamathink")); + return this.executeOllama(msg, match?.[3] || "", match?.[1]?.toLowerCase()?.startsWith("think")); } async executeOllama(msg: Message, text: string, think: boolean = false, voiceB64?: string | null): Promise { @@ -46,9 +1779,9 @@ export class OllamaChat extends ChatCommand { console.log("MESSAGE PARTS", messageParts); } - const chatMessages = !voiceB64 ? messageParts.map(part => { + const chatMessages: ChatMessage[] = !voiceB64 ? messageParts.map(part => { return { - role: part.bot ? "assistant" : "user", + role: part.bot ? "assistant" as const : "user" as const, content: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `"${part.name}":\n` : "") + part.content, images: part.images }; @@ -76,6 +1809,17 @@ export class OllamaChat extends ChatCommand { return total + (curr.images?.length ?? 0); }, 0); + const enabledTools = !voiceB64 && imagesCount === 0 ? + msg.from.id === Environment.CREATOR_ID ? getTools() + : [ + getCurrentDateTimeTool, + getWeatherTool, + ] : undefined; + + if (enabledTools?.length) { + addToolSystemPrompt(chatMessages); + } + if (!think && imagesCount) { try { const modelInfo = await commands.find(c => c instanceof OllamaGetModel)?.loadImageModelInfo(); @@ -111,16 +1855,19 @@ export class OllamaChat extends ChatCommand { } const uuid = crypto.randomUUID(); + const emptyMarkup = {inline_keyboard: []}; const cancelMarkup = {inline_keyboard: [[Cancel.withData(new OllamaCancel().data + " " + uuid).asButton()]]}; + const waitText = (!think && imagesCount && !voiceB64) ? + imagesCount > 1 ? Environment.analyzingPicturesText : Environment.analyzingPictureText + : voiceB64 ? Environment.transcribingAudioText : Environment.waitThinkText; + waitMessage = await replyToMessage({ message: msg, - text: (!think && imagesCount && !voiceB64) ? - imagesCount > 1 ? Environment.analyzingPicturesText : Environment.analyzingPictureText - : voiceB64 ? Environment.transcribingAudioText : Environment.waitThinkText + text: waitText }); - let options: Partial | null = null + let options: Partial | null = null; try { const optionsPath = path.join(Environment.DATA_PATH, "ollama_options.json"); @@ -131,32 +1878,39 @@ export class OllamaChat extends ChatCommand { logError(e); } - const stream = await ollama.chat({ - model: (think ? Environment.OLLAMA_THINK_MODEL : imagesCount ? Environment.OLLAMA_IMAGE_MODEL : Environment.OLLAMA_MODEL), - stream: true, - think: think, - messages: chatMessages, - options: >options - }); + const model = (think ? Environment.OLLAMA_THINK_MODEL : imagesCount ? Environment.OLLAMA_IMAGE_MODEL : Environment.OLLAMA_MODEL); - const newRequest = { - uuid: uuid, - stream: stream, - done: false, - fromId: msg.from.id, - chatId: msg.chat.id, - }; + async function createStream() { + const stream = await ollama.chat({ + model: model, + stream: true, + // TODO: 01/05/2026, Danil Nikolaev: проверять на наличие think + think: think, + messages: chatMessages, + options: options ?? undefined, + // TODO: 01/05/2026, Danil Nikolaev: проверять на наличие tools + tools: enabledTools, + }); - console.log("Pushing new request", newRequest); - ollamaRequests.push(newRequest); + const existingRequest = getOllamaRequest(uuid) as { stream?: unknown } | undefined; - await bot.editMessageReplyMarkup( - { - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: cancelMarkup + if (existingRequest) { + existingRequest.stream = stream; + } else { + const newRequest = { + uuid, + stream, + done: false, + fromId: msg.from!.id, + chatId: msg.chat.id, + }; + + console.log("Pushing new request", newRequest); + ollamaRequests.push(newRequest); } - ).catch(logError); + + return stream; + } let currentText = ""; let shouldBreak = false; @@ -167,23 +1921,22 @@ export class OllamaChat extends ChatCommand { getText: () => currentText, editFn: async (text) => { if (getOllamaRequest(uuid)?.done) return; + if (!waitMessage) return; try { await bot.editMessageText({ chat_id: chatId, - message_id: waitMessage?.message_id, - text: escapeMarkdownV2Text(text), + message_id: waitMessage.message_id, + text: escapeMarkdownV2Text((!text || !text.trim().length) ? waitText : text), parse_mode: "MarkdownV2", reply_markup: cancelMarkup }).catch(logError); console.log("editMessageText", text); - if (waitMessage) { - waitMessage.reply_to_message = msg; - waitMessage.text = text; - await MessageStore.put(waitMessage); - } + waitMessage.reply_to_message = msg; + waitMessage.text = text; + await MessageStore.put(waitMessage); } catch (e) { logError(e); } @@ -191,62 +1944,143 @@ export class OllamaChat extends ChatCommand { }); await editor.tick(); + // TODO: 01/05/2026, Danil Nikolaev: отображать thinking process + try { - let isThinking = false; + for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { + const stream = await createStream(); - for await (const chunk of stream) { - const content = chunk.message.content; - - if (content === "" || chunk.message.thinking) { - if (!isThinking) { - await bot.editMessageText({ + if (!waitMessage.reply_markup?.inline_keyboard?.length) { + await bot.editMessageReplyMarkup( + { chat_id: chatId, message_id: waitMessage.message_id, - text: "🤔 Размышляю...", - parse_mode: "MarkdownV2", reply_markup: cancelMarkup - }).catch(logError); + } + ).catch(logError); + waitMessage.reply_markup = cancelMarkup; + } + + let savedText = currentText; + let roundText = ""; + let roundToolCalls: ToolCall[] = []; + let isThinking = false; + + for await (const chunk of stream) { + const message = chunk.message; + const content = message.content ?? ""; + + console.log("CHUNK", chunk); + + if (message.thinking) { + if (!isThinking) { + await bot.editMessageText({ + chat_id: chatId, + message_id: waitMessage.message_id, + text: escapeMarkdownV2Text("🧠 Размышляю..."), + parse_mode: "MarkdownV2", + reply_markup: cancelMarkup + }).catch(logError); + + if (waitMessage) { + waitMessage.text = escapeMarkdownV2Text("🧠 Размышляю..."); + waitMessage.reply_markup = cancelMarkup; + await MessageStore.put(waitMessage); + } + } + + isThinking = true; } - isThinking = true; - } - - if (!isThinking) { - currentText += content; - } - - if (isThinking && !chunk.message.thinking) { - currentText += content; - } - - if (content === "" || !chunk.message.thinking) { - isThinking = false; - } - - if (currentText.length > 4096) { - currentText = currentText.slice(0, 4093) + "..."; - shouldBreak = true; - } - - if (getOllamaRequest(uuid)?.done) { - shouldBreak = true; - } - - if (shouldBreak || chunk.done) { - console.log("messageText", currentText); - console.log("length", currentText.length); - - if (shouldBreak) { - console.log("break", true); - } else { - console.log("ended", true); + if (!message.thinking && !content.includes("")) { + isThinking = false; } + if (content) { + const cleaned = cleanupThinkingContent(content, isThinking); + console.log("CLEANED", cleaned); + isThinking = cleaned.isThinking; + + if (cleaned.visibleContent) { + roundText += cleaned.visibleContent; + currentText = savedText + "\n\n" + roundText; + console.log("ROUND_TEXT", roundText); + } + } + + if (message.tool_calls && message.tool_calls.length > 0) { + roundToolCalls = message.tool_calls; + } + + if (currentText.length > TELEGRAM_MESSAGE_LIMIT) { + currentText = currentText.slice(0, TELEGRAM_MESSAGE_LIMIT - 3) + "..."; + shouldBreak = true; + } + + if (getOllamaRequest(uuid)?.done) { + shouldBreak = true; + } + + if (shouldBreak || chunk.done) { + console.log("messageText", currentText); + console.log("length", currentText.length); + + currentText = currentText.replaceAll(/^🔧 Использую инструмент.*$\r?\n?/gm, ""); + await editor.tick(); + + if (shouldBreak) { + console.log("break", true); + } else { + console.log("ended", true); + } + + waitMessage.reply_to_message = msg; + waitMessage.text = currentText; + await MessageStore.put(waitMessage); + break; + } + } + + if (shouldBreak) { + break; + } + + if (roundToolCalls.length === 0) { waitMessage.reply_to_message = msg; waitMessage.text = currentText; await MessageStore.put(waitMessage); break; } + + console.log("ROUND_TOOL_CALLS", roundToolCalls); + + chatMessages.push({ + role: "assistant", + content: roundText, + tool_calls: roundToolCalls + }); + + for (const toolCall of roundToolCalls) { + const toolName = toolCall.function.name; + const toolArgs = toolCall.function.arguments ?? {}; + + currentText += "\n🔧 Использую инструмент " + `\`${toolName}\`` + await editor.tick(); + + const result = await executeToolCall(toolName, toolArgs); + + console.log("TOOL CALL", "Name: ", toolName, "; Args: " + JSON.stringify(toolArgs), "; Result: " + result); + + chatMessages.push({ + role: "tool", + tool_name: toolName, + content: result + }); + } + + if (round === MAX_TOOL_ROUNDS - 1) { + throw new Error("Too many tool calls"); + } } } finally { const diff = Math.abs(Date.now() - startTime) / 1000; @@ -262,11 +2096,15 @@ export class OllamaChat extends ChatCommand { await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`}); } - await bot.editMessageReplyMarkup({ - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: {inline_keyboard: []} - }).catch(logError); + if ((waitMessage.reply_markup?.inline_keyboard?.length ?? 0) > 0) { + await bot.editMessageReplyMarkup({ + chat_id: chatId, + message_id: waitMessage.message_id, + reply_markup: emptyMarkup + }).catch(logError); + waitMessage.reply_markup = emptyMarkup; + } + console.log(`aborted request ${uuid}:`, abortOllamaRequest(uuid)); } } catch (e: any) { @@ -274,14 +2112,16 @@ export class OllamaChat extends ChatCommand { logError(e); if (waitMessage) { - await bot.editMessageReplyMarkup({ - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: {inline_keyboard: []} - }).catch(logError); + if ((waitMessage.reply_markup?.inline_keyboard?.length ?? 0) > 0) { + await bot.editMessageReplyMarkup({ + chat_id: chatId, + message_id: waitMessage.message_id, + reply_markup: {inline_keyboard: []} + }).catch(logError); + } await oldReplyToMessage(waitMessage, `Произошла ошибка!\n${e.toString()}`).catch(logError); } } } -} \ No newline at end of file +} diff --git a/src/common/environment.ts b/src/common/environment.ts index b3d8403..22fbc9b 100644 --- a/src/common/environment.ts +++ b/src/common/environment.ts @@ -28,12 +28,18 @@ export class Environment { static PROCESS_LINKS: boolean; - static DEFAULT_AI_PROVIDER: AiProvider; - static RATE_LIMIT_FALLBACK_POLICY: RateLimitFallbackPolicy; static IMAGE_HANDLE_POLICY: ImageHandlePolicy; static IMAGE_HANDLE_FALLBACK_POLICY: ImageHandleFallbackPolicy; + static BRAVE_SEARCH_API_KEY?: string; + static OPEN_WEATHER_MAP_API_KEY?: string; + + static FILE_TOOLS_ROOT_DIR?: string; + + // AI Stuff + static DEFAULT_AI_PROVIDER: AiProvider; + static SYSTEM_PROMPT?: string; static USE_NAMES_IN_PROMPT: boolean; static USE_SYSTEM_PROMPT: boolean; @@ -84,13 +90,6 @@ export class Environment { Environment.PROCESS_LINKS = ifTrue(process.env.PROCESS_LINKS); - const aiProvider = process.env.DEFAULT_AI_PROVIDER || "OLLAMA"; - if (Object.values(AiProvider).includes(aiProvider as AiProvider)) { - Environment.DEFAULT_AI_PROVIDER = aiProvider as AiProvider; - } else { - Environment.DEFAULT_AI_PROVIDER = AiProvider.OLLAMA; - } - const rateLimitFallbackPolicy = process.env.RATE_LIMIT_FALLBACK_POLICY || "NOTIFY_USER"; if (Object.values(RateLimitFallbackPolicy).includes(rateLimitFallbackPolicy as RateLimitFallbackPolicy)) { Environment.RATE_LIMIT_FALLBACK_POLICY = rateLimitFallbackPolicy as RateLimitFallbackPolicy; @@ -112,6 +111,18 @@ export class Environment { Environment.IMAGE_HANDLE_FALLBACK_POLICY = ImageHandleFallbackPolicy.NOTIFY_USER; } + Environment.BRAVE_SEARCH_API_KEY = process.env.BRAVE_SEARCH_API_KEY; + Environment.OPEN_WEATHER_MAP_API_KEY = process.env.OPEN_WEATHER_MAP_API_KEY; + + Environment.FILE_TOOLS_ROOT_DIR = process.env.FILE_TOOLS_ROOT_DIR; + + const aiProvider = process.env.DEFAULT_AI_PROVIDER || "OLLAMA"; + if (Object.values(AiProvider).includes(aiProvider as AiProvider)) { + Environment.DEFAULT_AI_PROVIDER = aiProvider as AiProvider; + } else { + Environment.DEFAULT_AI_PROVIDER = AiProvider.OLLAMA; + } + Environment.USE_NAMES_IN_PROMPT = ifTrue(process.env.USE_NAMES_IN_PROMPT); Environment.USE_SYSTEM_PROMPT = ifTrue(process.env.USE_SYSTEM_PROMPT || "true"); Environment.SEND_TIME_TOOK = ifTrue(process.env.SEND_TOOK_TIME || "false"); diff --git a/src/util/utils.ts b/src/util/utils.ts index ea67c92..99b793e 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -47,6 +47,7 @@ import {SendOptions} from "../model/send-options"; import {EditOptions} from "../model/edit-options"; import {StoredUser} from "../model/stored-user"; import {performFFmpeg} from "./ffmpeg"; +import {exec} from "node:child_process"; export const ignore = () => { }; @@ -2171,4 +2172,70 @@ export async function processInlineQuery(query: InlineQuery): Promise { export async function processCallbackQuery(query: CallbackQuery): Promise { console.log("CallbackQuery", query); await findAndExecuteCallbackCommand(callbackCommands, query); +} + +export async function runCommand(cmd: string): + Promise<{ + stdout: string | null | undefined; + stderr: string | null | undefined + }> { + + if (cmd.length > 500) { + throw new Error("Command is too long"); + } + + const forbiddenPatterns = [ + /\bsudo\b/, + /\bsu\b/, + /\brm\b/, + /\brmdir\b/, + /\bchmod\b/, + /\bchown\b/, + /\bdd\b/, + /\bmkfs\b/, + /\bmount\b/, + /\bumount\b/, + /\breboot\b/, + /\bshutdown\b/, + /\bkill\b/, + /\bcurl\b/, + /\bwget\b/, + /\bssh\b/, + /\bscp\b/, + /\brsync\b/, + /\bnc\b/, + /\bnmap\b/, + /\.\./, + /\/etc\/?/, + /\/home\/?/, + /\/root\/?/, + /~\//, + /\.ssh/, + /\.env/, + ]; + + for (const pattern of forbiddenPatterns) { + if (pattern.test(cmd)) { + throw new Error(`Forbidden shell command pattern: ${pattern}`); + } + } + + + try { + const {stdout, stderr} = exec(cmd); + if (stdout) { + console.log("COMMAND: ", cmd, "\n", 'Output:', stdout); + } + + if (stderr) { + console.error("COMMAND: ", cmd, "\n", 'Error:', stderr); + } + + return {stdout: (await stdout?.toArray())?.join(""), stderr: (await stderr?.toArray())?.join("")} + } catch (error: any) { + console.error('Error code:', error.code); + console.error('Stderr:', error.stderr); + + return {stdout: null, stderr: error.stderr}; + } } \ No newline at end of file