From 2fc60806ff9af07b70b248d8dc2d3af1f679b2a7 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Sun, 3 May 2026 15:16:14 +0300 Subject: [PATCH] feat(ollama): add tool calling support Add Ollama tool integration for web search, weather, datetime, filesystem operations, and shell evaluation. Implement multi-round tool call handling, streaming updates, thinking cleanup, and safer path validation for file tools. Also add related environment configuration, command execution helper, MarkdownV2 cancel handling fixes, and remove unused TryAgain callback command. --- src/callback_commands/cancel.ts | 2 +- src/callback_commands/ollama-cancel.ts | 6 +- src/callback_commands/try-again.ts | 21 - src/commands/ae.ts | 30 +- src/commands/ollama-chat.ts | 2020 ++++++++++++++++++++++-- src/common/environment.ts | 29 +- src/util/utils.ts | 67 + 7 files changed, 2046 insertions(+), 129 deletions(-) delete mode 100644 src/callback_commands/try-again.ts 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