diff --git a/.env.example b/.env.example index 2032fd8..c2501c4 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,18 @@ BOT_TOKEN=your_bot_token_here # To get your ID: send /id command to the bot and use the "from id" value CREATOR_ID=your_user_id_here +# Database connection +# Leave empty for local SQLite in ~/.local/share/tg-chat-bot/database.db. +# Set DATA_PATH=data if you want to keep files inside the repo. +# Set to postgres://... for PostgreSQL. +# Set to :memory: for ephemeral in-memory SQLite. +DATABASE_URL= +DATA_PATH= + +# Docker Compose image tag override +# Used by docker-compose.yml when pulling ghcr.io/melod1n/tg-chat-bot +IMAGE_TAG=1.0.0 + # ============================================ # BOT SETTINGS (Optional) # ============================================ @@ -31,25 +43,80 @@ ONLY_FOR_CREATOR_MODE=false # Use user names in AI prompts USE_NAMES_IN_PROMPT=true +# Custom system prompt for AI (or put it into data/SYSTEM_PROMPT.md) +SYSTEM_PROMPT= + +# Tool ranker fallback policy: +# MAIN_MODEL - rank tools through the provider's chat model if a dedicated ranker target is missing or fails +# ALL_TOOLS - skip ranker fallback and allow all tools +# NO_TOOLS - skip ranker fallback and allow no tools +TOOL_RANKER_FALLBACK_POLICY=ALL_TOOLS + # Maximum photo size in pixels MAX_PHOTO_SIZE=1280 +# Directory with localization JSON files +LOCALES_DIR=locales + # ============================================ # AI MODELS CONFIGURATION (Optional) # ============================================ -# Google Gemini -GEMINI_API_KEY= -GEMINI_MODEL=gemini-2.5-flash - # Mistral AI MISTRAL_API_KEY= MISTRAL_MODEL=mistral-small-latest +MISTRAL_TRANSCRIPTION_MODEL=voxtral-mini-latest +MISTRAL_TTS_MODEL= +MISTRAL_TTS_VOICE_ID= +MISTRAL_MAX_CONCURRENT_REQUESTS=3 # Ollama (Local AI Model) OLLAMA_ADDRESS= -OLLAMA_MODEL= +OLLAMA_CHAT_MODEL= +OLLAMA_IMAGE_MODEL= +OLLAMA_THINK_MODEL= +OLLAMA_AUDIO_MODEL=gemma4:e2b OLLAMA_API_KEY= +OLLAMA_EMBEDDING_MODEL=nomic-embed-text +OLLAMA_RAG_CHUNK_SIZE=1400 +OLLAMA_RAG_CHUNK_OVERLAP=220 +OLLAMA_RAG_TOP_K=8 +OLLAMA_RAG_MAX_CONTEXT_CHARS=14000 +OLLAMA_RAG_MIN_SCORE=0.12 +OLLAMA_RAG_MAX_ARCHIVE_FILES=200 +OLLAMA_RAG_MAX_ARCHIVE_BYTES=52428800 +OLLAMA_RAG_MAX_ARCHIVE_DEPTH=2 +OLLAMA_MAX_CONCURRENT_REQUESTS=1 -# Custom system prompt for AI -SYSTEM_PROMPT= \ No newline at end of file +# OpenAI +OPENAI_API_KEY= +OPENAI_BASE_URL= +OPENAI_MODEL=gpt-4.1-nano +OPENAI_IMAGE_MODEL=gpt-image-1-mini +OPENAI_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe +OPENAI_TTS_MODEL=gpt-4o-mini-tts +OPENAI_TTS_VOICE=alloy +OPENAI_TTS_INSTRUCTIONS= +OPENAI_MAX_CONCURRENT_REQUESTS=3 + +# Per-capability AI endpoint overrides +# Pattern: +# __MODEL= +# __BASE_URL= +# __API_KEY= +# +# Providers: OLLAMA, MISTRAL, OPENAI +# Capabilities: CHAT, VISION, OCR, THINKING, EXTENDED_THINKING, TOOLS, AUDIO, +# DOCUMENTS, OUTPUT_IMAGES, SPEECH_TO_TEXT, TEXT_TO_SPEECH +# Endpoint aliases are also supported: *_ENDPOINT and *_ADDRESS. +# Provider-wide fallback endpoints: OPENAI_BASE_URL, MISTRAL_BASE_URL, +# OLLAMA_ADDRESS or OLLAMA_BASE_URL. +# Capability aliases are also supported: IMAGE, THINK, RAG, EMBEDDING, +# TRANSCRIPTION, STT, TTS. +# +# Examples: +# OPENAI_SPEECH_TO_TEXT_MODEL=gpt-4o-mini-transcribe +# OPENAI_SPEECH_TO_TEXT_BASE_URL=https://api.openai.com/v1 +# OPENAI_SPEECH_TO_TEXT_API_KEY= +# MISTRAL_TTS_BASE_URL= +# OLLAMA_DOCUMENTS_ADDRESS=http://localhost:11434 diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 18adabe..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "env": { - "browser": true, - "es2021": true - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended" - ], - "overrides": [ - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module" - }, - "plugins": [ - "@typescript-eslint" - ], - "rules": { - "quotes": [ - "error", - "double" - ], - "semi": [ - "error", - "always" - ] - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7583413 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + pull_request: + +jobs: + node: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.19.0 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + bun: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Lint + run: bun run lint + + - name: Typecheck + run: bun run typecheck + + - name: Build + run: bun run build diff --git a/Dockerfile b/Dockerfile index 0f8a9bb..6df78d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,5 +25,6 @@ USER node COPY --from=builder --chown=node:node /app/dist ./dist COPY --from=builder --chown=node:node /app/assets ./assets +COPY --chown=node:node locales ./locales -CMD ["node", "dist/index.js"] \ No newline at end of file +CMD ["node", "dist/index.js"] diff --git a/Dockerfile-bun b/Dockerfile-bun index 842d91c..5ad7080 100644 --- a/Dockerfile-bun +++ b/Dockerfile-bun @@ -23,5 +23,6 @@ RUN bun install --frozen-lockfile --production COPY --from=builder /app/dist ./dist COPY --from=builder /app/assets ./assets +COPY locales ./locales -CMD [ "bun", "dist/index.js" ] \ No newline at end of file +CMD [ "bun", "dist/index.js" ] diff --git a/PIPELINE_TODO.md b/PIPELINE_TODO.md new file mode 100644 index 0000000..bad49ce --- /dev/null +++ b/PIPELINE_TODO.md @@ -0,0 +1,155 @@ +# User Request Pipeline TODO + +Этот чеклист описывает оставшиеся задачи по доведению pipeline до чистой архитектуры. Текущее состояние уже рабочее: есть `UserRequestPipeline`, stage audit, `ai_requests`, internal artifacts, unified size gate, RAG/STT/final/error/tool-result artifacts и response pipeline. Ниже перечислены задачи, которые ещё нужно сделать, чтобы убрать оставшиеся архитектурные компромиссы. + +## 1. Нормализовать хранение attachments, artifacts и audit + +- [x] Создать отдельную таблицу `attachments`. +- [x] Поля `attachments`: `id`, `messageChatId`, `messageId`, `direction`, `scope`, `kind`, `artifactKind`, `fileId`, `fileUniqueId`, `fileName`, `mimeType`, `cachePath`, `sizeBytes`, `sha256`, `metadata`, `createdAt`. +- [x] Создать отдельную таблицу `artifacts`. +- [x] Поля `artifacts`: `id`, `requestId`, `messageChatId`, `messageId`, `kind`, `stage`, `attachmentId`, `payload`, `createdAt`. +- [x] Создать отдельную таблицу `request_audit`. +- [x] Поля `request_audit`: `id`, `requestId`, `messageChatId`, `messageId`, `stage`, `status`, `startedAt`, `finishedAt`, `durationMs`, `provider`, `model`, `details`, `error`. +- [x] Оставить обратную совместимость с текущими JSON-полями `messages.attachments` и `messages.pipelineAudit`. +- [x] Добавить миграцию: переносить существующие `messages.attachments` в новую таблицу `attachments`. +- [x] Добавить миграцию: переносить существующие `messages.pipelineAudit` в новую таблицу `request_audit`. +- [x] Обновить backup/export/import, чтобы новые таблицы попадали в JSON и SQL dump. +- [x] Добавить DAO/store слой: `AttachmentStore`, `ArtifactStore`, `RequestAuditStore`. +- [x] Перевести новые записи на нормализованные таблицы. +- [x] Оставить чтение legacy JSON только как fallback. + +## 2. Сделать единый ArtifactStore API + +- [x] Ввести `ArtifactStore.put(...)`. +- [x] Ввести `ArtifactStore.getByRequestId(requestId)`. +- [x] Ввести `ArtifactStore.getByMessage(chatId, messageId)`. +- [x] Ввести `ArtifactStore.getLatestRagForReplyChain(chatId, messageId)`. +- [x] Ввести `ArtifactStore.getTranscriptForMessage(chatId, messageId)`. +- [x] Перевести `rag-artifact-store.ts` на `ArtifactStore`. +- [x] Перевести `transcript-artifact-store.ts` на `ArtifactStore`. +- [x] Перевести `final-response-artifact-store.ts` на `ArtifactStore`. +- [x] Перевести `tool-result-artifact-store.ts` на `ArtifactStore`. +- [x] Оставить физические JSON-файлы как storage backend для payload, но регистрировать их в БД. +- [x] Добавить единый size gate для artifact payload до записи файла. +- [x] Добавить cleanup policy для временных/устаревших artifact файлов. + +## 3. Расширить RAG artifact content + +- [x] Расширить общий тип `RagArtifact`. +- [x] Для Ollama сохранять extracted documents. +- [x] Для Ollama сохранять selected chunks. +- [x] Для Ollama сохранять chunk scores. +- [x] Для Ollama сохранять skipped documents и причины пропуска. +- [x] Для Ollama сохранять embedding model, `topK`, `chunkSize`, `chunkOverlap`, `maxContextChars`. +- [x] Для OpenAI сохранять `vectorStoreIds`. +- [x] Для OpenAI сохранять source file mapping: local attachment -> uploaded/vector store file. +- [x] Для Mistral сохранять `libraryId`. +- [x] Для Mistral сохранять uploaded document ids. +- [x] Для Mistral сохранять source file mapping: local attachment -> Mistral document id. +- [ ] Добавить единый `providerState` schema для всех providers. +- [ ] Добавить tests на сериализацию `RagArtifact`. +- [ ] Добавить tests на то, что internal RAG artifacts не попадают обратно в user document context. + +## 4. Вынести provider runners в adapter layer + +- [ ] Ввести интерфейс `AiProviderAdapter`. +- [ ] Методы adapter-а: `mapMessages`, `rankTools`, `callModel`, `extractTextDelta`, `extractToolCalls`, `appendToolResults`, `finalize`. +- [ ] Реализовать `OpenAiProviderAdapter`. +- [ ] Реализовать `MistralProviderAdapter`. +- [ ] Реализовать `OllamaProviderAdapter`. +- [ ] Перенести provider-specific tool schema mapping внутрь adapter-ов. +- [ ] Перенести provider-specific streaming parsing внутрь adapter-ов. +- [ ] Перенести provider-specific tool result append внутрь adapter-ов. +- [ ] Упростить `runOpenAi`, `runMistral`, `runOllama` или заменить их adapter-driven runner-ом. +- [ ] Оставить compatibility wrappers для текущих imports. +- [ ] Добавить tests на adapter contract без реальных API. + +## 5. Сделать tool-ranker полноценным pipeline stage + +- [ ] Вынести вызов `ToolRanker.selectTools(...)` из provider runners. +- [ ] Добавить stage `tool_rank`, который работает через provider adapter. +- [ ] Добавить stage `filter_tools`, который фильтрует provider-specific tools по результату ranker. +- [ ] Хранить `ToolRankDecision` в `UserRequestPipelineState.toolRankDecisions`. +- [ ] Сохранять `ToolRankDecision` в `request_audit.details`. +- [ ] Убрать дублирующий ручной `tool-rank-audit.ts`, если stage полностью заменит его. +- [ ] Сохранить status UX: `🧩 Выбираю подходящие инструменты...`. +- [ ] Гарантировать `clearStatus()` после ranker success/failure. +- [ ] Добавить fallback через `PipelineFallbackExecutor`: main model, all tools, no tools. +- [ ] Добавить tests на fallback ranker policy. + +## 6. Сделать model_call и tool_loop физически отдельными stages + +- [ ] Stage `model_call` должен делать только один model request. +- [ ] Stage `model_call` должен возвращать normalized model output. +- [ ] Stage `tool_loop` должен решать, есть ли tool calls. +- [ ] Stage `tool_loop` должен выполнять tools через общий `executeToolBatch`. +- [ ] Stage `tool_loop` должен добавлять tool results в provider adapter. +- [ ] Stage `tool_loop` должен управлять max rounds. +- [ ] Stage `tool_loop` должен сохранять tool result artifacts. +- [ ] Stage `tool_loop` должен уметь завершаться без tools как `skipped`. +- [ ] Убрать tool loop из `runOpenAi`. +- [ ] Убрать tool loop из `runMistral`. +- [ ] Убрать tool loop из `runOllama`. +- [ ] Добавить tests на multi-round fake adapter. + +## 7. Довести fallback notifications до централизованного UX + +- [ ] Добавить `PipelineFallbackNotifier`. +- [ ] Для `notify_user` отправлять пользователю понятное сообщение. +- [ ] Для `continue_without_stage` писать короткий debug/audit без user notification. +- [ ] Для `use_alternate_target` логировать исходный и alternate target. +- [ ] Для `fail_request` завершать request через единый error path. +- [ ] Добавить локализацию fallback messages. +- [ ] Добавить отдельные тексты для RAG failure, STT failure, TTS failure, tool failure. +- [ ] Не спамить пользователя несколькими fallback notifications за один request. +- [ ] Сохранять fallback notification в `request_audit.details`. + +## 8. Улучшить поведение reply-chain с документами + +- [ ] Явно описать стратегию merge: current user attachments + reply-chain user attachments. +- [ ] Исключать `scope: internal_artifact` всегда. +- [ ] Исключать `scope: bot_output`, если это не user-provided file. +- [ ] Если пользователь отвечает новым документом на ответ бота с предыдущим документом, использовать оба документа. +- [ ] Если пользователь отвечает текстом на ответ бота, использовать документы из reply-chain. +- [ ] Если пользователь явно говорит "этот файл", приоритет отдавать новому вложению. +- [ ] Если несколько документов, добавлять их имена в prompt/RAG context. +- [ ] Добавить tests на follow-up с новым документом. +- [ ] Добавить tests на follow-up без нового документа. +- [ ] Добавить tests на то, что RAG internal JSON не становится пользовательским документом. + +## 9. Интеграционные tests без реальных Telegram/AI API + +- [ ] Создать fake `TelegramStreamMessage`. +- [ ] Создать fake provider adapter. +- [ ] Создать fake message store или in-memory DB fixture. +- [ ] Test: oversized input attachment rejected before download. +- [ ] Test: document input creates RAG artifact. +- [ ] Test: voice input creates transcript artifact. +- [ ] Test: final answer creates final_text artifact. +- [ ] Test: thrown error creates error artifact. +- [ ] Test: tool call creates tool_result artifact. +- [ ] Test: generated file creates generated_file artifact. +- [ ] Test: TTS requested creates tts_audio artifact. +- [ ] Test: fallback `continue_without_stage` continues request. +- [ ] Test: fallback `fail_request` stops request. + +## 10. Operational cleanup and observability + +- [ ] Add retention policy for `data/cache/internal-artifacts`. +- [ ] Add retention policy for stale RAG vector/library provider state. +- [ ] Add command or admin view for recent `ai_requests`. +- [ ] Add command or admin view for request audit by message id. +- [ ] Add command to inspect artifacts for a message. +- [ ] Add log correlation by `requestId` across AI logs, tool logs and DB audit. +- [ ] Add metrics counters: requests, failures, fallbacks, tool calls, RAG runs, TTS runs. +- [ ] Add startup migration logs for `ai_requests`, `attachments`, `artifacts`, `request_audit`. + +## Suggested order + +- [x] 1. Normalize DB tables: `attachments`, `artifacts`, `request_audit`. +- [ ] 2. Build `ArtifactStore` and migrate current artifact helpers to it. +- [ ] 3. Add fake integration tests for reply-chain documents and artifacts. +- [ ] 4. Introduce provider adapter interface. +- [ ] 5. Move `tool_rank` into pipeline stage. +- [ ] 6. Split `model_call` and `tool_loop` physically. +- [ ] 7. Add centralized fallback user notifications. diff --git a/README.md b/README.md index 14d6dd6..36c4f10 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,52 @@ # Telegram Chat Bot -Bot for Telegram with a lot of commands and AI (Ollama/Gemini/Mistral) written in TypeScript + NodeJS/Bun runtime + Drizzle ORM (SQLite DB) +Bot for Telegram with a lot of commands and AI (Ollama/Mistral/OpenAI) written in TypeScript + NodeJS/Bun runtime + SQLite/PostgreSQL/in-memory storage ## Quick Start ```bash cp .env.example .env -# Edit .env: add BOT_TOKEN, CREATOR_ID and configure optional AI models (GEMINI_API_KEY, MISTRAL_API_KEY, OLLAMA_ADDRESS) +# Edit .env: add BOT_TOKEN, CREATOR_ID and configure optional AI models (MISTRAL_API_KEY, OPENAI_API_KEY, OLLAMA_ADDRESS) +# Optional: set DATABASE_URL to postgres://... for PostgreSQL or :memory: for ephemeral SQLite. +# Optional: set DATA_PATH if you want to override the default local storage directory. ``` **With Bun (Recommended):** ```bash bun install -bunx drizzle-kit generate && bunx drizzle-kit migrate bun run build && bun start ``` **With Node.js:** ```bash npm install -npx drizzle-kit generate && npx drizzle-kit migrate npm run build && npm start ``` +The bot initializes and migrates its database schema automatically on startup. +`/exportdb` sends the SQLite file when available, plus a `.sql` dump and a JSON backup. +`/importdb` restores the database from the JSON backup format. + +For local Ollama document RAG, install an embedding model locally and set it in `.env`: + +```bash +ollama pull nomic-embed-text +OLLAMA_EMBEDDING_MODEL=nomic-embed-text +``` + +Tool ranker fallback is configurable via `TOOL_RANKER_FALLBACK_POLICY`: + +- `MAIN_MODEL` - use the provider's main chat model to rank tools if a dedicated ranker target is missing or fails +- `ALL_TOOLS` - skip tool ranking fallback and allow all tools +- `NO_TOOLS` - skip tool ranking fallback and allow no tools + +The default is `ALL_TOOLS`. + **With Docker Compose:** ```bash docker compose up -d ``` +Set `IMAGE_TAG` in `.env` if you want to override the pinned release tag used by `docker-compose.yml`. **With Docker:** ```bash @@ -42,13 +62,14 @@ docker run -d --env-file .env -v $(pwd)/data:/config/data tg-bot-bun ## Requirements -- Node.js >= 18 OR Bun >= 1.0 +- Node.js >= 20.19 OR Bun >= 1.0 - Docker (optional) ## Features -- AI chat (Gemini, Mistral, Ollama) +- AI chat (Mistral, Ollama, OpenAI) +- Local document RAG for Ollama without third-party providers - Custom answers and commands - Admin management - User blocking (mute/unmute) diff --git a/bun.lock b/bun.lock index d8bef72..be37d64 100644 --- a/bun.lock +++ b/bun.lock @@ -5,139 +5,40 @@ "": { "name": "tg-chat-bot", "dependencies": { - "@google/genai": "^1.50.1", "@libsql/client": "^0.17.3", - "@mistralai/mistralai": "^1.15.1", - "@napi-rs/canvas": "^0.1.100", - "axios": "^1.15.2", + "@mistralai/mistralai": "^2.2.1", + "@napi-rs/canvas": "^1.0.0", + "axios": "^1.16.1", "dotenv": "^17.4.2", - "drizzle-orm": "1.0.0-beta.21", + "drizzle-orm": "0.45.2", "emoji-regex": "^10.6.0", "fluent-ffmpeg": "^2.1.3", "ollama": "^0.6.3", - "openai": "^6.35.0", - "puppeteer": "^24.42.0", - "puppeteer-extra": "^3.3.6", - "puppeteer-extra-plugin-stealth": "^2.11.2", + "openai": "^6.37.0", + "pg": "^8.20.0", "qrcode": "^1.5.4", "sharp": "^0.34.5", - "systeminformation": "^5.31.5", + "systeminformation": "^5.31.6", "twemoji": "^14.0.2", - "typescript-telegram-bot-api": "^0.11.0", - "youtubei.js": "^16.0.1", - "zod": "^4.3.6", + "typescript-telegram-bot-api": "^0.16.0", + "zod": "^4.4.3", }, "devDependencies": { - "@types/bun": "^1.3.13", + "@eslint/js": "^9.39.4", + "@types/bun": "^1.3.14", "@types/fluent-ffmpeg": "^2.1.28", - "@types/node": "^25.6.0", + "@types/node": "^25.8.0", + "@types/pg": "^8.20.0", "@types/qrcode": "^1.5.6", - "@typescript-eslint/eslint-plugin": "^8.59.1", - "@typescript-eslint/parser": "^8.59.1", - "drizzle-kit": "^1.0.0-beta.9-e89174b", "eslint": "^9.39.4", - "tsx": "^4.21.0", - "typescript": "^5.9.3", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.3", }, }, }, "packages": { - "@azure-rest/core-client": ["@azure-rest/core-client@2.5.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A=="], - - "@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="], - - "@azure/core-auth": ["@azure/core-auth@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg=="], - - "@azure/core-client": ["@azure/core-client@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w=="], - - "@azure/core-http-compat": ["@azure/core-http-compat@2.3.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-client": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0" } }, "sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g=="], - - "@azure/core-lro": ["@azure/core-lro@2.7.2", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-util": "^1.2.0", "@azure/logger": "^1.0.0", "tslib": "^2.6.2" } }, "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw=="], - - "@azure/core-paging": ["@azure/core-paging@1.6.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA=="], - - "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.22.2", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg=="], - - "@azure/core-tracing": ["@azure/core-tracing@1.3.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ=="], - - "@azure/core-util": ["@azure/core-util@1.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A=="], - - "@azure/identity": ["@azure/identity@4.13.0", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^4.2.0", "@azure/msal-node": "^3.5.0", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw=="], - - "@azure/keyvault-common": ["@azure/keyvault-common@2.0.0", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.5.0", "@azure/core-rest-pipeline": "^1.8.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.10.0", "@azure/logger": "^1.1.4", "tslib": "^2.2.0" } }, "sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w=="], - - "@azure/keyvault-keys": ["@azure/keyvault-keys@4.10.0", "", { "dependencies": { "@azure-rest/core-client": "^2.3.3", "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-http-compat": "^2.2.0", "@azure/core-lro": "^2.7.2", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.0", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/keyvault-common": "^2.0.0", "@azure/logger": "^1.1.4", "tslib": "^2.8.1" } }, "sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag=="], - - "@azure/logger": ["@azure/logger@1.3.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="], - - "@azure/msal-browser": ["@azure/msal-browser@4.27.0", "", { "dependencies": { "@azure/msal-common": "15.13.3" } }, "sha512-bZ8Pta6YAbdd0o0PEaL1/geBsPrLEnyY/RDWqvF1PP9RUH8EMLvUMGoZFYS6jSlUan6KZ9IMTLCnwpWWpQRK/w=="], - - "@azure/msal-common": ["@azure/msal-common@15.13.3", "", {}, "sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ=="], - - "@azure/msal-node": ["@azure/msal-node@3.8.4", "", { "dependencies": { "@azure/msal-common": "15.13.3", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-lvuAwsDpPDE/jSuVQOBMpLbXuVuLsPNRwWCyK3/6bPlBk0fGWegqoZ0qjZclMWyQ2JNvIY3vHY7hoFmFmFQcOw=="], - - "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@bufbuild/protobuf": ["@bufbuild/protobuf@2.11.0", "", {}, "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ=="], - - "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], - "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -156,11 +57,11 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], - "@google/genai": ["@google/genai@1.50.1", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], - "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], - "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], @@ -216,12 +117,6 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], - - "@js-joda/core": ["@js-joda/core@5.6.5", "", {}, "sha512-3zwefSMwHpu8iVUW8YYz227sIv6UFqO31p1Bf1ZH/Vom7CmNyUsXjDBlnNzcuhmOL1XfxZ3nvND42kR23XlbcQ=="], - - "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], - "@libsql/client": ["@libsql/client@0.17.3", "", { "dependencies": { "@libsql/core": "^0.17.3", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA=="], "@libsql/core": ["@libsql/core@0.17.3", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg=="], @@ -248,119 +143,77 @@ "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.29", "", { "os": "win32", "cpu": "x64" }, "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg=="], - "@mistralai/mistralai": ["@mistralai/mistralai@1.15.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.1" } }, "sha512-fb995eiz3r0KsBGtRjFV+/iLbX+UpfalxpF+YitT3R6ukrPD4PN+FGwwmYcRFhNAzVzDUtTVxQYnjQWEnwV5nw=="], + "@mistralai/mistralai": ["@mistralai/mistralai@2.2.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" } }, "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ=="], - "@napi-rs/canvas": ["@napi-rs/canvas@0.1.100", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.100", "@napi-rs/canvas-darwin-arm64": "0.1.100", "@napi-rs/canvas-darwin-x64": "0.1.100", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", "@napi-rs/canvas-linux-arm64-musl": "0.1.100", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-musl": "0.1.100", "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA=="], + "@napi-rs/canvas": ["@napi-rs/canvas@1.0.0", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.0", "@napi-rs/canvas-darwin-arm64": "1.0.0", "@napi-rs/canvas-darwin-x64": "1.0.0", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.0", "@napi-rs/canvas-linux-arm64-gnu": "1.0.0", "@napi-rs/canvas-linux-arm64-musl": "1.0.0", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.0", "@napi-rs/canvas-linux-x64-gnu": "1.0.0", "@napi-rs/canvas-linux-x64-musl": "1.0.0", "@napi-rs/canvas-win32-arm64-msvc": "1.0.0", "@napi-rs/canvas-win32-x64-msvc": "1.0.0" } }, "sha512-Jqxcy1XOIqj+lH9sl1GT+il6GR3uQv13vI2mrwubP3uT8Olak2ClDrK2RnxlQKjwv8BRr4b3ug0YR7c6hBX8wg=="], - "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.100", "", { "os": "android", "cpu": "arm64" }, "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ=="], + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.0", "", { "os": "android", "cpu": "arm64" }, "sha512-3hNKJObUK7JsCF9aJlVCs1J0/KE/gGfZNeK8MO1ge6bB3aicr5walGme9t9No1f/oyk9GgvdAT/rjSdsx3gbIw=="], - "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.100", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A=="], + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZIja19/BiGz2puhki+WUYSRriwFeFJ8Mi9eK3hZdSS85w4Y60cuEAJVhMCfKwswQkKkUtrnzdKMBuO7TupvexA=="], - "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.100", "", { "os": "darwin", "cpu": "x64" }, "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw=="], + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hImggWc82jqZVpEsFR9S7PE9OQYjq/H/D7vwCGB6X1jRH+UVBP1+1niJTPBOat1B154T6GKK7/kcFtoWgjgFzQ=="], - "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.100", "", { "os": "linux", "cpu": "arm" }, "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA=="], + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hlJRy6d+kWLKVOG/+1rEvNQVURZ0DxxRPJsLmEWwhwiXZUJc0BF5o9esALHSEP4CoJK4wChRtj3hnyBgVx2oWA=="], - "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw=="], + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Hru4T3RXkosRQafcjelv7AUzw9mXqmGYsxnzeDDOWveFCJyEPMSJltvGCM+jfH98seOCbfwm9KyFg6Jm5FhAA=="], - "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ=="], + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-LTUl9jS8WsLSUGaxQZKQkxfluOJRpgvBuxxdM4pYcjib+di8AU4OzQc6+L6SzGMLcKc9H0RAjojRatBhTMqYdg=="], - "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.100", "", { "os": "linux", "cpu": "none" }, "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw=="], + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.0", "", { "os": "linux", "cpu": "none" }, "sha512-Iz931SAZf+WVDzpjk52Q3ffW3zw0YflFwEZMgs036Wfu1kX/LrwT9wGjsuSqyduqefUkl91/vTdAjn8hQu5ezA=="], - "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg=="], + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pFEQ5eFK4JusgN1K6KkO9DKP/Hi1WMJOkF8Ch03/khTc4bFbCKkCCsJG4YcOMOW9bI4XbT2/eMAWxhO0xaWgPA=="], - "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA=="], + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jnvr8NrLHiZ3NCiOKWqDbkI4Ah+QDrqtZ+sddPZBltEb1mQ2coSvCSJYfict+oAwcm0c970oTmVySpjKP/lnaA=="], - "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.100", "", { "os": "win32", "cpu": "arm64" }, "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw=="], + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-y2j9/Gfd5joqiqxdP/L1smqjQ+uAx3C4N0EC7bDHrnZEEH8ToM/OC5p3uHvtj4Lq591aHj+ArL01UDLNwT5HgQ=="], - "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="], + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.0", "", { "os": "win32", "cpu": "x64" }, "sha512-qwdhh9N6Gge/hC4pL9S1tQp0iKwhSl/dYjg7+RGp9k26iRGRi5MqqUyKGOXIWli0zOcuy5Y2wIH/jk2ry6i/jA=="], "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], - "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], - - "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], - - "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], - - "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], - - "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], - - "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], - - "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], - - "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], - - "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], - - "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], - - "@puppeteer/browsers": ["@puppeteer/browsers@2.13.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA=="], - - "@tediousjs/connection-string": ["@tediousjs/connection-string@0.5.0", "", {}, "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ=="], - - "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], - - "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], - - "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/fluent-ffmpeg": ["@types/fluent-ffmpeg@2.1.28", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], - "@types/mssql": ["@types/mssql@9.1.8", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-mt9h5jWj+DYE5jxnKaWSV/GqDf9FV52XYVk6T3XZF69noEe+JJV6MKirii48l81+cjmAkSq+qeKX+k61fHkYrQ=="], - - "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], - "@types/readable-stream": ["@types/readable-stream@4.0.23", "", { "dependencies": { "@types/node": "*" } }, "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig=="], - - "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], - "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/type-utils": "8.59.1", "@typescript-eslint/utils": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.3", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.3", "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.1", "@typescript-eslint/types": "^8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1" } }, "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.3", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.1", "", {}, "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.3", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.3", "@typescript-eslint/tsconfig-utils": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.1", "@typescript-eslint/tsconfig-utils": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg=="], - - "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.2", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg=="], - - "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], - - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -368,51 +221,17 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "arr-union": ["arr-union@3.1.0", "", {}, "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q=="], - - "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], - "async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha1-x57Zf380y48robyXkLzDZkdLS3k="], - "axios": ["axios@1.15.2", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A=="], - - "b4a": ["b4a@1.7.3", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q=="], + "axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], + "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - "bare-fs": ["bare-fs@4.5.3", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ=="], - - "bare-os": ["bare-os@3.6.2", "", {}, "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A=="], - - "bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="], - - "bare-stream": ["bare-stream@2.7.0", "", { "dependencies": { "streamx": "^2.21.0" }, "peerDependencies": { "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-buffer", "bare-events"] }, "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A=="], - - "bare-url": ["bare-url@2.3.2", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw=="], - - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - - "basic-ftp": ["basic-ftp@5.1.0", "", {}, "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw=="], - - "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], - - "bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="], - - "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - - "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], - - "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - - "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], - - "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], - - "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -422,72 +241,38 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="], - "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], - "clone-deep": ["clone-deep@0.2.4", "", { "dependencies": { "for-own": "^0.1.3", "is-plain-object": "^2.0.1", "kind-of": "^3.0.2", "lazy-cache": "^1.0.3", "shallow-clone": "^0.1.2" } }, "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - - "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], - - "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], - - "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], - - "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "devtools-protocol": ["devtools-protocol@0.0.1595872", "", {}, "sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg=="], - "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], - "drizzle-kit": ["drizzle-kit@1.0.0-beta.9-e89174b", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "tsx": "^4.20.6" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-Xrw3k8E2CbSZr+kqe3k5W4oxd2fbEyczjKtyGIkAq0x9Wqpa/VtAT6Mkh83sIzqG4OSN7lOoUafsDxSE/AR7RA=="], - - "drizzle-orm": ["drizzle-orm@1.0.0-beta.21", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-HZcIbVn5J9T/Z91Wj12Pn7Pi8/1aykS/GPJf2lXeZnEuPjxaBfQ+YAt0Sl+XI+9R/D1BpK+2fdIqbpuaTbcvqA=="], + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - - "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - - "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - - "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -496,14 +281,8 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], - "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], @@ -512,9 +291,7 @@ "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - - "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], @@ -522,92 +299,46 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - - "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], - - "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], - - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], - - "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], "fluent-ffmpeg": ["fluent-ffmpeg@2.1.3", "", { "dependencies": { "async": "^0.2.9", "which": "^1.1.1" } }, "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q=="], - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], - - "for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="], - - "for-own": ["for-own@0.1.5", "", { "dependencies": { "for-in": "^1.0.1" } }, "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw=="], - - "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], - "fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], - - "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], - - "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], - - "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], - - "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], - - "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -616,153 +347,59 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - - "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - - "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], - - "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], - - "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], - - "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], - - "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], - - "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], - - "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="], - - "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], - "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], - - "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "jsonfile": ["jsonfile@5.0.0", "", { "dependencies": { "universalify": "^0.1.2" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w=="], - "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], - - "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], - - "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - "kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], - - "lazy-cache": ["lazy-cache@1.0.4", "", {}, "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ=="], - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], - "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], - - "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], - - "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], - - "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], - - "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], - - "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], - - "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], - - "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "merge-deep": ["merge-deep@3.0.3", "", { "dependencies": { "arr-union": "^3.1.0", "clone-deep": "^0.2.4", "kind-of": "^3.0.2" } }, "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA=="], - - "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], - "mime-db": ["mime-db@1.46.0", "", {}, "sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ=="], "mime-types": ["mime-types@2.1.29", "", { "dependencies": { "mime-db": "1.46.0" } }, "sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ=="], "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], - - "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], - - "mixin-object": ["mixin-object@2.0.1", "", { "dependencies": { "for-in": "^0.1.3", "is-extendable": "^0.1.1" } }, "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "mssql": ["mssql@11.0.1", "", { "dependencies": { "@tediousjs/connection-string": "^0.5.0", "commander": "^11.0.0", "debug": "^4.3.3", "rfdc": "^1.3.0", "tarn": "^3.0.2", "tedious": "^18.2.1" }, "bin": { "mssql": "bin/mssql" } }, "sha512-KlGNsugoT90enKlR8/G36H0kTxPthDhmtNUCwEHvgRza5Cjpjoj+P2X6eMpFUDN7pFrJZsKadL4x990G8RBE1w=="], - - "native-duplexpair": ["native-duplexpair@1.0.0", "", {}, "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA=="], - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], - - "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - - "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - "ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="], - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], - - "openai": ["openai@6.35.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q=="], + "openai": ["openai@6.37.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -770,176 +407,104 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], - "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], - "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], - - "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], - - "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="], - "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="], + + "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], - - "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], - "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], - "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], - - "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], - "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], - "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "puppeteer": ["puppeteer@24.42.0", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1595872", "puppeteer-core": "24.42.0", "typed-query-selector": "^2.12.1" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-94MoPfFp2eY3eYIMdINkez4IOP5TMHntlZbVx06fHlQTtiQiYgaY0L2Zzfod8PVUkPqP7m3Qlre2v8YS8cudPA=="], - - "puppeteer-core": ["puppeteer-core@24.42.0", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1595872", "typed-query-selector": "^2.12.1", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" } }, "sha512-T4zXokk/izH01fYPhyyev1A4piWiOKrYq7CUFpdoYQxmOnXoV6YjUabmfIjCYkNspSoAXIxRid3Tw+Vg0fthYg=="], - - "puppeteer-extra": ["puppeteer-extra@3.3.6", "", { "dependencies": { "@types/debug": "^4.1.0", "debug": "^4.1.1", "deepmerge": "^4.2.2" }, "peerDependencies": { "@types/puppeteer": "*", "puppeteer": "*", "puppeteer-core": "*" }, "optionalPeers": ["@types/puppeteer", "puppeteer", "puppeteer-core"] }, "sha512-rsLBE/6mMxAjlLd06LuGacrukP2bqbzKCLzV1vrhHFavqQE/taQ2UXv3H5P0Ls7nsrASa+6x3bDbXHpqMwq+7A=="], - - "puppeteer-extra-plugin": ["puppeteer-extra-plugin@3.2.3", "", { "dependencies": { "@types/debug": "^4.1.0", "debug": "^4.1.1", "merge-deep": "^3.0.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q=="], - - "puppeteer-extra-plugin-stealth": ["puppeteer-extra-plugin-stealth@2.11.2", "", { "dependencies": { "debug": "^4.1.1", "puppeteer-extra-plugin": "^3.2.3", "puppeteer-extra-plugin-user-preferences": "^2.4.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ=="], - - "puppeteer-extra-plugin-user-data-dir": ["puppeteer-extra-plugin-user-data-dir@2.4.1", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^10.0.0", "puppeteer-extra-plugin": "^3.2.3", "rimraf": "^3.0.2" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g=="], - - "puppeteer-extra-plugin-user-preferences": ["puppeteer-extra-plugin-user-preferences@2.4.1", "", { "dependencies": { "debug": "^4.1.1", "deepmerge": "^4.2.2", "puppeteer-extra-plugin": "^3.2.3", "puppeteer-extra-plugin-user-data-dir": "^2.4.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A=="], - "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], - "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - - "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], - - "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - - "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], - - "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], - - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], - "shallow-clone": ["shallow-clone@0.1.2", "", { "dependencies": { "is-extendable": "^0.1.1", "kind-of": "^2.0.1", "lazy-cache": "^0.2.3", "mixin-object": "^2.0.1" } }, "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw=="], - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - - "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], - - "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], - - "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], - - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], - - "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "systeminformation": ["systeminformation@5.31.5", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-5SyLdip4/3alxD4Kh+63bUQTJmu7YMfYQTC+koZy7X73HgNqZSD2P4wOZQWtUncvPvcEmnfIjCoygN4MRoEejQ=="], + "systeminformation": ["systeminformation@5.31.6", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA=="], - "tar-fs": ["tar-fs@3.1.1", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg=="], - - "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], - - "tarn": ["tarn@3.0.2", "", {}, "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ=="], - - "tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="], - - "text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="], - - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "twemoji": ["twemoji@14.0.2", "", { "dependencies": { "fs-extra": "^8.0.1", "jsonfile": "^5.0.0", "twemoji-parser": "14.0.0", "universalify": "^0.1.2" } }, "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA=="], "twemoji-parser": ["twemoji-parser@14.0.0", "", {}, "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "typed-query-selector": ["typed-query-selector@2.12.1", "", {}, "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript-eslint": ["typescript-eslint@8.59.3", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.3", "@typescript-eslint/parser": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg=="], - "typescript-telegram-bot-api": ["typescript-telegram-bot-api@0.11.0", "", { "dependencies": { "axios": "^1.7.7", "form-data": "^4.0.1" } }, "sha512-pWSv0fglpnETAGtptNaqHjqreUTunRstfxeI9opdhq7P8T8T/tbBH8nLzP7WVAoFW55F4I6biKa9NOx1bs5O3Q=="], + "typescript-telegram-bot-api": ["typescript-telegram-bot-api@0.16.0", "", { "dependencies": { "axios": "^1.7.7", "form-data": "^4.0.1" } }, "sha512-NaAjXucQiZ87U8La/IMaWDOghbMlJzfMbU4rG8ppFpgPOvEwat/zfN5BM+J2QDvKVGN87qJ+1nELnkm3ctSLnQ=="], - "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], - - "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], - - "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="], - "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], "which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], @@ -950,13 +515,9 @@ "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], @@ -964,192 +525,58 @@ "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], - "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "youtubei.js": ["youtubei.js@16.0.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0", "meriyah": "^6.1.4" } }, "sha512-3802bCAGkBc2/G5WUTc0l/bO5mPYJbQAHL04d9hE9PnrDHoBUT8MN721Yqt4RCNncAXdHcfee9VdJy3Fhq1r5g=="], - - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - - "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "@libsql/isomorphic-ws/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - "@puppeteer/browsers/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "@puppeteer/browsers/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "@mistralai/mistralai/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "@types/fluent-ffmpeg/@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], - "@types/mssql/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "@types/pg/@types/node": ["@types/node@25.6.1", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g=="], "@types/qrcode/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], - "@types/readable-stream/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], - "@types/ws/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], - "@types/yauzl/@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.3", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg=="], - - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], - "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], - - "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], - "mixin-object/for-in": ["for-in@0.1.8", "", {}, "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g=="], - - "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "protobufjs/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], - - "proxy-agent/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - - "puppeteer-extra-plugin-user-data-dir/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - - "puppeteer-extra-plugin-user-data-dir/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - - "shallow-clone/kind-of": ["kind-of@2.0.1", "", { "dependencies": { "is-buffer": "^1.0.2" } }, "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg=="], - - "shallow-clone/lazy-cache": ["lazy-cache@0.2.7", "", {}, "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ=="], - "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "tedious/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], - - "tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], - - "typescript-telegram-bot-api/axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], + "typescript-telegram-bot-api/axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="], "yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - - "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - - "@puppeteer/browsers/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], - - "@puppeteer/browsers/yargs/y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - - "@puppeteer/browsers/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "@types/fluent-ffmpeg/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/mssql/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@types/pg/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "@types/qrcode/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/readable-stream/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/yauzl/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], - - "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - - "protobufjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "puppeteer-extra-plugin-user-data-dir/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], - - "puppeteer-extra-plugin-user-data-dir/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - - "puppeteer-extra-plugin-user-data-dir/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - - "tedious/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], - - "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], - - "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], - - "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], - - "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], - - "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], - - "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], - - "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], - - "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], - - "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], - - "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], - - "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], - - "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], - - "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], - - "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], - - "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], - - "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], - - "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], - - "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], - - "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], - - "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], - - "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], - - "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], - - "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], - - "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], - - "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], - - "typescript-telegram-bot-api/axios/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - "@puppeteer/browsers/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "puppeteer-extra-plugin-user-data-dir/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], diff --git a/docker-compose.yml b/docker-compose.yml index 195d85c..0b9ec47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,13 @@ services: tgchatbot: container_name: tgchatbot - image: ghcr.io/melod1n/tg-chat-bot:latest + image: ghcr.io/melod1n/tg-chat-bot:${IMAGE_TAG:-1.0.0} restart: unless-stopped + env_file: + - .env environment: - PUID=1000 - PGID=1000 - TZ=Europe/Moscow volumes: - - ./config:/config \ No newline at end of file + - ./config:/config diff --git a/drizzle.config.ts b/drizzle.config.ts deleted file mode 100644 index 98a8dce..0000000 --- a/drizzle.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import "dotenv/config"; -import {defineConfig} from "drizzle-kit"; - -export default defineConfig({ - out: "./drizzle", - schema: "./src/db/schema.ts", - dialect: "sqlite", - dbCredentials: { - url: process.env.DB_FILE_NAME, - }, -}); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..55090de --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,42 @@ +import js from "@eslint/js"; +import {defineConfig} from "eslint/config"; +import tseslint from "typescript-eslint"; + +export default defineConfig( + { + ignores: [ + "dist/**", + "data/**", + "node_modules/**", + "**/*.tsbuildinfo", + ], + }, + js.configs.recommended, + tseslint.configs.recommended, + { + files: ["src/**/*.ts"], + linterOptions: { + reportUnusedDisableDirectives: "off", + }, + rules: { + "no-console": "error", + "no-control-regex": "off", + "no-case-declarations": "off", + "no-useless-escape": "off", + "no-extra-boolean-cast": "off", + "quotes": ["error", "double", {avoidEscape: true}], + "semi": ["error", "always"], + "prefer-const": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + "no-unused-vars": "off", + }, + }, + { + files: ["src/logging/logger.ts"], + rules: { + "no-console": "off", + }, + }, +); diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..e067487 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,226 @@ +{ + "language": { + "default": "Default", + "en": "English", + "ru": "Russian", + "ua": "Ukrainian", + "instructionName": "English" + }, + "providerChoice.default": "Default", + "errorText": "⚠️ An error occurred.", + "waitThinkText": "⏳ Let me think...", + "analyzingPictureText": "🔍 Analyzing the image...", + "analyzingPicturesText": "🔍 Analyzing the images...", + "reasoningText": "🤔 Reasoning...", + "transcribingAudioText": "🦻 Transcribing audio...", + "genImageText": "👨‍🎨 Generating an image...", + "cancelText": "❌ Cancel", + "regenerateText": "🔄 Regenerate", + "aiCancelCallbackText": "Cancel AI generation", + "aiRegenerateCallbackText": "Regenerate AI response", + "userSettingsCallbackText": "User settings", + "noAccessText": "No access", + "notBotCreatorText": "You are not the bot creator.", + "notBotAdministratorText": "You are not a bot administrator.", + "notAChatText": "This is not a chat.", + "notChatAdministratorText": "You are not a chat administrator.", + "botNotChatAdministratorText": "The bot is not a chat administrator.", + "replyRequiredText": "A reply to a message is required.", + "onlyOriginalAuthorText": "Only the author of the original message can perform this action.", + "commandsHeaderText": "Commands:\n\n", + "sentCommandsInDmText": "Sent commands in DM 😎", + "couldNotSendCommandsInDmText": "Could not send commands in DM ☹️\nSending them here instead", + "administratorsHeaderText": "*Administrators*:\n\n", + "noUserInfoText": "No user information", + "useLeaveCommandText": "Use /leave", + "databaseBackupCaption": "Database backup", + "databaseBackupSentText": "Successfully sent to the creator in DM!", + "noChoicesText": "Nothing to choose from", + "qrCodeMissingTextText": "No text found for QR code generation.", + "quoteMissingTextText": "Could not find text in the message 😢", + "quoteBuildFailedText": "Could not build the quote 😢", + "speechToTextInstructionText": "Send audio/voice/video-note or reply with /stt to a message containing audio.", + "speechToTextEmptyResultText": "Speech-to-text did not return transcription text.", + "textToSpeechInstructionText": "Send text after the command or reply with /tts to a message containing text.", + "titleMissingText": "Could not find a title...", + "betterFallbackText": "Better", + "pongText": "pong", + "modelListHeaderText": "Available models:\n\n", + "modelListLoadFailedText": "Could not load the model list", + "noCurrentModelText": "Model is not set. Use one of the listed values.", + "unsupportedAttachmentText": "This attachment type is not supported.", + "attachmentMissingFromCacheText": "Attachment file is missing from cache.", + "noSupportedTranscriptionProviderText": "No supported speech-to-text provider is configured.", + "noSupportedTextToSpeechProviderText": "No supported text-to-speech provider is configured.", + "noSpeechToTextProviderForAccessText": "No speech-to-text providers are configured for your access level.", + "noTextToSpeechProviderForAccessText": "No text-to-speech providers are configured for your access level.", + "noTextToSynthesizeText": "No text to synthesize.", + "speechFileTooLargeText": "The speech file is larger than 50 MB and cannot be sent.", + "userSettingsTitle": "User Settings", + "userSettingsAiProviderSelectionTitle": "AI Provider Selection", + "userSettingsInterfaceLanguageSelectionTitle": "Interface Language Selection", + "userSettingsResponseLanguageSelectionTitle": "Response Language Selection", + "userSettingsContextSizeSelectionTitle": "Context Size Selection", + "userSettingsVoiceModeSelectionTitle": "Voice Message Mode Selection", + "userSettingsImageOutputSelectionTitle": "Image Output Mode Selection", + "userSettingsTierLabel": "Tier", + "userSettingsAiProviderLabel": "AI provider", + "userSettingsInterfaceLanguageLabel": "Interface language", + "userSettingsResponseLanguageLabel": "LLM response language", + "userSettingsContextSizeLabel": "Context size", + "userSettingsVoiceModeLabel": "Voice messages", + "userSettingsImageOutputLabel": "Image output", + "userSettingsBackButtonText": "Back", + "userSettingsAiProviderButtonPrefix": "AI provider", + "userSettingsInterfaceLanguageButtonPrefix": "Interface language", + "userSettingsResponseLanguageButtonPrefix": "Response language", + "userSettingsContextSizeButtonPrefix": "Context", + "userSettingsVoiceModeButtonPrefix": "Voice", + "userSettingsImageOutputButtonPrefix": "Image output", + "userSettingsCreatorTierText": "Creator", + "userSettingsAdminTierText": "Admin", + "userSettingsUserTierText": "User", + "userSettingsSelectedPrefix": "✓ ", + "userSettingsContextSizeDefaultText": "Default", + "userSettingsVoiceModeExecuteText": "Run through AI", + "userSettingsVoiceModeTranscriptText": "Show transcript only", + "userSettingsImageOutputPhotoText": "As photo", + "userSettingsImageOutputDocumentText": "As document", + "startingImageGenText": "🌈 Starting image generation...", + "imageGenText": "🌈 Generating image...", + "finalizingImageGenText": "🌈 Finalizing image generation...", + "botCannotMakeItselfAdminText": "The bot cannot make itself an admin", + "botCreatorAlreadyAdminText": "The bot creator is already an admin", + "botCannotRemoveItselfFromAdminsText": "The bot cannot remove itself from admins", + "botCreatorCannotStopBeingAdminText": "The bot creator cannot stop being an admin", + "botWillNotBanCreatorText": "The bot will not ban its creator.", + "botWillNotBanAdminsText": "The bot will not ban its administrators.", + "botIsNotBannedByItselfText": "The bot is not banned by itself anyway.", + "botCreatorNeverBannedText": "The bot creator is not banned and never will be.", + "botAdminsNotBannedText": "Bot administrators are not banned anyway.", + "botWillNotIgnoreItselfText": "The bot will not ignore itself.", + "botWillNotIgnoreCreatorText": "The bot will not ignore its creator.", + "botWillNotIgnoreAdminsText": "The bot will not ignore its administrators.", + "botIsNotIgnoredByItselfText": "The bot is not ignored by itself anyway.", + "botCreatorNotIgnoredText": "The bot creator is not ignored and never will be.", + "botAdminsNotIgnoredText": "Bot administrators are not ignored anyway.", + "botAlreadyAlwaysListensToItselfText": "The bot already always listens to itself", + "botAlwaysListensToCreatorText": "The bot always listens to its creator", + "coinHeadsText": "Heads", + "coinTailsText": "Tails", + "distortReplyInstructionText": "Reply with /distort to a message containing an image (photo, document, or sticker).\nExample: /distort 16 80", + "distortMissingImageText": "I do not see an image in the reply. Send a photo or image file.", + "shutdownFallbackText": "...", + "shutdownSequenceTexts": [ + "well then, everyone", + "it was nice talking to you", + "but it is time for me to rest", + "all the best" + ], + "shutdownDoneText": "*R.I.P*", + "whenNowText": "right now", + "whenNeverText": "never", + "whenYearUnitText": "year", + "whenDayUnitText": "day", + "whenWeekUnitText": "week", + "whenMonthUnitText": "month", + "whenHourUnitText": "hour", + "whenMinuteUnitText": "minute", + "whenSecondUnitText": "second", + "getCancelledText": "{provider}\n❌ Generation cancelled.", + "getPartialImageGenText": "🌈 Generating image ({iteration}/{total})...", + "getImageGenDoneText.withModel": "👨‍🎨 Image generated. Model: `{model}`.", + "getImageGenDoneText.default": "👨‍🎨 Image generated.", + "getErrorText.withReason": "{errorText} Reason:\n{reason}", + "getUseToolText.python": "👨‍💻 Running `Python`", + "getUseToolText.codeInterpreter": "👨‍💻 Running `Code Interpreter`", + "getUseToolText.default": "🔧 Using tool `{name}`", + "getAnalyzingDocumentText.default": "🔍 Analyzing the document...", + "getAnalyzingDocumentText.single": "🔍 Analyzing document: `{name}`", + "getAnalyzingDocumentText.many": "🔍 Analyzing documents: {names}", + "getPreparingRAGText.default": "🔍 Preparing RAG for the document...", + "getPreparingRAGText.single": "🔍 Preparing RAG for document: `{name}`", + "getPreparingRAGText.many": "🔍 Preparing RAG for documents: {names}", + "getSelectingToolsText": "🧩 Choosing the right tools...", + "getBuildingRAGIndexText.default": "🧠 Building RAG index...", + "getBuildingRAGIndexText.withModel": "🧠 Building RAG index: `{modelName}`.", + "queueNoneText": "none", + "getAiQueueText.queued": "⏳ Request to {provider} is queued.", + "getAiQueueText.ahead": "Requests ahead: {count}.", + "getTelegramFileTooLargeText": "File {fileName} is larger than {maxSizeMb} MB and cannot be sent.", + "getUserIsNowAdminText": "{name} is now an admin!", + "getUserAlreadyAdminText": "{name} is already an admin 🤔", + "getUserNoLongerAdminText": "{name} is no longer an admin!", + "getUserWasNotAdminText": "{name} was not an admin 🤔", + "getUserBannedText": "{name} banned 🚫", + "getUserBanFailedText": "Could not ban {name} ☹️", + "getUserUnbannedText": "{name} unbanned ⛓️‍💥", + "getUserUnbanFailedText": "Could not unban {name} ☹️", + "getUserIgnoredText": "{name} is muted! 🔇", + "getUserAlreadyIgnoredText": "{name} is already muted 🤔", + "getUserIgnoreFailedText": "Could not mute {name} ☹️", + "getUserUnignoredText": "{name} is no longer muted! 🔈", + "getUserWasNotIgnoredText": "{name} was not muted 🤔", + "getUserUnignoreFailedText": "Could not unmute {name} ☹️", + "getChoiceText": "Chose *{choice}*", + "getCoinResultText": "It landed on *{result}*", + "getLoadedModelsText": "Loaded models: {models}", + "getSelectedModelText": "Selected model: `{model}`", + "getCurrentModelText": "Current model: `{model}`", + "getLoadingModelText": "Loading model `{model}`...", + "getUserSettingsContextSizeText": "{size} tokens", + "getQrCodeTextTooLongText": "Text is too long for QR ({actualLength} characters). It will be trimmed to {maxLength} characters.", + "getQrCodeReadyText": "QR code ready ✅\nContent:\n
{content}
", + "getQrCodeFailedText": "Could not generate QR: {reason}", + "getWhenPrefixText": "in ", + "getWhenPluralUnitText": "{unit}s", + "getWhenDurationText": "{prefix}{value} {unit}", + "commandDescriptions": { + "ae": "evaluation", + "adminsAdd": "Add user to admins", + "adminsRemove": "Remove user from admins", + "ban": "ban user from chat", + "choice": "Choose a random value", + "coin": "Heads or tails", + "debug": "Returns msg (or reply) as json", + "dice": "Sends random or specific dice", + "distort": "Distortion of picture", + "help": "Show list of commands", + "id": "ID of chat, user and reply (if replied to any message)", + "ignore": "Bot will ignore user", + "info": "Info about bot", + "leave": "Bot will leave current chat", + "mistralChat": "Chat with AI (Mistral)", + "mistralGetModel": "Get current Mistral model", + "mistralListModels": "List all Mistral models", + "mistralSetModel": "Set Mistral model", + "ollamaChat": "Chat with AI (Ollama)", + "ollamaGetModel": "Get current Ollama model", + "ollamaListModels": "List all Ollama models", + "ollamaSearch": "Web search via Ollama", + "ollamaSetModel": "Set Ollama model", + "openAiChat": "Chat with AI (OpenAI)", + "openAiGetModel": "Get current OpenAI model", + "openAiListModels": "List all OpenAI models", + "openAiSetModel": "Set OpenAI model", + "ping": "Ping between received and sent message", + "qr": "Generates QR-code from text you sent or replied to.", + "quote": "Make quote from text (or quote)", + "randomInt": "Ranged random integer from parameters", + "randomString": "literally random string (up to 4096 symbols)", + "settings": "User settings", + "shutdown": "Self-destruction sequence for bot (shutdown)", + "speechToText": "Transcribe speech to text", + "start": "Start the bot", + "systemInfo": "System information", + "textToSpeech": "Generate speech from text", + "title": "Change group title", + "test": "System functionality check", + "transliteration": "Transliteration EN <--> RU", + "unban": "unban user from chat", + "unignore": "Bot will start responding to the user", + "uptime": "Bot's uptime", + "whatBetter": "either a or b randomly (50% chance)", + "when": "random date" + } +} diff --git a/locales/ru.json b/locales/ru.json new file mode 100644 index 0000000..e75fe00 --- /dev/null +++ b/locales/ru.json @@ -0,0 +1,252 @@ +{ + "language": { + "default": "По умолчанию", + "en": "Английский", + "ru": "Русский", + "ua": "Украинский", + "instructionName": "Russian" + }, + "providerChoice.default": "По умолчанию", + "errorText": "⚠️ Произошла ошибка.", + "waitThinkText": "⏳ Дайте-ка подумать...", + "analyzingPictureText": "🔍 Анализирую изображение...", + "analyzingPicturesText": "🔍 Анализирую изображения...", + "reasoningText": "🤔 Рассуждаю...", + "transcribingAudioText": "🦻 Распознаю аудио...", + "genImageText": "👨‍🎨 Генерирую изображение...", + "cancelText": "❌ Отмена", + "regenerateText": "🔄 Сгенерировать заново", + "aiCancelCallbackText": "Отменить генерацию ИИ", + "aiRegenerateCallbackText": "Сгенерировать ответ ИИ заново", + "userSettingsCallbackText": "Настройки пользователя", + "noAccessText": "Нет доступа", + "notBotCreatorText": "Вы не создатель бота.", + "notBotAdministratorText": "Вы не администратор бота.", + "notAChatText": "Это не чат.", + "notChatAdministratorText": "Вы не администратор чата.", + "botNotChatAdministratorText": "Бот не является администратором чата.", + "replyRequiredText": "Нужно ответить на сообщение.", + "onlyOriginalAuthorText": "Это действие доступно только автору исходного сообщения.", + "dockerContainerLabelText": "Docker-контейнер", + "processLabelText": "Процесс", + "systemLabelText": "Система", + "systemInfoOsLabelText": "ОС", + "systemInfoRuntimeLabelText": "RUNTIME", + "systemInfoDockerLabelText": "DOCKER", + "systemInfoCpuLabelText": "CPU", + "systemInfoRamLabelText": "RAM", + "systemInfoCpuCoresText": "ядер", + "systemInfoCpuThreadsText": "потоков", + "idChatLabelText": "id чата", + "idFromLabelText": "id пользователя", + "idReplyLabelText": "id ответа", + "runtimeProviderLabelText": "провайдер", + "runtimeProviderCurrentLabelText": "текущий", + "runtimeModelLabelText": "модель", + "runtimeCapabilitiesLabelText": "возможности", + "runtimeExternalLabelText": "внешний", + "infoAiBlockLabelText": "AI", + "infoSupportedProvidersLabelText": "провайдеры", + "infoToolsBlockLabelText": "инструменты", + "infoCommandsBlockLabelText": "команды", + "infoPublicLabelText": "публичные", + "infoPrivateLabelText": "приватные", + "infoChatLabelText": "чат", + "infoCallbackLabelText": "колбэки", + "commandsHeaderText": "Команды:\n\n", + "sentCommandsInDmText": "Отправил команды в личные сообщения 😎", + "couldNotSendCommandsInDmText": "Не получилось отправить команды в личные сообщения ☹️\nОтправляю их сюда", + "administratorsHeaderText": "*Администраторы*:\n\n", + "noUserInfoText": "Нет информации о пользователе", + "useLeaveCommandText": "Используйте /leave", + "databaseBackupCaption": "Резервная копия базы данных", + "databaseBackupSentText": "Успешно отправил создателю в личные сообщения!", + "noChoicesText": "Не из чего выбирать", + "qrCodeMissingTextText": "Не найден текст для генерации QR-кода.", + "quoteMissingTextText": "Не удалось найти текст в сообщении 😢", + "quoteBuildFailedText": "Не удалось собрать цитату 😢", + "speechToTextInstructionText": "Отправьте аудио/voice/video-note или ответьте /stt на сообщение с аудио.", + "speechToTextEmptyResultText": "Распознавание речи не вернуло текст.", + "textToSpeechInstructionText": "Отправьте текст после команды или ответьте /tts на сообщение с текстом.", + "titleMissingText": "Не удалось найти заголовок...", + "betterFallbackText": "Лучше", + "pongText": "понг", + "modelListHeaderText": "Доступные модели:\n\n", + "modelListLoadFailedText": "Не удалось загрузить список моделей", + "noCurrentModelText": "Модель не задана. Используйте одно из значений из списка.", + "unsupportedAttachmentText": "Этот тип вложения не поддерживается.", + "attachmentMissingFromCacheText": "Файл вложения отсутствует в кэше.", + "noSupportedTranscriptionProviderText": "Не настроен ни один провайдер распознавания речи.", + "noSupportedTextToSpeechProviderText": "Не настроен ни один провайдер синтеза речи.", + "noSpeechToTextProviderForAccessText": "Для вашего уровня доступа не настроены провайдеры распознавания речи.", + "noTextToSpeechProviderForAccessText": "Для вашего уровня доступа не настроены провайдеры синтеза речи.", + "noTextToSynthesizeText": "Нет текста для синтеза речи.", + "speechFileTooLargeText": "Файл речи больше 50 МБ и не может быть отправлен.", + "userSettingsTitle": "Настройки пользователя", + "userSettingsAiProviderSelectionTitle": "Выбор AI-провайдера", + "userSettingsInterfaceLanguageSelectionTitle": "Выбор языка интерфейса", + "userSettingsResponseLanguageSelectionTitle": "Выбор языка ответов", + "userSettingsContextSizeSelectionTitle": "Выбор размера контекста", + "userSettingsVoiceModeSelectionTitle": "Режим голосовых сообщений", + "userSettingsImageOutputSelectionTitle": "Режим отправки изображений", + "userSettingsTierLabel": "Уровень", + "userSettingsAiProviderLabel": "AI-провайдер", + "userSettingsInterfaceLanguageLabel": "Язык интерфейса", + "userSettingsResponseLanguageLabel": "Язык ответов LLM", + "userSettingsContextSizeLabel": "Размер контекста", + "userSettingsVoiceModeLabel": "Голосовые сообщения", + "userSettingsImageOutputLabel": "Изображения", + "userSettingsBackButtonText": "Назад", + "userSettingsAiProviderButtonPrefix": "AI-провайдер", + "userSettingsInterfaceLanguageButtonPrefix": "Язык интерфейса", + "userSettingsResponseLanguageButtonPrefix": "Язык ответов", + "userSettingsContextSizeButtonPrefix": "Контекст", + "userSettingsVoiceModeButtonPrefix": "Голосовые", + "userSettingsImageOutputButtonPrefix": "Изображения", + "userSettingsCreatorTierText": "Создатель", + "userSettingsAdminTierText": "Админ", + "userSettingsUserTierText": "Пользователь", + "userSettingsSelectedPrefix": "✓ ", + "userSettingsContextSizeDefaultText": "По умолчанию", + "userSettingsVoiceModeExecuteText": "Выполнять через ИИ", + "userSettingsVoiceModeTranscriptText": "Только расшифровка", + "userSettingsImageOutputPhotoText": "Как фото", + "userSettingsImageOutputDocumentText": "Как документ", + "startingImageGenText": "🌈 Запускаю генерацию изображения...", + "imageGenText": "🌈 Генерирую изображение...", + "finalizingImageGenText": "🌈 Завершаю генерацию изображения...", + "botCannotMakeItselfAdminText": "Бот не может назначить себя администратором", + "botCreatorAlreadyAdminText": "Создатель бота уже администратор", + "botCannotRemoveItselfFromAdminsText": "Бот не может удалить себя из администраторов", + "botCreatorCannotStopBeingAdminText": "Создатель бота не может перестать быть администратором", + "botWillNotBanCreatorText": "Бот не будет банить своего создателя.", + "botWillNotBanAdminsText": "Бот не будет банить своих администраторов.", + "botIsNotBannedByItselfText": "Бот и так не забанен сам собой.", + "botCreatorNeverBannedText": "Создатель бота не забанен и никогда не будет.", + "botAdminsNotBannedText": "Администраторы бота и так не забанены.", + "botWillNotIgnoreItselfText": "Бот не будет игнорировать себя.", + "botWillNotIgnoreCreatorText": "Бот не будет игнорировать своего создателя.", + "botWillNotIgnoreAdminsText": "Бот не будет игнорировать своих администраторов.", + "botIsNotIgnoredByItselfText": "Бот и так не игнорирует сам себя.", + "botCreatorNotIgnoredText": "Создатель бота не игнорируется и никогда не будет.", + "botAdminsNotIgnoredText": "Администраторы бота и так не игнорируются.", + "botAlreadyAlwaysListensToItselfText": "Бот и так всегда слушает сам себя", + "botAlwaysListensToCreatorText": "Бот всегда слушает своего создателя", + "coinHeadsText": "Орёл", + "coinTailsText": "Решка", + "distortReplyInstructionText": "Ответьте /distort на сообщение с изображением (фото, документ или стикер).\nПример: /distort 16 80", + "distortMissingImageText": "Не вижу изображения в ответе. Отправьте фото или файл изображения.", + "shutdownFallbackText": "...", + "shutdownSequenceTexts": [ + "ну что ж, народ", + "было приятно пообщаться", + "но мне пора отдохнуть", + "всем добра" + ], + "shutdownDoneText": "*R.I.P*", + "whenNowText": "прямо сейчас", + "whenNeverText": "никогда", + "whenYearUnitText": "год", + "whenDayUnitText": "день", + "whenWeekUnitText": "неделя", + "whenMonthUnitText": "месяц", + "whenHourUnitText": "час", + "whenMinuteUnitText": "минута", + "whenSecondUnitText": "секунда", + "getCancelledText": "{provider}\n❌ Генерация отменена.", + "getPartialImageGenText": "🌈 Генерирую изображение ({iteration}/{total})...", + "getImageGenDoneText.withModel": "👨‍🎨 Изображение сгенерировано. Модель: `{model}`.", + "getImageGenDoneText.default": "👨‍🎨 Изображение сгенерировано.", + "getErrorText.withReason": "{errorText} Причина:\n{reason}", + "getUseToolText.python": "👨‍💻 Запускаю `Python`", + "getUseToolText.codeInterpreter": "👨‍💻 Запускаю `Code Interpreter`", + "getUseToolText.default": "🔧 Использую инструмент `{name}`", + "getAnalyzingDocumentText.default": "🔍 Анализирую документ...", + "getAnalyzingDocumentText.single": "🔍 Анализирую документ: `{name}`", + "getAnalyzingDocumentText.many": "🔍 Анализирую документы: {names}", + "getPreparingRAGText.default": "🔍 Готовлю RAG для документа...", + "getPreparingRAGText.single": "🔍 Готовлю RAG для документа: `{name}`", + "getPreparingRAGText.many": "🔍 Готовлю RAG для документов: {names}", + "getSelectingToolsText": "🧩 Выбираю подходящие инструменты...", + "getBuildingRAGIndexText.default": "🧠 Строю RAG-индекс...", + "getBuildingRAGIndexText.withModel": "🧠 Строю RAG-индекс: `{modelName}`.", + "queueNoneText": "нет", + "getAiQueueText.queued": "⏳ Запрос к {provider} поставлен в очередь.", + "getAiQueueText.ahead": "Запросов впереди: {count}.", + "getTelegramFileTooLargeText": "Файл {fileName} больше {maxSizeMb} МБ и не может быть отправлен.", + "getUserIsNowAdminText": "{name} теперь администратор!", + "getUserAlreadyAdminText": "{name} уже администратор 🤔", + "getUserNoLongerAdminText": "{name} больше не администратор!", + "getUserWasNotAdminText": "{name} не был администратором 🤔", + "getUserBannedText": "{name} забанен 🚫", + "getUserBanFailedText": "Не удалось забанить {name} ☹️", + "getUserUnbannedText": "{name} разбанен ⛓️‍💥", + "getUserUnbanFailedText": "Не удалось разбанить {name} ☹️", + "getUserIgnoredText": "{name} заглушён! 🔇", + "getUserAlreadyIgnoredText": "{name} уже заглушён 🤔", + "getUserIgnoreFailedText": "Не удалось заглушить {name} ☹️", + "getUserUnignoredText": "{name} больше не заглушён! 🔈", + "getUserWasNotIgnoredText": "{name} не был заглушён 🤔", + "getUserUnignoreFailedText": "Не удалось включить {name} обратно ☹️", + "getChoiceText": "Выбрал *{choice}*", + "getCoinResultText": "Выпало: *{result}*", + "getLoadedModelsText": "Загруженные модели: {models}", + "getSelectedModelText": "Выбрана модель: `{model}`", + "getCurrentModelText": "Текущая модель: `{model}`", + "getLoadingModelText": "Загружаю модель `{model}`...", + "getUserSettingsContextSizeText": "{size} токенов", + "getQrCodeTextTooLongText": "Текст слишком длинный для QR ({actualLength} символов). Обрежу до {maxLength} символов.", + "getQrCodeReadyText": "QR-код готов ✅\nСодержимое:\n
{content}
", + "getQrCodeFailedText": "Не удалось сгенерировать QR: {reason}", + "getWhenPrefixText": "через ", + "getWhenPluralUnitText": "{unit}", + "getWhenDurationText": "{prefix}{value} {unit}", + "commandDescriptions": { + "ae": "вычисление", + "adminsAdd": "Добавить пользователя в администраторы", + "adminsRemove": "Удалить пользователя из администраторов", + "ban": "забанить пользователя в чате", + "choice": "Выбрать случайное значение", + "coin": "Орёл или решка", + "debug": "Вернуть msg или reply в JSON", + "dice": "Отправить случайный или конкретный дайс", + "distort": "Искажение изображения", + "help": "Показать список команд", + "id": "ID чата, пользователя и ответа", + "ignore": "Бот будет игнорировать пользователя", + "info": "Информация о боте", + "leave": "Бот покинет текущий чат", + "mistralChat": "Чат с AI (Mistral)", + "mistralGetModel": "Показать текущую модель Mistral", + "mistralListModels": "Показать все модели Mistral", + "mistralSetModel": "Установить модель Mistral", + "ollamaChat": "Чат с AI (Ollama)", + "ollamaGetModel": "Показать текущую модель Ollama", + "ollamaListModels": "Показать все модели Ollama", + "ollamaSearch": "Веб-поиск через Ollama", + "ollamaSetModel": "Установить модель Ollama", + "openAiChat": "Чат с AI (OpenAI)", + "openAiGetModel": "Показать текущую модель OpenAI", + "openAiListModels": "Показать все модели OpenAI", + "openAiSetModel": "Установить модель OpenAI", + "ping": "Задержка между получением и отправкой сообщения", + "qr": "Сгенерировать QR-код из текста", + "quote": "Сделать цитату из текста", + "randomInt": "Случайное число из диапазона", + "randomString": "Случайная строка до 4096 символов", + "settings": "Настройки пользователя", + "shutdown": "Выключить бота", + "speechToText": "Распознать речь в текст", + "start": "Запустить бота", + "systemInfo": "Информация о системе", + "textToSpeech": "Сгенерировать речь из текста", + "title": "Изменить название группы", + "test": "Проверка системной функциональности", + "transliteration": "Транслитерация EN <--> RU", + "unban": "разбанить пользователя в чате", + "unignore": "Бот снова будет отвечать пользователю", + "uptime": "Время работы бота", + "whatBetter": "случайно выбрать a или b", + "when": "случайная дата" + } +} diff --git a/locales/ua.json b/locales/ua.json new file mode 100644 index 0000000..4574531 --- /dev/null +++ b/locales/ua.json @@ -0,0 +1,218 @@ +{ + "language": { + "default": "За замовчуванням", + "en": "Англійська", + "ru": "Російська", + "ua": "Українська", + "instructionName": "Ukrainian" + }, + "providerChoice.default": "За замовчуванням", + "errorText": "⚠️ Сталася помилка.", + "waitThinkText": "⏳ Дайте-но подумати...", + "analyzingPictureText": "🔍 Аналізую зображення...", + "analyzingPicturesText": "🔍 Аналізую зображення...", + "reasoningText": "🤔 Міркую...", + "transcribingAudioText": "🦻 Розпізнаю аудіо...", + "genImageText": "👨‍🎨 Генерую зображення...", + "cancelText": "❌ Скасувати", + "regenerateText": "🔄 Згенерувати заново", + "aiCancelCallbackText": "Скасувати генерацію AI", + "aiRegenerateCallbackText": "Згенерувати відповідь AI заново", + "userSettingsCallbackText": "Налаштування користувача", + "noAccessText": "Немає доступу", + "notBotCreatorText": "Ви не творець бота.", + "notBotAdministratorText": "Ви не адміністратор бота.", + "notAChatText": "Це не чат.", + "notChatAdministratorText": "Ви не адміністратор чату.", + "botNotChatAdministratorText": "Бот не є адміністратором чату.", + "replyRequiredText": "Потрібно відповісти на повідомлення.", + "onlyOriginalAuthorText": "Ця дія доступна лише автору початкового повідомлення.", + "dockerContainerLabelText": "Docker-контейнер", + "processLabelText": "Процес", + "systemLabelText": "Система", + "systemInfoOsLabelText": "ОС", + "systemInfoRuntimeLabelText": "RUNTIME", + "systemInfoDockerLabelText": "DOCKER", + "systemInfoCpuLabelText": "CPU", + "systemInfoRamLabelText": "RAM", + "systemInfoCpuCoresText": "ядер", + "systemInfoCpuThreadsText": "потоків", + "idChatLabelText": "id чату", + "idFromLabelText": "id користувача", + "idReplyLabelText": "id відповіді", + "runtimeProviderLabelText": "провайдер", + "runtimeModelLabelText": "модель", + "runtimeCapabilitiesLabelText": "можливості", + "runtimeExternalLabelText": "зовнішній", + "infoAiBlockLabelText": "AI", + "infoSupportedProvidersLabelText": "провайдери", + "infoToolsBlockLabelText": "інструменти", + "infoCommandsBlockLabelText": "команди", + "infoPublicLabelText": "публічні", + "infoPrivateLabelText": "приватні", + "infoChatLabelText": "чат", + "infoCallbackLabelText": "колбеки", + "commandsHeaderText": "Команди:\n\n", + "sentCommandsInDmText": "Надіслав команди в особисті повідомлення 😎", + "couldNotSendCommandsInDmText": "Не вдалося надіслати команди в особисті повідомлення ☹️\nНадсилаю їх сюди", + "administratorsHeaderText": "*Адміністратори*:\n\n", + "noUserInfoText": "Немає інформації про користувача", + "useLeaveCommandText": "Використайте /leave", + "databaseBackupCaption": "Резервна копія бази даних", + "databaseBackupSentText": "Успішно надіслав творцю в особисті повідомлення!", + "noChoicesText": "Немає з чого вибирати", + "qrCodeMissingTextText": "Не знайдено текст для генерації QR-коду.", + "quoteMissingTextText": "Не вдалося знайти текст у повідомленні 😢", + "quoteBuildFailedText": "Не вдалося створити цитату 😢", + "speechToTextInstructionText": "Надішліть аудіо/voice/video-note або відповідайте /stt на повідомлення з аудіо.", + "speechToTextEmptyResultText": "Розпізнавання мовлення не повернуло текст.", + "textToSpeechInstructionText": "Надішліть текст після команди або відповідайте /tts на повідомлення з текстом.", + "titleMissingText": "Не вдалося знайти заголовок...", + "betterFallbackText": "Краще", + "pongText": "понг", + "modelListHeaderText": "Доступні моделі:\n\n", + "modelListLoadFailedText": "Не вдалося завантажити список моделей", + "noCurrentModelText": "Модель не задана. Використайте одне зі значень зі списку.", + "unsupportedAttachmentText": "Цей тип вкладення не підтримується.", + "attachmentMissingFromCacheText": "Файл вкладення відсутній у кеші.", + "noSupportedTranscriptionProviderText": "Не налаштовано жодного провайдера розпізнавання мовлення.", + "noSupportedTextToSpeechProviderText": "Не налаштовано жодного провайдера синтезу мовлення.", + "noSpeechToTextProviderForAccessText": "Для вашого рівня доступу не налаштовано провайдери розпізнавання мовлення.", + "noTextToSpeechProviderForAccessText": "Для вашого рівня доступу не налаштовано провайдери синтезу мовлення.", + "noTextToSynthesizeText": "Немає тексту для синтезу мовлення.", + "speechFileTooLargeText": "Файл мовлення більший за 50 МБ і не може бути надісланий.", + "userSettingsTitle": "Налаштування користувача", + "userSettingsAiProviderSelectionTitle": "Вибір AI-провайдера", + "userSettingsInterfaceLanguageSelectionTitle": "Вибір мови інтерфейсу", + "userSettingsResponseLanguageSelectionTitle": "Вибір мови відповідей", + "userSettingsContextSizeSelectionTitle": "Вибір розміру контексту", + "userSettingsVoiceModeSelectionTitle": "Режим голосових повідомлень", + "userSettingsImageOutputSelectionTitle": "Режим надсилання зображень", + "userSettingsTierLabel": "Рівень", + "userSettingsAiProviderLabel": "AI-провайдер", + "userSettingsInterfaceLanguageLabel": "Мова інтерфейсу", + "userSettingsResponseLanguageLabel": "Мова відповідей LLM", + "userSettingsContextSizeLabel": "Розмір контексту", + "userSettingsVoiceModeLabel": "Голосові повідомлення", + "userSettingsImageOutputLabel": "Зображення", + "userSettingsBackButtonText": "Назад", + "userSettingsAiProviderButtonPrefix": "AI-провайдер", + "userSettingsInterfaceLanguageButtonPrefix": "Мова інтерфейсу", + "userSettingsResponseLanguageButtonPrefix": "Мова відповідей", + "userSettingsContextSizeButtonPrefix": "Контекст", + "userSettingsVoiceModeButtonPrefix": "Голосові", + "userSettingsImageOutputButtonPrefix": "Зображення", + "userSettingsCreatorTierText": "Творець", + "userSettingsAdminTierText": "Адмін", + "userSettingsUserTierText": "Користувач", + "userSettingsSelectedPrefix": "✓ ", + "userSettingsContextSizeDefaultText": "За замовчуванням", + "userSettingsVoiceModeExecuteText": "Виконувати через AI", + "userSettingsVoiceModeTranscriptText": "Лише розшифровка", + "userSettingsImageOutputPhotoText": "Як фото", + "userSettingsImageOutputDocumentText": "Як документ", + "startingImageGenText": "🌈 Запускаю генерацію зображення...", + "imageGenText": "🌈 Генерую зображення...", + "finalizingImageGenText": "🌈 Завершую генерацію зображення...", + "botCannotMakeItselfAdminText": "Бот не може призначити себе адміністратором", + "botCreatorAlreadyAdminText": "Творець бота вже адміністратор", + "botCannotRemoveItselfFromAdminsText": "Бот не може видалити себе з адміністраторів", + "botCreatorCannotStopBeingAdminText": "Творець бота не може перестати бути адміністратором", + "botWillNotBanCreatorText": "Бот не банитиме свого творця.", + "botWillNotBanAdminsText": "Бот не банитиме своїх адміністраторів.", + "botIsNotBannedByItselfText": "Бот і так не забанений сам собою.", + "botCreatorNeverBannedText": "Творець бота не забанений і ніколи не буде.", + "botAdminsNotBannedText": "Адміністратори бота і так не забанені.", + "botWillNotIgnoreItselfText": "Бот не ігноруватиме себе.", + "botWillNotIgnoreCreatorText": "Бот не ігноруватиме свого творця.", + "botWillNotIgnoreAdminsText": "Бот не ігноруватиме своїх адміністраторів.", + "botIsNotIgnoredByItselfText": "Бот і так не ігнорує сам себе.", + "botCreatorNotIgnoredText": "Творець бота не ігнорується і ніколи не буде.", + "botAdminsNotIgnoredText": "Адміністратори бота і так не ігноруються.", + "botAlreadyAlwaysListensToItselfText": "Бот і так завжди слухає сам себе", + "botAlwaysListensToCreatorText": "Бот завжди слухає свого творця", + "coinHeadsText": "Орел", + "coinTailsText": "Решка", + "distortReplyInstructionText": "Відповідайте /distort на повідомлення із зображенням (фото, документ або стікер).\nПриклад: /distort 16 80", + "distortMissingImageText": "Не бачу зображення у відповіді. Надішліть фото або файл зображення.", + "shutdownFallbackText": "...", + "shutdownSequenceTexts": [ + "ну що ж, усі", + "було приємно поспілкуватися", + "але мені час відпочити", + "усього доброго" + ], + "shutdownDoneText": "*R.I.P*", + "whenNowText": "прямо зараз", + "whenNeverText": "ніколи", + "whenYearUnitText": "рік", + "whenDayUnitText": "день", + "whenWeekUnitText": "тиждень", + "whenMonthUnitText": "місяць", + "whenHourUnitText": "година", + "whenMinuteUnitText": "хвилина", + "whenSecondUnitText": "секунда", + "getCancelledText": "{provider}\n❌ Генерацію скасовано.", + "getPartialImageGenText": "🌈 Генерую зображення ({iteration}/{total})...", + "getImageGenDoneText.withModel": "👨‍🎨 Зображення згенеровано. Модель: `{model}`.", + "getImageGenDoneText.default": "👨‍🎨 Зображення згенеровано.", + "getErrorText.withReason": "{errorText} Причина:\n{reason}", + "getUseToolText.python": "👨‍💻 Запускаю `Python`", + "getUseToolText.codeInterpreter": "👨‍💻 Запускаю `Code Interpreter`", + "getUseToolText.default": "🔧 Використовую інструмент `{name}`", + "getAnalyzingDocumentText.default": "🔍 Аналізую документ...", + "getAnalyzingDocumentText.single": "🔍 Аналізую документ: `{name}`", + "getAnalyzingDocumentText.many": "🔍 Аналізую документи: {names}", + "getPreparingRAGText.default": "🔍 Готую RAG для документа...", + "getPreparingRAGText.single": "🔍 Готую RAG для документа: `{name}`", + "getPreparingRAGText.many": "🔍 Готую RAG для документів: {names}", + "getSelectingToolsText": "🧩 Вибираю підхожі інструменти...", + "getBuildingRAGIndexText.default": "🧠 Будую RAG-індекс...", + "getBuildingRAGIndexText.withModel": "🧠 Будую RAG-індекс: `{modelName}`.", + "queueNoneText": "немає", + "getAiQueueText.queued": "⏳ Запит до {provider} поставлено в чергу.", + "getAiQueueText.ahead": "Запитів попереду: {count}.", + "getTelegramFileTooLargeText": "Файл {fileName} більший за {maxSizeMb} МБ і не може бути надісланий.", + "getUserIsNowAdminText": "{name} тепер адміністратор!", + "getUserAlreadyAdminText": "{name} вже адміністратор 🤔", + "getUserNoLongerAdminText": "{name} більше не адміністратор!", + "getUserWasNotAdminText": "{name} не був адміністратором 🤔", + "getUserBannedText": "{name} забанений 🚫", + "getUserBanFailedText": "Не вдалося забанити {name} ☹️", + "getUserUnbannedText": "{name} розбанений ⛓️‍💥", + "getUserUnbanFailedText": "Не вдалося розбанити {name} ☹️", + "getUserIgnoredText": "{name} заглушений! 🔇", + "getUserAlreadyIgnoredText": "{name} вже заглушений 🤔", + "getUserIgnoreFailedText": "Не вдалося заглушити {name} ☹️", + "getUserUnignoredText": "{name} більше не заглушений! 🔈", + "getUserWasNotIgnoredText": "{name} не був заглушений 🤔", + "getUserUnignoreFailedText": "Не вдалося увімкнути {name} назад ☹️", + "getChoiceText": "Вибрав *{choice}*", + "getCoinResultText": "Випало: *{result}*", + "getLoadedModelsText": "Завантажені моделі: {models}", + "getSelectedModelText": "Обрано модель: `{model}`", + "getCurrentModelText": "Поточна модель: `{model}`", + "getLoadingModelText": "Завантажую модель `{model}`...", + "getUserSettingsContextSizeText": "{size} токенів", + "getQrCodeTextTooLongText": "Текст занадто довгий для QR ({actualLength} символів). Обріжу до {maxLength} символів.", + "getQrCodeReadyText": "QR-код готовий ✅\nВміст:\n
{content}
", + "getQrCodeFailedText": "Не вдалося згенерувати QR: {reason}", + "getWhenPrefixText": "через ", + "getWhenPluralUnitText": "{unit}", + "getWhenDurationText": "{prefix}{value} {unit}", + "commandDescriptions": { + "help": "Показати список команд", + "settings": "Налаштування користувача", + "start": "Запустити бота", + "ping": "Затримка між отриманням і надсиланням повідомлення", + "info": "Інформація про бота", + "systemInfo": "Інформація про систему", + "speechToText": "Розпізнати мовлення в текст", + "textToSpeech": "Згенерувати мовлення з тексту", + "qr": "Згенерувати QR-код з тексту", + "quote": "Створити цитату з тексту", + "choice": "Вибрати випадкове значення", + "coin": "Орел або решка", + "when": "випадкова дата" + } +} diff --git a/package-lock.json b/package-lock.json index 30111e6..4947d09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,804 +8,46 @@ "name": "tg-chat-bot", "version": "1.0.0", "dependencies": { - "@google/genai": "^1.50.1", "@libsql/client": "^0.17.3", - "@mistralai/mistralai": "^1.15.1", - "@napi-rs/canvas": "^0.1.100", - "axios": "^1.15.2", + "@mistralai/mistralai": "^2.2.1", + "@napi-rs/canvas": "^1.0.0", + "axios": "^1.16.1", "dotenv": "^17.4.2", - "drizzle-orm": "1.0.0-beta.21", + "drizzle-orm": "0.45.2", "emoji-regex": "^10.6.0", "fluent-ffmpeg": "^2.1.3", "ollama": "^0.6.3", - "openai": "^6.35.0", - "puppeteer": "^24.42.0", - "puppeteer-extra": "^3.3.6", - "puppeteer-extra-plugin-stealth": "^2.11.2", + "openai": "^6.37.0", + "pg": "^8.20.0", "qrcode": "^1.5.4", "sharp": "^0.34.5", - "systeminformation": "^5.31.5", + "systeminformation": "^5.31.6", "twemoji": "^14.0.2", - "typescript-telegram-bot-api": "^0.11.0", - "youtubei.js": "^16.0.1", - "zod": "^4.3.6" + "typescript-telegram-bot-api": "^0.16.0", + "zod": "^4.4.3" }, "devDependencies": { - "@types/bun": "^1.3.13", + "@eslint/js": "^9.39.4", + "@types/bun": "^1.3.14", "@types/fluent-ffmpeg": "^2.1.28", - "@types/node": "^25.6.0", + "@types/node": "^25.8.0", + "@types/pg": "^8.20.0", "@types/qrcode": "^1.5.6", - "@typescript-eslint/eslint-plugin": "^8.59.1", - "@typescript-eslint/parser": "^8.59.1", - "drizzle-kit": "^1.0.0-beta.9-e89174b", "eslint": "^9.39.4", - "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.3" } }, - "node_modules/@azure-rest/core-client": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.5.1.tgz", - "integrity": "sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0", - "@azure/core-tracing": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/abort-controller": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", - "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", - "license": "MIT", - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-auth": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", - "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-util": "^1.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-client": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", - "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-http-compat": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.3.1.tgz", - "integrity": "sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-client": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-lro": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", - "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-util": "^1.2.0", - "@azure/logger": "^1.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-paging": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", - "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", - "license": "MIT", - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-rest-pipeline": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", - "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-tracing": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", - "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-util": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", - "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/identity": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz", - "integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.9.0", - "@azure/core-client": "^1.9.2", - "@azure/core-rest-pipeline": "^1.17.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^4.2.0", - "@azure/msal-node": "^3.5.0", - "open": "^10.1.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/keyvault-common": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.0.0.tgz", - "integrity": "sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.3.0", - "@azure/core-client": "^1.5.0", - "@azure/core-rest-pipeline": "^1.8.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.10.0", - "@azure/logger": "^1.1.4", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/keyvault-keys": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.10.0.tgz", - "integrity": "sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure-rest/core-client": "^2.3.3", - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.9.0", - "@azure/core-http-compat": "^2.2.0", - "@azure/core-lro": "^2.7.2", - "@azure/core-paging": "^1.6.2", - "@azure/core-rest-pipeline": "^1.19.0", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.11.0", - "@azure/keyvault-common": "^2.0.0", - "@azure/logger": "^1.1.4", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/logger": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", - "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/msal-browser": { - "version": "4.27.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.27.0.tgz", - "integrity": "sha512-bZ8Pta6YAbdd0o0PEaL1/geBsPrLEnyY/RDWqvF1PP9RUH8EMLvUMGoZFYS6jSlUan6KZ9IMTLCnwpWWpQRK/w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/msal-common": "15.13.3" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-common": { - "version": "15.13.3", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.3.tgz", - "integrity": "sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-node": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.4.tgz", - "integrity": "sha512-lvuAwsDpPDE/jSuVQOBMpLbXuVuLsPNRwWCyK3/6bPlBk0fGWegqoZ0qjZclMWyQ2JNvIY3vHY7hoFmFmFQcOw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/msal-common": "15.13.3", - "jsonwebtoken": "^9.0.0", - "uuid": "^8.3.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bufbuild/protobuf": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", - "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)" - }, - "node_modules/@drizzle-team/brocli": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.11.0.tgz", - "integrity": "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -825,6 +67,19 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", @@ -850,30 +105,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", @@ -924,40 +155,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/js": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", @@ -995,53 +192,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@google/genai": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz", - "integrity": "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==", - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1071,9 +259,9 @@ } }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "engines": { "node": ">=18" @@ -1162,6 +350,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1178,6 +369,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1194,6 +388,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1210,6 +407,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1226,6 +426,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1242,6 +445,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1258,6 +464,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1274,6 +483,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1290,6 +502,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1312,6 +527,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1334,6 +552,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1356,6 +577,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1378,6 +602,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1400,6 +627,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1422,6 +652,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1444,6 +677,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1535,43 +771,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@js-joda/core": { - "version": "5.6.5", - "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.6.5.tgz", - "integrity": "sha512-3zwefSMwHpu8iVUW8YYz227sIv6UFqO31p1Bf1ZH/Vom7CmNyUsXjDBlnNzcuhmOL1XfxZ3nvND42kR23XlbcQ==", - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/@js-temporal/polyfill": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", - "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "jsbi": "^4.3.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@libsql/client": { "version": "0.17.3", "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.3.tgz", @@ -1732,19 +931,20 @@ ] }, "node_modules/@mistralai/mistralai": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.15.1.tgz", - "integrity": "sha512-fb995eiz3r0KsBGtRjFV+/iLbX+UpfalxpF+YitT3R6ukrPD4PN+FGwwmYcRFhNAzVzDUtTVxQYnjQWEnwV5nw==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "license": "Apache-2.0", "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.24.1" + "zod-to-json-schema": "^3.25.0" } }, "node_modules/@napi-rs/canvas": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", - "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.0.tgz", + "integrity": "sha512-Jqxcy1XOIqj+lH9sl1GT+il6GR3uQv13vI2mrwubP3uT8Olak2ClDrK2RnxlQKjwv8BRr4b3ug0YR7c6hBX8wg==", "license": "MIT", "workspaces": [ "e2e/*" @@ -1757,23 +957,23 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.100", - "@napi-rs/canvas-darwin-arm64": "0.1.100", - "@napi-rs/canvas-darwin-x64": "0.1.100", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", - "@napi-rs/canvas-linux-arm64-musl": "0.1.100", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", - "@napi-rs/canvas-linux-x64-gnu": "0.1.100", - "@napi-rs/canvas-linux-x64-musl": "0.1.100", - "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", - "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + "@napi-rs/canvas-android-arm64": "1.0.0", + "@napi-rs/canvas-darwin-arm64": "1.0.0", + "@napi-rs/canvas-darwin-x64": "1.0.0", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.0", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.0", + "@napi-rs/canvas-linux-arm64-musl": "1.0.0", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.0", + "@napi-rs/canvas-linux-x64-gnu": "1.0.0", + "@napi-rs/canvas-linux-x64-musl": "1.0.0", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.0", + "@napi-rs/canvas-win32-x64-msvc": "1.0.0" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", - "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.0.tgz", + "integrity": "sha512-3hNKJObUK7JsCF9aJlVCs1J0/KE/gGfZNeK8MO1ge6bB3aicr5walGme9t9No1f/oyk9GgvdAT/rjSdsx3gbIw==", "cpu": [ "arm64" ], @@ -1791,9 +991,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", - "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-ZIja19/BiGz2puhki+WUYSRriwFeFJ8Mi9eK3hZdSS85w4Y60cuEAJVhMCfKwswQkKkUtrnzdKMBuO7TupvexA==", "cpu": [ "arm64" ], @@ -1811,9 +1011,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", - "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.0.tgz", + "integrity": "sha512-hImggWc82jqZVpEsFR9S7PE9OQYjq/H/D7vwCGB6X1jRH+UVBP1+1niJTPBOat1B154T6GKK7/kcFtoWgjgFzQ==", "cpu": [ "x64" ], @@ -1831,9 +1031,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", - "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.0.tgz", + "integrity": "sha512-hlJRy6d+kWLKVOG/+1rEvNQVURZ0DxxRPJsLmEWwhwiXZUJc0BF5o9esALHSEP4CoJK4wChRtj3hnyBgVx2oWA==", "cpu": [ "arm" ], @@ -1851,12 +1051,15 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", - "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.0.tgz", + "integrity": "sha512-5Hru4T3RXkosRQafcjelv7AUzw9mXqmGYsxnzeDDOWveFCJyEPMSJltvGCM+jfH98seOCbfwm9KyFg6Jm5FhAA==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1871,12 +1074,15 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", - "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.0.tgz", + "integrity": "sha512-LTUl9jS8WsLSUGaxQZKQkxfluOJRpgvBuxxdM4pYcjib+di8AU4OzQc6+L6SzGMLcKc9H0RAjojRatBhTMqYdg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1891,12 +1097,15 @@ } }, "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", - "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.0.tgz", + "integrity": "sha512-Iz931SAZf+WVDzpjk52Q3ffW3zw0YflFwEZMgs036Wfu1kX/LrwT9wGjsuSqyduqefUkl91/vTdAjn8hQu5ezA==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1911,12 +1120,15 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", - "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.0.tgz", + "integrity": "sha512-pFEQ5eFK4JusgN1K6KkO9DKP/Hi1WMJOkF8Ch03/khTc4bFbCKkCCsJG4YcOMOW9bI4XbT2/eMAWxhO0xaWgPA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1931,12 +1143,15 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", - "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.0.tgz", + "integrity": "sha512-jnvr8NrLHiZ3NCiOKWqDbkI4Ah+QDrqtZ+sddPZBltEb1mQ2coSvCSJYfict+oAwcm0c970oTmVySpjKP/lnaA==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1951,9 +1166,9 @@ } }, "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", - "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.0.tgz", + "integrity": "sha512-y2j9/Gfd5joqiqxdP/L1smqjQ+uAx3C4N0EC7bDHrnZEEH8ToM/OC5p3uHvtj4Lq591aHj+ArL01UDLNwT5HgQ==", "cpu": [ "arm64" ], @@ -1971,9 +1186,9 @@ } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", - "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.0.tgz", + "integrity": "sha512-qwdhh9N6Gge/hC4pL9S1tQp0iKwhSl/dYjg7+RGp9k26iRGRi5MqqUyKGOXIWli0zOcuy5Y2wIH/jk2ry6i/jA==", "cpu": [ "x64" ], @@ -1996,245 +1211,20 @@ "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", "license": "MIT" }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, - "node_modules/@puppeteer/browsers": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", - "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.4.3", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.4", - "tar-fs": "^3.1.1", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@puppeteer/browsers/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@puppeteer/browsers/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@puppeteer/browsers/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/@puppeteer/browsers/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@puppeteer/browsers/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@puppeteer/browsers/node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/@puppeteer/browsers/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/@tediousjs/connection-string": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz", - "integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "license": "MIT" - }, "node_modules/@types/bun": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.13.tgz", - "integrity": "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz", + "integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==", "dev": true, "license": "MIT", "dependencies": { - "bun-types": "1.3.13" - } - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" + "bun-types": "1.3.14" } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -2255,31 +1245,25 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/mssql": { - "version": "9.1.8", - "resolved": "https://registry.npmjs.org/@types/mssql/-/mssql-9.1.8.tgz", - "integrity": "sha512-mt9h5jWj+DYE5jxnKaWSV/GqDf9FV52XYVk6T3XZF69noEe+JJV6MKirii48l81+cjmAkSq+qeKX+k61fHkYrQ==", + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", "license": "MIT", - "peer": true, "dependencies": { - "@types/node": "*", - "tarn": "^3.0.1", - "tedious": "*" + "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" } }, "node_modules/@types/qrcode": { @@ -2292,22 +1276,6 @@ "@types/node": "*" } }, - "node_modules/@types/readable-stream": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", - "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2317,28 +1285,18 @@ "@types/node": "*" } }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", - "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/type-utils": "8.59.1", - "@typescript-eslint/utils": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2351,22 +1309,32 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.1", + "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", - "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "engines": { @@ -2382,14 +1350,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", - "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.1", - "@typescript-eslint/types": "^8.59.1", + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "engines": { @@ -2404,14 +1372,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", - "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1" + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2422,9 +1390,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", - "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", "dev": true, "license": "MIT", "engines": { @@ -2439,15 +1407,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", - "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2464,9 +1432,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", - "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", "dev": true, "license": "MIT", "engines": { @@ -2478,16 +1446,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", - "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.1", - "@typescript-eslint/tsconfig-utils": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2516,9 +1484,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -2545,16 +1513,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", - "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1" + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2569,13 +1537,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", - "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2599,34 +1567,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", - "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", - "license": "MIT", - "peer": true, - "dependencies": { - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "peer": true, - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2650,19 +1590,10 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2677,15 +1608,12 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=8" } }, "node_modules/ansi-styles": { @@ -2707,29 +1635,9 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, - "node_modules/arr-union": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", - "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/async": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", @@ -2738,281 +1646,74 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, - "node_modules/axios/node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" }, "engines": { "node": ">= 6" } }, - "node_modules/axios/node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/b4a": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", - "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.3.tgz", - "integrity": "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", - "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/basic-ftp": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", - "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/bl": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", - "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/readable-stream": "^4.0.0", - "buffer": "^6.0.3", - "inherits": "^2.0.4", - "readable-stream": "^4.2.0" - } - }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/bun-types": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.13.tgz", - "integrity": "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz", + "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==", "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" } }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3030,6 +1731,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3061,28 +1763,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chromium-bidi": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", - "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/chromium-bidi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -3094,77 +1774,6 @@ "wrap-ansi": "^6.2.0" } }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/clone-deep": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", - "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", - "license": "MIT", - "dependencies": { - "for-own": "^0.1.3", - "is-plain-object": "^2.0.1", - "kind-of": "^3.0.2", - "lazy-cache": "^1.0.3", - "shallow-clone": "^0.1.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3187,6 +1796,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3194,52 +1804,18 @@ "node": ">= 0.8" } }, - "node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3250,15 +1826,6 @@ "node": ">= 8" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3292,95 +1859,24 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/default-browser": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", - "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", - "license": "MIT", - "peer": true, - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", "engines": { "node": ">=0.4.0" } }, "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", "license": "Apache-2.0", "engines": { "node": ">=8" } }, - "node_modules/devtools-protocol": { - "version": "0.0.1595872", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1595872.tgz", - "integrity": "sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==", - "license": "BSD-3-Clause" - }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", @@ -3399,32 +1895,14 @@ "url": "https://dotenvx.com" } }, - "node_modules/drizzle-kit": { - "version": "1.0.0-beta.9-e89174b", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-1.0.0-beta.9-e89174b.tgz", - "integrity": "sha512-Xrw3k8E2CbSZr+kqe3k5W4oxd2fbEyczjKtyGIkAq0x9Wqpa/VtAT6Mkh83sIzqG4OSN7lOoUafsDxSE/AR7RA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@drizzle-team/brocli": "^0.11.0", - "@js-temporal/polyfill": "^0.5.1", - "esbuild": "^0.25.10", - "tsx": "^4.20.6" - }, - "bin": { - "drizzle-kit": "bin.cjs" - } - }, "node_modules/drizzle-orm": { - "version": "1.0.0-beta.21", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-1.0.0-beta.21.tgz", - "integrity": "sha512-HZcIbVn5J9T/Z91Wj12Pn7Pi8/1aykS/GPJf2lXeZnEuPjxaBfQ+YAt0Sl+XI+9R/D1BpK+2fdIqbpuaTbcvqA==", + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", - "@effect/sql": "^0.48.5", - "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", @@ -3432,33 +1910,25 @@ "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", - "@sinclair/typebox": ">=0.34.8", - "@sqlitecloud/drivers": ">=1.0.653", + "@prisma/client": "*", "@tidbcloud/serverless": "*", - "@tursodatabase/database": ">=0.2.1", - "@tursodatabase/database-common": ">=0.2.1", - "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", - "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", - "arktype": ">=2.0.0", - "better-sqlite3": ">=9.3.0", + "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", - "mssql": "^11.0.1", + "knex": "*", + "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", - "sqlite3": ">=5", - "typebox": ">=1.0.0", - "valibot": ">=1.0.0-beta.7", - "zod": "^3.25.0 || ^4.0.0" + "sqlite3": ">=5" }, "peerDependenciesMeta": { "@aws-sdk/client-rds-data": { @@ -3467,12 +1937,6 @@ "@cloudflare/workers-types": { "optional": true }, - "@effect/sql": { - "optional": true - }, - "@effect/sql-pg": { - "optional": true - }, "@electric-sql/pglite": { "optional": true }, @@ -3494,24 +1958,12 @@ "@planetscale/database": { "optional": true }, - "@sinclair/typebox": { - "optional": true - }, - "@sqlitecloud/drivers": { + "@prisma/client": { "optional": true }, "@tidbcloud/serverless": { "optional": true }, - "@tursodatabase/database": { - "optional": true - }, - "@tursodatabase/database-common": { - "optional": true - }, - "@tursodatabase/database-wasm": { - "optional": true - }, "@types/better-sqlite3": { "optional": true }, @@ -3530,9 +1982,6 @@ "@xata.io/client": { "optional": true }, - "arktype": { - "optional": true - }, "better-sqlite3": { "optional": true }, @@ -3545,6 +1994,12 @@ "gel": { "optional": true }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, "mysql2": { "optional": true }, @@ -3554,20 +2009,14 @@ "postgres": { "optional": true }, + "prisma": { + "optional": true + }, "sql.js": { "optional": true }, "sqlite3": { "optional": true - }, - "typebox": { - "optional": true - }, - "valibot": { - "optional": true - }, - "zod": { - "optional": true } } }, @@ -3585,54 +2034,12 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3678,57 +2085,6 @@ "node": ">= 0.4" } }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3742,27 +2098,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", @@ -3841,30 +2176,6 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", @@ -3877,29 +2188,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -3918,32 +2206,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -3974,6 +2236,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -3983,66 +2246,12 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4050,12 +2259,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4070,15 +2273,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4097,29 +2291,6 @@ } } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4198,9 +2369,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -4217,53 +2388,20 @@ } } }, - "node_modules/for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/for-own": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", - "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { - "for-in": "^1.0.1" + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" + "node": ">= 6" } }, "node_modules/fs-extra": { @@ -4289,27 +2427,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4319,35 +2436,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gaxios": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^5.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4394,77 +2482,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/get-uri/node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4491,33 +2508,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/google-auth-library": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", - "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.0.0", - "gcp-metadata": "^8.0.0", - "google-logging-utils": "^1.0.0", - "gtoken": "^8.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4536,19 +2526,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/gtoken": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", - "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", - "license": "MIT", - "dependencies": { - "gaxios": "^7.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4587,9 +2564,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4598,74 +2575,10 @@ "node": ">= 0.4" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause", - "peer": true - }, "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -4676,6 +2589,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -4698,69 +2612,6 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "license": "MIT" - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "license": "MIT", - "peer": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4793,106 +2644,23 @@ "node": ">=0.10.0" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "license": "MIT", - "peer": true, - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/js-base64": { "version": "3.7.8", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", "license": "BSD-3-Clause" }, - "node_modules/js-md4": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", - "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", - "license": "MIT", - "peer": true - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4901,22 +2669,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbi": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", - "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4924,12 +2676,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4956,50 +2702,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "license": "MIT", - "peer": true, - "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5010,27 +2712,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "license": "MIT", - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/lazy-cache": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", - "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5077,21 +2758,6 @@ "@libsql/win32-x64-msvc": "0.5.29" } }, - "node_modules/libsql/node_modules/detect-libc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", - "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5108,48 +2774,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT", - "peer": true - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT", - "peer": true - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT", - "peer": true - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT", - "peer": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT", - "peer": true - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT", - "peer": true - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5157,25 +2781,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT", - "peer": true - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5185,98 +2790,38 @@ "node": ">= 0.4" } }, - "node_modules/merge-deep": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", - "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", - "license": "MIT", - "dependencies": { - "arr-union": "^3.1.0", - "clone-deep": "^0.2.4", - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/meriyah": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", - "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", - "license": "ISC", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/mime-db": { - "version": "1.46.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.46.0.tgz", - "integrity": "sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.29", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.29.tgz", - "integrity": "sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ==", + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", "dependencies": { - "mime-db": "1.46.0" + "mime-db": "1.52.0" }, "engines": { "node": ">= 0.6" } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, - "node_modules/mixin-object": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", - "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", - "license": "MIT", - "dependencies": { - "for-in": "^0.1.3", - "is-extendable": "^0.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mixin-object/node_modules/for-in": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", - "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "node": "*" } }, "node_modules/ms": { @@ -5285,69 +2830,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mssql": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/mssql/-/mssql-11.0.1.tgz", - "integrity": "sha512-KlGNsugoT90enKlR8/G36H0kTxPthDhmtNUCwEHvgRza5Cjpjoj+P2X6eMpFUDN7pFrJZsKadL4x990G8RBE1w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@tediousjs/connection-string": "^0.5.0", - "commander": "^11.0.0", - "debug": "^4.3.3", - "rfdc": "^1.3.0", - "tarn": "^3.0.2", - "tedious": "^18.2.1" - }, - "bin": { - "mssql": "bin/mssql" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/mssql/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mssql/node_modules/tedious": { - "version": "18.6.2", - "resolved": "https://registry.npmjs.org/tedious/-/tedious-18.6.2.tgz", - "integrity": "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/core-auth": "^1.7.2", - "@azure/identity": "^4.2.1", - "@azure/keyvault-keys": "^4.4.0", - "@js-joda/core": "^5.6.1", - "@types/node": ">=18", - "bl": "^6.0.11", - "iconv-lite": "^0.6.3", - "js-md4": "^0.3.2", - "native-duplexpair": "^1.0.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/native-duplexpair": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", - "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", - "license": "MIT", - "peer": true - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5355,53 +2837,6 @@ "dev": true, "license": "MIT" }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/ollama": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz", @@ -5411,38 +2846,10 @@ "whatwg-fetch": "^3.6.20" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "license": "MIT", - "peer": true, - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/openai": { - "version": "6.35.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.35.0.tgz", - "integrity": "sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q==", + "version": "6.37.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.37.0.tgz", + "integrity": "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==", "license": "Apache-2.0", "bin": { "openai": "bin/cli" @@ -5510,19 +2917,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -5532,48 +2926,11 @@ "node": ">=6" } }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -5582,24 +2939,6 @@ "node": ">=6" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5609,51 +2948,104 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">= 16.0.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", "license": "MIT" }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } }, "node_modules/picomatch": { "version": "4.0.4", @@ -5668,6 +3060,54 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5678,97 +3118,19 @@ "node": ">= 0.8.0" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/promise-limit": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", "license": "ISC" }, - "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" + "engines": { + "node": ">=10" } }, "node_modules/punycode": { @@ -5781,275 +3143,6 @@ "node": ">=6" } }, - "node_modules/puppeteer": { - "version": "24.42.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.42.0.tgz", - "integrity": "sha512-94MoPfFp2eY3eYIMdINkez4IOP5TMHntlZbVx06fHlQTtiQiYgaY0L2Zzfod8PVUkPqP7m3Qlre2v8YS8cudPA==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.13.0", - "chromium-bidi": "14.0.0", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1595872", - "puppeteer-core": "24.42.0", - "typed-query-selector": "^2.12.1" - }, - "bin": { - "puppeteer": "lib/cjs/puppeteer/node/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core": { - "version": "24.42.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.42.0.tgz", - "integrity": "sha512-T4zXokk/izH01fYPhyyev1A4piWiOKrYq7CUFpdoYQxmOnXoV6YjUabmfIjCYkNspSoAXIxRid3Tw+Vg0fthYg==", - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.13.0", - "chromium-bidi": "14.0.0", - "debug": "^4.4.3", - "devtools-protocol": "0.0.1595872", - "typed-query-selector": "^2.12.1", - "webdriver-bidi-protocol": "0.4.1", - "ws": "^8.19.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-extra": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/puppeteer-extra/-/puppeteer-extra-3.3.6.tgz", - "integrity": "sha512-rsLBE/6mMxAjlLd06LuGacrukP2bqbzKCLzV1vrhHFavqQE/taQ2UXv3H5P0Ls7nsrASa+6x3bDbXHpqMwq+7A==", - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.0", - "debug": "^4.1.1", - "deepmerge": "^4.2.2" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "@types/puppeteer": "*", - "puppeteer": "*", - "puppeteer-core": "*" - }, - "peerDependenciesMeta": { - "@types/puppeteer": { - "optional": true - }, - "puppeteer": { - "optional": true - }, - "puppeteer-core": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", - "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.0", - "debug": "^4.1.1", - "merge-deep": "^3.0.1" - }, - "engines": { - "node": ">=9.11.2" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin-stealth": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", - "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "puppeteer-extra-plugin": "^3.2.3", - "puppeteer-extra-plugin-user-preferences": "^2.4.1" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin-user-data-dir": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", - "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^10.0.0", - "puppeteer-extra-plugin": "^3.2.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/puppeteer-extra-plugin-user-preferences": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", - "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "deepmerge": "^4.2.2", - "puppeteer-extra-plugin": "^3.2.3", - "puppeteer-extra-plugin-user-data-dir": "^2.4.1" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -6067,32 +3160,6 @@ "node": ">=10.13.0" } }, - "node_modules/qrcode/node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "peer": true, - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6112,96 +3179,16 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT", - "peer": true - }, - "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "peer": true - }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6216,42 +3203,6 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, - "node_modules/shallow-clone": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", - "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.1", - "kind-of": "^2.0.1", - "lazy-cache": "^0.2.3", - "mixin-object": "^2.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/shallow-clone/node_modules/kind-of": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", - "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", - "license": "MIT", - "dependencies": { - "is-buffer": "^1.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/shallow-clone/node_modules/lazy-cache": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", - "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -6296,10 +3247,20 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/sharp/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6312,118 +3273,22 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "license": "ISC", "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.2.0" + "node": ">= 10.x" } }, "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", @@ -6437,56 +3302,13 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { + "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -6498,15 +3320,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6534,9 +3347,9 @@ } }, "node_modules/systeminformation": { - "version": "5.31.5", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.5.tgz", - "integrity": "sha512-5SyLdip4/3alxD4Kh+63bUQTJmu7YMfYQTC+koZy7X73HgNqZSD2P4wOZQWtUncvPvcEmnfIjCoygN4MRoEejQ==", + "version": "5.31.6", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.6.tgz", + "integrity": "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA==", "license": "MIT", "os": [ "darwin", @@ -6559,72 +3372,6 @@ "url": "https://www.buymeacoffee.com/systeminfo" } }, - "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/tarn": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", - "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/tedious": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/tedious/-/tedious-19.2.0.tgz", - "integrity": "sha512-2dDjX0KP54riDvJPiiIozv0WRS/giJb3/JG2lWpa2dgM0Gha7mLAxbTR3ltPkGzfoS6M3oDnhYnWuzeaZibHuQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/core-auth": "^1.7.2", - "@azure/identity": "^4.2.1", - "@azure/keyvault-keys": "^4.4.0", - "@js-joda/core": "^5.6.5", - "@types/node": ">=18", - "bl": "^6.1.4", - "iconv-lite": "^0.7.0", - "js-md4": "^0.3.2", - "native-duplexpair": "^1.0.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">=18.17" - } - }, - "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -6659,511 +3406,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } + "license": "0BSD", + "optional": true }, "node_modules/twemoji": { "version": "14.0.2", @@ -7196,17 +3440,11 @@ "node": ">= 0.8.0" } }, - "node_modules/typed-query-selector": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz", - "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==", - "license": "MIT" - }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7216,36 +3454,44 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/typescript-telegram-bot-api": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/typescript-telegram-bot-api/-/typescript-telegram-bot-api-0.11.0.tgz", - "integrity": "sha512-pWSv0fglpnETAGtptNaqHjqreUTunRstfxeI9opdhq7P8T8T/tbBH8nLzP7WVAoFW55F4I6biKa9NOx1bs5O3Q==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/typescript-telegram-bot-api/-/typescript-telegram-bot-api-0.16.0.tgz", + "integrity": "sha512-NaAjXucQiZ87U8La/IMaWDOghbMlJzfMbU4rG8ppFpgPOvEwat/zfN5BM+J2QDvKVGN87qJ+1nELnkm3ctSLnQ==", "license": "ISC", "dependencies": { "axios": "^1.7.7", "form-data": "^4.0.1" } }, - "node_modules/typescript-telegram-bot-api/node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, "node_modules/universalify": { @@ -7267,31 +3513,6 @@ "punycode": "^2.1.0" } }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "peer": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/webdriver-bidi-protocol": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", - "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", - "license": "Apache-2.0" - }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", @@ -7302,6 +3523,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -7330,103 +3552,23 @@ } }, "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7444,20 +3586,13 @@ } } }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "license": "MIT", - "peer": true, - "dependencies": { - "is-wsl": "^3.1.0" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.4" } }, "node_modules/y18n": { @@ -7501,21 +3636,6 @@ "node": ">=6" } }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, "node_modules/yargs/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -7568,42 +3688,6 @@ "node": ">=8" } }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -7617,35 +3701,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/youtubei.js": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-16.0.1.tgz", - "integrity": "sha512-3802bCAGkBc2/G5WUTc0l/bO5mPYJbQAHL04d9hE9PnrDHoBUT8MN721Yqt4RCNncAXdHcfee9VdJy3Fhq1r5g==", - "funding": [ - "https://github.com/sponsors/LuanRT" - ], - "license": "MIT", - "dependencies": { - "@bufbuild/protobuf": "^2.0.0", - "meriyah": "^6.1.4" - } - }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", "peerDependencies": { - "zod": "^3.25 || ^4" + "zod": "^3.25.28 || ^4" } } } diff --git a/package.json b/package.json index da898b9..b2ddc2d 100644 --- a/package.json +++ b/package.json @@ -2,44 +2,44 @@ "name": "tg-chat-bot", "main": "src/index.ts", "version": "1.0.0", + "type": "module", "scripts": { "build": "tsc -p tsconfig.build.json", + "test": "npm run build && node --test test/*.test.mjs", + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "start": "node dist/index.js", - "bun:start": "bun run dist/index.js" + "bun:start": "bun run src/index.ts" }, "dependencies": { - "@google/genai": "^1.50.1", "@libsql/client": "^0.17.3", - "@mistralai/mistralai": "^1.15.1", - "@napi-rs/canvas": "^0.1.100", - "axios": "^1.15.2", + "@mistralai/mistralai": "^2.2.1", + "@napi-rs/canvas": "^1.0.0", + "axios": "^1.16.1", "dotenv": "^17.4.2", - "drizzle-orm": "1.0.0-beta.21", + "drizzle-orm": "0.45.2", "emoji-regex": "^10.6.0", "fluent-ffmpeg": "^2.1.3", "ollama": "^0.6.3", - "openai": "^6.35.0", - "puppeteer": "^24.42.0", - "puppeteer-extra": "^3.3.6", - "puppeteer-extra-plugin-stealth": "^2.11.2", + "openai": "^6.37.0", + "pg": "^8.20.0", "qrcode": "^1.5.4", "sharp": "^0.34.5", - "systeminformation": "^5.31.5", + "systeminformation": "^5.31.6", "twemoji": "^14.0.2", - "typescript-telegram-bot-api": "^0.11.0", - "youtubei.js": "^16.0.1", - "zod": "^4.3.6" + "typescript-telegram-bot-api": "^0.16.0", + "zod": "^4.4.3" }, "devDependencies": { - "@types/bun": "^1.3.13", - "@types/node": "^25.6.0", - "@types/qrcode": "^1.5.6", + "@eslint/js": "^9.39.4", + "@types/bun": "^1.3.14", "@types/fluent-ffmpeg": "^2.1.28", - "@typescript-eslint/eslint-plugin": "^8.59.1", - "@typescript-eslint/parser": "^8.59.1", - "drizzle-kit": "^1.0.0-beta.9-e89174b", + "@types/node": "^25.8.0", + "@types/pg": "^8.20.0", + "@types/qrcode": "^1.5.6", "eslint": "^9.39.4", - "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.3" } } diff --git a/src/ai/ai-logger.ts b/src/ai/ai-logger.ts new file mode 100644 index 0000000..69d7958 --- /dev/null +++ b/src/ai/ai-logger.ts @@ -0,0 +1 @@ +export * from "../logging/ai-logger"; diff --git a/src/ai/ai-runtime-target.ts b/src/ai/ai-runtime-target.ts new file mode 100644 index 0000000..ba45d47 --- /dev/null +++ b/src/ai/ai-runtime-target.ts @@ -0,0 +1,183 @@ +import {Mistral} from "@mistralai/mistralai"; +import {Ollama} from "ollama"; +import {OpenAI} from "openai"; +import {Environment} from "../common/environment"; +import {AiModelCapabilities} from "../model/ai-model-capabilities"; +import {AiProvider} from "../model/ai-provider"; + +export type AiCapabilityName = keyof AiModelCapabilities; +export type AiRuntimePurpose = AiCapabilityName | "chat"; + +export type AiRuntimeTarget = { + provider: AiProvider; + purpose: AiRuntimePurpose; + model: string; + baseUrl?: string; + apiKey?: string; + systemPromptAdditions?: string | null; +}; + +const PURPOSE_SUFFIXES: Record = { + chat: ["CHAT"], + vision: ["VISION", "IMAGE"], + ocr: ["OCR", "VISION", "IMAGE"], + thinking: ["THINKING", "THINK"], + extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"], + tools: ["TOOLS", "CHAT"], + toolRank: ["TOOL_RANK", "TOOL_RANKER"], + audio: ["AUDIO"], + documents: ["DOCUMENTS", "RAG", "EMBEDDING"], + outputImages: ["OUTPUT_IMAGES", "IMAGE"], + speechToText: ["SPEECH_TO_TEXT", "TRANSCRIPTION", "STT", "AUDIO"], + textToSpeech: ["TEXT_TO_SPEECH", "TTS"], +}; + +function providerPrefix(provider: AiProvider): string { + return provider.toString(); +} + +function env(name: string): string | undefined { + return Environment.getOptionalConfigValue(name); +} + +function firstEnv(names: string[]): string | undefined { + for (const name of names) { + const value = env(name); + if (value) return value; + } + + return undefined; +} + +function endpointEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[] { + const prefix = providerPrefix(provider); + return PURPOSE_SUFFIXES[purpose].flatMap(suffix => [ + `${prefix}_${suffix}_BASE_URL`, + `${prefix}_${suffix}_ENDPOINT`, + `${prefix}_${suffix}_ADDRESS`, + ]); +} + +function apiKeyEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[] { + const prefix = providerPrefix(provider); + return PURPOSE_SUFFIXES[purpose].map(suffix => `${prefix}_${suffix}_API_KEY`); +} + +function modelEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[] { + const prefix = providerPrefix(provider); + return PURPOSE_SUFFIXES[purpose].map(suffix => `${prefix}_${suffix}_MODEL`); +} + +function systemPromptEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[] { + const prefix = providerPrefix(provider); + return PURPOSE_SUFFIXES[purpose].flatMap(suffix => [ + `${prefix}_${suffix}_SYSTEM_PROMPT_ADDITIONS`, + `${prefix}_${suffix}_SYSTEM_PROMPT`, + ]); +} + +export function getProviderBaseUrl(provider: AiProvider): string | undefined { + switch (provider) { + case AiProvider.OLLAMA: + return env("OLLAMA_ADDRESS"); + case AiProvider.MISTRAL: + return env("MISTRAL_BASE_URL") ?? env("MISTRAL_ENDPOINT"); + case AiProvider.OPENAI: + return env("OPENAI_BASE_URL") ?? env("OPENAI_ENDPOINT"); + } +} + +export function getProviderApiKey(provider: AiProvider): string | undefined { + switch (provider) { + case AiProvider.OLLAMA: + return Environment.OLLAMA_API_KEY; + case AiProvider.MISTRAL: + return Environment.MISTRAL_API_KEY; + case AiProvider.OPENAI: + return Environment.OPENAI_API_KEY; + } +} + +export function getDefaultModelForPurpose(provider: AiProvider, purpose: AiRuntimePurpose): string { + switch (provider) { + case AiProvider.OLLAMA: + switch (purpose) { + case "vision": + case "ocr": + case "outputImages": + return Environment.OLLAMA_IMAGE_MODEL; + case "thinking": + case "extendedThinking": + return Environment.OLLAMA_THINK_MODEL; + case "audio": + case "speechToText": + return Environment.OLLAMA_AUDIO_MODEL; + case "documents": + return Environment.OLLAMA_EMBEDDING_MODEL; + default: + return Environment.OLLAMA_CHAT_MODEL; + } + case AiProvider.MISTRAL: + switch (purpose) { + case "speechToText": + return Environment.MISTRAL_TRANSCRIPTION_MODEL; + case "textToSpeech": + return Environment.MISTRAL_TTS_MODEL || Environment.MISTRAL_MODEL; + default: + return Environment.MISTRAL_MODEL; + } + case AiProvider.OPENAI: + switch (purpose) { + case "outputImages": + return Environment.OPENAI_IMAGE_MODEL; + case "speechToText": + return Environment.OPENAI_TRANSCRIPTION_MODEL; + case "textToSpeech": + return Environment.OPENAI_TTS_MODEL; + default: + return Environment.OPENAI_MODEL; + } + } +} + +export function resolveAiRuntimeTarget( + provider: AiProvider, + purpose: AiRuntimePurpose, + modelOverride?: string, +): AiRuntimeTarget { + const model = modelOverride + ?? firstEnv(modelEnvNames(provider, purpose)) + ?? getDefaultModelForPurpose(provider, purpose); + const baseUrl = firstEnv(endpointEnvNames(provider, purpose)) ?? getProviderBaseUrl(provider); + const apiKey = firstEnv(apiKeyEnvNames(provider, purpose)) ?? getProviderApiKey(provider); + const systemPromptAdditions = firstEnv(systemPromptEnvNames(provider, purpose)); + + return {provider, purpose, model, baseUrl, apiKey, systemPromptAdditions}; +} + +export function sameRuntimeEndpoint(left: AiRuntimeTarget, right: AiRuntimeTarget): boolean { + return left.provider === right.provider + && (left.baseUrl ?? "") === (right.baseUrl ?? "") + && (left.apiKey ?? "") === (right.apiKey ?? ""); +} + +export function createOpenAiClient(target: AiRuntimeTarget): OpenAI { + return new OpenAI({ + apiKey: target.apiKey, + baseURL: target.baseUrl, + }); +} + +export function createMistralClient(target: AiRuntimeTarget): Mistral { + return new Mistral({ + apiKey: target.apiKey, + serverURL: target.baseUrl, + }); +} + +export function createOllamaClient(target: AiRuntimeTarget): Ollama { + return new Ollama({ + host: target.baseUrl, + headers: target.apiKey ? {"Authorization": `Bearer ${target.apiKey}`} : undefined, + }); +} diff --git a/src/ai/cancel-registry.ts b/src/ai/cancel-registry.ts new file mode 100644 index 0000000..f48cf9b --- /dev/null +++ b/src/ai/cancel-registry.ts @@ -0,0 +1,55 @@ +import {randomUUID} from "node:crypto"; + +export type AiCancelRequest = { + id: string; + chatId: number; + messageId?: number; + fromId: number; + provider: string; + controller: AbortController; + onCancel?: () => Promise | void; +}; + +const requests = new Map(); + +export function createAiCancelRequest(params: Omit & { controller?: AbortController }): AiCancelRequest { + const request: AiCancelRequest = { + id: randomUUID(), + controller: params.controller ?? new AbortController(), + chatId: params.chatId, + messageId: params.messageId, + fromId: params.fromId, + provider: params.provider, + onCancel: params.onCancel, + }; + requests.set(request.id, request); + return request; +} + +export function setAiCancelMessageId(id: string, messageId: number): void { + const request = requests.get(id); + if (request) request.messageId = messageId; +} + +export function getAiCancelRequest(id: string): AiCancelRequest | undefined { + return requests.get(id); +} + +export async function abortAiRequest(id: string): Promise { + const request = requests.get(id); + if (!request) return false; + + request.controller.abort(); + + try { + await request.onCancel?.(); + } finally { + requests.delete(id); + } + + return true; +} + +export function finishAiRequest(id: string): void { + requests.delete(id); +} diff --git a/src/ai/chat-messages-types.ts b/src/ai/chat-messages-types.ts new file mode 100644 index 0000000..d8c09a0 --- /dev/null +++ b/src/ai/chat-messages-types.ts @@ -0,0 +1,45 @@ +import {AiToolCall} from "./tool-types"; +import {OllamaChatMessage} from "./ollama-chat-message"; +import {MistralChatMessage} from "./mistral-chat-message"; +import {MessageAudioPart, MessageImagePart} from "../common/message-part"; +import {OpenAIChatMessage} from "./openai-chat-message"; + +export type ChatMessage = { + role: "system" | "user" | "assistant" | "tool"; + content: string; + images?: string[]; + imageParts?: MessageImagePart[]; + documents?: string[]; + audios?: string[]; + audioParts?: MessageAudioPart[]; + videos?: string[]; + videoNotes?: string[]; + thinking?: string; + tool_calls?: AiToolCall[]; + tool_name?: string; +} + +export function asOllamaChatMessage(message: ChatMessage): OllamaChatMessage { + return { + role: message.role, + content: message.content, + thinking: message.thinking, + images: message.images, + tool_calls: message.tool_calls, + tool_name: message.tool_name + }; +} + +export function asMistralChatMessage(message: ChatMessage): MistralChatMessage { + return { + role: message.role, + content: message.content, + }; +} + +// export function asOpenAIChatMessage(message: ChatMessage): OpenAIChatMessage { +// return { +// +// } +// } +export type AiChatMessage = OpenAIChatMessage | OllamaChatMessage | MistralChatMessage; diff --git a/src/ai/conversation-pipeline.ts b/src/ai/conversation-pipeline.ts new file mode 100644 index 0000000..a30e844 --- /dev/null +++ b/src/ai/conversation-pipeline.ts @@ -0,0 +1,341 @@ +import {Message} from "typescript-telegram-bot-api"; +import type { + ResponseInputMessageContentList, + ResponseOutputMessage, + ResponseOutputText, +} from "openai/resources/responses/responses"; +import {AiProvider} from "../model/ai-provider"; +import {MessageStore} from "../common/message-store"; +import {collectReplyChainText} from "../util/utils"; +import type {AiDownloadedFile} from "./telegram-attachments"; +import type {MessageAudioPart, MessageImagePart, MessagePart} from "../common/message-part"; +import type {UserAiResponseLanguage} from "../common/user-ai-settings"; +import {getResponseLanguageInstruction} from "../common/user-ai-settings"; +import {pythonInterpreterToolPrompt} from "./tools/python-interpretator"; +import type {AttachmentKind, AiRuntimeTarget, RuntimeConfigSnapshot} from "./unified-ai-runner.shared"; +import type {OpenAIChatMessage} from "./openai-chat-message"; +import type {MistralChatMessage} from "./mistral-chat-message"; +import type {OllamaChatMessage} from "./ollama-chat-message"; + +export type ConversationAttachment = { + kind: AttachmentKind; + data: string; + mimeType: string; + fileName?: string; +}; + +export type ConversationTurn = { + bot: boolean; + name?: string; + langCode?: string; + userName?: string; + content: string; + deletedByBotAt?: number | null; + attachments: ConversationAttachment[]; +}; + +export type ConversationSnapshot = { + turns: ConversationTurn[]; + imageCount: number; + systemInstruction: string; +}; + +function buildAttachmentFromImage(image: MessageImagePart): ConversationAttachment { + return { + kind: "image", + data: image.data, + mimeType: image.mimeType || "image/jpeg", + fileName: "image.jpg", + }; +} + +function buildAttachmentFromAudio(audio: MessageAudioPart): ConversationAttachment { + return { + kind: "audio", + data: audio.data, + mimeType: audio.mimeType || "audio/mpeg", + fileName: "audio.bin", + }; +} + +function buildConversationAttachments(part: MessagePart): ConversationAttachment[] { + const attachments: ConversationAttachment[] = []; + + for (const image of part.imageParts ?? []) { + attachments.push(buildAttachmentFromImage(image)); + } + + for (const audio of part.audioParts ?? []) { + attachments.push(buildAttachmentFromAudio(audio)); + } + + for (const document of part.documents ?? []) { + attachments.push({ + kind: "document", + data: document, + mimeType: "application/octet-stream", + fileName: "document.bin", + }); + } + + for (const video of part.videos ?? []) { + attachments.push({ + kind: "video", + data: video, + mimeType: "video/mp4", + fileName: "video.mp4", + }); + } + + for (const videoNote of part.videoNotes ?? []) { + attachments.push({ + kind: "video-note", + data: videoNote, + mimeType: "video/mp4", + fileName: "video-note.mp4", + }); + } + + return attachments; +} + +function attachmentCounts(attachments: ConversationAttachment[]): Record { + return attachments.reduce>((counts, attachment) => { + counts[attachment.kind] += 1; + return counts; + }, { + image: 0, + document: 0, + audio: 0, + video: 0, + "video-note": 0, + }); +} + +function attachmentSummary(attachments: ConversationAttachment[]): string { + const counts = attachmentCounts(attachments); + const lines = Object.entries(counts) + .filter(([, count]) => count > 0) + .map(([kind, count]) => `- ${kind}: ${count}`); + + if (!lines.length) return ""; + + return ["[attachments]:", ...lines].join("\n"); +} + +function supportedAttachmentKinds(provider: AiProvider, bot: boolean): Set { + if (bot) return new Set(); + + switch (provider) { + case AiProvider.OPENAI: + return new Set(["image", "audio", "document", "video", "video-note"]); + case AiProvider.MISTRAL: + return new Set(["image"]); + case AiProvider.OLLAMA: + return new Set(); + } + + return new Set(); +} + +function renderContentText( + turn: ConversationTurn, + provider: AiProvider, + includeNames: boolean, +): string { + const parts = [turn.content.trim()]; + const supported = supportedAttachmentKinds(provider, turn.bot); + const unsupported = turn.attachments.filter(attachment => !supported.has(attachment.kind)); + + if (includeNames && !turn.bot) { + parts.unshift([ + "[user_info]:", + `name: ${turn.name ?? ""}`.trimEnd(), + `username: @${turn.userName ?? ""}`.trimEnd(), + "", + ].join("\n")); + } + + if (turn.bot && turn.deletedByBotAt) { + parts.push("[message_state]: deleted_by_bot"); + } + + if (unsupported.length) { + parts.push(attachmentSummary(unsupported)); + } + + return parts.filter(part => part.trim().length > 0).join("\n\n").trim(); +} + +function buildOpenAiOutputText(text: string): ResponseOutputText { + return { + type: "output_text", + text, + annotations: [], + }; +} + +function buildOpenAiInputMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): OpenAIChatMessage { + const text = renderContentText(turn, provider, includeNames); + const content: ResponseInputMessageContentList = [ + { + type: "input_text", + text, + }, + ]; + + for (const attachment of turn.attachments.filter(item => item.kind === "image")) { + content.push({ + type: "input_image", + image_url: `data:${attachment.mimeType};base64,${attachment.data}`, + detail: "auto", + }); + } + + return { + type: "message", + role: "user", + content, + }; +} + +function buildOpenAiAssistantMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): ResponseOutputMessage { + const text = renderContentText(turn, provider, includeNames); + return { + id: `msg_${Date.now()}`, + type: "message", + role: "assistant", + status: "completed", + phase: "final_answer", + content: [buildOpenAiOutputText(text)], + }; +} + +function buildOpenAiMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): OpenAIChatMessage { + return turn.bot + ? buildOpenAiAssistantMessage(turn, provider, includeNames) + : buildOpenAiInputMessage(turn, provider, includeNames); +} + +function buildMistralMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): MistralChatMessage { + const text = renderContentText(turn, provider, includeNames); + + if (turn.bot) { + return { + role: "assistant", + content: [{type: "text", text}], + }; + } + + return { + role: "user", + content: [ + {type: "text", text}, + ...turn.attachments + .filter(attachment => attachment.kind === "image") + .map(attachment => ({ + type: "image_url" as const, + imageUrl: `data:${attachment.mimeType};base64,${attachment.data}`, + })), + ], + }; +} + +function buildOllamaMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): OllamaChatMessage { + const text = renderContentText(turn, provider, includeNames); + return { + role: turn.bot ? "assistant" : "user", + content: text, + images: turn.bot ? undefined : turn.attachments.filter(attachment => attachment.kind === "image").map(attachment => attachment.data), + }; +} + +function buildSystemInstruction( + config: RuntimeConfigSnapshot, + responseLanguage: UserAiResponseLanguage, + includePythonToolPrompt: boolean, + additions?: string | null, +): string { + return [ + config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null, + config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null, + additions?.trim() ? additions.trim() : null, + includePythonToolPrompt ? pythonInterpreterToolPrompt : null, + ].filter(Boolean).join("\n\n"); +} + +export async function buildConversationSnapshot( + msg: Message, + textOverride: string, + downloads: AiDownloadedFile[], + config: RuntimeConfigSnapshot, + runtimeTarget: AiRuntimeTarget, + responseLanguage: UserAiResponseLanguage, + includePythonToolPrompt: boolean, +): Promise { + const storedMsg = await MessageStore.get(msg.chat.id, msg.message_id); + const messageParts = await collectReplyChainText({triggerMsg: storedMsg ?? msg, downloads}); + + if (messageParts.length && textOverride.trim()) { + const latest = messageParts[0]; + if (!latest.bot) latest.content = textOverride.trim(); + } + + const turns = messageParts + .reverse() + .map(part => ({ + bot: part.bot, + name: part.name, + langCode: part.langCode, + userName: part.userName, + content: part.content, + deletedByBotAt: part.deletedByBotAt, + attachments: buildConversationAttachments(part), + })); + + const imageCount = turns.reduce((sum, turn) => { + if (turn.bot) return sum; + return sum + turn.attachments.filter(attachment => attachment.kind === "image").length; + }, 0); + + return { + turns, + imageCount, + systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions), + }; +} + +export function serializeConversationSnapshot( + snapshot: ConversationSnapshot, + provider: AiProvider, + includeNames: boolean, +): { chatMessages: Array; imageCount: number } { + switch (provider) { + case AiProvider.OPENAI: { + const messages = snapshot.turns.map(turn => buildOpenAiMessage(turn, provider, includeNames)); + if (snapshot.systemInstruction) { + messages.unshift({role: "system", content: snapshot.systemInstruction, type: "message"}); + } + + return {chatMessages: messages, imageCount: snapshot.imageCount}; + } + case AiProvider.MISTRAL: { + const messages = snapshot.turns.map(turn => buildMistralMessage(turn, provider, includeNames)); + if (snapshot.systemInstruction) { + messages.unshift({role: "system", content: snapshot.systemInstruction}); + } + + return {chatMessages: messages, imageCount: snapshot.imageCount}; + } + case AiProvider.OLLAMA: { + const messages = snapshot.turns.map(turn => buildOllamaMessage(turn, provider, includeNames)); + if (snapshot.systemInstruction) { + messages.unshift({role: "system", content: snapshot.systemInstruction}); + } + + return {chatMessages: messages, imageCount: snapshot.imageCount}; + } + } + + return {chatMessages: [], imageCount: snapshot.imageCount}; +} diff --git a/src/ai/document-rag-pipeline.ts b/src/ai/document-rag-pipeline.ts new file mode 100644 index 0000000..f8b706c --- /dev/null +++ b/src/ai/document-rag-pipeline.ts @@ -0,0 +1,98 @@ +import {AiProvider} from "../model/ai-provider"; +import {AiDownloadedFile} from "./telegram-attachments"; +import {TelegramStreamMessage} from "./telegram-stream-message"; +import {deleteMistralLibrary, RuntimeConfigSnapshot, MistralDocumentReference, prepareMistralDocuments} from "./unified-ai-runner.shared"; +import {MistralChatMessage} from "./mistral-chat-message"; +import {OllamaChatMessage} from "./ollama-chat-message"; +import {prepareOllamaDocumentRag} from "./ollama-rag"; +import type {OllamaRagArtifactDetails} from "./ollama-rag"; +import {OpenAIChatMessage} from "./openai-chat-message"; +import {createOpenAiClient, createOllamaClient} from "./ai-runtime-target"; +import {prepareOpenAiDocumentRag} from "./unified-ai-runner.openai"; + +export type PreparedDocumentRag = + | { + provider: AiProvider.OPENAI; + vectorStoreIds: string[]; + uploadedFileIds: string[]; + cleanup: () => Promise; + } + | { + provider: AiProvider.MISTRAL; + documents: MistralDocumentReference[]; + libraryId?: string; + cleanup: () => Promise; + } + | { + provider: AiProvider.OLLAMA; + prepared: boolean; + artifact?: OllamaRagArtifactDetails; + cleanup: () => Promise; + }; + +export async function prepareDocumentRag( + provider: AiProvider, + downloads: AiDownloadedFile[], + messages: Array, + streamMessage: TelegramStreamMessage, + config: RuntimeConfigSnapshot, + signal: AbortSignal, + userQuery: string, +): Promise { + const documents = downloads.filter(download => download.kind === "document"); + if (!documents.length) return undefined; + + switch (provider) { + case AiProvider.OPENAI: { + const openAi = createOpenAiClient(config.openAiChatTarget); + const prepared = await prepareOpenAiDocumentRag(openAi, documents); + if (!prepared) { + throw new Error("OpenAI document RAG preparation returned no context."); + } + return { + provider, + vectorStoreIds: prepared.vectorStoreIds, + uploadedFileIds: prepared.uploadedFileIds, + cleanup: prepared.cleanup, + }; + } + case AiProvider.MISTRAL: { + const prepared = await prepareMistralDocuments(documents, messages as MistralChatMessage[], streamMessage, config.mistralChatTarget, signal); + return { + provider, + documents: prepared.documents, + libraryId: prepared.libraryId, + cleanup: async () => { + await deleteMistralLibrary(prepared.libraryId, config.mistralChatTarget); + }, + }; + } + case AiProvider.OLLAMA: { + const prepared = await prepareOllamaDocumentRag({ + downloads, + messages: messages as OllamaChatMessage[], + userQuery, + message: streamMessage, + config: { + embeddingModel: config.ollamaDocumentsTarget.model, + embeddingClient: createOllamaClient(config.ollamaDocumentsTarget), + chunkSize: config.ollamaRagChunkSize, + chunkOverlap: config.ollamaRagChunkOverlap, + topK: config.ollamaRagTopK, + maxContextChars: config.ollamaRagMaxContextChars, + minScore: config.ollamaRagMinScore, + maxArchiveFiles: config.ollamaRagMaxArchiveFiles, + maxArchiveBytes: config.ollamaRagMaxArchiveBytes, + maxArchiveDepth: config.ollamaRagMaxArchiveDepth, + }, + }); + + return { + provider, + prepared: prepared.prepared, + artifact: prepared.artifact, + cleanup: async () => undefined, + }; + } + } +} diff --git a/src/ai/final-response-artifact-store.ts b/src/ai/final-response-artifact-store.ts new file mode 100644 index 0000000..87077de --- /dev/null +++ b/src/ai/final-response-artifact-store.ts @@ -0,0 +1,58 @@ +import {AiProvider} from "../model/ai-provider"; +import type {StoredAttachment} from "../model/stored-attachment"; +import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store"; + +export async function persistFinalTextArtifactAttachment(params: { + provider: AiProvider; + model: string; + text: string; + chatId: number; + messageId: number; +}): Promise { + const text = params.text.trim(); + if (!text) return Promise.resolve(undefined); + + return await persistInternalJsonArtifactAttachment({ + artifactKind: "final_text", + fileNamePrefix: "final-text", + chatId: params.chatId, + messageId: params.messageId, + payload: { + provider: params.provider, + model: params.model, + text, + }, + metadata: { + provider: params.provider, + model: params.model, + textChars: text.length, + }, + }); +} + +export async function persistErrorArtifactAttachment(params: { + provider: AiProvider; + model: string; + message: string; + recoverable: boolean; + chatId: number; + messageId: number; +}): Promise { + return await persistInternalJsonArtifactAttachment({ + artifactKind: "error", + fileNamePrefix: "error", + chatId: params.chatId, + messageId: params.messageId, + payload: { + provider: params.provider, + model: params.model, + message: params.message, + recoverable: params.recoverable, + }, + metadata: { + provider: params.provider, + model: params.model, + recoverable: params.recoverable, + }, + }); +} diff --git a/src/ai/internal-artifact-store.ts b/src/ai/internal-artifact-store.ts new file mode 100644 index 0000000..e48ae1e --- /dev/null +++ b/src/ai/internal-artifact-store.ts @@ -0,0 +1,102 @@ +import fs from "node:fs"; +import path from "node:path"; +import {createHash} from "node:crypto"; +import {Environment} from "../common/environment"; +import {ArtifactStore} from "../common/artifact-store"; +import type {StoredAttachment} from "../model/stored-attachment"; +import {PIPELINE_ATTACHMENT_LIMIT_BYTES, type PipelineArtifactKind} from "./user-request-pipeline"; + +export type InternalArtifactAttachmentInput = { + artifactKind: PipelineArtifactKind; + fileNamePrefix: string; + chatId: number; + messageId: number; + requestId?: string; + payload: Record; + metadata?: Record; +}; + +const INTERNAL_ARTIFACT_RETENTION_MS = 14 * 24 * 60 * 60 * 1000; + +function sha256(buffer: Buffer): string { + return createHash("sha256").update(buffer).digest("hex"); +} + +function safeFileNamePart(value: string): string { + return value.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_").slice(0, 80) || "artifact"; +} + +export async function persistInternalJsonArtifactAttachment(input: InternalArtifactAttachmentInput): Promise { + const createdAt = new Date().toISOString(); + const buffer = Buffer.from(JSON.stringify({ + artifactKind: input.artifactKind, + createdAt, + ...input.payload, + }, null, 2), "utf8"); + + if (buffer.length > PIPELINE_ATTACHMENT_LIMIT_BYTES) { + throw new Error(`Internal ${input.artifactKind} artifact is larger than ${PIPELINE_ATTACHMENT_LIMIT_BYTES} bytes.`); + } + + const dir = path.join(Environment.DATA_PATH, "cache", "internal-artifacts", input.artifactKind); + fs.mkdirSync(dir, {recursive: true}); + + const digest = sha256(buffer); + const fileName = `${safeFileNamePart(input.fileNamePrefix)}-${input.chatId}-${input.messageId}-${Date.now()}.json`; + const cachePath = path.join(dir, fileName); + fs.writeFileSync(cachePath, buffer); + + const attachment: StoredAttachment = { + kind: "document", + fileId: cachePath, + fileUniqueId: digest, + fileName, + mimeType: "application/json", + cachePath, + sizeBytes: buffer.length, + sha256: digest, + scope: "internal_artifact", + artifactKind: input.artifactKind, + metadata: input.metadata, + }; + + await ArtifactStore.put({ + id: "", + requestId: input.requestId ?? `message:${input.chatId}:${input.messageId}:${input.artifactKind}`, + messageChatId: input.chatId, + messageId: input.messageId, + kind: input.artifactKind, + stage: input.artifactKind, + attachmentId: cachePath, + payload: { + artifactKind: input.artifactKind, + createdAt, + ...input.payload, + }, + createdAt, + attachment, + }); + + return attachment; +} + +export function cleanupInternalArtifactCache(now = Date.now()): void { + const root = path.join(Environment.DATA_PATH, "cache", "internal-artifacts"); + if (!fs.existsSync(root)) return; + + const cutoff = now - INTERNAL_ARTIFACT_RETENTION_MS; + for (const artifactKind of fs.readdirSync(root, {withFileTypes: true})) { + if (!artifactKind.isDirectory()) continue; + + const dir = path.join(root, artifactKind.name); + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + if (!entry.isFile()) continue; + + const filePath = path.join(dir, entry.name); + const stat = fs.statSync(filePath); + if (stat.mtimeMs < cutoff) { + fs.rmSync(filePath, {force: true}); + } + } + } +} diff --git a/src/ai/mistral-chat-message.ts b/src/ai/mistral-chat-message.ts new file mode 100644 index 0000000..430dc80 --- /dev/null +++ b/src/ai/mistral-chat-message.ts @@ -0,0 +1,113 @@ +export const MistralImageDetail = { + Low: "low", + Auto: "auto", + High: "high", +} as const; +export type MistralImageDetail = OpenEnum; + +declare const __brand: unique symbol; +export type Unrecognized = T & { [__brand]: "unrecognized" }; + +export type OpenEnum>> = + | T[keyof T] + | Unrecognized; + +export const BuiltInConnectors = { + WebSearch: "web_search", + WebSearchPremium: "web_search_premium", + CodeInterpreter: "code_interpreter", + ImageGeneration: "image_generation", + DocumentLibrary: "document_library", +} as const; +export type BuiltInConnectors = OpenEnum; + +export type MistralTextChunk = { + type: "text"; + text: string; +}; + +export type MistralToolReferenceChunk = { + type: "tool_reference" | undefined; + tool: BuiltInConnectors | string; + title: string; + url?: string | null | undefined; + favicon?: string | null | undefined; + description?: string | null | undefined; +}; + +export type MistralThinkChunk = { + type: "thinking"; + thinking: Array; + signature?: string | null | undefined; + closed?: boolean | undefined; +}; + +export type MistralImageURLChunk = { + type: "image_url"; + imageUrl: string | { + url: string; + detail?: MistralImageDetail | null | undefined; + }; +} + +export type MistralContentChunk = + | MistralTextChunk + | MistralThinkChunk + | MistralImageURLChunk + +/* + | (ImageURLChunk & { type: "image_url" }) + | (DocumentURLChunk & { type: "document_url" }) + | (TextChunk & { type: "text" }) + | (ReferenceChunk & { type: "reference" }) + | (FileChunk & { type: "file" }) + | (ThinkChunk & { type: "thinking" }) + | AudioChunk + */ + +export type MistralFunctionCall = { + name: string; + arguments: AiJsonObject | string; +}; + +export type MistralToolCall = { + id?: string | undefined; + type?: string | undefined; + function: MistralFunctionCall; + index?: number | undefined; +}; + +export type MistralAssistantMessage = { + role: "assistant"; + content?: string | Array | null | undefined; + toolCalls?: Array | null | undefined; + prefix?: boolean | undefined; +} + +export type MistralSystemMessageContentChunks = + | MistralTextChunk + | MistralThinkChunk; + +export type MistralSystemMessage = { + role: "system"; + content: string; +} + +export type MistralToolMessage = { + role: "tool"; + content: string | Array | null; + toolCallId?: string | null | undefined; + name?: string | null | undefined; +}; + +export type MistralUserMessage = { + role: "user"; + content: string | Array | null; +}; + +export type MistralChatMessage = + | MistralAssistantMessage + | MistralSystemMessage + | MistralToolMessage + | MistralUserMessage +import {AiJsonObject} from "./tool-types"; diff --git a/src/ai/ollama-chat-message.ts b/src/ai/ollama-chat-message.ts new file mode 100644 index 0000000..5e6c3c8 --- /dev/null +++ b/src/ai/ollama-chat-message.ts @@ -0,0 +1,3 @@ +import {Message} from "ollama"; + +export type OllamaChatMessage = Message; \ No newline at end of file diff --git a/src/ai/ollama-rag.ts b/src/ai/ollama-rag.ts new file mode 100644 index 0000000..86e38f5 --- /dev/null +++ b/src/ai/ollama-rag.ts @@ -0,0 +1,1439 @@ +import path from "node:path"; +import zlib from "node:zlib"; +import {Ollama} from "ollama"; +import {AiDownloadedFile} from "./telegram-attachments"; +import {TelegramStreamMessage} from "./telegram-stream-message"; +import {OllamaChatMessage} from "./ollama-chat-message"; +import {Environment} from "../common/environment"; + +export type OllamaDocumentRagConfig = { + embeddingModel: string; + embeddingClient?: Ollama; + chunkSize: number; + chunkOverlap: number; + topK: number; + maxContextChars: number; + minScore: number; + maxArchiveFiles: number; + maxArchiveBytes: number; + maxArchiveDepth: number; +}; + +type SourceDocument = { + documentIndex: number; + fileName: string; + text: string; +}; + +type SkippedDocument = { + documentIndex: number; + fileName: string; + reason: string; +}; + +type DocumentChunk = { + sourceId: string; + documentIndex: number; + documentName: string; + chunkIndex: number; + chunkCount: number; + text: string; + score?: number; +}; + +const EMBEDDING_BATCH_SIZE = 32; +const TAR_BLOCK_SIZE = 512; + +const TEXT_EXTENSIONS = new Set([ + ".txt", + ".md", + ".markdown", + ".rst", + ".csv", + ".json", + ".jsonl", + ".xml", + ".html", + ".htm", + ".yaml", + ".yml", + ".toml", + ".ini", + ".env", + ".conf", + ".properties", + ".log", + ".ps1", + ".sh", + ".bat", + ".cmd", + ".js", + ".jsx", + ".ts", + ".tsx", + ".py", + ".rb", + ".go", + ".java", + ".c", + ".cc", + ".cpp", + ".h", + ".hpp", + ".php", + ".sql", + ".patch" +]); + +const ZIP_MIME_TYPES = new Set([ + "application/zip", + "application/x-zip", + "application/x-zip-compressed", + "multipart/x-zip", +]); + +const TAR_MIME_TYPES = new Set([ + "application/x-tar", + "application/tar", +]); + +const GZIP_MIME_TYPES = new Set([ + "application/gzip", + "application/x-gzip", + "application/gzip-compressed", +]); + +function isPlainTextDocument(doc: AiDownloadedFile): boolean { + const ext = path.extname(doc.fileName).toLowerCase(); + const mime = (doc.mimeType ?? "").toLowerCase(); + + return mime.startsWith("text/") + || mime === "application/json" + || mime === "application/xml" + || TEXT_EXTENSIONS.has(ext); +} + +function isPdfDocument(doc: AiDownloadedFile): boolean { + return path.extname(doc.fileName).toLowerCase() === ".pdf" + || (doc.mimeType ?? "").toLowerCase() === "application/pdf"; +} + +function isDocxDocument(doc: AiDownloadedFile): boolean { + return path.extname(doc.fileName).toLowerCase() === ".docx" + || (doc.mimeType ?? "").toLowerCase() === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; +} + +function lowerFileName(fileName: string): string { + return fileName.toLowerCase(); +} + +function isZipArchiveDocument(doc: AiDownloadedFile): boolean { + const ext = path.extname(doc.fileName).toLowerCase(); + const mime = (doc.mimeType ?? "").toLowerCase(); + return ext === ".zip" || ZIP_MIME_TYPES.has(mime); +} + +function isTarArchiveDocument(doc: AiDownloadedFile): boolean { + const ext = path.extname(doc.fileName).toLowerCase(); + const mime = (doc.mimeType ?? "").toLowerCase(); + return ext === ".tar" || TAR_MIME_TYPES.has(mime); +} + +function isTarGzipArchiveDocument(doc: AiDownloadedFile): boolean { + const name = lowerFileName(doc.fileName); + return name.endsWith(".tar.gz") || name.endsWith(".tgz"); +} + +function isGzipDocument(doc: AiDownloadedFile): boolean { + const ext = path.extname(doc.fileName).toLowerCase(); + const mime = (doc.mimeType ?? "").toLowerCase(); + return ext === ".gz" || GZIP_MIME_TYPES.has(mime); +} + +function isArchiveDocument(doc: AiDownloadedFile): boolean { + if (isDocxDocument(doc)) return false; + return isZipArchiveDocument(doc) + || isTarGzipArchiveDocument(doc) + || isTarArchiveDocument(doc) + || isGzipDocument(doc); +} + +function normalizeDocumentText(value: string): string { + return value + .replace(/\u0000/g, "") + .replace(/[\u0001-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, " ") + .replace(/\p{Private_Use}/gu, " ") + .replace(/\r\n?/g, "\n") + .replace(/[^\S\n]+/g, " ") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function decodeTextDocument(doc: AiDownloadedFile): string { + return normalizeDocumentText(doc.buffer.toString("utf8")); +} + +function streamHasFlateDecode(header: string): boolean { + return /\/Filter\s*(?:\/FlateDecode|\[[\s\S]*?\/FlateDecode[\s\S]*?\])/.test(header); +} + +function extractPdfStreams(buffer: Buffer, raw: string): Buffer[] { + const streams: Buffer[] = []; + const streamToken = /stream(?:\r\n|\n|\r)/g; + let match: RegExpExecArray | null; + + while ((match = streamToken.exec(raw))) { + const dataStart = streamToken.lastIndex; + const endstream = raw.indexOf("endstream", dataStart); + if (endstream < 0) break; + + let dataEnd = endstream; + while (dataEnd > dataStart) { + const previous = raw.charCodeAt(dataEnd - 1); + if (previous !== 10 && previous !== 13) break; + dataEnd--; + } + + const header = raw.slice(Math.max(0, match.index - 2048), match.index); + const stream = buffer.subarray(dataStart, dataEnd); + + if (streamHasFlateDecode(header)) { + try { + streams.push(zlib.inflateSync(stream)); + } catch { + streams.push(stream); + } + } else { + streams.push(stream); + } + + streamToken.lastIndex = endstream + "endstream".length; + } + + return streams; +} + +function isOctalDigit(value: string): boolean { + return value >= "0" && value <= "7"; +} + +function decodePdfBytes(bytes: number[]): string { + if (bytes.length >= 2 && bytes[0] === 0xFE && bytes[1] === 0xFF) { + let text = ""; + for (let i = 2; i + 1 < bytes.length; i += 2) { + text += String.fromCharCode((bytes[i] << 8) | bytes[i + 1]); + } + return text; + } + + const buffer = Buffer.from(bytes); + const utf8 = buffer.toString("utf8"); + const replacementCount = (utf8.match(/\uFFFD/g) ?? []).length; + return replacementCount > Math.max(1, bytes.length * 0.02) + ? buffer.toString("latin1") + : utf8; +} + +function decodePdfLiteralString(raw: string): string { + const bytes: number[] = []; + + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + if (ch !== "\\") { + bytes.push(raw.charCodeAt(i) & 0xFF); + continue; + } + + const next = raw[++i]; + if (!next) break; + + switch (next) { + case "n": + bytes.push(10); + break; + case "r": + bytes.push(13); + break; + case "t": + bytes.push(9); + break; + case "b": + bytes.push(8); + break; + case "f": + bytes.push(12); + break; + case "(": + case ")": + case "\\": + bytes.push(next.charCodeAt(0)); + break; + case "\r": + if (raw[i + 1] === "\n") i++; + break; + case "\n": + break; + default: + if (isOctalDigit(next)) { + let octal = next; + for (let j = 0; j < 2 && isOctalDigit(raw[i + 1] ?? ""); j++) { + octal += raw[++i]; + } + bytes.push(Number.parseInt(octal, 8) & 0xFF); + } else { + bytes.push(next.charCodeAt(0) & 0xFF); + } + } + } + + return decodePdfBytes(bytes); +} + +function decodePdfHexString(raw: string): string { + let hex = raw.replace(/[^0-9A-Fa-f]/g, ""); + if (!hex.length) return ""; + if (hex.length % 2 !== 0) hex += "0"; + + const bytes: number[] = []; + for (let i = 0; i < hex.length; i += 2) { + bytes.push(Number.parseInt(hex.slice(i, i + 2), 16)); + } + + return decodePdfBytes(bytes); +} + +function readPdfLiteralOperand(input: string, start: number): { value: string; nextIndex: number } | null { + let depth = 1; + let raw = ""; + + for (let i = start + 1; i < input.length; i++) { + const ch = input[i]; + + if (ch === "\\") { + raw += ch; + if (i + 1 < input.length) raw += input[++i]; + continue; + } + + if (ch === "(") { + depth++; + raw += ch; + continue; + } + + if (ch === ")") { + depth--; + if (depth === 0) { + return {value: decodePdfLiteralString(raw), nextIndex: i + 1}; + } + raw += ch; + continue; + } + + raw += ch; + } + + return null; +} + +function readPdfHexOperand(input: string, start: number): { value: string; nextIndex: number } | null { + const end = input.indexOf(">", start + 1); + if (end < 0) return null; + return {value: decodePdfHexString(input.slice(start + 1, end)), nextIndex: end + 1}; +} + +function extractPdfStringOperands(input: string): string[] { + const values: string[] = []; + + for (let i = 0; i < input.length; i++) { + if (input[i] === "(") { + const literal = readPdfLiteralOperand(input, i); + if (!literal) continue; + values.push(literal.value); + i = literal.nextIndex - 1; + continue; + } + + if (input[i] === "<" && input[i + 1] !== "<") { + const hex = readPdfHexOperand(input, i); + if (!hex) continue; + values.push(hex.value); + i = hex.nextIndex - 1; + } + } + + return values; +} + +function extractPdfOperatorText(content: string): string { + const blocks = [...content.matchAll(/BT([\s\S]*?)ET/g)].map(match => match[1] ?? ""); + const target = blocks.length ? blocks.join("\n") : content; + const parts: string[] = []; + + for (const match of target.matchAll(/\[([\s\S]*?)\]\s*TJ/g)) { + const text = extractPdfStringOperands(match[1] ?? "").join(""); + if (text.trim()) parts.push(text); + } + + for (const match of target.matchAll(/(\((?:\\[\s\S]|[^\\()])*\)|<[0-9A-Fa-f\s]+>)\s*(?:Tj|'|")/g)) { + const operand = match[1] ?? ""; + const text = operand.startsWith("(") + ? (readPdfLiteralOperand(operand, 0)?.value ?? "") + : decodePdfHexString(operand.slice(1, -1)); + if (text.trim()) parts.push(text); + } + + return normalizeDocumentText(parts.join(" ")); +} + +function extractPdfText(buffer: Buffer): string { + const raw = buffer.toString("latin1"); + const texts = extractPdfStreams(buffer, raw) + .map(stream => extractPdfOperatorText(stream.toString("latin1"))) + .filter(text => text.trim().length > 0); + + if (texts.length) { + return normalizeDocumentText(texts.join("\n")); + } + + return extractPdfOperatorText(raw); +} + +type ZipEntry = { + name: string; + compressionMethod: number; + generalPurposeBitFlag: number; + compressedSize: number; + uncompressedSize: number; + localHeaderOffset: number; +}; + +function findZipEndOfCentralDirectory(buffer: Buffer): number { + const min = Math.max(0, buffer.length - 0xFFFF - 22); + + for (let i = buffer.length - 22; i >= min; i--) { + if (buffer.readUInt32LE(i) === 0x06054B50) return i; + } + + throw new Error(Environment.zipCentralDirectoryNotFoundText); +} + +function listZipEntries(buffer: Buffer): ZipEntry[] { + const eocd = findZipEndOfCentralDirectory(buffer); + const entryCount = buffer.readUInt16LE(eocd + 10); + let offset = buffer.readUInt32LE(eocd + 16); + const entries: ZipEntry[] = []; + + for (let i = 0; i < entryCount; i++) { + if (buffer.readUInt32LE(offset) !== 0x02014B50) { + throw new Error(Environment.zipInvalidCentralDirectoryText); + } + + const generalPurposeBitFlag = buffer.readUInt16LE(offset + 8); + const compressionMethod = buffer.readUInt16LE(offset + 10); + const compressedSize = buffer.readUInt32LE(offset + 20); + const uncompressedSize = buffer.readUInt32LE(offset + 24); + const fileNameLength = buffer.readUInt16LE(offset + 28); + const extraLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + const localHeaderOffset = buffer.readUInt32LE(offset + 42); + const name = buffer.subarray(offset + 46, offset + 46 + fileNameLength).toString("utf8"); + + entries.push({ + name, + compressionMethod, + generalPurposeBitFlag, + compressedSize, + uncompressedSize, + localHeaderOffset + }); + offset += 46 + fileNameLength + extraLength + commentLength; + } + + return entries; +} + +function readZipEntry(buffer: Buffer, entry: ZipEntry): Buffer { + const offset = entry.localHeaderOffset; + if (buffer.readUInt32LE(offset) !== 0x04034B50) { + throw new Error(Environment.getZipInvalidLocalHeaderText(entry.name)); + } + + const fileNameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + const dataStart = offset + 30 + fileNameLength + extraLength; + const compressed = buffer.subarray(dataStart, dataStart + entry.compressedSize); + + if (entry.compressionMethod === 0) return compressed; + if (entry.compressionMethod === 8) return zlib.inflateRawSync(compressed); + + throw new Error(Environment.getZipUnsupportedCompressionMethodText(entry.compressionMethod, entry.name)); +} + +type ExtractedRagDocument = { + fileName: string; + text: string; +}; + +export type OllamaRagArtifactDetails = { + query: string; + extractedDocuments: Array<{ + documentIndex: number; + fileName: string; + textChars: number; + }>; + selectedChunks: Array<{ + sourceId: string; + documentIndex: number; + documentName: string; + chunkIndex: number; + chunkCount: number; + textChars: number; + score?: number; + }>; + skippedDocuments: Array<{ + documentIndex: number; + fileName: string; + reason: string; + }>; + providerState: { + embeddingModel: string; + topK: number; + chunkSize: number; + chunkOverlap: number; + maxContextChars: number; + minScore: number; + maxArchiveFiles: number; + maxArchiveBytes: number; + maxArchiveDepth: number; + }; +}; + +type ArchiveSkippedDocument = { + fileName: string; + reason: string; +}; + +type ArchiveExtractionState = { + fileCount: number; + uncompressedBytes: number; + skipped: ArchiveSkippedDocument[]; +}; + +function mimeTypeFromFileName(fileName: string): string | undefined { + const name = lowerFileName(fileName); + const ext = path.extname(name); + + if (name.endsWith(".tar.gz") || ext === ".tgz") return "application/gzip"; + + switch (ext) { + case ".txt": + case ".md": + case ".markdown": + case ".rst": + case ".csv": + case ".log": + case ".ini": + case ".env": + case ".conf": + case ".properties": + return "text/plain"; + case ".html": + case ".htm": + return "text/html"; + case ".json": + case ".jsonl": + return "application/json"; + case ".xml": + return "application/xml"; + case ".yaml": + case ".yml": + return "application/yaml"; + case ".pdf": + return "application/pdf"; + case ".docx": + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + case ".zip": + return "application/zip"; + case ".tar": + return "application/x-tar"; + case ".gz": + return "application/gzip"; + default: + return undefined; + } +} + +function normalizeArchiveEntryName(name: string): string | null { + const withoutNulls = name.replace(/\u0000/g, ""); + const normalized = path.posix.normalize(withoutNulls.replace(/\\/g, "/").replace(/^\/+/, "")); + + if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) { + return null; + } + + return normalized; +} + +function isIgnorableArchiveEntry(name: string): boolean { + const parts = name.split("/"); + const base = parts[parts.length - 1] ?? ""; + return parts.includes("__MACOSX") || base === ".DS_Store" || base.length === 0; +} + +function archiveEntryDoc(parent: AiDownloadedFile, entryName: string, buffer: Buffer): AiDownloadedFile { + const fileName = `${parent.fileName}/${entryName}`; + return { + kind: "document", + fileId: `${parent.fileId}:${entryName}`, + fileName, + mimeType: mimeTypeFromFileName(entryName), + buffer, + path: `${parent.path}!${entryName}`, + }; +} + +function reserveArchiveFile( + state: ArchiveExtractionState, + config: OllamaDocumentRagConfig, + fileName: string, + uncompressedBytes: number, +): boolean { + if (state.fileCount >= config.maxArchiveFiles) { + state.skipped.push({ + fileName, + reason: `archive file limit exceeded (${config.maxArchiveFiles})`, + }); + return false; + } + + if (uncompressedBytes > config.maxArchiveBytes || state.uncompressedBytes + uncompressedBytes > config.maxArchiveBytes) { + state.skipped.push({ + fileName, + reason: `uncompressed data limit exceeded (${config.maxArchiveBytes} bytes)`, + }); + return false; + } + + state.fileCount++; + state.uncompressedBytes += uncompressedBytes; + return true; +} + +function pushArchiveSkip(state: ArchiveExtractionState, fileName: string, reason: Error | string | object | null | undefined): void { + state.skipped.push({ + fileName, + reason: reason instanceof Error ? reason.message : String(reason), + }); +} + +function extractArchiveChildDocuments( + parent: AiDownloadedFile, + entryName: string, + buffer: Buffer, + config: OllamaDocumentRagConfig, + state: ArchiveExtractionState, + depth: number, +): ExtractedRagDocument[] { + const child = archiveEntryDoc(parent, entryName, buffer); + + try { + return extractRagDocumentsFromFile(child, config, state, depth + 1); + } catch (e) { + pushArchiveSkip(state, child.fileName, e instanceof Error ? e : String(e)); + return []; + } +} + +function extractZipArchiveDocuments( + doc: AiDownloadedFile, + config: OllamaDocumentRagConfig, + state: ArchiveExtractionState, + depth: number, +): ExtractedRagDocument[] { + const documents: ExtractedRagDocument[] = []; + + for (const entry of listZipEntries(doc.buffer)) { + const normalizedName = normalizeArchiveEntryName(entry.name); + if (!normalizedName || normalizedName.endsWith("/") || isIgnorableArchiveEntry(normalizedName)) continue; + + const displayName = `${doc.fileName}/${normalizedName}`; + if ((entry.generalPurposeBitFlag & 1) !== 0) { + pushArchiveSkip(state, displayName, "encrypted ZIP entries are not supported"); + continue; + } + + if (entry.compressedSize === 0xFFFFFFFF || entry.uncompressedSize === 0xFFFFFFFF) { + pushArchiveSkip(state, displayName, "ZIP64 entries are not supported yet"); + continue; + } + + if (!reserveArchiveFile(state, config, displayName, entry.uncompressedSize)) continue; + + try { + const buffer = readZipEntry(doc.buffer, entry); + documents.push(...extractArchiveChildDocuments(doc, normalizedName, buffer, config, state, depth)); + } catch (e) { + pushArchiveSkip(state, displayName, e instanceof Error ? e : String(e)); + } + } + + return documents; +} + +function readTarString(buffer: Buffer, offset: number, length: number): string { + const slice = buffer.subarray(offset, offset + length); + const nullIndex = slice.indexOf(0); + return slice + .subarray(0, nullIndex >= 0 ? nullIndex : slice.length) + .toString("utf8") + .trim(); +} + +function readTarSize(buffer: Buffer, offset: number): number { + const raw = buffer.subarray(offset, offset + 12); + + if ((raw[0] & 0x80) !== 0) { + let size = BigInt(raw[0] & 0x7F); + for (let i = 1; i < raw.length; i++) { + size = (size << 8n) + BigInt(raw[i]); + } + if (size > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(Environment.tarFileTooLargeText); + return Number(size); + } + + const text = raw.toString("ascii").replace(/\u0000/g, "").trim(); + if (!text) return 0; + const size = Number.parseInt(text, 8); + if (!Number.isSafeInteger(size) || size < 0) throw new Error(Environment.tarInvalidEntrySizeText); + return size; +} + +function isTarZeroBlock(buffer: Buffer, offset: number): boolean { + if (offset + TAR_BLOCK_SIZE > buffer.length) return false; + + for (let i = offset; i < offset + TAR_BLOCK_SIZE; i++) { + if (buffer[i] !== 0) return false; + } + + return true; +} + +function tarDataEnd(dataStart: number, size: number): number { + return dataStart + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; +} + +function parsePaxPath(buffer: Buffer): string | undefined { + const text = buffer.toString("utf8"); + let offset = 0; + + while (offset < text.length) { + const spaceIndex = text.indexOf(" ", offset); + if (spaceIndex < 0) break; + + const recordLength = Number.parseInt(text.slice(offset, spaceIndex), 10); + if (!Number.isSafeInteger(recordLength) || recordLength <= 0) break; + + const record = text.slice(spaceIndex + 1, offset + recordLength).replace(/\n$/, ""); + const eqIndex = record.indexOf("="); + if (eqIndex > 0 && record.slice(0, eqIndex) === "path") { + return record.slice(eqIndex + 1); + } + + offset += recordLength; + } + + return undefined; +} + +function bufferLooksLikeTar(buffer: Buffer): boolean { + if (buffer.length < TAR_BLOCK_SIZE) return false; + return buffer.subarray(257, 263).toString("ascii").startsWith("ustar"); +} + +function extractTarArchiveDocuments( + doc: AiDownloadedFile, + config: OllamaDocumentRagConfig, + state: ArchiveExtractionState, + depth: number, +): ExtractedRagDocument[] { + const documents: ExtractedRagDocument[] = []; + let offset = 0; + let pendingLongName: string | undefined; + let pendingPaxPath: string | undefined; + + while (offset + TAR_BLOCK_SIZE <= doc.buffer.length) { + if (isTarZeroBlock(doc.buffer, offset)) break; + + const name = readTarString(doc.buffer, offset, 100); + const size = readTarSize(doc.buffer, offset + 124); + const typeFlag = String.fromCharCode(doc.buffer[offset + 156] || 0); + const prefix = readTarString(doc.buffer, offset + 345, 155); + const dataStart = offset + TAR_BLOCK_SIZE; + const dataEnd = dataStart + size; + + if (dataEnd > doc.buffer.length) { + throw new Error(Environment.tarEntryExceedsBoundsText); + } + + const payload = doc.buffer.subarray(dataStart, dataEnd); + offset = tarDataEnd(dataStart, size); + + if (typeFlag === "L") { + pendingLongName = payload.toString("utf8").replace(/\u0000.*$/s, "").trim(); + continue; + } + + if (typeFlag === "x") { + pendingPaxPath = parsePaxPath(payload); + continue; + } + + const rawName = pendingPaxPath || pendingLongName || (prefix ? `${prefix}/${name}` : name); + pendingLongName = undefined; + pendingPaxPath = undefined; + + const normalizedName = normalizeArchiveEntryName(rawName); + if (!normalizedName || normalizedName.endsWith("/") || isIgnorableArchiveEntry(normalizedName)) continue; + if (typeFlag !== "0" && typeFlag !== "\u0000" && typeFlag !== "") continue; + + const displayName = `${doc.fileName}/${normalizedName}`; + if (!reserveArchiveFile(state, config, displayName, size)) continue; + + documents.push(...extractArchiveChildDocuments(doc, normalizedName, payload, config, state, depth)); + } + + return documents; +} + +function gzipInnerName(fileName: string): string { + const name = lowerFileName(fileName); + if (name.endsWith(".tgz")) return path.basename(fileName, path.extname(fileName)) + ".tar"; + if (name.endsWith(".tar.gz")) return fileName.slice(0, -3); + if (name.endsWith(".gz")) return fileName.slice(0, -3); + return `${fileName}.unpacked`; +} + +function extractGzipDocuments( + doc: AiDownloadedFile, + config: OllamaDocumentRagConfig, + state: ArchiveExtractionState, + depth: number, +): ExtractedRagDocument[] { + const inflated = zlib.gunzipSync(doc.buffer, {maxOutputLength: config.maxArchiveBytes + 1}); + if (inflated.length > config.maxArchiveBytes) { + throw new Error(Environment.getGzipUncompressedLimitText(config.maxArchiveBytes)); + } + + const innerName = gzipInnerName(doc.fileName); + const tarGzip = isTarGzipArchiveDocument(doc) || bufferLooksLikeTar(inflated); + if (tarGzip) { + const tarDoc: AiDownloadedFile = { + ...doc, + fileName: doc.fileName, + mimeType: "application/x-tar", + buffer: inflated, + path: `${doc.path}!${innerName}`, + }; + return extractTarArchiveDocuments(tarDoc, config, state, depth); + } + + if (!reserveArchiveFile(state, config, `${doc.fileName}/${innerName}`, inflated.length)) return []; + return extractArchiveChildDocuments(doc, innerName, inflated, config, state, depth); +} + +function extractArchiveDocuments( + doc: AiDownloadedFile, + config: OllamaDocumentRagConfig, + state: ArchiveExtractionState, + depth: number, +): ExtractedRagDocument[] { + if (depth >= config.maxArchiveDepth) { + throw new Error(Environment.getNestedArchiveDepthLimitText(config.maxArchiveDepth)); + } + + if (isZipArchiveDocument(doc)) return extractZipArchiveDocuments(doc, config, state, depth); + if (isTarGzipArchiveDocument(doc) || isGzipDocument(doc)) return extractGzipDocuments(doc, config, state, depth); + if (isTarArchiveDocument(doc)) return extractTarArchiveDocuments(doc, config, state, depth); + + throw new Error(Environment.getUnsupportedArchiveFormatText(doc.fileName)); +} + +function decodeXmlEntities(value: string): string { + return value + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +function extractDocxXmlText(xml: string): string { + const paragraphs = [...xml.matchAll(//g)] + .map(match => { + const paragraphXml = match[0]; + const parts: string[] = []; + + for (const run of paragraphXml.matchAll(/]*>([\s\S]*?)<\/w:t>|]*\/>|]*\/>|]*\/>/g)) { + if (run[1] !== undefined) { + parts.push(decodeXmlEntities(run[1])); + } else { + parts.push("\n"); + } + } + + return parts.join(""); + }) + .map(paragraph => paragraph.trim()) + .filter(paragraph => paragraph.length > 0); + + return normalizeDocumentText(paragraphs.join("\n\n")); +} + +function extractDocxText(buffer: Buffer): string { + const entries = listZipEntries(buffer); + const textEntryNames = entries + .map(entry => entry.name) + .filter(name => /^word\/(?:document|footnotes|endnotes|comments|header\d+|footer\d+)\.xml$/i.test(name)); + + if (!textEntryNames.includes("word/document.xml")) { + throw new Error(Environment.docxDocumentXmlMissingText); + } + + const entryByName = new Map(entries.map(entry => [entry.name, entry])); + const texts = textEntryNames + .map(name => entryByName.get(name)) + .filter((entry): entry is ZipEntry => !!entry) + .map(entry => extractDocxXmlText(readZipEntry(buffer, entry).toString("utf8"))) + .filter(text => text.trim().length > 0); + + return normalizeDocumentText(texts.join("\n\n")); +} + +function utf8ReplacementRatio(value: string): number { + if (!value.length) return 0; + return (value.match(/\uFFFD/g) ?? []).length / value.length; +} + +function documentTextLooksReadable(text: string): boolean { + const compact = text.replace(/\s+/g, ""); + if (compact.length < 24) return compact.length > 0; + + const replacements = utf8ReplacementRatio(text); + if (replacements > 0.03) return false; + + const lettersAndNumbers = compact.match(/[\p{L}\p{N}]/gu)?.length ?? 0; + const readableRatio = lettersAndNumbers / compact.length; + if (readableRatio < 0.45) return false; + + const words = text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]{1,}/gu) ?? []; + if (compact.length > 80 && words.length < 6) return false; + + const veryLongTokens = words.filter(word => word.length > 80).length; + return veryLongTokens <= Math.max(1, Math.floor(words.length * 0.05)); +} + +function assertReadableDocumentText(doc: AiDownloadedFile, text: string): string { + const normalized = normalizeDocumentText(text); + if (!normalized.trim()) { + throw new Error(Environment.getDocumentEmptyOrNoExtractableText(doc.fileName)); + } + + if (documentTextLooksReadable(normalized)) return normalized; + + const ext = path.extname(doc.fileName).toLowerCase(); + const format = ext || doc.mimeType || "this format"; + throw new Error( + `Could not extract readable text from "${doc.fileName}" (${format}). ` + + "Local RAG does not send documents to third-party providers and can only read the available text layer. " + + "If this is a scan, image, or PDF with non-standard font encoding, OCR or a text version of the document is required." + ); +} + +function extractDocumentText(doc: AiDownloadedFile): string { + let text: string; + + if (isPlainTextDocument(doc)) { + text = decodeTextDocument(doc); + return assertReadableDocumentText(doc, text); + } + + if (isPdfDocument(doc)) { + text = extractPdfText(doc.buffer); + return assertReadableDocumentText(doc, text); + } + + if (isDocxDocument(doc)) { + text = extractDocxText(doc.buffer); + return assertReadableDocumentText(doc, text); + } + + throw new Error(Environment.getUnsupportedLocalRagDocumentFormatText(doc.fileName)); +} + +function extractRagDocumentsFromFile( + doc: AiDownloadedFile, + config: OllamaDocumentRagConfig, + state: ArchiveExtractionState, + depth = 0, +): ExtractedRagDocument[] { + if (isArchiveDocument(doc)) { + return extractArchiveDocuments(doc, config, state, depth); + } + + const text = extractDocumentText(doc); + return [{ + fileName: doc.fileName, + text, + }]; +} + +function extractRagDocuments(doc: AiDownloadedFile, config: OllamaDocumentRagConfig): { + documents: ExtractedRagDocument[]; + skipped: ArchiveSkippedDocument[]; +} { + const state: ArchiveExtractionState = { + fileCount: 0, + uncompressedBytes: 0, + skipped: [], + }; + + return { + documents: extractRagDocumentsFromFile(doc, config, state), + skipped: state.skipped, + }; +} + +function tailText(value: string, maxLength: number): string { + if (maxLength <= 0 || value.length <= maxLength) return value; + return value.slice(value.length - maxLength).replace(/^\S+\s+/, "").trim(); +} + +function splitLongSegment(segment: string, chunkSize: number, overlap: number): string[] { + const chunks: string[] = []; + let start = 0; + + while (start < segment.length) { + let end = Math.min(segment.length, start + chunkSize); + + if (end < segment.length) { + const window = segment.slice(start, end); + const boundary = Math.max( + window.lastIndexOf("\n"), + window.lastIndexOf(". "), + window.lastIndexOf("! "), + window.lastIndexOf("? "), + window.lastIndexOf("; "), + ); + + if (boundary > chunkSize * 0.55) { + end = start + boundary + 1; + } + } + + chunks.push(segment.slice(start, end).trim()); + if (end >= segment.length) break; + start = Math.max(start + 1, end - overlap); + } + + return chunks.filter(chunk => chunk.length > 0); +} + +function chunkText(text: string, chunkSize: number, overlap: number): string[] { + const chunks: string[] = []; + const paragraphs = normalizeDocumentText(text) + .split(/\n{2,}/) + .map(paragraph => paragraph.trim()) + .filter(paragraph => paragraph.length > 0); + + let current = ""; + + const flush = () => { + if (!current.trim()) return; + chunks.push(current.trim()); + current = ""; + }; + + for (const paragraph of paragraphs) { + if (paragraph.length > chunkSize) { + flush(); + chunks.push(...splitLongSegment(paragraph, chunkSize, overlap)); + continue; + } + + if (!current) { + current = paragraph; + continue; + } + + const candidate = `${current}\n\n${paragraph}`; + if (candidate.length <= chunkSize) { + current = candidate; + continue; + } + + const overlapText = tailText(current, overlap); + flush(); + current = overlapText && overlapText.length + paragraph.length + 2 <= chunkSize + ? `${overlapText}\n\n${paragraph}` + : paragraph; + } + + flush(); + return chunks; +} + +function buildChunks(documents: SourceDocument[], config: OllamaDocumentRagConfig): DocumentChunk[] { + return documents.flatMap(document => { + const texts = chunkText(document.text, config.chunkSize, config.chunkOverlap); + return texts.map((text, chunkIndex) => ({ + sourceId: `doc${document.documentIndex + 1}-${chunkIndex + 1}`, + documentIndex: document.documentIndex, + documentName: document.fileName, + chunkIndex, + chunkCount: texts.length, + text, + })); + }); +} + +async function embedTexts(model: string, texts: string[], ollama?: Ollama): Promise { + if (!ollama) return []; + + const result: number[][] = []; + + for (let i = 0; i < texts.length; i += EMBEDDING_BATCH_SIZE) { + const batch = texts.slice(i, i + EMBEDDING_BATCH_SIZE); + const response = await ollama.embed({ + model: model, + input: batch, + truncate: true, + keep_alive: 0 + }); + + if (!Array.isArray(response.embeddings) || response.embeddings.length !== batch.length) { + throw new Error(Environment.getOllamaEmbeddingInvalidResponseText(model)); + } + + result.push(...response.embeddings); + } + + return result; +} + +function cosineSimilarity(a: number[], b: number[]): number { + let dot = 0; + let aNorm = 0; + let bNorm = 0; + const size = Math.min(a.length, b.length); + + for (let i = 0; i < size; i++) { + dot += a[i] * b[i]; + aNorm += a[i] * a[i]; + bNorm += b[i] * b[i]; + } + + if (!aNorm || !bNorm) return 0; + return dot / (Math.sqrt(aNorm) * Math.sqrt(bNorm)); +} + +function cleanOllamaUserContent(value: string): string { + return value + .replace(/^\[user_info\]:[\s\S]*?\n\n/, "") + .trim(); +} + +function buildRetrievalQuery(userQuery: string, messages: OllamaChatMessage[]): string { + const direct = cleanOllamaUserContent(userQuery); + if (direct.length) return direct; + + const lastUser = [...messages].reverse().find(message => message.role === "user" && message.content.trim().length > 0); + if (lastUser) { + const content = cleanOllamaUserContent(lastUser.content); + if (content.length) return content; + } + + return "Create a brief summary of the document and list the key points."; +} + +function selectWithinContext(chunks: DocumentChunk[], maxContextChars: number): DocumentChunk[] { + const selected: DocumentChunk[] = []; + let chars = 0; + + for (const chunk of chunks) { + if (chars + chunk.text.length > maxContextChars && selected.length) break; + selected.push(chunk); + chars += chunk.text.length; + } + + return selected; +} + +function chunkKey(chunk: DocumentChunk): string { + return `${chunk.sourceId}:${chunk.chunkIndex}`; +} + +async function retrieveChunks( + chunks: DocumentChunk[], + query: string, + config: OllamaDocumentRagConfig, +): Promise { + if (!config.embeddingModel.trim()) { + throw new Error(Environment.localRagEmbeddingModelRequiredText); + } + + const embeddings = await embedTexts(config.embeddingModel, [query, ...chunks.map(chunk => chunk.text)], config.embeddingClient); + const queryEmbedding = embeddings[0]; + const chunkEmbeddings = embeddings.slice(1); + + const ranked = chunks + .map((chunk, index) => ({ + ...chunk, + score: cosineSimilarity(queryEmbedding, chunkEmbeddings[index] ?? []), + })) + .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + + const selected: DocumentChunk[] = []; + const selectedKeys = new Set(); + let chars = 0; + + const addChunk = (chunk: DocumentChunk, force = false): boolean => { + const key = chunkKey(chunk); + if (selectedKeys.has(key)) return false; + if (selected.length >= config.topK) return false; + if (!force && (chunk.score ?? 0) < config.minScore && selected.length >= Math.min(3, config.topK)) return false; + if (chars + chunk.text.length > config.maxContextChars && selected.length) return false; + + selected.push(chunk); + selectedKeys.add(key); + chars += chunk.text.length; + return true; + }; + + const bestChunkByDocument = new Map(); + for (const chunk of ranked) { + if (!bestChunkByDocument.has(chunk.documentIndex)) { + bestChunkByDocument.set(chunk.documentIndex, chunk); + } + } + + if (bestChunkByDocument.size > 1) { + const perDocumentTop = [...bestChunkByDocument.values()] + .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + + for (const chunk of perDocumentTop) { + addChunk(chunk, true); + } + } + + for (const chunk of ranked) { + addChunk(chunk); + } + + return selected.length + ? selected + : selectWithinContext(ranked.slice(0, Math.min(config.topK, ranked.length)), config.maxContextChars); +} + +function formatRagContext(chunks: DocumentChunk[], totalChunks: number, documents: SourceDocument[], skippedDocuments: SkippedDocument[]): string { + const documentNames = documents.map(document => `doc${document.documentIndex + 1}: ${document.fileName}`); + const skipped = skippedDocuments.map(document => `doc${document.documentIndex + 1}: ${document.fileName} (${document.reason})`); + const formattedChunks = chunks.map(chunk => { + const score = typeof chunk.score === "number" ? `\nscore: ${chunk.score.toFixed(3)}` : ""; + return [ + `[source: ${chunk.sourceId}]`, + `file: ${chunk.documentName}`, + `chunk: ${chunk.chunkIndex + 1}/${chunk.chunkCount}${score}`, + "", + chunk.text, + ].join("\n"); + }).join("\n\n---\n\n"); + + return [ + "", + "Local RAG context from the user's already attached documents. If the user attached an archive, its supported files were extracted locally and listed as separate documents.", + "Important: the user has already provided a document. Do not ask them to send the document again, and do not say that there is no document.", + "The following are not external links or abstract sources, but extracted text from the attached document.", + "Rules:", + "- Answer the user's question using these fragments as the primary source.", + "- If the user asks what the document contains, what it is about, or asks for a brief description of the document, provide a summary based on the fragments below.", + "- If the answer is not present in the found fragments, explicitly say that it is not in the document context.", + "- When appropriate, include fragment ids in the format [doc1-2].", + "- If there are multiple documents, take all listed documents into account. For comparisons, clearly separate the output by document.", + `Documents/files from archives processed: ${documents.length}. Total found: ${documents.length + skippedDocuments.length}. Selected fragments: ${chunks.length} out of ${totalChunks}.`, + `Document names: ${documentNames.join(", ")}.`, + skipped.length ? `Not included in RAG: ${skipped.join("; ")}.` : "", + "", + formattedChunks, + ].filter(line => line.length > 0).join("\n"); +} + +function buildOllamaRagArtifactDetails( + query: string, + documents: SourceDocument[], + selected: DocumentChunk[], + skippedDocuments: SkippedDocument[], + config: OllamaDocumentRagConfig, +): OllamaRagArtifactDetails { + return { + query, + extractedDocuments: documents.map(document => ({ + documentIndex: document.documentIndex, + fileName: document.fileName, + textChars: document.text.length, + })), + selectedChunks: selected.map(chunk => ({ + sourceId: chunk.sourceId, + documentIndex: chunk.documentIndex, + documentName: chunk.documentName, + chunkIndex: chunk.chunkIndex, + chunkCount: chunk.chunkCount, + textChars: chunk.text.length, + score: chunk.score, + })), + skippedDocuments: skippedDocuments.map(document => ({ + documentIndex: document.documentIndex, + fileName: document.fileName, + reason: document.reason, + })), + providerState: { + embeddingModel: config.embeddingModel, + topK: config.topK, + chunkSize: config.chunkSize, + chunkOverlap: config.chunkOverlap, + maxContextChars: config.maxContextChars, + minScore: config.minScore, + maxArchiveFiles: config.maxArchiveFiles, + maxArchiveBytes: config.maxArchiveBytes, + maxArchiveDepth: config.maxArchiveDepth, + }, + }; +} + +function injectOllamaRagContext(messages: OllamaChatMessage[], context: string): void { + const systemIndex = messages.findIndex(message => message.role === "system"); + + if (systemIndex >= 0) { + messages[systemIndex] = { + ...messages[systemIndex], + content: `${messages[systemIndex].content}\n\n${context}`, + }; + return; + } + + messages.unshift({ + role: "system", + content: context, + }); +} + +export async function buildOllamaDocumentRagContext(params: { + downloads: AiDownloadedFile[]; + messages: OllamaChatMessage[]; + userQuery: string; + config: OllamaDocumentRagConfig; + onStatus?: (status: string) => Promise | void; +}): Promise<{context: string; artifact: OllamaRagArtifactDetails} | null> { + const docs = params.downloads.filter(download => download.kind === "document"); + if (!docs.length) return null; + + const setStatus = async (status: string): Promise => { + await params.onStatus?.(status); + }; + + await setStatus(Environment.getPreparingRAGText(docs.map(d => d.fileName))); + + const documents: SourceDocument[] = []; + const skippedDocuments: SkippedDocument[] = []; + let nextDocumentIndex = 0; + + for (const doc of docs) { + try { + const extracted = extractRagDocuments(doc, params.config); + + for (const document of extracted.documents) { + if (!document.text.trim()) { + skippedDocuments.push({ + documentIndex: nextDocumentIndex++, + fileName: document.fileName, + reason: `Document \`${document.fileName}\` is empty or contains no extractable text.`, + }); + continue; + } + + documents.push({ + documentIndex: nextDocumentIndex++, + fileName: document.fileName, + text: document.text, + }); + } + + for (const skipped of extracted.skipped) { + skippedDocuments.push({ + documentIndex: nextDocumentIndex++, + fileName: skipped.fileName, + reason: skipped.reason, + }); + } + } catch (e) { + skippedDocuments.push({ + documentIndex: nextDocumentIndex++, + fileName: doc.fileName, + reason: e instanceof Error ? e.message : String(e), + }); + } + } + + if (!documents.length) { + throw new Error( + "Could not extract readable text from any document.\n" + + skippedDocuments.map(doc => `- ${doc.fileName}: ${doc.reason}`).join("\n") + ); + } + + const chunks = buildChunks(documents, params.config); + if (!chunks.length) { + throw new Error(Environment.localRagChunksBuildFailedText); + } + + const totalContextChars = chunks.reduce((sum, chunk) => sum + chunk.text.length, 0); + const selected = totalContextChars <= params.config.maxContextChars + ? selectWithinContext(chunks, params.config.maxContextChars) + : await (async () => { + await setStatus(Environment.getBuildingRAGIndexText(params.config.embeddingModel)); + return retrieveChunks(chunks, buildRetrievalQuery(params.userQuery, params.messages), params.config); + })(); + + if (!selected.length) { + throw new Error(Environment.localRagNoSuitableFragmentsText); + } + + return { + context: formatRagContext(selected, chunks.length, documents, skippedDocuments), + artifact: buildOllamaRagArtifactDetails(buildRetrievalQuery(params.userQuery, params.messages), documents, selected, skippedDocuments, params.config), + }; +} + +export async function prepareOllamaDocumentRag(params: { + downloads: AiDownloadedFile[]; + messages: OllamaChatMessage[]; + userQuery: string; + message: TelegramStreamMessage; + config: OllamaDocumentRagConfig; +}): Promise<{prepared: boolean; artifact?: OllamaRagArtifactDetails}> { + const context = await buildOllamaDocumentRagContext({ + downloads: params.downloads, + messages: params.messages, + userQuery: params.userQuery, + config: params.config, + onStatus: async status => { + params.message.setStatus(status); + await params.message.flush(); + }, + }); + + if (!context) return {prepared: false}; + injectOllamaRagContext(params.messages, context.context); + return {prepared: true, artifact: context.artifact}; +} diff --git a/src/ai/openai-chat-message.ts b/src/ai/openai-chat-message.ts new file mode 100644 index 0000000..d1c4f71 --- /dev/null +++ b/src/ai/openai-chat-message.ts @@ -0,0 +1,19 @@ +import type { + ResponseInputMessageContentList, + ResponseOutputMessage, +} from "openai/resources/responses/responses"; + +type OpenAIInputChatMessage = { + type: "message"; + role: "system" | "user"; + content: string | ResponseInputMessageContentList; +}; + +type OpenAIOutputChatMessage = { + type: "message"; + role: "assistant"; + content: ResponseOutputMessage["content"]; + phase?: ResponseOutputMessage["phase"]; +} & Pick; + +export type OpenAIChatMessage = OpenAIInputChatMessage | OpenAIOutputChatMessage; diff --git a/src/ai/provider-aliases.ts b/src/ai/provider-aliases.ts new file mode 100644 index 0000000..a3522cd --- /dev/null +++ b/src/ai/provider-aliases.ts @@ -0,0 +1,18 @@ +import {AiProvider} from "../model/ai-provider"; + +const PROVIDER_ALIASES = new Map([ + ["openai", AiProvider.OPENAI], + ["chatgpt", AiProvider.OPENAI], + ["gpt", AiProvider.OPENAI], + ["mistral", AiProvider.MISTRAL], + ["ollama", AiProvider.OLLAMA], +]); + +export function parseProviderToken(token: string | undefined): AiProvider | undefined { + if (!token) return undefined; + return PROVIDER_ALIASES.get(token.toLowerCase().replace(/:$/, "")); +} + +export function providerDisplayName(provider: AiProvider): string { + return provider.charAt(0) + provider.slice(1).toLowerCase(); +} diff --git a/src/ai/provider-model-runtime.ts b/src/ai/provider-model-runtime.ts new file mode 100644 index 0000000..60ddc6e --- /dev/null +++ b/src/ai/provider-model-runtime.ts @@ -0,0 +1,298 @@ +import {AiProvider} from "../model/ai-provider"; +import {AiModelCapabilities} from "../model/ai-model-capabilities"; +import {Environment} from "../common/environment"; +import {logError} from "../util/utils"; +import {AiCapabilityInfo} from "../model/ai-capability-info"; +import {isOllamaSpeechToTextModel} from "./speech-to-text-models"; +import { + AiCapabilityName, + AiRuntimeTarget, + createMistralClient, + createOllamaClient, + createOpenAiClient, + resolveAiRuntimeTarget, + sameRuntimeEndpoint, +} from "./ai-runtime-target"; + +const CAPABILITY_NAMES: AiCapabilityName[] = [ + "chat", + "vision", + "ocr", + "thinking", + "extendedThinking", + "tools", + "audio", + "documents", + "outputImages", + "speechToText", + "textToSpeech", +]; + +export function getRuntimeModel(provider: AiProvider): string { + switch (provider) { + case AiProvider.OLLAMA: + return Environment.OLLAMA_CHAT_MODEL; + case AiProvider.MISTRAL: + return Environment.MISTRAL_MODEL; + case AiProvider.OPENAI: + return Environment.OPENAI_MODEL; + } +} + +export function setRuntimeModel(provider: AiProvider, model: string): void { + switch (provider) { + case AiProvider.OLLAMA: + Environment.OLLAMA_CHAT_MODEL = model; + break; + case AiProvider.MISTRAL: + Environment.MISTRAL_MODEL = model; + break; + case AiProvider.OPENAI: + Environment.OPENAI_MODEL = model; + break; + } +} + +function capability(supported: boolean, target?: AiRuntimeTarget, runtimeTarget?: AiRuntimeTarget): AiCapabilityInfo { + const result: AiCapabilityInfo = {supported}; + if (target?.model) result.model = target.model; + if (target) { + result.endpoint = { + provider: target.provider, + baseUrl: target.baseUrl, + external: runtimeTarget ? !sameRuntimeEndpoint(target, runtimeTarget) : false, + }; + } + if (target && runtimeTarget && (target.model !== runtimeTarget.model || !sameRuntimeEndpoint(target, runtimeTarget))) { + result.external = true; + } + return result; +} + +function buildCapabilities(overrides: Partial>): AiModelCapabilities { + return Object.assign(new AiModelCapabilities(), { + chat: {supported: false}, + vision: {supported: false}, + ocr: {supported: false}, + thinking: {supported: false}, + extendedThinking: {supported: false}, + tools: {supported: false}, + audio: {supported: false}, + documents: {supported: false}, + outputImages: {supported: false}, + speechToText: {supported: false}, + textToSpeech: {supported: false}, + ...overrides, + }); +} + +function lowerModelName(model: string): string { + return model.toLowerCase(); +} + +function isOpenAiTextModel(model: string): boolean { + const name = lowerModelName(model); + if (!name) return false; + if (/^(gpt-image|dall-e|tts-|whisper|text-embedding|text-moderation|omni-moderation)/.test(name)) return false; + if (name.includes("transcribe")) return false; + return /^(gpt-|o\d|chatgpt-|codex-|computer-use)/.test(name); +} + +function isOpenAiReasoningModel(model: string): boolean { + const name = lowerModelName(model); + return /^o\d/.test(name) || name.startsWith("gpt-5"); +} + +function isOpenAiVisionModel(model: string): boolean { + const name = lowerModelName(model); + if (!isOpenAiTextModel(model)) return false; + if (name.startsWith("gpt-3.5")) return false; + if (name.includes("audio-preview") || name.includes("search-preview")) return false; + return true; +} + +export async function getModelCapabilities( + provider: AiProvider, + model: string, + purpose: AiCapabilityName | "chat" = "chat", +): Promise { + if (!model) return undefined; + + try { + const runtimeTarget = resolveAiRuntimeTarget(provider, "chat", getRuntimeModel(provider)); + const target = resolveAiRuntimeTarget(provider, purpose, model); + + switch (provider) { + case AiProvider.OLLAMA: { + const ollama = createOllamaClient(target); + const info = await ollama.show({model}); + const modelCapabilities = Array.isArray(info.capabilities) ? info.capabilities : []; + const has = (cap: string): boolean => modelCapabilities.includes(cap); + const audioSupported = isOllamaSpeechToTextModel(model); + const documentsTarget = resolveAiRuntimeTarget(provider, "documents"); + + return buildCapabilities({ + chat: capability(true, target, runtimeTarget), + vision: capability(has("vision"), target, runtimeTarget), + ocr: capability(has("ocr"), target, runtimeTarget), + thinking: capability(has("thinking"), target, runtimeTarget), + extendedThinking: capability(has("thinking") && model.includes("gpt-oss"), target, runtimeTarget), + tools: capability(has("tools"), target, runtimeTarget), + audio: capability(audioSupported, target, runtimeTarget), + documents: capability(!!documentsTarget.model, documentsTarget, runtimeTarget), + speechToText: capability(audioSupported, target, runtimeTarget), + }); + } + case AiProvider.MISTRAL: { + const mistral = createMistralClient(target); + const info = await mistral.models.retrieve({modelId: model}); + const caps = info.type !== "UNKNOWN" ? info.capabilities : undefined; + const speechTarget = resolveAiRuntimeTarget(provider, "speechToText"); + const ttsTarget = resolveAiRuntimeTarget(provider, "textToSpeech"); + + return buildCapabilities({ + chat: capability(true, target, runtimeTarget), + vision: capability(!!caps?.vision, target, runtimeTarget), + ocr: capability(!!caps?.ocr, target, runtimeTarget), + thinking: capability(!!caps?.reasoning, target, runtimeTarget), + tools: capability(!!caps?.functionCalling, target, runtimeTarget), + audio: capability(!!caps?.audio, target, runtimeTarget), + documents: capability(true, target, runtimeTarget), + speechToText: capability(!!speechTarget.model || !!caps?.audioTranscription, speechTarget, runtimeTarget), + textToSpeech: capability(!!ttsTarget.apiKey && !!ttsTarget.model, ttsTarget, runtimeTarget), + }); + } + case AiProvider.OPENAI: { + const textModel = isOpenAiTextModel(model); + const reasoningModel = isOpenAiReasoningModel(model); + const imageTarget = resolveAiRuntimeTarget(provider, "outputImages"); + const speechTarget = resolveAiRuntimeTarget(provider, "speechToText"); + const ttsTarget = resolveAiRuntimeTarget(provider, "textToSpeech"); + + return buildCapabilities({ + chat: capability(true, target, runtimeTarget), + vision: capability(isOpenAiVisionModel(model), target, runtimeTarget), + ocr: capability(isOpenAiVisionModel(model), target, runtimeTarget), + thinking: capability(reasoningModel, target, runtimeTarget), + extendedThinking: capability(reasoningModel, target, runtimeTarget), + tools: capability(textModel, target, runtimeTarget), + documents: capability(textModel, target, runtimeTarget), + outputImages: capability(!!imageTarget.model, imageTarget, runtimeTarget), + speechToText: capability(!!speechTarget.model, speechTarget, runtimeTarget), + textToSpeech: capability(!!ttsTarget.apiKey && !!ttsTarget.model, ttsTarget, runtimeTarget), + }); + } + } + + } catch (e) { + logError(e instanceof Error ? e : String(e)); + return undefined; + } +} + +export async function getRuntimeCapabilities( + provider: AiProvider = Environment.DEFAULT_AI_PROVIDER, + model: string | undefined = getRuntimeModel(provider), + target?: AiRuntimeTarget +): Promise { + const runtimeTarget = target ?? resolveAiRuntimeTarget(provider, "chat", model ?? getRuntimeModel(provider)); + const result = await getModelCapabilities(provider, runtimeTarget.model, target?.purpose ?? "chat") ?? buildCapabilities({}); + + for (const capabilityName of CAPABILITY_NAMES) { + if (provider === AiProvider.OPENAI && (capabilityName === "vision" || capabilityName === "ocr")) { + continue; + } + + const target = resolveAiRuntimeTarget(provider, capabilityName); + if (target.model === runtimeTarget.model && sameRuntimeEndpoint(target, runtimeTarget)) continue; + + const targetCapabilities = await getModelCapabilities(provider, target.model, capabilityName); + const capabilityInfo = targetCapabilities?.[capabilityName]; + if (capabilityInfo) { + result[capabilityName] = capabilityInfo; + } + } + + return result; +} + +export async function getFormattedCapabilities( + provider: AiProvider = Environment.DEFAULT_AI_PROVIDER, + model: string | undefined = getRuntimeModel(provider), + caps?: AiModelCapabilities, +): Promise { + if (!caps) caps = await getRuntimeCapabilities(provider, model); + + const line = (title: string, value?: AiCapabilityInfo) => { + const state = value?.supported ? "✅" : "❌"; + const external = value?.external ?? (!!value?.model && value.model !== model); + return Environment.getRuntimeCapabilityLineText({ + state, + title, + model: value?.model, + endpointBaseUrl: value?.endpoint?.baseUrl, + external, + }); + }; + + return [ + line(Environment.runtimeCapabilityChatText, caps.chat), + line(Environment.runtimeCapabilityVisionText, caps.vision), + line(Environment.runtimeCapabilityOcrText, caps.ocr), + line(Environment.runtimeCapabilityThinkingText, caps.thinking), + line(Environment.runtimeCapabilityExtendedThinkingText, caps.extendedThinking), + line(Environment.runtimeCapabilityToolsText, caps.tools), + line(Environment.runtimeCapabilityAudioText, caps.audio), + line(Environment.runtimeCapabilitySpeechToTextText, caps.speechToText), + line(Environment.runtimeCapabilityTextToSpeechText, caps.textToSpeech), + line(Environment.runtimeCapabilityDocumentsText, caps.documents), + line(Environment.runtimeCapabilityOutputImagesText, caps.outputImages), + ]; +} + +export async function formatRuntimeModelInfo( + provider: AiProvider = Environment.DEFAULT_AI_PROVIDER, + model: string | undefined = getRuntimeModel(provider), + caps?: AiModelCapabilities, +): Promise { + return Environment.getRuntimeModelInfoText( + provider.toString().toLowerCase(), + model, + await getFormattedCapabilities(provider, model, caps) + ); +} + + +type NamedModel = { + id?: string; + name?: string; + model?: string; +}; + +type ModelListResponse = { + models?: NamedModel[]; + data?: NamedModel[]; +}; + +export async function listProviderModels(provider: AiProvider): Promise { + const target = resolveAiRuntimeTarget(provider, "chat", getRuntimeModel(provider)); + + switch (provider) { + case AiProvider.OLLAMA: { + const ollama = createOllamaClient(target); + const result = await ollama.list() as ModelListResponse; + return (result.models ?? []).map(m => m.model || m.name).filter((name): name is string => !!name); + } + case AiProvider.MISTRAL: { + const mistralAi = createMistralClient(target); + const result = await mistralAi.models.list() as ModelListResponse | NamedModel[]; + const items = Array.isArray(result) ? result : result.data ?? result.models ?? []; + return items.map(m => m.id || m.name || String(m)).filter((name): name is string => !!name); + } + case AiProvider.OPENAI: { + const openAi = createOpenAiClient(target); + const result = await openAi.models.list() as ModelListResponse; + return (result.data ?? []).map(m => m.id).filter((id): id is string => !!id); + } + } +} diff --git a/src/ai/provider-request-queue.ts b/src/ai/provider-request-queue.ts new file mode 100644 index 0000000..73c56f9 --- /dev/null +++ b/src/ai/provider-request-queue.ts @@ -0,0 +1,196 @@ +import {Environment} from "../common/environment"; +import {AiProvider} from "../model/ai-provider"; +import {appLogger} from "../logging/logger"; +import type {BoundaryValue} from "../common/boundary-types"; + +const logger = appLogger.child("ai-provider-queue"); + +export type AiRequestQueueTarget = { + provider: AiProvider; + model: string; + baseUrl?: string; +}; + +type QueueEntry = { + target: AiRequestQueueTarget; + queueKey: string; + run: () => Promise; + resolve: (value: BoundaryValue) => void; + reject: (reason?: Error | string | BoundaryValue | null | undefined) => void; + onPositionChange: (requestsBefore: number) => Promise | void; + signal?: AbortSignal; + abortHandler?: () => void; + started: boolean; +}; + +type EnqueueOptions = { + signal?: AbortSignal; + onPositionChange: (requestsBefore: number) => Promise | void; + run: () => Promise; +}; + +class AiProviderRequestQueue { + private readonly waiting = new Map(); + private readonly active = new Map(); + + enqueue(target: AiRequestQueueTarget, options: EnqueueOptions): Promise { + if (options.signal?.aborted) { + logger.debug("enqueue.rejected.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl}); + return Promise.reject(new Error("Aborted")); + } + + return new Promise((resolve, reject) => { + const queueKey = this.queueKey(target); + const entry: QueueEntry = { + target, + queueKey, + run: options.run, + resolve: value => resolve(value as T), + reject, + onPositionChange: options.onPositionChange, + signal: options.signal, + started: false, + }; + + entry.abortHandler = () => { + if (entry.started) return; + + const removed = this.removeWaitingEntry(entry); + if (!removed) return; + + logger.debug("entry.cancelled", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queueKey}); + reject(new Error("Aborted")); + this.schedule(target); + }; + + options.signal?.addEventListener("abort", entry.abortHandler, {once: true}); + this.getOrCreateQueue(queueKey).push(entry); + logger.debug("enqueue.accepted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queued: this.getOrCreateQueue(queueKey).length, active: this.activeCount(queueKey)}); + this.schedule(target); + }); + } + + private getQueue(queueKey: string): QueueEntry[] | undefined { + return this.waiting.get(queueKey); + } + + private getOrCreateQueue(queueKey: string): QueueEntry[] { + let queue = this.waiting.get(queueKey); + if (!queue) { + queue = []; + this.waiting.set(queueKey, queue); + } + return queue; + } + + private activeCount(queueKey: string): number { + return this.active.get(queueKey) ?? 0; + } + + private setActiveCount(queueKey: string, count: number): void { + if (count <= 0) { + this.active.delete(queueKey); + return; + } + this.active.set(queueKey, count); + } + + private maxActive(target: AiRequestQueueTarget): number { + return Math.max(1, Environment.getAiProviderMaxConcurrentRequests(target.provider)); + } + + private normalizeBaseUrl(baseUrl: string | undefined): string { + return (baseUrl ?? "").trim().replace(/\/+$/, ""); + } + + private queueKey(target: AiRequestQueueTarget): string { + return JSON.stringify([ + target.provider, + this.normalizeBaseUrl(target.baseUrl), + target.model.trim(), + ]); + } + + private removeWaitingEntry(entry: QueueEntry): boolean { + const queue = this.getQueue(entry.queueKey); + if (!queue) return false; + + const index = queue.indexOf(entry); + if (index < 0) return false; + + queue.splice(index, 1); + if (entry.abortHandler) { + entry.signal?.removeEventListener("abort", entry.abortHandler); + } + this.deleteQueueIfIdle(entry.queueKey, queue); + return true; + } + + private schedule(target: AiRequestQueueTarget): void { + const queueKey = this.queueKey(target); + const queue = this.getOrCreateQueue(queueKey); + + while (queue.length && this.activeCount(queueKey) < this.maxActive(target)) { + const entry = queue.shift(); + if (!entry) continue; + + if (entry.abortHandler) { + entry.signal?.removeEventListener("abort", entry.abortHandler); + } + + if (entry.signal?.aborted) { + logger.debug("entry.skipped.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queueKey}); + entry.reject(new Error("Aborted")); + continue; + } + + entry.started = true; + this.setActiveCount(queueKey, this.activeCount(queueKey) + 1); + logger.debug("entry.started", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queued: queue.length, active: this.activeCount(queueKey)}); + void this.runEntry(entry); + } + + this.updateWaitingMessages(target); + if (!queue.length && this.activeCount(queueKey) <= 0) { + this.waiting.delete(queueKey); + } + } + + private async runEntry(entry: QueueEntry): Promise { + try { + entry.resolve(await entry.run()); + logger.debug("entry.done", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl}); + } catch (e) { + const error = e instanceof Error ? e : String(e); + logger.error("entry.failed", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl, error}); + entry.reject(error); + } finally { + this.setActiveCount(entry.queueKey, this.activeCount(entry.queueKey) - 1); + this.schedule(entry.target); + } + } + + private updateWaitingMessages(target: AiRequestQueueTarget): void { + const queueKey = this.queueKey(target); + const active = this.activeCount(queueKey); + const queue = [...(this.getQueue(queueKey) ?? [])]; + + Promise.allSettled(queue.map((entry, index) => { + return entry.onPositionChange(active + index); + })).then(results => { + for (const result of results) { + if (result.status === "rejected") { + logger.error("position_update.failed", {provider: target.provider, model: target.model, reason: result.reason instanceof Error ? result.reason : String(result.reason)}); + } + } + }).catch(error => logger.error("position_updates.failed", {provider: target.provider, model: target.model, error: error instanceof Error ? error : String(error)})); + } + + private deleteQueueIfIdle(queueKey: string, queue: QueueEntry[]): void { + if (!queue.length && this.activeCount(queueKey) <= 0) { + this.waiting.delete(queueKey); + } + } +} + +export const aiProviderRequestQueue = new AiProviderRequestQueue(); diff --git a/src/ai/rag-artifact-store.ts b/src/ai/rag-artifact-store.ts new file mode 100644 index 0000000..2d4da3b --- /dev/null +++ b/src/ai/rag-artifact-store.ts @@ -0,0 +1,153 @@ +import type {StoredAttachment} from "../model/stored-attachment"; +import {AiProvider} from "../model/ai-provider"; +import type {AiDownloadedFile} from "./telegram-attachments"; +import type {PreparedDocumentRag} from "./document-rag-pipeline"; +import type {OllamaRagArtifactDetails} from "./ollama-rag"; +import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store"; + +type RagArtifactPayload = { + artifactKind: "rag"; + provider: AiProvider; + createdAt: string; + sources: Array<{ + fileId: string; + fileName: string; + mimeType?: string; + sizeBytes?: number; + sha256?: string; + uploadedFileId?: string; + documentId?: string; + }>; + providerState: { + vectorStoreIds?: string[]; + libraryId?: string; + documentCount?: number; + prepared?: boolean; + uploadedFileIds?: string[]; + embeddingModel?: string; + topK?: number; + chunkSize?: number; + chunkOverlap?: number; + maxContextChars?: number; + extractedDocuments?: Array<{ + documentIndex: number; + fileName: string; + textChars: number; + }>; + selectedChunks?: Array<{ + sourceId: string; + documentIndex: number; + documentName: string; + chunkIndex: number; + chunkCount: number; + textChars: number; + score?: number; + }>; + skippedDocuments?: Array<{ + documentIndex: number; + fileName: string; + reason: string; + }>; + query?: string; + ollama?: OllamaRagArtifactDetails["providerState"]; + }; +}; + +function providerState(prepared: PreparedDocumentRag, details?: NonNullable[0]["details"]>): RagArtifactPayload["providerState"] { + switch (prepared.provider) { + case AiProvider.OPENAI: + return { + vectorStoreIds: prepared.vectorStoreIds, + uploadedFileIds: prepared.uploadedFileIds, + }; + case AiProvider.MISTRAL: + return { + libraryId: prepared.libraryId, + documentCount: prepared.documents.length, + }; + case AiProvider.OLLAMA: + return { + prepared: prepared.prepared, + embeddingModel: details?.embeddingModel, + topK: details?.topK, + chunkSize: details?.chunkSize, + chunkOverlap: details?.chunkOverlap, + maxContextChars: details?.maxContextChars, + }; + } +} + +export async function persistRagArtifactAttachment(params: { + provider: AiProvider; + prepared: PreparedDocumentRag | undefined; + downloads: AiDownloadedFile[]; + chatId: number; + messageId: number; + details?: { + uploadedFileIds?: string[]; + sourceDocuments?: Array<{ + fileId: string; + fileName: string; + mimeType?: string; + sizeBytes?: number; + sha256?: string; + uploadedFileId?: string; + documentId?: string; + }>; + embeddingModel?: string; + topK?: number; + chunkSize?: number; + chunkOverlap?: number; + maxContextChars?: number; + artifact?: OllamaRagArtifactDetails; + }; +}): Promise { + if (!params.prepared) return Promise.resolve(undefined); + + const sources = params.downloads + .filter(download => download.kind === "document") + .map((download, index) => ({ + fileId: download.fileId, + fileName: download.fileName, + mimeType: download.mimeType, + sizeBytes: download.sizeBytes ?? download.buffer.length, + sha256: download.sha256, + uploadedFileId: params.details?.uploadedFileIds?.[index], + })); + + if (!sources.length) return Promise.resolve(undefined); + + const payload: RagArtifactPayload = { + artifactKind: "rag", + provider: params.provider, + createdAt: new Date().toISOString(), + sources, + providerState: { + ...providerState(params.prepared, params.details), + ...(params.details?.artifact ? { + extractedDocuments: params.details.artifact.extractedDocuments, + selectedChunks: params.details.artifact.selectedChunks, + skippedDocuments: params.details.artifact.skippedDocuments, + query: params.details.artifact.query, + ollama: params.details.artifact.providerState, + } : {}), + }, + }; + return await persistInternalJsonArtifactAttachment({ + artifactKind: "rag", + fileNamePrefix: "rag", + chatId: params.chatId, + messageId: params.messageId, + payload, + metadata: { + provider: params.provider, + sourceFileNames: sources.map(source => source.fileName), + ...payload.providerState, + embeddingModel: params.details?.embeddingModel, + topK: params.details?.topK, + chunkSize: params.details?.chunkSize, + chunkOverlap: params.details?.chunkOverlap, + maxContextChars: params.details?.maxContextChars, + }, + }); +} diff --git a/src/ai/regenerate-callback.ts b/src/ai/regenerate-callback.ts new file mode 100644 index 0000000..9b34f18 --- /dev/null +++ b/src/ai/regenerate-callback.ts @@ -0,0 +1,24 @@ +import {AiProvider} from "../model/ai-provider"; + +export const AI_REGENERATE_CALLBACK = "/regenerate_ai"; + +export type AiRegenerateCallbackData = { + provider: AiProvider; + think: boolean; +}; + +export function buildAiRegenerateCallbackData(provider: AiProvider, think = false): string { + return `${AI_REGENERATE_CALLBACK} ${provider} ${think ? "1" : "0"}`; +} + +export function parseAiRegenerateCallbackData(data: string): AiRegenerateCallbackData | null { + if (!data.startsWith(AI_REGENERATE_CALLBACK)) return null; + + const [, provider, think] = data.split(/\s+/); + if (!Object.values(AiProvider).includes(provider as AiProvider)) return null; + + return { + provider: provider as AiProvider, + think: think === "1" || think === "true", + }; +} diff --git a/src/ai/speech-to-text-models.ts b/src/ai/speech-to-text-models.ts new file mode 100644 index 0000000..ca69a9f --- /dev/null +++ b/src/ai/speech-to-text-models.ts @@ -0,0 +1,8 @@ +const OLLAMA_SPEECH_TO_TEXT_MODELS = new Set([ + "gemma4:e2b", + "gemma4:e4b", +]); + +export function isOllamaSpeechToTextModel(model: string | undefined | null): boolean { + return !!model && OLLAMA_SPEECH_TO_TEXT_MODELS.has(model.trim().toLowerCase()); +} diff --git a/src/ai/speech-to-text.ts b/src/ai/speech-to-text.ts new file mode 100644 index 0000000..b80297a --- /dev/null +++ b/src/ai/speech-to-text.ts @@ -0,0 +1,195 @@ +import fs, {openAsBlob} from "node:fs"; +import {AiProvider} from "../model/ai-provider"; +import { + getAvailableAiProviderChoices, + normalizeAiProviderChoice, + resolveEffectiveAiProviderForUser, +} from "../common/user-ai-settings"; +import {providerDisplayName} from "./provider-aliases"; +import {AiDownloadedFile} from "./telegram-attachments"; +import {isOllamaSpeechToTextModel} from "./speech-to-text-models"; +import {createMistralClient, createOllamaClient, createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target"; +import {Environment} from "../common/environment"; + +export type TranscribedSpeech = { + provider: AiProvider; + model: string; + text: string; + fileName: string; +}; + +export type SpeechToTextRequest = { + provider: AiProvider; + audio: AiDownloadedFile; + signal?: AbortSignal; +}; + +export type SpeechToTextProviderResolution = { + provider: AiProvider; + fallback: boolean; +}; + +export type SpeechToTextResolveOptions = { + allowFallback?: boolean; +}; + +export function isTranscribableAudioDownload(download: AiDownloadedFile): boolean { + if (download.kind === "audio") return true; + return download.kind === "video-note" && (download.mimeType?.startsWith("audio/") || download.path.toLowerCase().endsWith(".wav")); +} + +export function isSpeechToTextConfigured(provider: AiProvider): boolean { + switch (provider) { + case AiProvider.OPENAI: + const openAiTarget = resolveAiRuntimeTarget(provider, "speechToText"); + return !!openAiTarget.apiKey && !!openAiTarget.model; + case AiProvider.MISTRAL: + const mistralTarget = resolveAiRuntimeTarget(provider, "speechToText"); + return !!mistralTarget.apiKey && !!mistralTarget.model; + case AiProvider.OLLAMA: + const ollamaTarget = resolveAiRuntimeTarget(provider, "speechToText"); + return !!ollamaTarget.baseUrl && isOllamaSpeechToTextModel(ollamaTarget.model); + } +} + +export async function resolveSpeechToTextProviderForUser( + userId: number, + preferredProvider?: AiProvider, + options: SpeechToTextResolveOptions = {}, +): Promise { + const allowFallback = options.allowFallback ?? true; + const availableChoices = getAvailableAiProviderChoices(userId); + const allowedProviders = availableChoices + .map(choice => normalizeAiProviderChoice(choice)) + .filter((choice): choice is AiProvider => !!choice && choice !== "DEFAULT"); + + if (preferredProvider) { + if (!allowedProviders.includes(preferredProvider)) { + throw new Error(Environment.getProviderNotAvailableForAccessText(providerDisplayName(preferredProvider))); + } + + if (isSpeechToTextConfigured(preferredProvider)) { + return {provider: preferredProvider, fallback: false}; + } + + if (!allowFallback) { + throw new Error(Environment.getProviderSpeechToTextUnsupportedText(providerDisplayName(preferredProvider))); + } + } + + const effectiveProvider = await resolveEffectiveAiProviderForUser(userId); + if (isSpeechToTextConfigured(effectiveProvider)) { + return { + provider: effectiveProvider, + fallback: preferredProvider !== undefined && preferredProvider !== effectiveProvider + }; + } + + const fallbackProvider = allowedProviders.find(isSpeechToTextConfigured); + if (!fallbackProvider) { + throw new Error(Environment.noSpeechToTextProviderForAccessText); + } + + return {provider: fallbackProvider, fallback: true}; +} + +export async function transcribeSpeech(request: SpeechToTextRequest): Promise { + if (request.signal?.aborted) throw new Error("Aborted"); + + switch (request.provider) { + case AiProvider.OPENAI: + return transcribeOpenAiSpeech(request.audio, request.signal); + case AiProvider.MISTRAL: + return transcribeMistralSpeech(request.audio, request.signal); + case AiProvider.OLLAMA: + return transcribeOllamaSpeech(request.audio, request.signal); + } +} + +export async function transcribeSpeechDownloads(provider: AiProvider, downloads: AiDownloadedFile[], signal?: AbortSignal): Promise { + const audios = downloads.filter(isTranscribableAudioDownload); + const transcriptions: string[] = []; + + for (const [index, audio] of audios.entries()) { + if (signal?.aborted) throw new Error("Aborted"); + + const result = await transcribeSpeech({provider, audio, signal}); + const text = result.text.trim(); + if (!text) continue; + + transcriptions.push(audios.length > 1 + ? `[${index + 1}. ${audio.fileName}]\n${text}` + : text); + } + + return transcriptions.join("\n\n").trim(); +} + +async function transcribeOpenAiSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise { + const target = resolveAiRuntimeTarget(AiProvider.OPENAI, "speechToText"); + const openAi = createOpenAiClient(target); + const file = fs.createReadStream(audio.path); + try { + const result = await openAi.audio.transcriptions.create({ + file, + model: target.model, + }, {signal}); + + return { + provider: AiProvider.OPENAI, + model: target.model, + text: result.text || "", + fileName: audio.fileName, + }; + } finally { + file.destroy(); + } +} + +async function transcribeMistralSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise { + const target = resolveAiRuntimeTarget(AiProvider.MISTRAL, "speechToText"); + const mistralAi = createMistralClient(target); + const result = await mistralAi.audio.transcriptions.complete({ + model: target.model, + file: await openAsBlob(audio.path), + }, {signal}); + + return { + provider: AiProvider.MISTRAL, + model: target.model, + text: result.text || "", + fileName: audio.fileName, + }; +} + +async function transcribeOllamaSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise { + if (signal?.aborted) throw new Error("Aborted"); + + const target = resolveAiRuntimeTarget(AiProvider.OLLAMA, "speechToText"); + const model = target.model; + if (!isOllamaSpeechToTextModel(model)) { + throw new Error(Environment.ollamaSpeechToTextModelRequiredText); + } + + const ollama = createOllamaClient(target); + const response = await ollama.chat({ + model, + stream: false, + think: false, + messages: [{ + role: "user", + content: "Transcribe the attached audio verbatim. Reply only with the transcription text. Do not answer the speaker.", + images: [audio.buffer.toString("base64")], + }], + options: { + temperature: 0, + }, + }); + + return { + provider: AiProvider.OLLAMA, + model, + text: response?.message?.content || "", + fileName: audio.fileName, + }; +} diff --git a/src/ai/telegram-attachments.ts b/src/ai/telegram-attachments.ts new file mode 100644 index 0000000..5610bc3 --- /dev/null +++ b/src/ai/telegram-attachments.ts @@ -0,0 +1,447 @@ +import {Message} from "typescript-telegram-bot-api"; +import {bot} from "../index"; +import {downloadTelegramFile, logError} from "../util/utils"; +import fs from "node:fs"; +import path from "node:path"; +import {Environment} from "../common/environment"; +import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment"; +import {performFFmpeg} from "../util/ffmpeg"; +import ffmpeg from "fluent-ffmpeg"; +import {AsyncSemaphore, KeyedAsyncLock} from "../util/async-lock"; +import {appLogger} from "../logging/logger"; +import {createHash} from "node:crypto"; +import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline/types"; + +export type AiDownloadedFile = { + kind: StoredAttachmentKind; + fileId: string; + fileName: string; + mimeType?: string; + buffer: Buffer; + path: string; + sizeBytes?: number; + sha256?: string; +}; + +export type RejectedTelegramAttachment = { + kind: StoredAttachmentKind; + fileId: string; + fileUniqueId?: string; + fileName: string; + mimeType?: string; + sizeBytes: number; + limitBytes: number; + reason: "too_large"; +}; + +export type TelegramAttachmentDescriptor = { + kind: StoredAttachmentKind; + fileId: string; + fileUniqueId?: string; + fileName: string; + mimeType?: string; + sizeBytes?: number; +}; + +export type MessageAttachmentCacheResult = { + attachments: StoredAttachment[]; + rejected: RejectedTelegramAttachment[]; +}; + +const cachePathLocks = new KeyedAsyncLock(); +const ffmpegSemaphore = new AsyncSemaphore(2); +const logger = appLogger.child("attachments"); + +function safeFileName(value: string): string { + return value.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_").slice(0, 180); +} + +function extensionFromMimeType(mimeType?: string): string { + switch ((mimeType || "").toLowerCase()) { + case "audio/ogg": + case "audio/opus": + return ".ogg"; + case "audio/mpeg": + case "audio/mp3": + return ".mp3"; + case "audio/mp4": + case "audio/x-m4a": + return ".m4a"; + case "audio/wav": + case "audio/wave": + case "audio/x-wav": + return ".wav"; + case "audio/webm": + return ".webm"; + case "image/jpeg": + return ".jpg"; + case "image/png": + return ".png"; + case "image/webp": + return ".webp"; + case "application/pdf": + return ".pdf"; + case "text/plain": + return ".txt"; + case "application/zip": + case "application/x-zip": + case "application/x-zip-compressed": + return ".zip"; + case "application/x-tar": + case "application/tar": + return ".tar"; + case "application/gzip": + case "application/x-gzip": + case "application/gzip-compressed": + return ".gz"; + case "video/mp4": + return ".mp4"; + default: + return ""; + } +} + +function fileNameWithExtension(fileName: string, mimeType?: string, telegramFilePath?: string): string { + if (path.extname(fileName)) return fileName; + + const telegramExt = telegramFilePath ? path.extname(telegramFilePath) : ""; + const ext = telegramExt || extensionFromMimeType(mimeType); + return ext ? `${fileName}${ext}` : fileName; +} + +function cacheDirFor(kind: StoredAttachmentKind): string { + const dirName = kind === "image" ? "photo" : kind; + return path.join(Environment.DATA_PATH, "cache", dirName); +} + +function cachePathFor(kind: StoredAttachmentKind, fileUniqueId: string | undefined, fileId: string, fileName: string): string { + const base = safeFileName(fileUniqueId || fileId); + const ext = path.extname(fileName); + return path.join(cacheDirFor(kind), `${base}${ext || ""}`); +} + +function fileSha256(location: string): string | undefined { + if (!fs.existsSync(location)) return undefined; + return createHash("sha256").update(fs.readFileSync(location)).digest("hex"); +} + +function rejectIfTooLarge( + rejected: RejectedTelegramAttachment[], + kind: StoredAttachmentKind, + fileId: string, + fileName: string, + mimeType?: string, + sizeBytes?: number, + fileUniqueId?: string, +): boolean { + if (!sizeBytes || sizeBytes <= PIPELINE_ATTACHMENT_LIMIT_BYTES) { + return false; + } + + rejected.push({ + kind, + fileId, + fileUniqueId, + fileName, + mimeType, + sizeBytes, + limitBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES, + reason: "too_large", + }); + logger.warn("message.cache.rejected.too_large", {kind, fileId, fileName, mimeType, sizeBytes}); + return true; +} + +export function collectTelegramAttachmentDescriptors(msg: Message): TelegramAttachmentDescriptor[] { + const attachments: TelegramAttachmentDescriptor[] = []; + + if (msg.photo?.length) { + const size = msg.photo[msg.photo.length - 1]!; + attachments.push({ + kind: "image", + fileId: size.file_id, + fileUniqueId: size.file_unique_id, + fileName: `${size.file_unique_id || size.file_id}.jpg`, + mimeType: "image/jpeg", + sizeBytes: size.file_size, + }); + } + + if (msg.document) { + const doc = msg.document; + attachments.push({ + kind: doc.mime_type?.startsWith("image/") + ? "image" + : doc.mime_type?.startsWith("audio/") + ? "audio" + : "document", + fileId: doc.file_id, + fileUniqueId: doc.file_unique_id, + fileName: doc.file_name || `${doc.file_unique_id || doc.file_id}`, + mimeType: doc.mime_type, + sizeBytes: doc.file_size, + }); + } + + if (msg.voice) { + attachments.push({ + kind: "audio", + fileId: msg.voice.file_id, + fileUniqueId: msg.voice.file_unique_id, + fileName: `${msg.voice.file_unique_id || msg.voice.file_id}.ogg`, + mimeType: msg.voice.mime_type || "audio/ogg", + sizeBytes: msg.voice.file_size, + }); + } + + if (msg.audio) { + attachments.push({ + kind: "audio", + fileId: msg.audio.file_id, + fileUniqueId: msg.audio.file_unique_id, + fileName: msg.audio.file_name || `${msg.audio.file_unique_id || msg.audio.file_id}.mp3`, + mimeType: msg.audio.mime_type, + sizeBytes: msg.audio.file_size, + }); + } + + if (msg.video_note) { + attachments.push({ + kind: "video-note", + fileId: msg.video_note.file_id, + fileUniqueId: msg.video_note.file_unique_id, + fileName: `${msg.video_note.file_unique_id || msg.video_note.file_id}.mp4`, + mimeType: "video/mp4", + sizeBytes: msg.video_note.file_size, + }); + } + + return attachments; +} + +async function downloadToCache( + kind: StoredAttachmentKind, + fileId: string, + fileName: string, + mimeType?: string, + fileUniqueId?: string, + sizeBytes?: number, +): Promise { + const startedAt = Date.now(); + logger.debug("download.start", {kind, fileId, fileName, mimeType}); + const file = await bot.getFile({file_id: fileId}); + const finalFileName = fileNameWithExtension(fileName, mimeType, file.file_path); + const location = cachePathFor(kind, fileUniqueId, fileId, finalFileName); + + await cachePathLocks.runExclusive(location, async () => { + if (fs.existsSync(location)) { + logger.trace("download.cache_hit", {kind, location}); + return; + } + + const buffer = await downloadTelegramFile(file.file_path); + if (!buffer) { + logger.warn("download.empty", {kind, fileId, telegramFilePath: file.file_path}); + return; + } + + const tempLocation = `${location}.${process.pid}.${Date.now()}.tmp`; + fs.mkdirSync(path.dirname(location), {recursive: true}); + fs.writeFileSync(tempLocation, buffer); + fs.renameSync(tempLocation, location); + logger.debug("download.saved", {kind, location, bytes: buffer.length, duration: logger.duration(startedAt)}); + }); + + const resolvedSizeBytes = sizeBytes ?? (fs.existsSync(location) ? fs.statSync(location).size : undefined); + return { + kind, + fileId, + fileUniqueId, + fileName: finalFileName, + mimeType, + cachePath: location, + sizeBytes: resolvedSizeBytes, + sha256: fileSha256(location), + }; +} + +async function convertAudioToWav(input: string, output: string, noVideo = false): Promise { + const startedAt = Date.now(); + logger.debug("audio.convert.start", {input, output, noVideo}); + await cachePathLocks.runExclusive(output, async () => { + if (fs.existsSync(output)) { + logger.trace("audio.convert.cache_hit", {output}); + return; + } + + await ffmpegSemaphore.runExclusive(async () => { + if (fs.existsSync(output)) { + logger.trace("audio.convert.cache_hit", {output}); + return; + } + + const tempOutput = `${output}.${process.pid}.${Date.now()}.tmp.wav`; + try { + await performFFmpeg(() => { + const command = ffmpeg(input); + if (noVideo) command.noVideo(); + return command + .toFormat("wav") + .save(tempOutput) + .on("progress", (progress) => { + logger.trace("audio.convert.progress", {input, output, progress}); + }); + }); + fs.renameSync(tempOutput, output); + logger.debug("audio.convert.done", {input, output, duration: logger.duration(startedAt)}); + } catch (e) { + if (fs.existsSync(tempOutput)) { + fs.rmSync(tempOutput, {force: true}); + } + logger.error("audio.convert.failed", {input, output, error: e instanceof Error ? e : String(e)}); + throw e; + } + }); + }); +} + +export async function cacheMessageAttachmentsWithRejections(msg: Message): Promise { + const startedAt = Date.now(); + const result: StoredAttachment[] = []; + const rejected: RejectedTelegramAttachment[] = []; + logger.debug("message.cache.start", {chatId: msg.chat?.id, messageId: msg.message_id}); + + try { + if (msg.photo?.length) { + const size = msg.photo[msg.photo.length - 1]!; + const fileName = `${size.file_unique_id || size.file_id}.jpg`; + const mimeType = "image/jpeg"; + if (!rejectIfTooLarge(rejected, "image", size.file_id, fileName, mimeType, size.file_size, size.file_unique_id)) { + const file = await downloadToCache("image", size.file_id, fileName, mimeType, size.file_unique_id, size.file_size); + if (file) result.push(file); + } + } + + if (msg.document) { + const doc = msg.document; + const kind: StoredAttachmentKind = doc.mime_type?.startsWith("image/") + ? "image" + : doc.mime_type?.startsWith("audio/") + ? "audio" + : "document"; + const fileName = doc.file_name || `${doc.file_unique_id || doc.file_id}`; + if (!rejectIfTooLarge(rejected, kind, doc.file_id, fileName, doc.mime_type, doc.file_size, doc.file_unique_id)) { + const file = await downloadToCache(kind, doc.file_id, fileName, doc.mime_type, doc.file_unique_id, doc.file_size); + if (file) result.push(file); + } + } + + if (msg.voice) { + const fileName = `${msg.voice.file_unique_id || msg.voice.file_id}.ogg`; + const mimeType = msg.voice.mime_type || "audio/ogg"; + const file = rejectIfTooLarge(rejected, "audio", msg.voice.file_id, fileName, mimeType, msg.voice.file_size, msg.voice.file_unique_id) + ? null + : await downloadToCache("audio", msg.voice.file_id, fileName, mimeType, msg.voice.file_unique_id, msg.voice.file_size); + if (file) { + const output = cachePathFor("audio", msg.voice.file_unique_id, msg.voice.file_id, `${msg.voice.file_unique_id || msg.voice.file_id}.wav`); + try { + await convertAudioToWav(file.cachePath, output); + file.cachePath = output; + file.fileName = file?.fileName?.replace(".ogg", ".wav"); + file.mimeType = "audio/wav"; + file.sizeBytes = fs.existsSync(output) ? fs.statSync(output).size : file.sizeBytes; + file.sha256 = fileSha256(output); + } catch (e) { + logError(e instanceof Error ? e : String(e)); + } + } + + if (file) result.push(file); + } + + if (msg.audio) { + const fileName = msg.audio.file_name || `${msg.audio.file_unique_id || msg.audio.file_id}.mp3`; + if (!rejectIfTooLarge(rejected, "audio", msg.audio.file_id, fileName, msg.audio.mime_type, msg.audio.file_size, msg.audio.file_unique_id)) { + const file = await downloadToCache("audio", msg.audio.file_id, fileName, msg.audio.mime_type, msg.audio.file_unique_id, msg.audio.file_size); + if (file) result.push(file); + } + } + + if (msg.video_note) { + const fileName = `${msg.video_note.file_unique_id || msg.video_note.file_id}.mp4`; + const mimeType = "video/mp4"; + const file = rejectIfTooLarge(rejected, "video-note", msg.video_note.file_id, fileName, mimeType, msg.video_note.file_size, msg.video_note.file_unique_id) + ? null + : await downloadToCache("video-note", msg.video_note.file_id, fileName, mimeType, msg.video_note.file_unique_id, msg.video_note.file_size); + if (file) { + const output = cachePathFor("audio", msg.video_note.file_unique_id, msg.video_note.file_id, `${msg.video_note.file_unique_id || msg.video_note.file_id}.wav`); + try { + await convertAudioToWav(file.cachePath, output, true); + file.cachePath = output; + file.fileName = file?.fileName?.replace(".mp4", ".wav"); + file.mimeType = "audio/wav"; + file.sizeBytes = fs.existsSync(output) ? fs.statSync(output).size : file.sizeBytes; + file.sha256 = fileSha256(output); + } catch (e) { + logError(e instanceof Error ? e : String(e)); + } + } + + if (file) result.push(file); + } + } catch (e) { + logError(e instanceof Error ? e : String(e)); + } + + logger.debug("message.cache.done", { + chatId: msg.chat?.id, + messageId: msg.message_id, + attachments: result.length, + rejected: rejected.length, + duration: logger.duration(startedAt), + }); + return {attachments: result, rejected}; +} + +export async function cacheMessageAttachments(msg: Message): Promise { + const {attachments} = await cacheMessageAttachmentsWithRejections(msg); + return attachments; +} + +export function attachmentsToDownloadedFiles(attachments: StoredAttachment[]): AiDownloadedFile[] { + logger.trace("downloaded_files.build", {attachments: attachments.length}); + return attachments + .filter(attachment => fs.existsSync(attachment.cachePath)) + .flatMap(attachment => { + const sizeBytes = attachment.sizeBytes ?? fs.statSync(attachment.cachePath).size; + if (sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES) { + logger.warn("downloaded_files.skipped.too_large", { + kind: attachment.kind, + fileName: attachment.fileName, + sizeBytes, + limitBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES, + }); + return []; + } + + return [{ + kind: attachment.kind, + fileId: attachment.fileId, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + buffer: fs.readFileSync(attachment.cachePath), + path: attachment.cachePath, + sizeBytes, + sha256: attachment.sha256, + }]; + }); +} + +export function cleanupDownloads(files: AiDownloadedFile[]): void { + logger.trace("downloaded_files.cleanup", {files: files.length}); + // Files stay on disk in the message cache; drop in-memory buffers eagerly. + for (const file of files) { + file.buffer = Buffer.alloc(0); + } + files.length = 0; +} diff --git a/src/ai/telegram-stream-message.ts b/src/ai/telegram-stream-message.ts new file mode 100644 index 0000000..9fa2624 --- /dev/null +++ b/src/ai/telegram-stream-message.ts @@ -0,0 +1,711 @@ +import {FileOptions, InlineKeyboardMarkup, Message} from "typescript-telegram-bot-api"; +import {bot} from "../index"; +import {buildCancelledGenerationText, logError, replyToMessage} from "../util/utils"; +import {Environment} from "../common/environment"; +import {MessageStore} from "../common/message-store"; +import {createQueuedFunction} from "../util/async-lock"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; +import fs from "node:fs"; +import path from "node:path"; +import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment"; +import {StoredMessage} from "../model/stored-message"; +import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer"; +import {AiProvider} from "../model/ai-provider"; +import {AI_IMAGE_OUTPUT_MODE_DOCUMENT, UserAiImageOutputMode} from "../common/user-ai-settings"; +import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline"; + +const TELEGRAM_LIMIT = 4096; +const TELEGRAM_CAPTION_LIMIT = 1024; +const TELEGRAM_PHOTO_LIMIT_BYTES = 10 * 1024 * 1024; +const EDIT_INTERVAL_MS = 4500; + +export type TelegramArtifactFile = { + kind: "image" | "file"; + path: string; + fileName: string; + mimeType?: string; + sizeBytes: number; +}; + +export type TelegramToolExecutionRecord = { + toolName: string; + callId: string; + argumentsText: string; + resultChars: number; + startedAt: string; + finishedAt: string; +}; + +export type TelegramOutputAttachmentRecord = { + artifactKind: "generated_file" | "tts_audio"; + fileName: string; + mimeType?: string; + sizeBytes?: number; + messageId?: number; +}; + +export class TelegramStreamMessage { + private waitMessage: Message | null = null; + private timer: NodeJS.Timeout | null = null; + private lastSent = ""; + private text = ""; + private status = ""; + private mediaMode = false; + private cancelled = false; + private cancelledProvider = ""; + private readonly sendImagesAsDocuments: boolean; + private readonly startedAt = Date.now(); + private readonly enqueueEdit = createQueuedFunction(); + private readonly toolExecutions: TelegramToolExecutionRecord[] = []; + private readonly outputAttachments: TelegramOutputAttachmentRecord[] = []; + + constructor( + private readonly sourceMessage: Message, + private readonly cancelRequestId: string, + private readonly stream: boolean, + private readonly regenerateCallbackData?: string, + private readonly targetMessage?: Message, + private readonly cancelProvider?: AiProvider, + private readonly isGuest?: boolean, + imageOutputMode: UserAiImageOutputMode = "photo", + ) { + this.sendImagesAsDocuments = imageOutputMode === AI_IMAGE_OUTPUT_MODE_DOCUMENT; + } + + keyboard(): InlineKeyboardMarkup { + return { + inline_keyboard: [[{ + text: Environment.cancelText, + callback_data: this.cancelProvider + ? `/cancel_ai ${this.cancelRequestId} ${this.cancelProvider}` + : `/cancel_ai ${this.cancelRequestId}`, + }]], + }; + } + + emptyKeyboard(): InlineKeyboardMarkup { + return {inline_keyboard: []}; + } + + regenerateKeyboard(): InlineKeyboardMarkup | null { + if (!this.regenerateCallbackData) return null; + + return { + inline_keyboard: [[{ + text: Environment.regenerateText, + callback_data: this.regenerateCallbackData, + }]], + }; + } + + private isMessageNotModified(message: string): boolean { + return message.includes("message is not modified"); + } + + private async updateKeyboard(replyMarkup: InlineKeyboardMarkup): Promise { + if (!this.waitMessage) return; + + try { + await enqueueTelegramApiCall( + () => bot.editMessageReplyMarkup({ + chat_id: this.waitMessage!.chat.id, + message_id: this.waitMessage!.message_id, + reply_markup: replyMarkup, + }), + { + method: "editMessageReplyMarkup", + chatId: this.waitMessage.chat.id, + chatType: this.waitMessage.chat.type, + } + ); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + if (!this.isMessageNotModified(message)) logError(e instanceof Error ? e : message); + } + } + + private async removeKeyboard(): Promise { + await this.updateKeyboard(this.emptyKeyboard()); + } + + private startFlushTimer(): void { + if (this.timer) clearInterval(this.timer); + this.timer = setInterval(() => this.flush().catch(logError), EDIT_INTERVAL_MS); + } + + private visibleText(): string { + const parts = [this.text, this.status].filter(v => v && v.trim().length); + let value = parts.join("\n\n").trim() || Environment.waitThinkText; + if (value.length > TELEGRAM_LIMIT) { + value = value.substring(0, TELEGRAM_LIMIT - 1); + } + return value; + } + + private visibleCaption(): string { + let value = this.visibleText(); + if (value.length > TELEGRAM_CAPTION_LIMIT) { + value = value.substring(0, TELEGRAM_CAPTION_LIMIT - 1); + } + return value; + } + + async start(initialStatus: string): Promise { + this.status = initialStatus; + const rawText = this.visibleText(); + const formatted = prepareTelegramMarkdownV2(rawText, {mode: "draft"}); + + if (this.targetMessage) { + this.waitMessage = this.targetMessage; + + try { + await MessageStore.put(this.targetMessage).catch(logError); + const result = await enqueueTelegramApiCall( + () => bot.editMessageText({ + chat_id: this.targetMessage!.chat.id, + message_id: this.targetMessage!.message_id, + text: formatted, + parse_mode: "MarkdownV2", + reply_markup: this.keyboard(), + }), + { + method: "editMessageText", + chatId: this.targetMessage.chat.id, + chatType: this.targetMessage.chat.type, + } + ); + if (result && result !== true) this.waitMessage = result; + this.mediaMode = false; + this.lastSent = rawText; + await this.store(); + this.startFlushTimer(); + return this.waitMessage; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + if (this.isMessageNotModified(message)) { + this.lastSent = rawText; + await this.updateKeyboard(this.keyboard()); + await this.store(); + this.startFlushTimer(); + return this.waitMessage; + } + + logError(e instanceof Error ? e : message); + this.waitMessage = null; + this.mediaMode = false; + } + } + + this.waitMessage = await replyToMessage({ + message: this.sourceMessage, + text: formatted, + reply_markup: this.keyboard(), + parse_mode: "MarkdownV2" + }); + this.lastSent = rawText; + this.startFlushTimer(); + return this.waitMessage; + } + + setStatus(status: string): void { + if (this.cancelled) return; + this.status = status; + } + + getStatus(): string { + return this.status; + } + + clearStatus(): void { + if (this.cancelled) return; + this.status = ""; + } + + append(delta: string): void { + if (this.cancelled) return; + if (!delta) return; + this.text += delta; + } + + replaceText(text: string): void { + if (this.cancelled) return; + this.text = text; + } + + getText(): string { + return this.text; + } + + recordToolExecution(record: TelegramToolExecutionRecord): void { + this.toolExecutions.push(record); + } + + getToolExecutions(): TelegramToolExecutionRecord[] { + return [...this.toolExecutions]; + } + + recordOutputAttachment(record: TelegramOutputAttachmentRecord): void { + this.outputAttachments.push(record); + } + + getOutputAttachments(): TelegramOutputAttachmentRecord[] { + return [...this.outputAttachments]; + } + + sourceChatId(): number { + return this.sourceMessage.chat.id; + } + + sourceMessageId(): number { + return this.sourceMessage.message_id; + } + + async flush(replyMarkup: InlineKeyboardMarkup | null = this.keyboard(), end?: boolean): Promise { + return this.enqueueEdit(() => this.flushUnsafe(replyMarkup, end)); + } + + private async flushUnsafe(replyMarkup: InlineKeyboardMarkup | null = this.keyboard(), end?: boolean): Promise { + if (!this.waitMessage && this.stream) return; + + const next = this.mediaMode ? this.visibleCaption() : this.visibleText(); + const shouldRemoveKeyboard = replyMarkup === null; + if (next === this.lastSent && shouldRemoveKeyboard) { + await this.removeKeyboard(); + return; + } + + const formatted = prepareTelegramMarkdownV2(next, {mode: end ? "final" : "draft"}); + + if (next === this.lastSent && replyMarkup !== null) { + if (end) await this.updateKeyboard(replyMarkup); + return; + } + + try { + if (!this.stream && end && !this.waitMessage) { + if (this.isGuest) { + // await enqueueTelegramApiCall(() => bot.answerGuestQuery({ + // guest_query_id: this.sourceMessage.guest_query_id ?? "", + // result: {} + // }), + // {}); + } else { + await replyToMessage({ + message: this.sourceMessage, + text: formatted, + parse_mode: "MarkdownV2", + }); + } + } else { + if (this.waitMessage) { + const result = this.mediaMode + ? await enqueueTelegramApiCall( + () => bot.editMessageCaption({ + chat_id: this.waitMessage!.chat.id, + message_id: this.waitMessage!.message_id, + caption: formatted, + parse_mode: "MarkdownV2", + reply_markup: replyMarkup ?? this.emptyKeyboard(), + }), + { + method: "editMessageCaption", + chatId: this.waitMessage.chat.id, + chatType: this.waitMessage.chat.type, + } + ) + : await enqueueTelegramApiCall( + () => bot.editMessageText({ + chat_id: this.waitMessage!.chat.id, + message_id: this.waitMessage!.message_id, + text: formatted, + parse_mode: "MarkdownV2", + reply_markup: replyMarkup ?? this.emptyKeyboard(), + }), + { + method: "editMessageText", + chatId: this.waitMessage.chat.id, + chatType: this.waitMessage.chat.type, + } + ); + if (result && result !== true) this.waitMessage = result; + } + } + if (shouldRemoveKeyboard) await this.removeKeyboard(); + this.lastSent = next; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + if (shouldRemoveKeyboard && this.isMessageNotModified(message)) { + await this.removeKeyboard(); + this.lastSent = next; + return; + } + if (!this.isMessageNotModified(message)) logError(e instanceof Error ? e : message); + } + } + + async cancel(provider: string): Promise { + if (this.timer) clearInterval(this.timer); + this.timer = null; + this.cancelled = true; + this.cancelledProvider = provider; + this.status = ""; + this.text = buildCancelledGenerationText(this.text, this.cancelledProvider, this.mediaMode ? TELEGRAM_CAPTION_LIMIT : TELEGRAM_LIMIT); + await this.flush(this.regenerateKeyboard(), true); + await this.store(); + } + + async showImage(image: Buffer, attachment?: StoredAttachment): Promise { + return this.enqueueEdit(() => this.showImageUnsafe(image, attachment)); + } + + async sendArtifact(file: TelegramArtifactFile): Promise { + return this.enqueueEdit(() => this.sendArtifactUnsafe(file)); + } + + private async showImageUnsafe(image: Buffer, attachment?: StoredAttachment): Promise { + if (this.cancelled) return; + const next = this.visibleCaption(); + const useDocument = this.sendImagesAsDocuments; + + if (!this.waitMessage) { + if (this.stream) return; + + const upload = useDocument ? this.createImageUpload(image, attachment) : null; + try { + this.waitMessage = useDocument + ? await this.sendImageAsDocument(upload!, next) + : await enqueueTelegramApiCall( + () => bot.sendPhoto({ + chat_id: this.sourceMessage.chat.id, + photo: image, + caption: prepareTelegramMarkdownV2(next, {mode: "final"}), + parse_mode: "MarkdownV2", + reply_parameters: {message_id: this.sourceMessage.message_id}, + }), + { + method: "sendPhoto", + chatId: this.sourceMessage.chat.id, + chatType: this.sourceMessage.chat.type, + } + ); + } finally { + if (upload) this.destroyUpload(upload); + } + this.mediaMode = true; + this.lastSent = next; + await this.storeMediaMessage(this.waitMessage, attachment); + return; + } + + const upload = useDocument ? this.createImageUpload(image, attachment) : null; + try { + const result = await enqueueTelegramApiCall( + () => bot.editMessageMedia({ + chat_id: this.waitMessage!.chat.id, + message_id: this.waitMessage!.message_id, + media: useDocument + ? { + type: "document", + media: upload!, + caption: prepareTelegramMarkdownV2(next, {mode: "final"}), + parse_mode: "MarkdownV2", + } + : { + type: "photo", + media: image, + caption: prepareTelegramMarkdownV2(next, {mode: "final"}), + parse_mode: "MarkdownV2", + }, + reply_markup: this.keyboard(), + }), + { + method: "editMessageMedia", + chatId: this.waitMessage.chat.id, + chatType: this.waitMessage.chat.type, + } + ); + if (result && result !== true) this.waitMessage = result; + this.mediaMode = true; + this.lastSent = next; + await this.storeMediaMessage(this.waitMessage, attachment); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + if (useDocument) { + try { + this.waitMessage = await this.sendImageAsDocument(upload!, next); + this.mediaMode = true; + this.lastSent = next; + await this.storeMediaMessage(this.waitMessage, attachment); + return; + } catch (fallbackError) { + logError(fallbackError instanceof Error ? fallbackError : String(fallbackError)); + } + } + + if (!message.includes("message is not modified")) logError(e instanceof Error ? e : message); + } finally { + if (upload) this.destroyUpload(upload); + } + } + + private async storeMediaMessage(sent: Message | null, attachment?: StoredAttachment): Promise { + if (!sent || !attachment) return; + + const stored: StoredMessage = { + chatId: sent.chat.id, + id: sent.message_id, + replyToMessageId: sent.reply_to_message?.message_id ?? this.sourceMessage.message_id, + fromId: sent.from?.id ?? 0, + text: sent.caption ?? this.visibleText(), + date: sent.date ?? Math.floor(Date.now() / 1000), + attachments: [attachment], + }; + + await MessageStore.put(stored); + } + + private async sendArtifactUnsafe(file: TelegramArtifactFile): Promise { + if (this.cancelled) return null; + + if (file.sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES) { + throw new Error(Environment.getTelegramFileTooLargeText( + file.fileName, + PIPELINE_ATTACHMENT_LIMIT_BYTES / 1024 / 1024, + )); + } + + const caption = file.fileName.slice(0, TELEGRAM_CAPTION_LIMIT); + const isPhoto = this.isPhotoArtifact(file); + + await enqueueTelegramApiCall( + () => bot.sendChatAction({ + chat_id: this.sourceMessage.chat.id, + action: isPhoto ? "upload_photo" : "upload_document", + }), + { + method: "sendChatAction", + chatId: this.sourceMessage.chat.id, + chatType: this.sourceMessage.chat.type, + } + ).catch(logError); + + let sent: Message; + if (isPhoto) { + try { + sent = await enqueueTelegramApiCall( + async () => { + const upload = this.createArtifactUpload(file); + try { + return await bot.sendPhoto({ + chat_id: this.sourceMessage.chat.id, + photo: upload, + caption, + reply_parameters: {message_id: this.sourceMessage.message_id}, + }); + } finally { + this.destroyUpload(upload); + } + }, + { + method: "sendPhoto", + chatId: this.sourceMessage.chat.id, + chatType: this.sourceMessage.chat.type, + } + ); + } catch (e) { + logError(e instanceof Error ? e : String(e)); + sent = await this.sendArtifactAsDocument(file, caption); + } + } else { + sent = await this.sendArtifactAsDocument(file, caption); + } + + await this.storeArtifactMessage(sent, file); + this.recordOutputAttachment({ + artifactKind: "generated_file", + fileName: file.fileName, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + messageId: sent.message_id, + }); + return sent; + } + + private isPhotoArtifact(file: TelegramArtifactFile): boolean { + if (this.sendImagesAsDocuments) return false; + return file.kind === "image" + && file.sizeBytes <= TELEGRAM_PHOTO_LIMIT_BYTES + && ["image/jpeg", "image/png", "image/webp"].includes((file.mimeType || "").toLowerCase()); + } + + private createImageUpload(image: Buffer, attachment?: StoredAttachment): FileOptions { + if (attachment?.cachePath && fs.existsSync(attachment.cachePath)) { + return new FileOptions(fs.createReadStream(attachment.cachePath), { + filename: attachment.fileName || path.basename(attachment.cachePath), + contentType: attachment.mimeType || "application/octet-stream", + }); + } + + return new FileOptions(image, { + filename: attachment?.fileName ?? `image_${Date.now()}.png`, + contentType: attachment?.mimeType || "image/png", + }); + } + + private createArtifactUpload(file: TelegramArtifactFile): FileOptions { + return new FileOptions(fs.createReadStream(file.path), { + filename: file.fileName, + contentType: file.mimeType || "application/octet-stream", + }); + } + + private destroyUpload(upload: FileOptions): void { + if ("destroy" in upload.file && typeof upload.file.destroy === "function") { + upload.file.destroy(); + } + } + + private async sendImageAsDocument(upload: FileOptions, caption: string): Promise { + return enqueueTelegramApiCall( + () => bot.sendDocument({ + chat_id: this.sourceMessage.chat.id, + document: upload, + caption: prepareTelegramMarkdownV2(caption, {mode: "final"}), + parse_mode: "MarkdownV2", + reply_parameters: {message_id: this.sourceMessage.message_id}, + }), + { + method: "sendDocument", + chatId: this.sourceMessage.chat.id, + chatType: this.sourceMessage.chat.type, + } + ); + } + + private async sendArtifactAsDocument(file: TelegramArtifactFile, caption: string): Promise { + return enqueueTelegramApiCall( + async () => { + const upload = this.createArtifactUpload(file); + try { + return await bot.sendDocument({ + chat_id: this.sourceMessage.chat.id, + document: upload, + caption, + reply_parameters: {message_id: this.sourceMessage.message_id}, + }); + } finally { + this.destroyUpload(upload); + } + }, + { + method: "sendDocument", + chatId: this.sourceMessage.chat.id, + chatType: this.sourceMessage.chat.type, + } + ); + } + + private async storeArtifactMessage(sent: Message, file: TelegramArtifactFile): Promise { + const photo = sent.photo?.[sent.photo.length - 1]; + const attachmentKind: StoredAttachmentKind = file.kind === "image" ? "image" : "document"; + const attachment: StoredAttachment = { + kind: attachmentKind, + fileId: sent.document?.file_id ?? photo?.file_id ?? file.path, + fileUniqueId: sent.document?.file_unique_id ?? photo?.file_unique_id, + fileName: file.fileName, + mimeType: file.mimeType, + cachePath: file.path, + sizeBytes: file.sizeBytes, + scope: "bot_output", + artifactKind: "generated_file", + }; + + const stored: StoredMessage = { + chatId: sent.chat.id, + id: sent.message_id, + replyToMessageId: sent.reply_to_message?.message_id ?? this.sourceMessage.message_id, + fromId: sent.from?.id ?? 0, + text: sent.caption ?? file.fileName, + date: sent.date ?? Math.floor(Date.now() / 1000), + attachments: [attachment], + }; + + await MessageStore.put(stored); + } + + async storeInternalAttachment(attachment: StoredAttachment): Promise { + if (!this.waitMessage) return; + + const stored = await MessageStore.get(this.waitMessage.chat.id, this.waitMessage.message_id); + await MessageStore.put({ + chatId: this.waitMessage.chat.id, + id: this.waitMessage.message_id, + replyToMessageId: this.waitMessage.reply_to_message?.message_id ?? this.sourceMessage.message_id, + fromId: this.waitMessage.from?.id ?? 0, + text: this.visibleText(), + date: this.waitMessage.date ?? Math.floor(Date.now() / 1000), + attachments: [ + ...(stored?.attachments ?? []), + attachment, + ], + pipelineAudit: stored?.pipelineAudit, + }); + } + + async storePipelineAudit(events: StoredMessage["pipelineAudit"]): Promise { + if (!this.waitMessage || !events?.length) return; + + const stored = await MessageStore.get(this.waitMessage.chat.id, this.waitMessage.message_id); + await MessageStore.put({ + chatId: this.waitMessage.chat.id, + id: this.waitMessage.message_id, + replyToMessageId: this.waitMessage.reply_to_message?.message_id ?? this.sourceMessage.message_id, + fromId: this.waitMessage.from?.id ?? 0, + text: this.visibleText(), + date: this.waitMessage.date ?? Math.floor(Date.now() / 1000), + attachments: stored?.attachments, + pipelineAudit: [ + ...(stored?.pipelineAudit ?? []), + ...events, + ], + }); + } + + async finish(removeKeyboard = true): Promise { + if (this.timer) clearInterval(this.timer); + this.timer = null; + + if (this.cancelled) { + await this.flush(removeKeyboard ? this.regenerateKeyboard() : this.keyboard(), true); + await this.store(); + return; + } + + if (Environment.SEND_TIME_TOOK) { + const diff = Date.now() - this.startedAt; + if (this.text.length + 32 < TELEGRAM_LIMIT) this.text += `\n\n⏱️ ${diff}ms`; + } + + this.clearStatus(); + await this.flush(removeKeyboard ? this.regenerateKeyboard() : this.keyboard(), true); + + await this.store(); + } + + async fail(error: Error | string | object | null | undefined): Promise { + if (this.timer) clearInterval(this.timer); + this.timer = null; + this.status = ""; + this.text = `${Environment.errorText}\n${error instanceof Error ? error.message : String(error)}`; + await this.flush(this.regenerateKeyboard(), true); + } + + private async store(): Promise { + if (!this.waitMessage) return; + try { + await MessageStore.put({...this.waitMessage, text: this.visibleText()} as Message); + } catch (e) { + logError(e instanceof Error ? e : String(e)); + } + } +} diff --git a/src/ai/text-to-speech.ts b/src/ai/text-to-speech.ts new file mode 100644 index 0000000..34e56f2 --- /dev/null +++ b/src/ai/text-to-speech.ts @@ -0,0 +1,321 @@ +import fs from "node:fs"; +import path from "node:path"; +import {randomUUID} from "node:crypto"; +import {FileOptions, Message} from "typescript-telegram-bot-api"; +import {AiProvider} from "../model/ai-provider"; +import {Environment} from "../common/environment"; +import {bot} from "../index"; +import { + getAvailableAiProviderChoices, + normalizeAiProviderChoice, + resolveEffectiveAiProviderForUser, +} from "../common/user-ai-settings"; +import {providerDisplayName} from "./provider-aliases"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; +import {MessageStore} from "../common/message-store"; +import {StoredAttachment} from "../model/stored-attachment"; +import {StoredMessage} from "../model/stored-message"; +import {logError} from "../util/utils"; +import {SpeechRequest} from "@mistralai/mistralai/models/components"; +import {createMistralClient, createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target"; +import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline"; + +const MAX_TTS_TEXT_CHARS = 4096; + +export type TextToSpeechFormat = "mp3" | "wav" | "flac" | "opus" | "aac" | "pcm"; + +export type SynthesizedSpeech = { + provider: AiProvider; + model: string; + voice?: string; + format: TextToSpeechFormat; + mimeType: string; + fileName: string; + path: string; + sizeBytes: number; +}; + +export type TextToSpeechRequest = { + provider: AiProvider; + text: string; + voice?: string; +}; + +export type TextToSpeechProviderResolution = { + provider: AiProvider; + fallback: boolean; +}; + +type SpeechFileParams = Omit & { + buffer: Buffer; +}; + +function ttsCacheDir(): string { + return path.join(Environment.DATA_PATH, "cache", "audio"); +} + +function assertText(text: string): string { + const normalized = text.trim(); + if (!normalized) { + throw new Error(Environment.noTextToSynthesizeText); + } + + if (normalized.length > MAX_TTS_TEXT_CHARS) { + throw new Error(Environment.getTextToSpeechTooLongText(normalized.length, MAX_TTS_TEXT_CHARS)); + } + + return normalized; +} + +export function isTextToSpeechConfigured(provider: AiProvider): boolean { + switch (provider) { + case AiProvider.OPENAI: + const openAiTarget = resolveAiRuntimeTarget(provider, "textToSpeech"); + return !!openAiTarget.apiKey && !!openAiTarget.model; + case AiProvider.MISTRAL: + const mistralTarget = resolveAiRuntimeTarget(provider, "textToSpeech"); + return !!mistralTarget.apiKey && !!mistralTarget.model; + case AiProvider.OLLAMA: + return false; + } +} + +export async function resolveTextToSpeechProviderForUser( + userId: number, + explicitProvider?: AiProvider, +): Promise { + const availableChoices = getAvailableAiProviderChoices(userId); + const allowedProviders = availableChoices + .map(choice => normalizeAiProviderChoice(choice)) + .filter((choice): choice is AiProvider => !!choice && choice !== "DEFAULT"); + + if (explicitProvider) { + if (!allowedProviders.includes(explicitProvider)) { + throw new Error(Environment.getProviderNotAvailableForAccessText(providerDisplayName(explicitProvider))); + } + + if (!isTextToSpeechConfigured(explicitProvider)) { + throw new Error(Environment.getProviderTextToSpeechUnsupportedText(providerDisplayName(explicitProvider))); + } + + return {provider: explicitProvider, fallback: false}; + } + + const effectiveProvider = await resolveEffectiveAiProviderForUser(userId); + if (isTextToSpeechConfigured(effectiveProvider)) { + return {provider: effectiveProvider, fallback: false}; + } + + const fallbackProvider = allowedProviders.find(isTextToSpeechConfigured); + if (!fallbackProvider) { + throw new Error(Environment.noTextToSpeechProviderForAccessText); + } + + return {provider: fallbackProvider, fallback: true}; +} + +export async function synthesizeSpeech(request: TextToSpeechRequest): Promise { + const text = assertText(request.text); + + switch (request.provider) { + case AiProvider.OPENAI: + return synthesizeOpenAiSpeech(text, request.voice); + case AiProvider.MISTRAL: + return synthesizeMistralSpeech(text, request.voice); + case AiProvider.OLLAMA: + throw new Error(Environment.ollamaTextToSpeechUnsupportedText); + } +} + +async function synthesizeOpenAiSpeech(text: string, voice?: string): Promise { + const target = resolveAiRuntimeTarget(AiProvider.OPENAI, "textToSpeech"); + const openAi = createOpenAiClient(target); + const response = await openAi.audio.speech.create({ + model: target.model, + voice: voice || Environment.OPENAI_TTS_VOICE, + input: text, + response_format: "mp3", + instructions: Environment.OPENAI_TTS_INSTRUCTIONS, + }); + + const buffer = Buffer.from(await response.arrayBuffer()); + + return writeSpeechFile({ + provider: AiProvider.OPENAI, + model: target.model, + voice: voice || Environment.OPENAI_TTS_VOICE, + buffer, + format: "mp3", + mimeType: "audio/mpeg", + }); +} + +async function synthesizeMistralSpeech(text: string, voice?: string): Promise { + const target = resolveAiRuntimeTarget(AiProvider.MISTRAL, "textToSpeech"); + const mistralAi = createMistralClient(target); + const request: SpeechRequest = { + input: text, + responseFormat: "mp3" + // stream: false, + }; + + if (target.model) request.model = target.model; + if (voice || Environment.MISTRAL_TTS_VOICE_ID) request.voiceId = voice || Environment.MISTRAL_TTS_VOICE_ID; + + const response = await mistralAi.audio.speech.complete(request) as {audioData?: string; audio_data?: string}; + const audioData = response?.audioData ?? response?.audio_data; + if (typeof audioData !== "string" || !audioData.trim()) { + throw new Error(Environment.mistralTtsNoAudioDataText); + } + + const buffer = Buffer.from(audioData, "base64"); + + return writeSpeechFile({ + provider: AiProvider.MISTRAL, + model: target.model || "mistral speech", + voice: voice || Environment.MISTRAL_TTS_VOICE_ID, + buffer, + format: "mp3", + mimeType: "audio/mpeg", + }); +} + +function writeSpeechFile(params: SpeechFileParams): SynthesizedSpeech { + fs.mkdirSync(ttsCacheDir(), {recursive: true}); + + const fileName = `${params.provider.toLowerCase()}-tts-${Date.now()}-${randomUUID()}.${params.format}`; + const filePath = path.join(ttsCacheDir(), fileName); + fs.writeFileSync(filePath, params.buffer); + + return { + provider: params.provider, + model: params.model, + voice: params.voice, + format: params.format, + mimeType: params.mimeType, + fileName, + path: filePath, + sizeBytes: params.buffer.length, + }; +} + +function createSpeechUpload(speech: SynthesizedSpeech): FileOptions { + return new FileOptions(fs.createReadStream(speech.path), { + filename: speech.fileName, + contentType: speech.mimeType, + }); +} + +function destroyUpload(upload: FileOptions): void { + if ("destroy" in upload.file && typeof upload.file.destroy === "function") { + upload.file.destroy(); + } +} + +export async function sendSynthesizedSpeech(sourceMessage: Message, speech: SynthesizedSpeech): Promise { + if (speech.sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES) { + throw new Error(Environment.speechFileTooLargeText); + } + + const caption = Environment.getTextToSpeechCaption(providerDisplayName(speech.provider), speech.model, speech.voice); + + await enqueueTelegramApiCall( + () => bot.sendChatAction({ + chat_id: sourceMessage.chat.id, + action: speech.format === "mp3" || speech.format === "opus" ? "upload_voice" : "upload_document", + }), + {method: "sendChatAction", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type} + ).catch(logError); + + let sent: Message; + if (speech.format === "mp3" || speech.format === "opus") { + try { + sent = await enqueueTelegramApiCall( + async () => { + const upload = createSpeechUpload(speech); + try { + return await bot.sendVoice({ + chat_id: sourceMessage.chat.id, + voice: upload, + caption, + reply_parameters: {message_id: sourceMessage.message_id}, + }); + } finally { + // destroyUpload(upload); + } + }, + {method: "sendVoice", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type} + ); + } catch (e) { + logError(e instanceof Error ? e : String(e)); + sent = await sendSpeechDocument(sourceMessage, speech, caption); + } + } else { + sent = await sendSpeechDocument(sourceMessage, speech, caption); + } + + await storeSpeechMessage(sent, sourceMessage, speech); + return sent; +} + +export function speechToOutputAttachmentRecord(speech: SynthesizedSpeech, messageId?: number) { + return { + artifactKind: "tts_audio" as const, + fileName: speech.fileName, + mimeType: speech.mimeType, + sizeBytes: speech.sizeBytes, + messageId, + }; +} + +async function sendSpeechDocument(sourceMessage: Message, speech: SynthesizedSpeech, caption: string): Promise { + return enqueueTelegramApiCall( + async () => { + const upload = createSpeechUpload(speech); + try { + return await bot.sendDocument({ + chat_id: sourceMessage.chat.id, + document: upload, + caption, + reply_parameters: {message_id: sourceMessage.message_id}, + }); + } finally { + destroyUpload(upload); + } + }, + {method: "sendDocument", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type} + ); +} + +async function storeSpeechMessage(sent: Message, sourceMessage: Message, speech: SynthesizedSpeech): Promise { + const file = sent.voice ?? sent.audio ?? sent.document; + const attachment: StoredAttachment = { + kind: "audio", + fileId: file?.file_id ?? speech.path, + fileUniqueId: file?.file_unique_id, + fileName: speech.fileName, + mimeType: speech.mimeType, + cachePath: speech.path, + sizeBytes: speech.sizeBytes, + scope: "bot_output", + artifactKind: "tts_audio", + metadata: { + provider: speech.provider, + model: speech.model, + voice: speech.voice, + format: speech.format, + }, + }; + + const stored: StoredMessage = { + chatId: sent.chat.id, + id: sent.message_id, + replyToMessageId: sent.reply_to_message?.message_id ?? sourceMessage.message_id, + fromId: sent.from?.id ?? 0, + text: sent.caption ?? speech.fileName, + date: sent.date ?? Math.floor(Date.now() / 1000), + attachments: [attachment], + }; + + await MessageStore.put(stored); +} diff --git a/src/ai/tool-mappers.ts b/src/ai/tool-mappers.ts new file mode 100644 index 0000000..faf8173 --- /dev/null +++ b/src/ai/tool-mappers.ts @@ -0,0 +1,81 @@ +import {AiTool} from "./tool-types"; +import {AiProvider} from "../model/ai-provider"; +import {getTools} from "./tools/registry"; +import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search"; +import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator"; + +export type AiProviderName = "ollama" | "openai" | "mistral"; + +export function getOllamaTools(forCreator?: boolean): AiTool[] { + return getTools(forCreator); +} + +const openAiForbiddenTools = [ + WEB_SEARCH_TOOL_NAME, + PYTHON_INTERPRETER_TOOL_NAME +]; + +function allowedOpenAiTool(tool: AiTool): boolean { + return !openAiForbiddenTools.includes(tool.function.name); +} + +export function getOpenAITools(forCreator?: boolean): AiTool[] { + return getTools(forCreator).filter(allowedOpenAiTool).map(tool => ({ + type: "function", + function: tool.function, + })); +} + +export type OpenAiResponseTool = { + type: "function"; + name: string; + description?: string; + parameters?: object; + strict: false; +}; + +export type OpenAiCodeInterpreterTool = { + type: "code_interpreter"; + container: { + type: "auto"; + file_ids?: string[]; + memory_limit?: "1g" | "4g" | "16g" | "64g" | null; + } | string; +}; + +export function getOpenAIResponsesTools(forCreator?: boolean): OpenAiResponseTool[] { + return getOpenAITools(forCreator).map(tool => ({ + type: "function", + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters, + strict: false, + })); +} + +export function getOpenAICodeInterpreterTool(): OpenAiCodeInterpreterTool { + return { + type: "code_interpreter", + container: { + type: "auto", + }, + }; +} + +export function getMistralTools(forCreator?: boolean): AiTool[] { + return getTools(forCreator).map(tool => ({ + type: "function", + function: tool.function, + })); +} + +export function getProviderTools(provider: AiProvider, forCreator?: boolean): AiTool[] { + switch (provider) { + case AiProvider.OLLAMA: + return getOllamaTools(forCreator); + case AiProvider.MISTRAL: + return getMistralTools(forCreator); + case AiProvider.OPENAI: + return getOpenAITools(forCreator); + } +} diff --git a/src/ai/tool-rank-audit.ts b/src/ai/tool-rank-audit.ts new file mode 100644 index 0000000..9d22e81 --- /dev/null +++ b/src/ai/tool-rank-audit.ts @@ -0,0 +1,32 @@ +import {AiProvider} from "../model/ai-provider"; +import type {TelegramStreamMessage} from "./telegram-stream-message"; +import type {PipelineAuditEvent} from "./user-request-pipeline"; +import {logError} from "../util/utils"; + +export async function storeToolRankAudit(params: { + streamMessage: TelegramStreamMessage; + provider: AiProvider; + model: string; + round: number; + startedAt: number; + startedAtIso: string; + selectedTools?: string[]; + error?: unknown; +}): Promise { + const event: PipelineAuditEvent = { + stage: "tool_rank", + status: params.error ? "failed" : "succeeded", + startedAt: params.startedAtIso, + finishedAt: new Date().toISOString(), + durationMs: Date.now() - params.startedAt, + provider: params.provider, + model: params.model, + details: { + round: params.round, + selectedTools: params.selectedTools ?? [], + }, + error: params.error instanceof Error ? params.error.message : params.error ? String(params.error) : undefined, + }; + + await params.streamMessage.storePipelineAudit([event]).catch(logError); +} diff --git a/src/ai/tool-ranker-metadata.ts b/src/ai/tool-ranker-metadata.ts new file mode 100644 index 0000000..1d45989 --- /dev/null +++ b/src/ai/tool-ranker-metadata.ts @@ -0,0 +1,536 @@ +import type {BoundaryValue} from "../common/boundary-types"; + +export type ToolRankerExample = { + user: string; + toolNames: string[]; + note?: string; +}; + +export type ToolRankerToolInfo = { + name: string; + description: string; + rankerHint: string; + examples?: ToolRankerExample[]; +}; + +const tool = ( + name: string, + description: string, + rankerHint: string, + examples: ToolRankerExample[] = [], +): ToolRankerToolInfo => ({ + name, + description, + rankerHint, + examples: examples.length ? examples : undefined, +}); + +const example = (user: string, toolNames: string[], note?: string): ToolRankerExample => ({ + user, + toolNames, + note, +}); + +export const TOOL_RANKER_TOOL_INFOS = { + no_tool: tool( + "no_tool", + "No tool action is needed.", + "Use for normal answers, explanations, advice, planning, code writing without execution, rewriting, translation, and general conversation.", + [ + example("объясни docker volumes", ["no_tool"]), + example("напиши промпт для Claude", ["no_tool"]), + example("как лучше спроектировать эту архитектуру?", ["no_tool"]), + ], + ), + get_datetime: tool( + "get_datetime", + "Get the current date, time, or timezone-aware moment.", + "Use for current date/time, today/tomorrow/yesterday, timezone-aware time, and calculations based on the current moment.", + [ + example("какое сегодня число?", ["get_datetime"]), + example("который час?", ["get_datetime"]), + example("что будет через 10 дней?", ["get_datetime"]), + ], + ), + get_financial_market_data: tool( + "get_financial_market_data", + "Get current market, price, currency, or ticker data.", + "Use for current/recent stocks, crypto, fiat exchange rates, commodities, indices, futures, and market prices.", + [ + example("сколько сейчас BTC?", ["get_financial_market_data"]), + example("курс USD/RUB", ["get_financial_market_data"]), + example("цена золота сейчас", ["get_financial_market_data"]), + ], + ), + get_weather: tool( + "get_weather", + "Get current weather or forecast data.", + "Use for weather, rain, snow, wind, temperature, forecast, and weather-dependent planning.", + [ + example("погода завтра", ["get_weather"]), + example("будет дождь сегодня?", ["get_weather"]), + example("можно сегодня на велике?", ["get_weather"]), + ], + ), + read_file: tool( + "read_file", + "Read a known local file path.", + "Use when the user asks to read, open, inspect, or summarize a known local file path.", + [ + example("прочитай src/index.ts", ["read_file"]), + example("посмотри package.json", ["read_file"]), + example("открой этот файл", ["read_file"]), + ], + ), + list_directory: tool( + "list_directory", + "List files or folders in a local path.", + "Use when the user asks to list files/folders, inspect a directory, show project structure, or see what exists in a path.", + [ + example("покажи структуру проекта", ["list_directory"]), + example("что лежит в src?", ["list_directory"]), + example("выведи список файлов", ["list_directory"]), + ], + ), + search_files: tool( + "search_files", + "Search local files by name, content, symbol, or keyword.", + "Use when the exact file path is unknown and the user wants to find files, usages, TODOs, symbols, classes, functions, or error messages.", + [ + example("найди где используется sendMessage", ["search_files"]), + example("найди все TODO", ["search_files"]), + example("где определён BotService?", ["search_files"]), + ], + ), + create_file: tool( + "create_file", + "Create a new small file.", + "Use when the user asks to create a new file with specific content.", + [ + example("создай README.md", ["create_file"]), + example("создай .env.example", ["create_file"]), + example("сделай docker-compose.yml", ["create_file"]), + ], + ), + update_file: tool( + "update_file", + "Replace an existing file completely.", + "Use only for full file replacement or overwrite.", + [ + example("полностью перезапиши config.json", ["update_file"]), + example("замени файл этой версией", ["update_file"]), + example("overwrite this file", ["update_file"]), + ], + ), + edit_file_patch: tool( + "edit_file_patch", + "Apply a targeted patch to an existing file.", + "Use for targeted edits, patches, diffs, refactors, and changes that should preserve most of the file.", + [ + example("исправь этот баг патчем", ["edit_file_patch"]), + example("добавь эту опцию в существующий конфиг", ["edit_file_patch"]), + example("измени только эту функцию", ["edit_file_patch"]), + ], + ), + create_directory: tool( + "create_directory", + "Create directories or folder trees.", + "Use when the user asks to create folders or directory structures.", + [ + example("создай папку src/services", ["create_directory"]), + example("создай структуру директорий", ["create_directory"]), + ], + ), + copy_path: tool( + "copy_path", + "Copy a file or folder path.", + "Use when the user asks to copy or duplicate a file or folder.", + [ + example("скопируй config.example.json в config.json", ["copy_path"]), + example("дублируй эту папку", ["copy_path"]), + ], + ), + rename_path: tool( + "rename_path", + "Rename or move a file or folder.", + "Use when the user asks to rename or move a file or folder.", + [ + example("переименуй файл", ["rename_path"]), + example("перемести notes.md в archive", ["rename_path"]), + ], + ), + delete_path: tool( + "delete_path", + "Delete a file or folder.", + "Use only when the user clearly asks to delete or remove something.", + [ + example("удали папку dist", ["delete_path"]), + example("remove node_modules", ["delete_path"]), + example("delete this file", ["delete_path"]), + ], + ), + send_file_as_attachment: tool( + "send_file_as_attachment", + "Send a local file as an attachment.", + "Use when the user wants to receive, export, send, attach, or download a local file as an attachment.", + [ + example("пришли мне этот файл", ["send_file_as_attachment"]), + example("отправь заметку файлом", ["send_file_as_attachment"]), + example("export this as attachment", ["send_file_as_attachment"]), + ], + ), + begin_file_write: tool( + "begin_file_write", + "Start a large chunked file write.", + "Use with write_file_chunk and finish_file_write for large file creation or writing.", + [ + example("создай большой markdown отчёт", ["begin_file_write", "write_file_chunk", "finish_file_write"]), + example("запиши большой файл чанками", ["begin_file_write", "write_file_chunk", "finish_file_write"]), + ], + ), + write_file_chunk: tool( + "write_file_chunk", + "Append a chunk to an active large file write.", + "Use together with begin_file_write and finish_file_write for chunked file writing.", + ), + finish_file_write: tool( + "finish_file_write", + "Complete an active large file write.", + "Use together with begin_file_write and write_file_chunk to finish chunked file writing.", + ), + cancel_file_write: tool( + "cancel_file_write", + "Cancel an active large file write.", + "Use when the user asks to cancel an active file write operation.", + [ + example("отмени запись файла", ["cancel_file_write"]), + example("cancel file write", ["cancel_file_write"]), + ], + ), + shell_execute: tool( + "shell_execute", + "Run shell commands in the workspace environment.", + "Use for terminal commands, tests, builds, docker, git, npm, pnpm, bun, gradle, diagnostics, logs, install commands, or system inspection.", + [ + example("запусти npm test", ["shell_execute"]), + example("собери проект", ["shell_execute"]), + example("проверь docker logs", ["shell_execute"]), + ], + ), + python_interpreter: tool( + "python_interpreter", + "Execute Python code.", + "Use when the user explicitly asks to run Python code, execute Python, calculate with Python, or test a Python script.", + [ + example("выполни этот python код", ["python_interpreter"]), + example("посчитай это питоном", ["python_interpreter"]), + example("напиши и запусти python скрипт", ["python_interpreter"]), + ], + ), + code_interpreter: tool( + "code_interpreter", + "Run sandboxed code and data analysis.", + "Use for sandbox computation, data/file analysis, CSV processing, archive processing, charts, tables, and generated reports.", + [ + example("проанализируй CSV", ["code_interpreter"]), + example("построй график", ["code_interpreter"]), + example("обработай архив", ["code_interpreter"]), + ], + ), + image_generation: tool( + "image_generation", + "Generate or edit an image.", + "Use when the user asks to generate, create, edit, transform, restyle, enhance, remove, add, replace, recolor, upscale, or alter an image.", + [ + example("сделай его лысым", ["image_generation"]), + example("убери фон", ["image_generation"]), + example("сделай в стиле аниме", ["image_generation"]), + ], + ), + web_search: tool( + "web_search", + "Search the public web for current, recent, or external information.", + "Use only for current/recent/public online information, search, verification, links, documentation, comparisons, or external data.", + [ + example("найди актуальную документацию OpenAI API", ["web_search"]), + example("проверь, вышел ли Kotlin 2.3", ["web_search"]), + example("какие сейчас цены на VPS?", ["web_search"]), + ], + ), + file_search: tool( + "file_search", + "Search uploaded documents or indexed vector-store files.", + "Use for uploaded documents, vector stores, PDFs/docs already indexed or attached to the assistant context.", + [ + example("найди в моих документах про MCP", ["file_search"]), + example("что в загруженном PDF написано про оплату?", ["file_search"]), + example("поищи в базе знаний", ["file_search"]), + ], + ), +} as const satisfies Record; + +export type ToolRankerToolName = keyof typeof TOOL_RANKER_TOOL_INFOS; + +function isString(value: BoundaryValue): value is string { + return typeof value === "string"; +} + +function normalizeToolNames(names: readonly string[]): string[] { + const unique: string[] = []; + + for (const name of names) { + if (!name || unique.includes(name)) { + continue; + } + + unique.push(name); + } + + return unique; +} + +function extractJsonCandidate(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + + const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); + if (fenced?.[1]) { + return fenced[1].trim(); + } + + const firstObjectStart = trimmed.indexOf("{"); + const lastObjectEnd = trimmed.lastIndexOf("}"); + if (firstObjectStart !== -1 && lastObjectEnd !== -1 && lastObjectEnd > firstObjectStart) { + return trimmed.slice(firstObjectStart, lastObjectEnd + 1).trim(); + } + + const firstArrayStart = trimmed.indexOf("["); + const lastArrayEnd = trimmed.lastIndexOf("]"); + if (firstArrayStart !== -1 && lastArrayEnd !== -1 && lastArrayEnd > firstArrayStart) { + return trimmed.slice(firstArrayStart, lastArrayEnd + 1).trim(); + } + + return trimmed; +} + +function parseSelectionValue(value: BoundaryValue): string[] { + if (typeof value === "string") { + return [value]; + } + + if (Array.isArray(value)) { + return value.filter(isString); + } + + if (value !== null && typeof value === "object") { + const rawToolNames = (value as Record).toolNames; + return parseSelectionValue(rawToolNames as BoundaryValue); + } + + return []; +} + +function asOptionalString(value: BoundaryValue): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function isRecord(value: BoundaryValue): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function toolNamesFromTool(tool: BoundaryValue): string[] { + if (!isRecord(tool)) { + return []; + } + + const functionValue = isRecord(tool.function) ? tool.function : undefined; + const directName = functionValue?.name ?? tool.name ?? (typeof tool.type === "string" && tool.type !== "function" ? tool.type : undefined); + const name = asOptionalString(directName); + + return name ? [name] : []; +} + +export function getToolRankerToolInfo(name: string): ToolRankerToolInfo | undefined { + return TOOL_RANKER_TOOL_INFOS[name as ToolRankerToolName]; +} + +export function getToolRankerToolInfos(names: readonly string[]): ToolRankerToolInfo[] { + return normalizeToolNames(names) + .map(name => getToolRankerToolInfo(name)) + .filter((tool): tool is ToolRankerToolInfo => !!tool); +} + +export function getToolRankerAvailableToolInfos(availableTools: readonly BoundaryValue[]): ToolRankerToolInfo[] { + return getToolRankerToolInfos([ + "no_tool", + ...availableTools.flatMap(toolNamesFromTool), + ]); +} + +function renderToolLine(tool: ToolRankerToolInfo, compact: boolean): string { + if (compact) { + return `- ${tool.name}: ${tool.rankerHint}`; + } + + return `- ${tool.name}: ${tool.description}\n ${tool.rankerHint}`; +} + +function renderExamples(tool: ToolRankerToolInfo, maxExamplesPerTool: number): string[] { + if (!tool.examples?.length || maxExamplesPerTool <= 0) { + return []; + } + + return tool.examples.slice(0, maxExamplesPerTool).flatMap(example => { + const lines = [ + `User: ${JSON.stringify(example.user)}`, + ]; + + if (example.note?.trim()) { + lines.push(`Note: ${example.note.trim()}`); + } + + lines.push(JSON.stringify({toolNames: example.toolNames})); + return lines; + }); +} + +function buildPriorityLines(tools: readonly ToolRankerToolInfo[]): string[] { + const names = new Set(tools.map(tool => tool.name)); + const lines: string[] = []; + + const pushIfAvailable = (name: string, line: string): void => { + if (names.has(name)) { + lines.push(`- ${line}`); + } + }; + + pushIfAvailable("get_datetime", "current date/time -> get_datetime"); + pushIfAvailable("get_financial_market_data", "market prices, currency, crypto, stocks -> get_financial_market_data"); + pushIfAvailable("get_weather", "weather or forecast -> get_weather"); + pushIfAvailable("image_generation", "image creation or editing -> image_generation"); + pushIfAvailable("file_search", "uploaded/vector documents -> file_search"); + pushIfAvailable("read_file", "known local file path -> read_file"); + pushIfAvailable("list_directory", "project structure or directory listing -> list_directory"); + pushIfAvailable("search_files", "local file/content search or unknown file path -> search_files"); + pushIfAvailable("edit_file_patch", "targeted existing file edit -> edit_file_patch"); + pushIfAvailable("update_file", "full existing file replacement -> update_file"); + pushIfAvailable("create_file", "small new file -> create_file"); + pushIfAvailable("begin_file_write", "large file writing -> begin_file_write + write_file_chunk + finish_file_write"); + pushIfAvailable("delete_path", "delete/remove only when the user clearly asks -> delete_path"); + pushIfAvailable("shell_execute", "terminal commands, builds, tests, git, docker -> shell_execute"); + pushIfAvailable("python_interpreter", "explicit Python execution -> python_interpreter"); + pushIfAvailable("code_interpreter", "sandbox computation or data analysis -> code_interpreter"); + + return lines; +} + +function buildRulesSection(availableToolNames: readonly string[]): string[] { + const names = new Set(availableToolNames); + const rules: string[] = [ + "You are a tool router, not an answering model.", + "Your only job is to select the minimal set of tools needed for the user's latest request.", + "Return ONLY valid JSON: {\"toolNames\":[\"tool1\",\"tool2\"]}", + "No explanations.", + "No markdown.", + "No arguments.", + "Use only tool names from Available tools.", + "If no tool is needed, return {\"toolNames\":[\"no_tool\"]}.", + "Pick the smallest correct tool set.", + "Prefer specialized tools over generic tools.", + "Use multiple tools only when the request likely needs a combination of capabilities.", + "Be extra careful with destructive tools.", + ]; + + if (names.has("web_search")) { + rules.push("Do not use web_search just because you are unsure."); + } + + if (names.has("delete_path")) { + rules.push("delete_path only when the user clearly asks to delete or remove something."); + } + + if (names.has("update_file")) { + rules.push("update_file only for full file replacement."); + } + + if (names.has("edit_file_patch")) { + rules.push("edit_file_patch for targeted file edits."); + } + + return rules; +} + +export function buildToolRankerSystemPrompt(params: { + availableTools: ToolRankerToolInfo[]; + includeExamples?: boolean; + maxExamplesPerTool?: number; + compact?: boolean; +}): string { + const includeExamples = params.includeExamples ?? false; + const maxExamplesPerTool = Math.max(0, params.maxExamplesPerTool ?? 1); + const compact = params.compact ?? true; + const availableTools = getToolRankerToolInfos(params.availableTools.map(tool => tool.name)); + const availableToolNames = availableTools.map(tool => tool.name); + + const sections: string[] = [ + ...buildRulesSection(availableToolNames), + "", + "Available tools:", + ...availableTools.map(tool => renderToolLine(tool, compact)), + ]; + + const priorityLines = buildPriorityLines(availableTools); + if (priorityLines.length) { + sections.push("", "Priority:", ...priorityLines); + } + + if (includeExamples) { + const exampleLines = availableTools.flatMap(tool => renderExamples(tool, maxExamplesPerTool)); + if (exampleLines.length) { + sections.push("", "Examples:", ...exampleLines); + } + } + + sections.push("", "Return ONLY JSON."); + return sections.join("\n"); +} + +export function sanitizeToolRankerResult(params: { + raw: string; + availableToolNames: readonly string[]; +}): string[] { + const raw = params.raw.trim(); + if (!raw) { + return ["no_tool"]; + } + + const candidate = extractJsonCandidate(raw); + let parsed: BoundaryValue; + + try { + parsed = JSON.parse(candidate) as BoundaryValue; + } catch { + return ["no_tool"]; + } + + const availableToolNames = new Set(params.availableToolNames.filter(Boolean)); + const selected: string[] = []; + + for (const name of normalizeToolNames(parseSelectionValue(parsed))) { + if (name === "no_tool") { + selected.push(name); + continue; + } + + if (availableToolNames.has(name)) { + selected.push(name); + } + } + + const deduped = normalizeToolNames(selected); + const withoutNoTool = deduped.filter(name => name !== "no_tool"); + + return withoutNoTool.length > 0 ? withoutNoTool : ["no_tool"]; +} diff --git a/src/ai/tool-ranker-pipeline.ts b/src/ai/tool-ranker-pipeline.ts new file mode 100644 index 0000000..97d2e46 --- /dev/null +++ b/src/ai/tool-ranker-pipeline.ts @@ -0,0 +1,116 @@ +import type {BoundaryValue} from "../common/boundary-types"; +import type {AiRuntimeTarget} from "./ai-runtime-target"; +import {AiProvider} from "../model/ai-provider"; +import {RuntimeConfigSnapshot, toolSchemaNames} from "./unified-ai-runner.shared"; +import { + buildToolRankerSystemPrompt, + getToolRankerAvailableToolInfos, + type ToolRankerToolInfo, +} from "./tool-ranker-metadata"; + +export type ToolRankerMessage = { + role?: string; + content?: BoundaryValue; +}; + +export type ToolRankerSelection = { + toolNames: string[]; + usedRanker: boolean; +}; + +export type ToolRankerContext = { + provider: AiProvider; + round: number; + userQuery: string; + availableTools: readonly BoundaryValue[]; + targetModel: string; + rankerPrompt?: string | null; + promptAdditions?: string | null; +}; + +export type ToolRankerPromptPlan = { + availableToolNames: string[]; + availableToolInfos: ToolRankerToolInfo[]; + prompt: string; +}; + +export function latestUserTextFromMessages(messages: readonly ToolRankerMessage[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message?.role !== "user") continue; + if (typeof message.content === "string") return message.content; + + if (Array.isArray(message.content)) { + return message.content + .map(part => { + if (typeof part === "object" && part !== null && "text" in part && typeof part.text === "string") { + return part.text; + } + return ""; + }) + .filter(Boolean) + .join("\n"); + } + } + + return ""; +} + +export function buildToolRankerPrompt(context: ToolRankerContext): ToolRankerPromptPlan { + const availableToolInfos = getToolRankerAvailableToolInfos(context.availableTools); + const availableToolNames = availableToolInfos.map(tool => tool.name); + const prompt = buildToolRankerSystemPrompt({ + availableTools: availableToolInfos, + includeExamples: true, + maxExamplesPerTool: 1, + compact: true, + }); + + return { + availableToolNames, + availableToolInfos, + prompt: [ + context.rankerPrompt?.trim() || null, + context.promptAdditions?.trim() || null, + prompt, + ].filter((line): line is string => Boolean(line?.trim?.() ?? line)).join("\n\n"), + }; +} + +export function filterRankedTools(availableTools: readonly T[], toolNames: readonly string[]): T[] { + const selected = new Set(toolNames); + return availableTools.filter(tool => toolSchemaNames(tool).some(name => selected.has(name))); +} + +export function buildRankerContext(config: RuntimeConfigSnapshot, provider: AiProvider, target: AiRuntimeTarget, round: number, userQuery: string, availableTools: readonly BoundaryValue[]): ToolRankerContext { + return { + provider, + round, + userQuery, + availableTools, + targetModel: target.model, + rankerPrompt: config.rankerToolPrompt, + promptAdditions: target.systemPromptAdditions ?? null, + }; +} + +export function buildRankerTarget(config: RuntimeConfigSnapshot, provider: AiProvider): AiRuntimeTarget | undefined { + const target = provider === AiProvider.OLLAMA + ? config.ollamaToolRankerTarget + : provider === AiProvider.MISTRAL + ? config.mistralToolRankerTarget + : provider === AiProvider.OPENAI + ? config.openAiToolRankerTarget + : undefined; + + if (!target?.model) return undefined; + + return { + provider: target.provider, + purpose: target.purpose, + model: target.model, + baseUrl: target.baseUrl, + apiKey: target.apiKey, + systemPromptAdditions: target.systemPromptAdditions ?? null, + }; +} diff --git a/src/ai/tool-result-artifact-store.ts b/src/ai/tool-result-artifact-store.ts new file mode 100644 index 0000000..33a37e2 --- /dev/null +++ b/src/ai/tool-result-artifact-store.ts @@ -0,0 +1,28 @@ +import type {StoredAttachment} from "../model/stored-attachment"; +import type {ToolCallData} from "./unified-ai-runner.shared"; +import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store"; + +export async function persistToolResultArtifactAttachment(params: { + toolCall: ToolCallData; + resultText: string; + chatId: number; + messageId: number; +}): Promise { + return await persistInternalJsonArtifactAttachment({ + artifactKind: "tool_result", + fileNamePrefix: `tool-${params.toolCall.name}`, + chatId: params.chatId, + messageId: params.messageId, + payload: { + toolName: params.toolCall.name, + callId: params.toolCall.id, + argumentsText: params.toolCall.argumentsText, + resultText: params.resultText, + }, + metadata: { + toolName: params.toolCall.name, + callId: params.toolCall.id, + resultChars: params.resultText.length, + }, + }); +} diff --git a/src/ai/tool-types.ts b/src/ai/tool-types.ts new file mode 100644 index 0000000..2742752 --- /dev/null +++ b/src/ai/tool-types.ts @@ -0,0 +1,38 @@ + +export type AiJsonPrimitive = string | number | boolean | null; +export interface AiJsonObject { + readonly [key: string]: AiJsonValue; +} +export type AiJsonValue = AiJsonPrimitive | undefined | readonly AiJsonValue[] | AiJsonObject; +export interface AiToolParameters { + type: "object" | "string" | "number" | "integer" | "boolean" | "array"; + properties?: Record; + required?: readonly string[]; + items?: AiToolParameters; + enum?: readonly string[]; + description?: string; + minItems?: number; + maxItems?: number; + minimum?: number; + maximum?: number; + default?: AiJsonValue; + additionalProperties?: boolean | AiToolParameters; +} + +export type AiTool = { + type: "function"; + function: { + name: string; + description?: string; + type?: string; + parameters?: AiToolParameters; + }; +}; + +export type AiToolCall = { + function: { + name: string; + arguments: AiJsonObject; + }; +}; + diff --git a/src/ai/tools/create-note.ts b/src/ai/tools/create-note.ts new file mode 100644 index 0000000..79281e8 --- /dev/null +++ b/src/ai/tools/create-note.ts @@ -0,0 +1,91 @@ +import {AiTool} from "../tool-types"; +import path from "node:path"; +import {readFile, writeFile} from "node:fs/promises"; +import {NOTES_HEADER, notesDir, notesRootFile} from "../../index"; +import {asNonEmptyString} from "./utils"; +import fs from "node:fs"; +import {toolsLogger} from "./tool-logger"; +import {AiJsonObject} from "../tool-types"; + +const logger = toolsLogger.child("create-note"); + +export type CreateNoteResult = + | { success: true; filePath: string } + | { success: false; error: string }; + +export const createNoteTool = { + type: "function", + function: { + name: "create_note", + description: "Create a new Markdown note with a valid file name, optional title, and Markdown-formatted content.", + parameters: { + type: "object", + properties: { + fileName: { + type: "string", + description: "The valid file name for the note. It must be suitable for use as a file name and must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters. Use a clear, concise name based on the note topic. Include the .md extension if the user provides it or if Markdown files are expected." + }, + title: { + type: "string", + description: "The title of the note. Use a concise, human-readable title based on the user's request or the note content." + }, + content: { + type: "string", + description: "The full content of the note formatted as valid Markdown. Preserve existing Markdown formatting when provided. If the source content has little or no formatting, add appropriate Markdown structure such as headings, paragraphs, lists, links, code blocks, tables, or emphasis where useful, without changing the meaning." + } + }, + required: ["fileName", "content"], + } + } +} satisfies AiTool; + +export async function createNote( + args?: AiJsonObject +): Promise { + const startedAt = Date.now(); + logger.debug("start", {args}); + + const fileName = asNonEmptyString(args?.fileName) ?? ""; + if (!fileName.trim().length) { + return {success: false, error: "No file name provided"}; + } + const title = asNonEmptyString(args?.title) ?? fileName; + + const content = asNonEmptyString(args?.content) ?? ""; + if (!content.trim().length) { + return {success: false, error: "No content provided"}; + } + + const newFilePath = path.join(notesDir, fileName.endsWith(".md") ? fileName : fileName + ".md"); + const linkMarkdown = `* [${title}](${path.relative(path.dirname(notesRootFile), newFilePath)})`; + + try { + if (fs.existsSync(newFilePath)) { + return {success: false, error: "File already exists"}; + } + + await writeFile(newFilePath, content, "utf-8"); + + let rootContent: string; + try { + rootContent = await readFile(notesRootFile, "utf-8"); + } catch (e) { + rootContent = ""; + } + + const notesHeaderIndex = rootContent.indexOf(NOTES_HEADER); + if (notesHeaderIndex >= 0) { + rootContent += "\n" + linkMarkdown; + } else { + rootContent = NOTES_HEADER + "\n" + linkMarkdown; + } + + await writeFile(notesRootFile, rootContent, "utf-8"); + logger.debug("done", {fileName, filePath: newFilePath, duration: logger.duration(startedAt)}); + return {success: true, filePath: newFilePath}; + } catch (error) { + logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); + const errorMessage = error instanceof Error ? error.message : String(error); + return {success: false, error: `Failed to process files: ${errorMessage}`}; + } +} diff --git a/src/ai/tools/datetime.ts b/src/ai/tools/datetime.ts new file mode 100644 index 0000000..27b972d --- /dev/null +++ b/src/ai/tools/datetime.ts @@ -0,0 +1,93 @@ +import {AiTool} from "../tool-types"; +import {asNonEmptyString} from "./utils"; +import {AiJsonObject} from "../tool-types"; + +export const getCurrentDateTimeTool = { + type: "function", + function: { + name: "get_datetime", + description: + "Get the real current date and time. Use this tool before answering any request that depends on today, now, current time, current date, weekday, timestamp, timezone conversion, or relative dates like yesterday, tomorrow, next week, or 3 days ago.", + 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 AiTool; + +export const dateTimeToolPrompt = [ + "Datetime tool rules:", + "- Use `get_datetime` whenever the answer depends on the real current date/time.", + "- Never guess the current date/time. Call the tool first.", + "", + "Arguments:", + "- `timeZone`: optional IANA timezone, e.g. `Europe/Moscow`, `Europe/Berlin`, `UTC`.", + "- `locale`: optional locale, e.g. `ru-RU`, `en-US`.", + "", + "After the tool returns:", + "- Base the answer on the returned value.", + "- Do not expose raw tool JSON unless asked.", +].join("\n"); + +function getSystemTimeZone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +export function getCurrentDateTime(args?: AiJsonObject) { + 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), + }; + } +} diff --git a/src/ai/tools/files.ts b/src/ai/tools/files.ts new file mode 100644 index 0000000..7874a15 --- /dev/null +++ b/src/ai/tools/files.ts @@ -0,0 +1,2430 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import {z} from "zod"; + +import {Environment} from "../../common/environment"; +import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types"; +import { + MAX_COPY_ENTRIES, + MAX_COPY_TOTAL_BYTES, + MAX_DIRECTORY_ENTRIES, + MAX_FILE_ATTACHMENT_BYTES, + MAX_FILE_READ_BYTES, + MAX_FILE_SEARCH_CONTENT_BYTES, + MAX_FILE_SEARCH_ENTRIES, + MAX_FILE_SEARCH_RESULTS, + MAX_FILE_SEARCH_SNIPPET_CHARS, + MAX_FILE_WRITE_BYTES, + MAX_FILE_WRITE_CHUNK_BYTES, + MAX_PATCH_OPERATIONS, + MAX_PATCH_PREVIEW_CHARS, + MAX_PATCH_REPLACE_BYTES, + MAX_PATCH_SEARCH_BYTES, + MAX_STREAM_WRITE_IDLE_MS, + MAX_STREAM_WRITE_SESSIONS, +} from "./limits"; +import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils"; + +// ============================================================================= +// Public types and schemas +// ============================================================================= + +export type LocalFileAttachment = { + type: "local_file"; + fileName: string; + relativePath: string; + mimeType: string; + sizeBytes: number; +}; + +export type SendFileAttachmentResult = + | { + success: true; + attachment: LocalFileAttachment; +} + | { + success: false; + error: string; +}; + +export const LocalFileAttachmentSchema = z.object({ + type: z.literal("local_file"), + fileName: z.string(), + relativePath: z.string(), + mimeType: z.string(), + sizeBytes: z.number(), +}); + +export const SendFileAttachmentResultSchema = z.discriminatedUnion("success", [ + z.object({ + success: z.literal(true), + attachment: LocalFileAttachmentSchema, + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + +type CopyPathStats = { + entries: number; + totalBytes: number; +}; + +type SearchResultType = "file" | "directory"; + +type FileSearchResult = { + path: string; + name: string; + type: SearchResultType; + sizeBytes: number | null; + modifiedAt: string; + matchedBy: { + name: boolean; + path: boolean; + content: boolean; + }; + contentMatch?: { + line: number; + column: number; + snippet: string; + }; +}; + +const PATCH_OPERATION_TYPES = [ + "replace", + "insert_before", + "insert_after", + "delete", +] as const; + +type PatchOperationType = (typeof PATCH_OPERATION_TYPES)[number]; + +type ParsedPatchOperation = { + type: PatchOperationType; + search: string; + replace: string; +}; + +type AppliedPatchOperation = { + index: number; + type: PatchOperationType; + line: number; + column: number; + searchBytes: number; + replaceBytes: number; +}; + +type FileWriteSession = { + sessionId: string; + targetAbsolutePath: string; + targetRelativePath: string; + tempAbsolutePath: string; + tempRelativePath: string; + overwrite: boolean; + bytesWritten: number; + nextChunkIndex: number; + createdAtMs: number; + updatedAtMs: number; + rootDir: string; + userId?: number | null; +}; + +const fileWriteSessions = new Map(); + +// ============================================================================= +// Tool declarations +// ============================================================================= + +export 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/task.txt.", + }, + maxBytes: { + type: "number", + description: `Optional max bytes to read. Maximum allowed value is ${MAX_FILE_READ_BYTES}.`, + }, + }, + required: ["path"], + }, + }, +} satisfies AiTool; + +export 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 AiTool; + +export const searchFilesTool = { + type: "function", + function: { + name: "search_files", + description: + "Search for files and optionally directories inside the hardcoded root directory. Can search by file name/path and optionally by exact text content. Use only relative paths. Going up with ../ and absolute paths are forbidden. Symlinks are forbidden.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: + "Relative directory path to search inside. Use . for root. Default is root.", + }, + query: { + type: "string", + description: + "Case-insensitive substring to search in file/directory name and relative path. Optional if contentQuery is provided.", + }, + contentQuery: { + type: "string", + description: + "Optional exact text substring to search inside UTF-8 text files. Binary files and large files are skipped.", + }, + recursive: { + type: "boolean", + description: "Whether to search recursively. Default is true.", + }, + caseSensitive: { + type: "boolean", + description: + "Whether query and contentQuery should be case-sensitive. Default is false.", + }, + includeDirectories: { + type: "boolean", + description: + "Whether to include matching directories in results. Default is false.", + }, + extensions: { + type: "array", + description: + 'Optional list of file extensions to include, for example [".ts", ".json"]. Applies only to files.', + items: { + type: "string", + }, + }, + maxResults: { + type: "number", + description: `Optional max results. Maximum allowed value is ${MAX_FILE_SEARCH_RESULTS}.`, + }, + }, + required: [], + }, + }, +} satisfies AiTool; + +export 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 AiTool; + +export 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 AiTool; + +export const editFilePatchTool = { + type: "function", + function: { + name: "edit_file_patch", + description: + "Edit a UTF-8 text file inside the hardcoded root directory by applying exact-match patch operations. Use this instead of rewriting the whole file. Every search fragment must match exactly and must appear exactly once.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Relative file path inside the root directory.", + }, + operations: { + type: "array", + minItems: 1, + maxItems: MAX_PATCH_OPERATIONS, + description: + "Patch operations applied sequentially. Each search fragment must match the current file content exactly and appear exactly once.", + items: { + type: "object", + properties: { + type: { + type: "string", + enum: ["replace", "insert_before", "insert_after", "delete"], + description: "Patch operation type.", + }, + search: { + type: "string", + description: + "Exact text fragment to find in the current file content. Must be copied exactly from read_file output.", + }, + replace: { + type: "string", + description: + "Replacement or inserted text. Required for replace, insert_before and insert_after. Ignored for delete.", + }, + }, + required: ["type", "search"], + }, + }, + dryRun: { + type: "boolean", + description: + "If true, validate and preview the patch without writing changes. Default is false.", + }, + createBackup: { + type: "boolean", + description: + "If true, create a timestamped .bak file before writing changes. Ignored in dryRun mode. Default is false.", + }, + }, + required: ["path", "operations"], + }, + }, +} satisfies AiTool; + +export 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 AiTool; + +export 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 AiTool; + +export 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 AiTool; + +export 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 AiTool; + +export const sendFileAsAttachmentTool = { + type: "function", + function: { + name: "send_file_as_attachment", + description: + "Prepare a file inside the hardcoded root directory to be sent to the user as an attachment. Returns a local file descriptor that the host application should use to upload or send the file. Does not return file bytes or file content.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: + "Relative file path inside the root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.", + }, + fileName: { + type: "string", + description: + 'Optional attachment file name visible to the user. If omitted, the original file basename is used. Must not contain /, \\, :, *, ?, \", <, >, |, or control characters.', + }, + maxBytes: { + type: "number", + description: `Optional max allowed file size. Maximum allowed value is ${MAX_FILE_ATTACHMENT_BYTES}.`, + }, + }, + required: ["path"], + }, + }, +} satisfies AiTool; + +export const beginFileWriteTool = { + type: "function", + function: { + name: "begin_file_write", + description: + "Begin chunked creation of a UTF-8 text file inside the hardcoded root directory. Creates a temporary file and returns a sessionId. Use this for large files instead of create_file.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Relative target file path inside the root directory.", + }, + overwrite: { + type: "boolean", + description: + "Whether to overwrite the target file if it already exists. Default is false.", + }, + createParents: { + type: "boolean", + description: + "Whether to create parent directories automatically. Default is true.", + }, + }, + required: ["path"], + }, + }, +} satisfies AiTool; + +export const writeFileChunkTool = { + type: "function", + function: { + name: "write_file_chunk", + description: + "Append one UTF-8 text chunk to an active chunked file write session. Chunks must be written sequentially by chunkIndex starting from 1.", + parameters: { + type: "object", + properties: { + sessionId: { + type: "string", + description: "Session id returned by begin_file_write.", + }, + chunkIndex: { + type: "number", + description: "Sequential chunk number starting from 1.", + }, + chunk: { + type: "string", + description: `UTF-8 text chunk. Maximum allowed size is ${MAX_FILE_WRITE_CHUNK_BYTES} bytes.`, + }, + }, + required: ["sessionId", "chunkIndex", "chunk"], + }, + }, +} satisfies AiTool; + +export const finishFileWriteTool = { + type: "function", + function: { + name: "finish_file_write", + description: + "Finish an active chunked file write session by moving the temporary file to the final target path.", + parameters: { + type: "object", + properties: { + sessionId: { + type: "string", + description: "Session id returned by begin_file_write.", + }, + }, + required: ["sessionId"], + }, + }, +} satisfies AiTool; + +export const cancelFileWriteTool = { + type: "function", + function: { + name: "cancel_file_write", + description: + "Cancel an active chunked file write session and delete the temporary file.", + parameters: { + type: "object", + properties: { + sessionId: { + type: "string", + description: "Session id returned by begin_file_write.", + }, + }, + required: ["sessionId"], + }, + }, +} satisfies AiTool; + +export const fileToolsToolPrompt = [ + "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 search_files to find files by name, path or text content before reading or editing unfamiliar files.", + "- Use read_file for reading files.", + "- Use list_directory for reading directories.", + "- Use create_file for creating small or medium files in one call.", + "- Use begin_file_write, write_file_chunk and finish_file_write for large files.", + "- For chunked file writing, chunkIndex starts from 1 and must increase by 1 on every write_file_chunk call.", + "- If chunked file writing fails or is no longer needed, use cancel_file_write.", + "- Use create_directory for creating directories.", + "- Use update_file for replacing, appending or prepending file content.", + "- Use edit_file_patch for small exact-match file edits instead of rewriting the whole file.", + "- Before using edit_file_patch, read the relevant file or fragment first.", + "- For edit_file_patch, search fragments must be copied exactly from current file content.", + "- Do not guess patch context. If unsure, read the file first.", + "- Use rename_path for renaming or moving files/directories inside the root.", + "- Use delete_path for deleting files/directories inside the root.", + "- Use send_file_as_attachment when the user asks to receive, download, export or upload a file as an attachment.", + "- send_file_as_attachment returns only a local file descriptor. The host application must actually send the file.", + "", +].join("\n"); + +// ============================================================================= +// Exported tool implementations +// ============================================================================= + +export async function readFile(args?: AiJsonObject) { + const {absolutePath, relativePath, rootDir} = resolveSafeToolPath( + args?.path, + ".", + args?.userId, + ); + + await assertNoSymlinkInPath(absolutePath, rootDir); + + 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"), + }; +} + +export async function listDirectory(args?: AiJsonObject) { + const {absolutePath, relativePath, rootDir} = resolveSafeToolPath( + args?.path, + ".", + args?.userId, + ); + + await assertNoSymlinkInPath(absolutePath, rootDir); + + 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, + }; +} + +export async function searchFiles(args?: AiJsonObject) { + const start = resolveSafeToolPath(args?.path, ".", args?.userId); + + await assertNoSymlinkInPath(start.absolutePath, start.rootDir); + + const startStat = await fs.promises.lstat(start.absolutePath); + + if (!startStat.isDirectory()) { + throw new Error(`Search path is not a directory: ${start.relativePath}`); + } + + const query = asNonEmptyString(args?.query); + const contentQuery = asNonEmptyString(args?.contentQuery); + + if (!query && !contentQuery) { + throw new Error("Either query or contentQuery must be provided."); + } + + const recursive = asBoolean(args?.recursive, true); + const caseSensitive = asBoolean(args?.caseSensitive, false); + const includeDirectories = asBoolean(args?.includeDirectories, false); + const extensions = parseSearchExtensions(args?.extensions); + const maxResults = asPositiveInt( + args?.maxResults, + MAX_FILE_SEARCH_RESULTS, + MAX_FILE_SEARCH_RESULTS, + ); + + const normalizedQuery = query + ? normalizeForSearch(query, caseSensitive) + : null; + + const results: FileSearchResult[] = []; + const pendingDirectories: Array<{ + absolutePath: string; + relativePath: string; + }> = [start]; + + let scannedEntries = 0; + let truncated = false; + + while (pendingDirectories.length > 0) { + const current = pendingDirectories.shift(); + + if (!current) { + break; + } + + const entries = await fs.promises.readdir(current.absolutePath, { + withFileTypes: true, + }); + + for (const entry of entries) { + scannedEntries++; + + if (scannedEntries > MAX_FILE_SEARCH_ENTRIES) { + truncated = true; + pendingDirectories.length = 0; + break; + } + + if (results.length >= maxResults) { + truncated = true; + pendingDirectories.length = 0; + break; + } + + const entryAbsolutePath = path.join(current.absolutePath, entry.name); + const entryRelativePath = + current.relativePath === "." + ? entry.name + : path.join(current.relativePath, entry.name); + + const entryStat = await fs.promises.lstat(entryAbsolutePath); + + if (entryStat.isSymbolicLink()) { + continue; + } + + const isDirectory = entryStat.isDirectory(); + const isFile = entryStat.isFile(); + + if (!isDirectory && !isFile) { + continue; + } + + if (isDirectory && recursive) { + pendingDirectories.push({ + absolutePath: entryAbsolutePath, + relativePath: entryRelativePath, + }); + } + + if (isFile && !matchesExtension(entryRelativePath, extensions)) { + continue; + } + + if (isDirectory && !includeDirectories) { + continue; + } + + const normalizedName = normalizeForSearch(entry.name, caseSensitive); + const normalizedPath = normalizeForSearch( + entryRelativePath, + caseSensitive, + ); + + const matchedByName = normalizedQuery + ? normalizedName.includes(normalizedQuery) + : false; + const matchedByPath = normalizedQuery + ? normalizedPath.includes(normalizedQuery) + : false; + + let contentMatch: FileSearchResult["contentMatch"] | undefined; + + if (isFile && contentQuery) { + const match = await tryFindTextInFile({ + absolutePath: entryAbsolutePath, + query: contentQuery, + caseSensitive, + }); + + if (match) { + contentMatch = match; + } + } + + const matchedByContent = Boolean(contentMatch); + + if (!matchedByName && !matchedByPath && !matchedByContent) { + continue; + } + + results.push({ + path: entryRelativePath, + name: entry.name, + type: isDirectory ? "directory" : "file", + sizeBytes: isFile ? entryStat.size : null, + modifiedAt: entryStat.mtime.toISOString(), + matchedBy: { + name: matchedByName, + path: matchedByPath, + content: matchedByContent, + }, + contentMatch, + }); + } + } + + return { + ok: true, + path: start.relativePath, + query: query ?? null, + contentQuery: contentQuery ?? null, + recursive, + caseSensitive, + includeDirectories, + extensions, + scannedEntries, + returnedResults: results.length, + maxResults, + truncated, + results, + }; +} + +export async function createFile(args?: AiJsonObject) { + const {absolutePath, relativePath, rootDir} = resolveSafeToolPath( + args?.path, + ".", + args?.userId, + ); + + 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, rootDir, {allowMissingTail: true}); + await fs.promises.mkdir(parentPath, {recursive: true}); + } else { + await assertNoSymlinkInPath(parentPath, rootDir); + } + + 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, + }; +} + +export async function updateFile(args?: AiJsonObject) { + const {absolutePath, relativePath, rootDir} = resolveSafeToolPath( + args?.path, + ".", + args?.userId, + ); + + 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, rootDir); + + const exists = await pathExists(absolutePath); + + if (!exists && !createIfMissing) { + throw new Error(`File does not exist: ${relativePath}`); + } + + if (exists) { + await assertNoSymlinkInPath(absolutePath, rootDir); + + 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, + }; +} + +export async function editFilePatch(args?: AiJsonObject) { + const {absolutePath, relativePath, rootDir} = resolveSafeToolPath( + args?.path, + ".", + args?.userId, + ); + + assertNotRoot(relativePath); + + await assertNoSymlinkInPath(absolutePath, rootDir); + + const stat = await fs.promises.lstat(absolutePath); + + if (stat.isSymbolicLink()) { + throw new Error("Symlink targets are not allowed."); + } + + if (!stat.isFile()) { + throw new Error(`Path is not a file: ${relativePath}`); + } + + if (stat.size > MAX_FILE_READ_BYTES) { + throw new Error( + `File is too large to patch: ${stat.size} bytes. Max allowed: ${MAX_FILE_READ_BYTES} bytes.`, + ); + } + + const operations = parsePatchOperations(args?.operations); + const dryRun = asBoolean(args?.dryRun, false); + const createBackup = asBoolean(args?.createBackup, false); + + const buffer = await fs.promises.readFile(absolutePath); + + if (buffer.includes(0)) { + throw new Error("Binary files are not supported."); + } + + const originalContent = buffer.toString("utf8"); + let content = originalContent; + + const appliedOperations: AppliedPatchOperation[] = []; + + for (const [index, operation] of operations.entries()) { + const occurrences = findExactOccurrences(content, operation.search); + + if (occurrences.length === 0) { + throw new Error( + `Operation #${index} failed: search fragment was not found.`, + ); + } + + if (occurrences.length > 1) { + throw new Error( + `Operation #${index} failed: search fragment is ambiguous and appears ${occurrences.length} times.`, + ); + } + + const position = occurrences[0]; + const location = getLineColumn(content, position); + const replacement = buildPatchReplacement(operation); + + content = replaceAt( + content, + position, + operation.search.length, + replacement, + ); + + const resultSizeBytes = Buffer.byteLength(content, "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.`, + ); + } + + appliedOperations.push({ + index, + type: operation.type, + line: location.line, + column: location.column, + searchBytes: Buffer.byteLength(operation.search, "utf8"), + replaceBytes: Buffer.byteLength(replacement, "utf8"), + }); + } + + const changed = content !== originalContent; + let backupPath: string | null = null; + + if (!dryRun && changed) { + if (createBackup) { + backupPath = await createPatchBackup(absolutePath, originalContent, rootDir); + } + + await writeTextFileAtomic(absolutePath, content, rootDir); + } + + return { + ok: true, + path: relativePath, + dryRun, + changed, + backupPath, + operationsApplied: appliedOperations, + beforeSizeBytes: Buffer.byteLength(originalContent, "utf8"), + afterSizeBytes: Buffer.byteLength(content, "utf8"), + preview: dryRun ? buildPatchPreview(originalContent, content) : undefined, + }; +} + +export async function createDirectory(args?: AiJsonObject) { + const {absolutePath, relativePath, rootDir} = resolveSafeToolPath( + args?.path, + ".", + args?.userId, + ); + + const recursive = asBoolean(args?.recursive, true); + + await assertNoSymlinkInPath(absolutePath, rootDir, {allowMissingTail: true}); + + await fs.promises.mkdir(absolutePath, { + recursive, + }); + + return { + ok: true, + path: relativePath, + recursive, + }; +} + +export async function copyPath(args?: AiJsonObject) { + const source = resolveSafeToolPath(args?.sourcePath, undefined, args?.userId); + const target = resolveSafeToolPath(args?.targetPath, undefined, args?.userId); + + assertNotRoot(source.relativePath); + assertNotRoot(target.relativePath); + + await assertNoSymlinkInPath(source.absolutePath, source.rootDir); + + 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, source.rootDir, { + allowMissingTail: true, + }); + + await fs.promises.mkdir(targetParentPath, { + recursive: true, + }); + + await assertNoSymlinkInPath(targetParentPath, source.rootDir); + } else { + await assertNoSymlinkInPath(targetParentPath, source.rootDir); + } + + const stats: CopyPathStats = { + entries: 0, + totalBytes: 0, + }; + + await copyPathRecursive({ + sourceAbsolutePath: source.absolutePath, + targetAbsolutePath: target.absolutePath, + overwrite, + stats, + rootDir: source.rootDir + }); + + return { + ok: true, + from: source.relativePath, + to: target.relativePath, + recursive, + overwrite, + entriesCopied: stats.entries, + bytesCopied: stats.totalBytes, + }; +} + +export async function renamePath(args?: AiJsonObject) { + const source = resolveSafeToolPath(args?.sourcePath, undefined, args?.userId); + const target = resolveSafeToolPath(args?.targetPath, undefined, args?.userId); + + assertNotRoot(source.relativePath); + assertNotRoot(target.relativePath); + + await assertNoSymlinkInPath(source.absolutePath, source.rootDir); + + 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, target.rootDir, {allowMissingTail: true}); + await fs.promises.mkdir(targetParentPath, {recursive: true}); + } else { + await assertNoSymlinkInPath(targetParentPath, target.rootDir); + } + + 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, + }; +} + +export async function deletePath(args?: AiJsonObject) { + const {absolutePath, relativePath, rootDir} = resolveSafeToolPath( + args?.path, + ".", + args?.userId, + ); + + assertNotRoot(relativePath); + + await assertNoSymlinkInPath(absolutePath, rootDir); + + 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, + }; +} + +export async function sendFileAsAttachment( + args?: AiJsonObject, +): Promise { + try { + const target = resolveSafeToolPath(args?.path, undefined, args?.userId); + + assertNotRoot(target.relativePath); + + await assertNoSymlinkInPath(target.absolutePath, target.rootDir); + + const stat = await fs.promises.lstat(target.absolutePath); + + if (stat.isSymbolicLink()) { + return { + success: false, + error: "Symlink targets are not allowed.", + }; + } + + if (!stat.isFile()) { + return { + success: false, + error: `Path is not a file: ${target.relativePath}`, + }; + } + + const maxBytes = asPositiveInt( + args?.maxBytes, + MAX_FILE_ATTACHMENT_BYTES, + MAX_FILE_ATTACHMENT_BYTES, + ); + + if (stat.size > maxBytes) { + return { + success: false, + error: `File is too large: ${stat.size} bytes. Max allowed: ${maxBytes} bytes.`, + }; + } + + const requestedFileName = asNonEmptyString(args?.fileName); + const fileName = + requestedFileName?.trim() || path.basename(target.relativePath); + + if (!isSafeAttachmentFileName(fileName)) { + return { + success: false, + error: "Invalid or unsafe attachment file name provided.", + }; + } + + return { + success: true, + attachment: { + type: "local_file", + fileName, + relativePath: target.relativePath, + mimeType: guessMimeType(fileName), + sizeBytes: stat.size, + }, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + return { + success: false, + error: `Failed to prepare file attachment: ${errorMessage}`, + }; + } +} + +export async function beginFileWrite(args?: AiJsonObject) { + await cleanupExpiredFileWriteSessions(); + + if (fileWriteSessions.size >= MAX_STREAM_WRITE_SESSIONS) { + throw new Error( + `Too many active file write sessions. Max allowed: ${MAX_STREAM_WRITE_SESSIONS}.`, + ); + } + + const target = resolveSafeToolPath(args?.path, undefined, args?.userId); + + assertNotRoot(target.relativePath); + + const overwrite = asBoolean(args?.overwrite, false); + const createParents = asBoolean(args?.createParents, true); + + const targetParentPath = path.dirname(target.absolutePath); + + if (createParents) { + await assertNoSymlinkInPath(targetParentPath, target.rootDir, { + allowMissingTail: true, + }); + + await fs.promises.mkdir(targetParentPath, { + recursive: true, + }); + + await assertNoSymlinkInPath(targetParentPath, target.rootDir); + } else { + await assertNoSymlinkInPath(targetParentPath, target.rootDir); + } + + 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 (targetStat.isDirectory()) { + throw new Error( + `Path is a directory, not a file: ${target.relativePath}`, + ); + } + + if (!overwrite) { + throw new Error(`File already exists: ${target.relativePath}`); + } + } + + const sessionId = crypto.randomUUID(); + const tempAbsolutePath = path.join( + targetParentPath, + `.${path.basename(target.absolutePath)}.${sessionId}.tmp`, + ); + const tempRelativePath = path.relative(target.rootDir, tempAbsolutePath); + + await fs.promises.writeFile(tempAbsolutePath, "", { + encoding: "utf8", + flag: "wx", + }); + + const now = Date.now(); + const session: FileWriteSession = { + sessionId, + targetAbsolutePath: target.absolutePath, + targetRelativePath: target.relativePath, + tempAbsolutePath, + tempRelativePath, + overwrite, + bytesWritten: 0, + nextChunkIndex: 1, + createdAtMs: now, + updatedAtMs: now, + rootDir: target.rootDir, + userId: parseTelegramUserId(args?.userId) + }; + + fileWriteSessions.set(sessionId, session); + + return { + ok: true, + sessionId, + path: target.relativePath, + tempPath: tempRelativePath, + overwrite, + nextChunkIndex: session.nextChunkIndex, + bytesWritten: session.bytesWritten, + }; +} + +export async function writeFileChunk(args?: AiJsonObject) { + await cleanupExpiredFileWriteSessions(); + + const session = getFileWriteSession(args?.sessionId); + const chunkIndex = parsePositiveInteger(args?.chunkIndex, "chunkIndex"); + + if (chunkIndex !== session.nextChunkIndex) { + throw new Error( + `Invalid chunkIndex. Expected ${session.nextChunkIndex}, got ${chunkIndex}.`, + ); + } + + const chunk = asString(args?.chunk, ""); + + if (chunk.includes("\0")) { + throw new Error("Binary content is not supported."); + } + + const chunkSizeBytes = Buffer.byteLength(chunk, "utf8"); + + if (chunkSizeBytes > MAX_FILE_WRITE_CHUNK_BYTES) { + throw new Error( + `Chunk is too large: ${chunkSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_CHUNK_BYTES} bytes.`, + ); + } + + const resultSizeBytes = session.bytesWritten + chunkSizeBytes; + + 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 assertNoSymlinkInPath(session.tempAbsolutePath, session.rootDir); + + const tempStat = await fs.promises.lstat(session.tempAbsolutePath); + + if (!tempStat.isFile()) { + throw new Error("Temporary write path is not a file."); + } + + if (tempStat.isSymbolicLink()) { + throw new Error("Symlink temporary files are not allowed."); + } + + await fs.promises.appendFile(session.tempAbsolutePath, chunk, { + encoding: "utf8", + }); + + session.bytesWritten = resultSizeBytes; + session.nextChunkIndex++; + session.updatedAtMs = Date.now(); + + return { + ok: true, + sessionId: session.sessionId, + path: session.targetRelativePath, + acceptedChunkIndex: chunkIndex, + chunkSizeBytes, + bytesWritten: session.bytesWritten, + nextChunkIndex: session.nextChunkIndex, + }; +} + +export async function finishFileWrite(args?: AiJsonObject) { + await cleanupExpiredFileWriteSessions(); + + const session = getFileWriteSession(args?.sessionId); + + await assertNoSymlinkInPath(path.dirname(session.targetAbsolutePath), session.rootDir); + await assertNoSymlinkInPath(session.tempAbsolutePath, session.rootDir); + + const tempStat = await fs.promises.lstat(session.tempAbsolutePath); + + if (!tempStat.isFile()) { + throw new Error("Temporary write path is not a file."); + } + + if (tempStat.isSymbolicLink()) { + throw new Error("Symlink temporary files are not allowed."); + } + + if (await pathExists(session.targetAbsolutePath)) { + const targetStat = await fs.promises.lstat(session.targetAbsolutePath); + + if (targetStat.isSymbolicLink()) { + throw new Error("Symlink targets are not allowed."); + } + + if (targetStat.isDirectory()) { + throw new Error( + `Path is a directory, not a file: ${session.targetRelativePath}`, + ); + } + + if (!session.overwrite) { + throw new Error(`File already exists: ${session.targetRelativePath}`); + } + + await fs.promises.rm(session.targetAbsolutePath, { + force: false, + }); + } + + await fs.promises.rename( + session.tempAbsolutePath, + session.targetAbsolutePath, + ); + + fileWriteSessions.delete(session.sessionId); + + const finalStat = await fs.promises.stat(session.targetAbsolutePath); + + return { + ok: true, + sessionId: session.sessionId, + path: session.targetRelativePath, + sizeBytes: finalStat.size, + chunksWritten: session.nextChunkIndex - 1, + overwritten: session.overwrite, + }; +} + +export async function cancelFileWrite(args?: AiJsonObject) { + const session = getFileWriteSession(args?.sessionId); + + fileWriteSessions.delete(session.sessionId); + + await fs.promises.rm(session.tempAbsolutePath, { + force: true, + }); + + return { + ok: true, + sessionId: session.sessionId, + path: session.targetRelativePath, + cancelled: true, + bytesWritten: session.bytesWritten, + chunksWritten: session.nextChunkIndex - 1, + }; +} + +// ============================================================================= +// Path and filesystem helpers +// ============================================================================= + +function parseTelegramUserId(input: AiJsonValue | null | undefined): number | null { + if (input === null || input === undefined) { + return null; + } + + if ( + typeof input !== "number" || + !Number.isSafeInteger(input) || + input <= 0 + ) { + throw new Error("userId must be a positive safe integer."); + } + + return input; +} + +function requireFileToolsRootDir(userIdInput?: AiJsonValue | null | undefined): string { + const baseRootDir = Environment.FILE_TOOLS_ROOT_DIR as string; + const userId = parseTelegramUserId(userIdInput); + + if (userId === null) { + return baseRootDir; + } + + return path.join(baseRootDir, String(userId)); +} + +async function ensureFileToolsRootExists(rootDir: string): Promise { + await fs.promises.mkdir(rootDir, {recursive: true}); + + const stat = await fs.promises.stat(rootDir); + + if (!stat.isDirectory()) { + throw new Error(`File tools root is not a directory: ${rootDir}`); + } +} + +function resolveSafeToolPath( + inputPath: AiJsonValue | null | undefined, + fallback = ".", + userIdInput?: AiJsonValue | null | undefined, +): { + absolutePath: string; + relativePath: string; + rootDir: string; +} { + const rootDir = requireFileToolsRootDir(userIdInput); + 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(rootDir, normalizedInputPath); + const relativePath = path.relative(rootDir, absolutePath); + + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + throw new Error( + "Path escapes the root directory. Going up is not allowed.", + ); + } + + return { + absolutePath, + relativePath: relativePath || ".", + rootDir, + }; +} + +function assertNotRoot(relativePath: string): void { + if (relativePath === ".") { + throw new Error("Operation on the root directory itself is not allowed."); + } +} + +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, + rootDir: string, + options?: { + allowMissingTail?: boolean; + }, +): Promise { + await ensureFileToolsRootExists(rootDir); + + const relativePath = path.relative(rootDir, absolutePath); + + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + throw new Error("Path escapes the root directory."); + } + + if (!relativePath || relativePath === ".") { + return; + } + + const parts = relativePath.split(path.sep).filter(Boolean); + let currentPath = rootDir; + + 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 (error) { + if (error instanceof Error && "code" in error && (error as {code?: string}).code === "ENOENT" && options?.allowMissingTail) { + return; + } + + throw error; + } + } +} + +async function pathExists(absolutePath: string): Promise { + try { + await fs.promises.lstat(absolutePath); + return true; + } catch (error) { + if (error instanceof Error && "code" in error && (error as {code?: string}).code === "ENOENT") { + return false; + } + + throw error; + } +} + +async function writeTextFileAtomic( + absolutePath: string, + content: string, + rootDir: string +): Promise { + const directory = path.dirname(absolutePath); + const basename = path.basename(absolutePath); + const tempPath = path.join( + directory, + `.${basename}.${process.pid}.${Date.now()}.tmp`, + ); + + try { + await fs.promises.writeFile(tempPath, content, { + encoding: "utf8", + flag: "wx", + }); + + await assertNoSymlinkInPath(absolutePath, rootDir); + + const targetStat = await fs.promises.lstat(absolutePath); + + if (!targetStat.isFile()) { + throw new Error( + "Target path stopped being a regular file during patch write.", + ); + } + + if (targetStat.isSymbolicLink()) { + throw new Error("Symlink targets are not allowed."); + } + + await fs.promises.rename(tempPath, absolutePath); + } catch (error) { + await fs.promises.rm(tempPath, { + force: true, + }); + + throw error; + } +} + +async function createPatchBackup( + absolutePath: string, + originalContent: string, + rootDir: string, +): Promise { + const safeTimestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backupAbsolutePath = `${absolutePath}.bak.${safeTimestamp}`; + + await fs.promises.writeFile(backupAbsolutePath, originalContent, { + encoding: "utf8", + flag: "wx", + }); + + return path.relative(rootDir, backupAbsolutePath); +} + +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"; +} + +// ============================================================================= +// Copy helpers +// ============================================================================= + +async function copyPathRecursive(params: { + sourceAbsolutePath: string; + targetAbsolutePath: string; + overwrite: boolean; + stats: CopyPathStats; + rootDir: string; +}): 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(params.rootDir, 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, + rootDir: params.rootDir, + }); + } + + return; + } + + throw new Error("Only files and directories can be copied."); +} + +// ============================================================================= +// Patch helpers +// ============================================================================= + +function isPatchOperationType(value: string): value is PatchOperationType { + return (PATCH_OPERATION_TYPES as readonly string[]).includes(value); +} + +function parsePatchOperations(input: AiJsonValue | null | undefined): ParsedPatchOperation[] { + if (!Array.isArray(input)) { + throw new Error("operations must be an array."); + } + + if (input.length === 0) { + throw new Error("operations must not be empty."); + } + + if (input.length > MAX_PATCH_OPERATIONS) { + throw new Error( + `Too many patch operations. Max allowed: ${MAX_PATCH_OPERATIONS}.`, + ); + } + + return input.map((rawOperation, index) => { + if ( + !rawOperation || + typeof rawOperation !== "object" || + Array.isArray(rawOperation) + ) { + throw new Error(`Operation #${index} must be an object.`); + } + + const operation = rawOperation as AiJsonObject; + const rawType = asNonEmptyString(operation.type)?.toLowerCase(); + + if (!rawType || !isPatchOperationType(rawType)) { + throw new Error( + `Operation #${index} has unsupported type: ${String(operation.type)}.`, + ); + } + + const search = asNonEmptyString(operation.search); + + if (!search) { + throw new Error( + `Operation #${index}: search must be a non-empty string.`, + ); + } + + const searchBytes = Buffer.byteLength(search, "utf8"); + + if (searchBytes > MAX_PATCH_SEARCH_BYTES) { + throw new Error( + `Operation #${index}: search fragment is too large: ${searchBytes} bytes. Max allowed: ${MAX_PATCH_SEARCH_BYTES} bytes.`, + ); + } + + let replace = ""; + + if (rawType !== "delete") { + if (typeof operation.replace !== "string") { + throw new Error(`Operation #${index}: replace must be a string.`); + } + + replace = operation.replace; + + const replaceBytes = Buffer.byteLength(replace, "utf8"); + + if (replaceBytes > MAX_PATCH_REPLACE_BYTES) { + throw new Error( + `Operation #${index}: replace fragment is too large: ${replaceBytes} bytes. Max allowed: ${MAX_PATCH_REPLACE_BYTES} bytes.`, + ); + } + } + + return { + type: rawType, + search, + replace, + }; + }); +} + +function findExactOccurrences(content: string, search: string): number[] { + const positions: number[] = []; + let fromIndex = 0; + + while (true) { + const index = content.indexOf(search, fromIndex); + + if (index === -1) { + break; + } + + positions.push(index); + fromIndex = index + search.length; + } + + return positions; +} + +function getLineColumn( + content: string, + index: number, +): { + line: number; + column: number; +} { + const before = content.slice(0, index); + const lines = before.split("\n"); + + return { + line: lines.length, + column: lines[lines.length - 1].length + 1, + }; +} + +function buildPatchReplacement(operation: ParsedPatchOperation): string { + if (operation.type === "replace") { + return operation.replace; + } + + if (operation.type === "insert_before") { + return operation.replace + operation.search; + } + + if (operation.type === "insert_after") { + return operation.search + operation.replace; + } + + return ""; +} + +function replaceAt( + content: string, + startIndex: number, + searchLength: number, + replacement: string, +): string { + return ( + content.slice(0, startIndex) + + replacement + + content.slice(startIndex + searchLength) + ); +} + +function buildPatchPreview(before: string, after: string): string { + if (before === after) { + return "No content changes."; + } + + let prefixLength = 0; + + while ( + prefixLength < before.length && + prefixLength < after.length && + before[prefixLength] === after[prefixLength] + ) { + prefixLength++; + } + + let suffixLength = 0; + + while ( + suffixLength < before.length - prefixLength && + suffixLength < after.length - prefixLength && + before[before.length - 1 - suffixLength] === + after[after.length - 1 - suffixLength] + ) { + suffixLength++; + } + + const contextChars = Math.floor(MAX_PATCH_PREVIEW_CHARS / 4); + const beforeChangedStart = Math.max(0, prefixLength - contextChars); + const beforeChangedEnd = Math.min( + before.length, + before.length - suffixLength + contextChars, + ); + const afterChangedStart = Math.max(0, prefixLength - contextChars); + const afterChangedEnd = Math.min( + after.length, + after.length - suffixLength + contextChars, + ); + + const beforeSnippet = before.slice(beforeChangedStart, beforeChangedEnd); + const afterSnippet = after.slice(afterChangedStart, afterChangedEnd); + + const preview = [ + "--- BEFORE ---", + beforeChangedStart > 0 ? "... truncated ..." : "", + beforeSnippet, + beforeChangedEnd < before.length ? "... truncated ..." : "", + "--- AFTER ---", + afterChangedStart > 0 ? "... truncated ..." : "", + afterSnippet, + afterChangedEnd < after.length ? "... truncated ..." : "", + ] + .filter(Boolean) + .join("\n"); + + if (preview.length <= MAX_PATCH_PREVIEW_CHARS) { + return preview; + } + + return `${preview.slice(0, MAX_PATCH_PREVIEW_CHARS)}\n... preview truncated ...`; +} + +// ============================================================================= +// Search helpers +// ============================================================================= + +function normalizeForSearch(value: string, caseSensitive: boolean): string { + return caseSensitive ? value : value.toLowerCase(); +} + +function parseSearchExtensions(input: AiJsonValue | null | undefined): string[] | null { + if (input === undefined || input === null) { + return null; + } + + if (!Array.isArray(input)) { + throw new Error("extensions must be an array of strings."); + } + + const extensions = input + .map((value) => asNonEmptyString(value)) + .filter((value): value is string => Boolean(value)) + .map((value) => { + const trimmed = value.trim(); + return trimmed.startsWith(".") + ? trimmed.toLowerCase() + : `.${trimmed.toLowerCase()}`; + }); + + return extensions.length > 0 ? [...new Set(extensions)] : null; +} + +function matchesExtension( + relativePath: string, + extensions: string[] | null, +): boolean { + if (!extensions) { + return true; + } + + return extensions.includes(path.extname(relativePath).toLowerCase()); +} + +function findContentMatch(params: { + content: string; + query: string; + caseSensitive: boolean; +}): { + line: number; + column: number; + snippet: string; +} | null { + const normalizedContent = normalizeForSearch( + params.content, + params.caseSensitive, + ); + const normalizedQuery = normalizeForSearch( + params.query, + params.caseSensitive, + ); + + const index = normalizedContent.indexOf(normalizedQuery); + + if (index === -1) { + return null; + } + + const before = params.content.slice(0, index); + const lines = before.split("\n"); + + const line = lines.length; + const column = lines[lines.length - 1].length + 1; + + const snippetStart = Math.max( + 0, + index - Math.floor(MAX_FILE_SEARCH_SNIPPET_CHARS / 2), + ); + const snippetEnd = Math.min( + params.content.length, + index + params.query.length + Math.floor(MAX_FILE_SEARCH_SNIPPET_CHARS / 2), + ); + + const snippet = [ + snippetStart > 0 ? "... " : "", + params.content.slice(snippetStart, snippetEnd), + snippetEnd < params.content.length ? " ..." : "", + ].join(""); + + return { + line, + column, + snippet, + }; +} + +async function tryFindTextInFile(params: { + absolutePath: string; + query: string; + caseSensitive: boolean; +}): Promise<{ + line: number; + column: number; + snippet: string; +} | null> { + const stat = await fs.promises.lstat(params.absolutePath); + + if (!stat.isFile()) { + return null; + } + + if (stat.size > MAX_FILE_SEARCH_CONTENT_BYTES) { + return null; + } + + const buffer = await fs.promises.readFile(params.absolutePath); + + if (buffer.includes(0)) { + return null; + } + + const content = buffer.toString("utf8"); + + return findContentMatch({ + content, + query: params.query, + caseSensitive: params.caseSensitive, + }); +} + +// ============================================================================= +// Attachment helpers +// ============================================================================= + +function isSafeAttachmentFileName(fileName: string): boolean { + if (!fileName.trim()) { + return false; + } + + if (fileName !== path.basename(fileName)) { + return false; + } + + if (/[\0-\x1f<>:"/\\|?*]/.test(fileName)) { + return false; + } + + if (fileName === "." || fileName === "..") { + return false; + } + + return true; +} + +function guessMimeType(fileName: string): string { + const extension = path.extname(fileName).toLowerCase(); + + const mimeTypes: Record = { + ".txt": "text/plain", + ".md": "text/markdown", + ".markdown": "text/markdown", + ".json": "application/json", + ".jsonl": "application/x-ndjson", + ".csv": "text/csv", + ".html": "text/html", + ".htm": "text/html", + ".xml": "application/xml", + ".yaml": "application/yaml", + ".yml": "application/yaml", + + ".pdf": "application/pdf", + ".zip": "application/zip", + ".tar": "application/x-tar", + ".gz": "application/gzip", + ".7z": "application/x-7z-compressed", + + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".svg": "image/svg+xml", + + ".mp3": "audio/mpeg", + ".flac": "audio/flac", + ".wav": "audio/wav", + ".ogg": "audio/ogg", + ".m4a": "audio/mp4", + + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mkv": "video/x-matroska", + }; + + return mimeTypes[extension] ?? "application/octet-stream"; +} + +// ============================================================================= +// Chunked write helpers +// ============================================================================= + +function parsePositiveInteger(value: AiJsonValue | null | undefined, fieldName: string): number { + const numberValue = + typeof value === "number" + ? value + : typeof value === "string" + ? Number(value) + : NaN; + + if (!Number.isSafeInteger(numberValue) || numberValue < 1) { + throw new Error(`${fieldName} must be a positive integer.`); + } + + return numberValue; +} + +function getFileWriteSession(sessionIdInput: AiJsonValue | null | undefined): FileWriteSession { + const sessionId = asNonEmptyString(sessionIdInput); + + if (!sessionId) { + throw new Error("sessionId is required."); + } + + const session = fileWriteSessions.get(sessionId); + + if (!session) { + throw new Error(`File write session not found or expired: ${sessionId}`); + } + + return session; +} + +async function cleanupExpiredFileWriteSessions(): Promise { + const now = Date.now(); + + for (const [sessionId, session] of fileWriteSessions.entries()) { + if (now - session.updatedAtMs <= MAX_STREAM_WRITE_IDLE_MS) { + continue; + } + + fileWriteSessions.delete(sessionId); + + await fs.promises.rm(session.tempAbsolutePath, { + force: true, + }); + } +} diff --git a/src/ai/tools/limits.ts b/src/ai/tools/limits.ts new file mode 100644 index 0000000..2539d60 --- /dev/null +++ b/src/ai/tools/limits.ts @@ -0,0 +1,17 @@ +export const MAX_FILE_READ_BYTES = 128 * 1024 * 1024; +export const MAX_FILE_WRITE_BYTES = 128 * 1024 * 1024; +export const MAX_DIRECTORY_ENTRIES = 200; +export const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024; +export const MAX_COPY_ENTRIES = 500; +export const MAX_PATCH_OPERATIONS = 20; +export const MAX_PATCH_SEARCH_BYTES = 64 * 1024; +export const MAX_PATCH_REPLACE_BYTES = 256 * 1024; +export const MAX_PATCH_PREVIEW_CHARS = 6000; +export const MAX_FILE_SEARCH_ENTRIES = 5000; +export const MAX_FILE_SEARCH_RESULTS = 100; +export const MAX_FILE_SEARCH_CONTENT_BYTES = 512 * 1024; +export const MAX_FILE_SEARCH_SNIPPET_CHARS = 300; +export const MAX_FILE_WRITE_CHUNK_BYTES = 64 * 1024; +export const MAX_STREAM_WRITE_SESSIONS = 20; +export const MAX_STREAM_WRITE_IDLE_MS = 15 * 60 * 1000; +export const MAX_FILE_ATTACHMENT_BYTES = 20 * 1024 * 1024; \ No newline at end of file diff --git a/src/ai/tools/market-rates.ts b/src/ai/tools/market-rates.ts new file mode 100644 index 0000000..0fd7369 --- /dev/null +++ b/src/ai/tools/market-rates.ts @@ -0,0 +1,78 @@ +import {AiTool} from "../tool-types"; +import axios from "axios"; +import {toolsLogger} from "./tool-logger"; +import {AiJsonObject} from "../tool-types"; + +const logger = toolsLogger.child("market-rates"); + +export const GET_FINANCIAL_MARKET_DATA_TOOL_NAME = "get_financial_market_data"; + +export const getFinancialMarketData = { + type: "function", + function: { + name: GET_FINANCIAL_MARKET_DATA_TOOL_NAME, + description: + "Retrieve the latest exchange rates for supported currency, crypto, and precious metal pairs, including 24-hour change data when available. Supported pairs: USD/RUB, USD/EUR, USD/KZT, USD/UAH, USD/BYN, USD/GBP, USD/CNY, TON/USD, BTC/USD, ETH/USD, SOL/USD, and XAU/USD. Use this tool when the user asks for current rates, currency conversion, crypto prices, gold price, or recent 24-hour movement. This tool takes no parameters.", + parameters: { + type: "object", + properties: {}, + required: [], + }, + }, +} satisfies AiTool; + +export const getFinancialMarketDataToolPrompt = [ + "Currency rates tool rules:", + `- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` whenever the answer depends on current exchange rates, crypto prices, or gold price.`, + `- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` when the user asks whether a supported asset went up or down recently.`, + `- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` when the user asks for the 24-hour change, percentage change, or movement direction for a supported pair.`, + "- Never guess current rates, prices, or 24-hour changes. Call the tool first.", + "- Do not use this tool for unsupported pairs unless the user asks about one of the supported pairs listed below.", + "- Do not use this tool for historical rates beyond the provided 24-hour comparison.", + "", + "Supported pairs:", + "- `usd_to_rub`: USD to RUB.", + "- `usd_to_eur`: USD to EUR.", + "- `usd_to_kzt`: USD to KZT.", + "- `usd_to_uah`: USD to UAH.", + "- `usd_to_byn`: USD to BYN.", + "- `usd_to_gbp`: USD to GBP.", + "- `usd_to_cny`: USD to CNY.", + "- `ton_to_usd`: TON to USD.", + "- `btc_to_usd`: BTC to USD.", + "- `eth_to_usd`: ETH to USD.", + "- `sol_to_usd`: SOL to USD.", + "- `xau_to_usd`: gold/XAU to USD.", + "", + "Arguments:", + "- This tool takes no arguments.", + "", + "Returned data:", + "- Each supported pair contains `rate` with the latest available rate.", + "- Each supported pair may contain `change.absolute` with the absolute 24-hour change.", + "- Each supported pair may contain `change.percent` with the percentage 24-hour change.", + "- Each supported pair may contain `change.direction` with the movement direction, e.g. `up`, `down`, or `flat`.", + "- `has_24h_comparison`: whether 24-hour comparison data is available.", + "", + "After the tool returns:", + "- Base the answer only on the returned values.", + "- If `has_24h_comparison` is false, provide only the current rates and say that 24-hour comparison is unavailable.", + "- Do not expose raw tool JSON unless asked.", + "- Format the answer in a user-friendly way.", + "- For fiat pairs, show the rate with the target currency, for example: `USD/RUB is 75.22 RUB, down 0.16% over 24 hours.`", + "- For crypto and gold pairs, show the USD price, for example: `BTC/USD is $81,451.66, up 0.22% over 24 hours.`", + "- When the user asks for all rates, group fiat currencies separately from crypto and gold.", +].join("\n"); + +export async function getMarketRates(): Promise { + const startedAt = Date.now(); + try { + logger.info("start"); + const response = await axios.get("https://apid.r00t.top/api/v2/currency/rates"); + logger.debug("done", {duration: logger.duration(startedAt), status: response.status}); + return response.data; + } catch (error) { + logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); + return undefined; + } +} diff --git a/src/ai/tools/notes.ts b/src/ai/tools/notes.ts new file mode 100644 index 0000000..096aa66 --- /dev/null +++ b/src/ai/tools/notes.ts @@ -0,0 +1,449 @@ +import {AiTool} from "../tool-types"; +import path from "node:path"; +import {readdir, readFile, stat, unlink, writeFile} from "node:fs/promises"; +import {notesDir, notesRootFile} from "../../index"; +import {asNonEmptyString} from "./utils"; +import {toolsLogger} from "./tool-logger"; +import {z} from "zod"; +import {AiJsonObject} from "../tool-types"; + +const logger = toolsLogger.child("notes"); + +export type NoteListItem = { + fileName: string; + filePath: string; + relativePath: string; + title: string; +}; + +export type ListNotesResult = + | { success: true; notes: NoteListItem[] } + | { success: false; error: string }; + +export type GetNoteContentResult = + | { + success: true; + fileName: string; + filePath: string; + relativePath: string; + title: string; + content: string; +} | { success: false; error: string }; + +export const listNotesTool = { + type: "function", + function: { + name: "list_notes", + description: "Display all available Markdown notes from the notes directory.", + parameters: { + type: "object", + properties: {}, + required: [], + }, + }, +} satisfies AiTool; + +export const getNoteContentTool = { + type: "function", + function: { + name: "get_note_content", + description: "Get the full Markdown content of a specific note by its file name.", + parameters: { + type: "object", + properties: { + fileName: { + type: "string", + description: + "The file name of the note to read. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.", + }, + }, + required: ["fileName"], + }, + }, +} satisfies AiTool; + +export async function listNotes(): Promise { + const startedAt = Date.now(); + logger.debug("list.start"); + + try { + const entries = await readdir(notesDir, {withFileTypes: true}); + + const markdownFiles = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter((fileName) => fileName.endsWith(".md") && !fileName.startsWith("index")); + + const notes: NoteListItem[] = await Promise.all( + markdownFiles.map(async (fileName) => { + const filePath = path.join(notesDir, fileName); + const relativePath = path.relative(path.dirname(notesRootFile), filePath); + + let content = ""; + try { + content = await readFile(filePath, "utf-8"); + } catch { + // Ignore content read errors for individual files. + } + + return { + fileName, + filePath, + relativePath, + title: extractNoteTitle(fileName, content), + }; + }), + ); + + notes.sort((a, b) => a.title.localeCompare(b.title)); + + logger.debug("list.done", {notes: notes.length, duration: logger.duration(startedAt)}); + return {success: true, notes}; + } catch (error) { + logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); + const errorMessage = error instanceof Error ? error.message : String(error); + return {success: false, error: `Failed to list notes: ${errorMessage}`}; + } +} + +export async function getNoteContent( + args?: AiJsonObject, +): Promise { + const startedAt = Date.now(); + logger.debug("get_content.start", {args}); + + const fileName = asNonEmptyString(args?.fileName) ?? ""; + if (!fileName.trim().length) { + return {success: false, error: "No file name provided"}; + } + + if (fileName.trim().includes("index")) { + return {success: false, error: "It is forbidden to access `index.md`"}; + } + + const noteFilePath = buildSafeNoteFilePath(fileName); + if (!noteFilePath) { + return {success: false, error: "Invalid or unsafe file name provided"}; + } + + try { + const content = await readFile(noteFilePath, "utf-8"); + const normalizedFileName = path.basename(noteFilePath); + const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath); + + logger.debug("get_content.done", { + fileName: normalizedFileName, + relativePath, + chars: content.length, + duration: logger.duration(startedAt) + }); + return { + success: true, + fileName: normalizedFileName, + filePath: noteFilePath, + relativePath, + title: extractNoteTitle(normalizedFileName, content), + content, + }; + } catch (error) { + logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); + const errorMessage = error instanceof Error ? error.message : String(error); + return {success: false, error: `Failed to read note: ${errorMessage}`}; + } +} + +function extractNoteTitle(fileName: string, content: string): string { + const headingMatch = content.match(/^#\s+(.+)$/m); + const heading = headingMatch?.[1]?.trim(); + + if (heading) { + return heading; + } + + return path.basename(fileName, ".md"); +} + +export function buildSafeNoteFilePath(fileName: string): string | null { + const normalizedFileName = fileName.endsWith(".md") ? fileName : `${fileName}.md`; + + if (!normalizedFileName.trim().length) { + return null; + } + + const unsafeFileNamePattern = /[/\\:*?"<>|\x00-\x1F]/; + if (unsafeFileNamePattern.test(normalizedFileName)) { + return null; + } + + const resolvedNotesDir = path.resolve(notesDir); + const resolvedFilePath = path.resolve(notesDir, normalizedFileName); + + if (!resolvedFilePath.startsWith(resolvedNotesDir + path.sep)) { + return null; + } + + return resolvedFilePath; +} + +export type UpdateNoteContentResult = + | { success: true; filePath: string } + | { success: false; error: string }; + +export type DeleteNoteResult = + | { success: true; filePath: string } + | { success: false; error: string }; + +export const updateNoteContentTool = { + type: "function", + function: { + name: "update_note_content", + description: "Update the full Markdown content of an existing note by its file name.", + parameters: { + type: "object", + properties: { + fileName: { + type: "string", + description: + "The file name of the note to update. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.", + }, + content: { + type: "string", + description: + "The new full content of the note formatted as valid Markdown. This replaces the previous content completely.", + }, + }, + required: ["fileName", "content"], + }, + }, +} satisfies AiTool; + +export const deleteNoteTool = { + type: "function", + function: { + name: "delete_note", + description: "Delete an existing Markdown note by its file name and remove its link from the notes root file if present. It is forbidden to delete/edit/rename `index.md` note.", + parameters: { + type: "object", + properties: { + fileName: { + type: "string", + description: + "The file name of the note to delete. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters. It is forbidden to delete/edit/rename `index.md` note.", + }, + }, + required: ["fileName"], + }, + }, +} satisfies AiTool; + +export async function updateNoteContent( + args?: AiJsonObject, +): Promise { + const startedAt = Date.now(); + logger.debug("update_content.start", {args}); + + const fileName = asNonEmptyString(args?.fileName) ?? ""; + if (!fileName.trim().length) { + return {success: false, error: "No file name provided"}; + } + + if (fileName.trim().includes("index")) { + return {success: false, error: "It is forbidden to edit `index.md`"}; + } + + const content = asNonEmptyString(args?.content) ?? ""; + if (!content.trim().length) { + return {success: false, error: "No content provided"}; + } + + const noteFilePath = buildSafeNoteFilePath(fileName); + if (!noteFilePath) { + return {success: false, error: "Invalid or unsafe file name provided"}; + } + + try { + await readFile(noteFilePath, "utf-8"); + await writeFile(noteFilePath, content, "utf-8"); + logger.debug("update_content.done", { + fileName, + filePath: noteFilePath, + chars: content.length, + duration: logger.duration(startedAt) + }); + + return {success: true, filePath: noteFilePath}; + } catch (error) { + logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); + const errorMessage = error instanceof Error ? error.message : String(error); + return {success: false, error: `Failed to update note: ${errorMessage}`}; + } +} + +export async function deleteNote( + args?: AiJsonObject, +): Promise { + const startedAt = Date.now(); + logger.debug("delete.start", {args}); + + const fileName = asNonEmptyString(args?.fileName) ?? ""; + if (!fileName.trim().length) { + return {success: false, error: "No file name provided"}; + } + + if (fileName.trim().includes("index")) { + return {success: false, error: "It is forbidden to delete `index.md`"}; + } + + const noteFilePath = buildSafeNoteFilePath(fileName); + if (!noteFilePath) { + return {success: false, error: "Invalid or unsafe file name provided"}; + } + + try { + await unlink(noteFilePath); + await removeNoteLinkFromRoot(noteFilePath); + logger.debug("delete.done", {fileName, filePath: noteFilePath, duration: logger.duration(startedAt)}); + + return {success: true, filePath: noteFilePath}; + } catch (error) { + logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); + const errorMessage = error instanceof Error ? error.message : String(error); + return {success: false, error: `Failed to delete note: ${errorMessage}`}; + } +} + +async function removeNoteLinkFromRoot(noteFilePath: string): Promise { + let rootContent: string; + + try { + rootContent = await readFile(notesRootFile, "utf-8"); + } catch { + return; + } + + const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath); + const normalizedRelativePath = relativePath.replaceAll("\\", "\\\\"); + + const escapedRelativePath = escapeRegExp(normalizedRelativePath); + const linkLinePattern = new RegExp( + `^\\s*[-*]\\s+\\[[^\\]]+]\\(${escapedRelativePath}\\)\\s*$\\n?`, + "gm", + ); + + const updatedRootContent = rootContent.replace(linkLinePattern, ""); + + if (updatedRootContent !== rootContent) { + await writeFile(notesRootFile, updatedRootContent.trimEnd() + "\n", "utf-8"); + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export type NoteFileAttachment = { + type: "local_file"; + fileName: string; + // filePath: string; + relativePath: string; + mimeType: "text/markdown"; + sizeBytes: number; +}; + +export type GetNoteFileResult = + | { + success: true; + attachment: NoteFileAttachment; +} | { success: false; error: string }; + +export const NoteFileAttachmentSchema = z.object({ + type: z.literal("local_file"), + fileName: z.string(), + // filePath: z.string(), + relativePath: z.string(), + mimeType: z.literal("text/markdown"), + sizeBytes: z.number(), +}); + +export const GetNoteFileResultSchema = z.discriminatedUnion("success", [ + z.object({ + success: z.literal(true), + attachment: NoteFileAttachmentSchema, + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + +export const sendNoteAsFileTool = { + type: "function", + function: { + name: "send_note_as_file", + description: + "Prepare a Markdown note file to be sent to the user as a .md attachment. Returns a local file descriptor that the host application should use to upload or send the file.", + parameters: { + type: "object", + properties: { + fileName: { + type: "string", + description: + "The file name of the note to send. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.", + }, + }, + required: ["fileName"], + }, + }, +} satisfies AiTool; + +export async function sendNoteAsFile( + args?: AiJsonObject, +): Promise { + logger.debug("start", {args}); + + const fileName = asNonEmptyString(args?.fileName) ?? ""; + if (!fileName.trim().length) { + return {success: false, error: "No file name provided"}; + } + + const noteFilePath = buildSafeNoteFilePath(fileName); + if (!noteFilePath) { + return {success: false, error: "Invalid or unsafe file name provided"}; + } + + try { + // Проверяем, что файл существует и действительно читается. + await readFile(noteFilePath, "utf-8"); + + const fileStat = await stat(noteFilePath); + if (!fileStat.isFile()) { + return {success: false, error: "Note path is not a file"}; + } + + const normalizedFileName = path.basename(noteFilePath); + const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath); + + const result: GetNoteFileResult = { + success: true, + attachment: { + type: "local_file", + fileName: normalizedFileName, + // filePath: noteFilePath, + relativePath, + mimeType: "text/markdown", + sizeBytes: fileStat.size, + }, + }; + + logger.debug("done", { + fileName: result.attachment.fileName, + relativePath: result.attachment.relativePath, + sizeBytes: result.attachment.sizeBytes + }); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return {success: false, error: `Failed to prepare note file: ${errorMessage}`}; + } +} diff --git a/src/ai/tools/python-interpretator.ts b/src/ai/tools/python-interpretator.ts new file mode 100644 index 0000000..de0c3f9 --- /dev/null +++ b/src/ai/tools/python-interpretator.ts @@ -0,0 +1,822 @@ +import {spawn} from "node:child_process"; +import {copyFile, lstat, mkdir, readdir, rm, writeFile} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import {AiTool} from "../tool-types"; +import {Environment} from "../../common/environment"; +import {toolsLogger} from "./tool-logger"; +import {randomUUID} from "node:crypto"; +import {AiJsonObject} from "../tool-types"; + +const logger = toolsLogger.child("python-interpreter"); + +export const PYTHON_INTERPRETER_TOOL_NAME = "python_interpreter"; + +export type PythonInterpreterArgs = { + /** + * Full Python 3 script. + * The model should use print(...) to expose useful output. + */ + code: string; + + /** + * Optional stdin passed to the Python process. + */ + stdin?: string; + + /** + * Optional timeout override. + */ + timeoutMs?: number; +}; + +export type PythonInterpreterOptions = { + pythonBinary?: string; + syntaxTimeoutMs?: number; + executionTimeoutMs?: number; + maxCodeChars?: number; + maxOutputChars?: number; + maxArtifactBytes?: number; + maxArtifactCount?: number; + inputFiles?: PythonInterpreterInputFile[]; +}; + +type ProcessRunResult = { + exitCode: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + timedOut: boolean; + outputTruncated: boolean; + durationMs: number; +}; + +export type PythonInterpreterInputFile = { + kind?: string; + path: string; + fileName: string; + mimeType?: string; +}; + +export type PythonInterpreterRuntimeInputFile = PythonInterpreterInputFile & { + index: number; + path: string; + sourcePath: string; + relativePath: string; + sizeBytes: number; +}; + +export type PythonInterpreterArtifact = { + kind: "image" | "file"; + path: string; + relativePath: string; + fileName: string; + mimeType?: string; + sizeBytes: number; +}; + +export type PythonInterpreterSkippedArtifact = { + path: string; + relativePath: string; + fileName: string; + sizeBytes?: number; + reason: string; + maxSizeBytes?: number; +}; + +export type PythonToolResult = + | { + ok: true; + phase: "execution"; + stdout: string; + stderr: string; + exitCode: number | null; + durationMs: number; + outputTruncated: boolean; + inputDir?: string; + outputDir?: string; + inputFiles?: PythonInterpreterRuntimeInputFile[]; + artifacts?: PythonInterpreterArtifact[]; + skippedArtifacts?: PythonInterpreterSkippedArtifact[]; +} + | { + ok: false; + phase: "syntax" | "execution" | "internal"; + error: string; + stdout?: string; + stderr?: string; + exitCode?: number | null; + signal?: NodeJS.Signals | null; + timedOut?: boolean; + durationMs?: number; + outputTruncated?: boolean; +}; + +const DEFAULT_PYTHON_BINARY = process.platform === "win32" ? "python" : "python3"; +const DEFAULT_SYNTAX_TIMEOUT_MS = 3_000; +const DEFAULT_EXECUTION_TIMEOUT_MS = 8_000; +const DEFAULT_MAX_CODE_CHARS = 100_000; +const DEFAULT_MAX_OUTPUT_CHARS = 20_000; +export const PYTHON_INTERPRETER_MAX_ARTIFACT_BYTES = 50 * 1024 * 1024; +const DEFAULT_MAX_ARTIFACT_COUNT = 20; +const PYTHON_INPUTS_DIR_NAME = "inputs"; +const PYTHON_OUTPUTS_DIR_NAME = "outputs"; +const PYTHON_ATTACHMENTS_FILE_NAME = "attachments.json"; +const PYTHON_USER_CODE_FILE_NAME = "user_code.py"; +const PYTHON_RUNNER_FILE_NAME = "main.py"; + +const PYTHON_CODE_TEMPLATE = [ + "from pathlib import Path", + "import json", + "", + "# These globals are predefined by the python_interpreter runtime:", + "# INPUT_DIR = Path('inputs')", + "# OUTPUT_DIR = Path('outputs')", + "# ATTACHMENTS_FILE = Path('attachments.json')", + "", + "attachments = load_attachments()", + "# Read attached files from INPUT_DIR, for example:", + "# text = (INPUT_DIR/attachments[0]['fileName']).read_text(encoding='utf-8')", + "", + "# Save every user-visible generated file into outputs.", + "# Example:", + "# (OUTPUT_DIR/'result.txt').write_text('done', encoding='utf-8')", + "", + "print('done')", +].join("\n"); + +export const pythonInterpreterToolPrompt = [ + "Python interpreter rules:", + "- You have access to the `python_interpreter` tool for Python 3 code.", + "- Each Python run starts in a temporary workspace.", + "- Incoming user files are always in `inputs/`.", + "- Outgoing user-visible files must always be saved into `outputs/`.", + "- Attachment metadata is always in `attachments.json`.", + "- The runtime predefines these globals in executed code: `INPUT_DIR`, `OUTPUT_DIR`, `ATTACHMENTS_FILE`, `WORK_DIR`, `input_path(name)`, `output_path(name)`, and `load_attachments()`.", + "- Use `input_path(filename)` for reading incoming files.", + "- Use `output_path(filename)` for files that should be returned to the user.", + "- Do not invent other directories for user attachments or generated artifacts.", + "- Prefer this template:", + "```python", + PYTHON_CODE_TEMPLATE, + "```", + "", +].join("\n"); + +export const pythonInterpreterTool = { + type: "function", + function: { + name: PYTHON_INTERPRETER_TOOL_NAME, + description: + "Validate and execute short Python 3 code. Use for calculations, data transformations, parsing, chart rendering, and image/file processing. The code must print useful text results. The runtime always creates hardcoded directories `inputs/` and `outputs/` in the current working directory. User attachments are copied into `inputs/` and described in `attachments.json`. The executed code has predefined globals: INPUT_DIR, OUTPUT_DIR, ATTACHMENTS_FILE, WORK_DIR, input_path(name), output_path(name), and load_attachments(). Put every user-visible output image or file into `outputs/`; every regular file there up to 50 MB will be returned by the tool and sent to the user.", + parameters: { + type: "object", + required: ["code"], + properties: { + code: { + type: "string", + description: + `Complete Python 3 script to execute. Use print(...) for the final answer. Do not use markdown fences. Read incoming files only from INPUT_DIR / "file" or input_path("file"). Save charts/images/files intended for the user only into OUTPUT_DIR / "file" or output_path("file"). You can inspect attachments via load_attachments(). Template:\n${PYTHON_CODE_TEMPLATE}`, + }, + stdin: { + type: "string", + description: "Optional stdin passed to the Python script.", + }, + timeoutMs: { + type: "integer", + description: "Optional execution timeout in milliseconds. Default is 8000.", + }, + }, + }, + }, +} satisfies AiTool; + +export async function runPythonInterpreter( + rawArgs: string | AiJsonObject | undefined, + options: PythonInterpreterOptions = {}, +): Promise { + let args: PythonInterpreterArgs; + + try { + args = parsePythonInterpreterArgs(rawArgs, options); + } catch (error) { + return { + ok: false, + phase: "internal", + error: errorToString(error instanceof Error ? error : String(error)), + }; + } + + const syntaxStartedAt = Date.now(); + const syntax = await validatePythonSyntax(args.code, options); + logger.debug("syntax.done", {duration: logger.duration(syntaxStartedAt), ok: syntax.ok}); + + if (!syntax.ok) { + return syntax; + } + + const executionStartedAt = Date.now(); + const result = await executePythonCode(args, options); + logger.debug("execution.done", {duration: logger.duration(executionStartedAt), ok: result.ok, phase: result.phase}); + + return result; +} + +export async function validatePythonSyntax( + code: string, + options: PythonInterpreterOptions = {}, +): Promise { + const pythonBinary = options.pythonBinary ?? DEFAULT_PYTHON_BINARY; + const timeoutMs = options.syntaxTimeoutMs ?? DEFAULT_SYNTAX_TIMEOUT_MS; + const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS; + + const syntaxCheckScript = ` +import ast +import sys +source = sys.stdin.read() + +try: + ast.parse(source, filename="") +except SyntaxError as e: + print(f"SyntaxError: {e.msg} at line {e.lineno}, column {e.offset}", file=sys.stderr) + if e.text: + print(e.text.rstrip(), file=sys.stderr) + sys.exit(1) +except Exception as e: + print(f"{type(e).__name__}: {e}", file=sys.stderr) + sys.exit(1) +`.trim(); + + const result = await runProcess({ + command: pythonBinary, + args: ["-I", "-B", "-S", "-c", syntaxCheckScript], + input: code, + timeoutMs, + maxOutputChars, + env: buildSafeEnv(), + }); + + if (result.timedOut) { + return { + ok: false, + phase: "syntax", + error: `Python syntax check timed out after ${timeoutMs} ms.`, + stderr: result.stderr, + durationMs: result.durationMs, + timedOut: true, + outputTruncated: result.outputTruncated, + }; + } + + if (result.exitCode !== 0) { + return { + ok: false, + phase: "syntax", + error: result.stderr.trim() || "Python syntax check failed.", + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + signal: result.signal, + durationMs: result.durationMs, + outputTruncated: result.outputTruncated, + }; + } + + return { + ok: true, + phase: "execution", + stdout: "", + stderr: "", + exitCode: 0, + durationMs: result.durationMs, + outputTruncated: result.outputTruncated, + }; +} + +async function executePythonCode( + args: PythonInterpreterArgs, + options: PythonInterpreterOptions = {}, +): Promise { + const startedAt = Date.now(); + logger.info("execute.start", {args, options}); + + const pythonBinary = + options.pythonBinary ?? process.env.PYTHON_INTERPRETER_BINARY ?? "python"; + + const timeoutMs = args.timeoutMs ?? options.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS; + const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS; + + const tempDir = path.join(Environment.DATA_PATH, "cache", "python", "python-temp-" + randomUUID()); + const inputDir = path.join(tempDir, PYTHON_INPUTS_DIR_NAME); + const outputDir = path.join(tempDir, PYTHON_OUTPUTS_DIR_NAME); + const attachmentsPath = path.join(tempDir, PYTHON_ATTACHMENTS_FILE_NAME); + await mkdir(tempDir, {recursive: true}); + await mkdir(inputDir, {recursive: true}); + await mkdir(outputDir, {recursive: true}); + const userScriptPath = path.join(tempDir, PYTHON_USER_CODE_FILE_NAME); + const runnerPath = path.join(tempDir, PYTHON_RUNNER_FILE_NAME); + + try { + const inputFiles = await prepareInputFiles(options.inputFiles ?? [], inputDir); + await writeFile(attachmentsPath, JSON.stringify(inputFiles, null, 2), { + encoding: "utf8", + mode: 0o600, + }); + + await writeFile(userScriptPath, args.code, { + encoding: "utf8", + mode: 0o600, + }); + + await writeFile(runnerPath, buildPythonRunnerScript(), { + encoding: "utf8", + mode: 0o600, + }); + + logger.debug("script.written", {tempDir, userScriptPath, runnerPath, duration: logger.duration(startedAt)}); + + const result = await runProcess({ + command: pythonBinary, + args: ["-I", "-B", runnerPath], + input: args.stdin ?? "", + cwd: tempDir, + timeoutMs, + maxOutputChars, + env: { + ...buildSafeEnv(tempDir), + PYTHON_INPUT_DIR: inputDir, + PYTHON_OUTPUT_DIR: outputDir, + PYTHON_ATTACHMENTS_FILE: attachmentsPath, + }, + }); + + logger.debug("process.done", { + duration: logger.duration(startedAt), + exitCode: result.exitCode, + timedOut: result.timedOut, + outputTruncated: result.outputTruncated + }); + + if (result.timedOut) { + logger.warn("process.timeout", {duration: logger.duration(startedAt)}); + return { + ok: false, + phase: "execution", + error: `Python execution timed out after ${timeoutMs} ms.`, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + signal: result.signal, + timedOut: true, + durationMs: result.durationMs, + outputTruncated: result.outputTruncated, + }; + } + + if (result.outputTruncated) { + logger.warn("process.output_truncated", { + duration: logger.duration(startedAt), + stdoutChars: result.stdout.length, + stderrChars: result.stderr.length + }); + + return { + ok: false, + phase: "execution", + error: `Python output exceeded limit of ${maxOutputChars} characters.`, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + signal: result.signal, + timedOut: false, + durationMs: result.durationMs, + outputTruncated: true, + }; + } + + if (result.exitCode !== 0) { + logger.warn("process.non_zero_exit", {duration: logger.duration(startedAt), result}); + + return { + ok: false, + phase: "execution", + error: result.stderr.trim() || `Python exited with code ${result.exitCode}.`, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + signal: result.signal, + timedOut: false, + durationMs: result.durationMs, + outputTruncated: result.outputTruncated, + }; + } + + logger.debug("process.ok", {duration: logger.duration(startedAt)}); + + const { + artifacts, + skippedArtifacts + } = await collectOutputArtifacts(outputDir, options); + + return { + ok: true, + phase: "execution", + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + durationMs: result.durationMs, + outputTruncated: result.outputTruncated, + inputDir, + outputDir, + inputFiles, + artifacts, + skippedArtifacts, + }; + } catch (error) { + logger.error("execute.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); + return { + ok: false, + phase: "internal", + error: errorToString(error instanceof Error ? error : String(error)), + }; + } finally { + await rm(tempDir, { + recursive: true, + force: true, + }); + } +} + +function buildPythonRunnerScript(): string { + return ` +import json +import runpy +from pathlib import Path + +WORK_DIR = Path(__file__).resolve().parent +INPUT_DIR = WORK_DIR / ${JSON.stringify(PYTHON_INPUTS_DIR_NAME)} +OUTPUT_DIR = WORK_DIR / ${JSON.stringify(PYTHON_OUTPUTS_DIR_NAME)} +ATTACHMENTS_FILE = WORK_DIR / ${JSON.stringify(PYTHON_ATTACHMENTS_FILE_NAME)} +USER_CODE_FILE = WORK_DIR / ${JSON.stringify(PYTHON_USER_CODE_FILE_NAME)} + +INPUT_DIR.mkdir(parents=True, exist_ok=True) +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + +def input_path(name=""): + return INPUT_DIR / name + +def output_path(name=""): + return OUTPUT_DIR / name + +def load_attachments(): + if not ATTACHMENTS_FILE.exists(): + return [] + return json.loads(ATTACHMENTS_FILE.read_text(encoding="utf-8")) + +runpy.run_path( + str(USER_CODE_FILE), + run_name="__main__", + init_globals={ + "Path": Path, + "WORK_DIR": WORK_DIR, + "INPUT_DIR": INPUT_DIR, + "OUTPUT_DIR": OUTPUT_DIR, + "ATTACHMENTS_FILE": ATTACHMENTS_FILE, + "input_path": input_path, + "output_path": output_path, + "load_attachments": load_attachments, + }, +) +`.trimStart(); +} + +async function prepareInputFiles( + inputFiles: PythonInterpreterInputFile[], + inputDir: string, +): Promise { + const prepared: PythonInterpreterRuntimeInputFile[] = []; + + for (const [index, file] of inputFiles.entries()) { + const sourcePath = path.resolve(file.path); + const info = await lstat(sourcePath).catch(() => null); + if (!info?.isFile()) continue; + + const fileName = uniqueInputFileName(index, file.fileName || path.basename(sourcePath)); + const runtimePath = path.join(inputDir, fileName); + await copyFile(sourcePath, runtimePath); + + prepared.push({ + ...file, + index, + path: runtimePath, + sourcePath, + relativePath: path.join(PYTHON_INPUTS_DIR_NAME, fileName).replace(/\\/g, "/"), + sizeBytes: info.size, + fileName, + }); + } + + return prepared; +} + +async function collectOutputArtifacts( + outputDir: string, + options: PythonInterpreterOptions, +): Promise<{ + artifacts: PythonInterpreterArtifact[]; + skippedArtifacts: PythonInterpreterSkippedArtifact[]; +}> { + const maxBytes = options.maxArtifactBytes ?? PYTHON_INTERPRETER_MAX_ARTIFACT_BYTES; + const maxCount = options.maxArtifactCount ?? DEFAULT_MAX_ARTIFACT_COUNT; + const artifacts: PythonInterpreterArtifact[] = []; + const skippedArtifacts: PythonInterpreterSkippedArtifact[] = []; + + const walk = async (dir: string): Promise => { + const entries = await readdir(dir, {withFileTypes: true}).catch(() => []); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const info = await lstat(fullPath).catch(() => null); + if (!info) continue; + + const relativePath = path.relative(outputDir, fullPath).replace(/\\/g, "/"); + if (info.isSymbolicLink()) { + skippedArtifacts.push({ + path: fullPath, + relativePath, + fileName: safeFileName(entry.name), + reason: "Symbolic links are not returned.", + }); + continue; + } + + if (info.isDirectory()) { + await walk(fullPath); + continue; + } + + if (!info.isFile()) continue; + + const fileName = safeFileName(entry.name); + if (info.size > maxBytes) { + skippedArtifacts.push({ + path: fullPath, + relativePath, + fileName, + sizeBytes: info.size, + reason: `File exceeds the ${maxBytes} byte limit.`, + maxSizeBytes: maxBytes, + }); + continue; + } + + if (artifacts.length >= maxCount) { + skippedArtifacts.push({ + path: fullPath, + relativePath, + fileName, + sizeBytes: info.size, + reason: `Artifact count exceeds the ${maxCount} file limit.`, + }); + continue; + } + + const mimeType = mimeTypeFromPath(fullPath); + if (mimeType) { + artifacts.push({ + kind: mimeType?.startsWith("image/") ? "image" : "file", + path: fullPath, + relativePath, + fileName, + mimeType, + sizeBytes: info.size, + }); + } else { + skippedArtifacts.push({ + path: fullPath, + relativePath, + fileName, + sizeBytes: info.size, + reason: "Unsupported mimeType for extension " + path.extname(fullPath) + }); + } + } + }; + + await walk(outputDir); + + return {artifacts, skippedArtifacts}; +} + +function safeFileName(value: string): string { + const sanitized = path.basename(value) + .replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_") + .trim() + .slice(0, 180); + + return sanitized || "file"; +} + +function uniqueInputFileName(index: number, value: string): string { + const safe = safeFileName(value); + const ext = path.extname(safe); + const base = path.basename(safe, ext).slice(0, 140) || "input"; + return `${index + 1}_${base}${ext}`; +} + +function mimeTypeFromPath(filePath: string): string | undefined { + switch (path.extname(filePath).toLowerCase()) { + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".png": + return "image/png"; + case ".webp": + return "image/webp"; + case ".gif": + return "image/gif"; + case ".bmp": + return "image/bmp"; + case ".svg": + return "image/svg+xml"; + case ".pdf": + return "application/pdf"; + case ".txt": + return "text/plain"; + case ".csv": + return "text/csv"; + case ".json": + return "application/json"; + case ".zip": + return "application/zip"; + case ".mp3": + return "audio/mpeg"; + case ".wav": + return "audio/wav"; + case ".mp4": + return "video/mp4"; + default: + return undefined; + } +} + +function parsePythonInterpreterArgs( + rawArgs: string | AiJsonObject | undefined, + options: PythonInterpreterOptions, +): PythonInterpreterArgs { + let args = rawArgs; + + if (typeof rawArgs === "string") { + try { + args = JSON.parse(rawArgs); + } catch { + args = {code: rawArgs}; + } + } + + if (!args || typeof args !== "object" || Array.isArray(args)) { + throw new Error("Tool arguments must be an object."); + } + + const record = args as AiJsonObject; + const code = record.code; + + if (typeof code !== "string" || !code.trim()) { + throw new Error("Tool argument `code` must be a non-empty string."); + } + + const maxCodeChars = options.maxCodeChars ?? DEFAULT_MAX_CODE_CHARS; + if (code.length > maxCodeChars) { + throw new Error(`Python code is too large: ${code.length} chars, max ${maxCodeChars}.`); + } + + const stdin = record.stdin; + if (stdin !== undefined && typeof stdin !== "string") { + throw new Error("Tool argument `stdin` must be a string when provided."); + } + + const timeoutMs = record.timeoutMs; + if ( + timeoutMs !== undefined && + (!Number.isInteger(timeoutMs) || Number(timeoutMs) < 100 || Number(timeoutMs) > 60_000) + ) { + throw new Error("Tool argument `timeoutMs` must be an integer from 100 to 60000."); + } + + return { + code, + stdin: typeof stdin === "string" ? stdin : undefined, + timeoutMs: timeoutMs === undefined ? undefined : Number(timeoutMs), + }; +} + +async function runProcess(params: { + command: string; + args: string[]; + input?: string; + cwd?: string; + env?: NodeJS.ProcessEnv; + timeoutMs: number; + maxOutputChars: number; +}): Promise { + const startedAt = Date.now(); + + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let timedOut = false; + let outputTruncated = false; + let settled = false; + + const child = spawn(params.command, params.args, { + cwd: params.cwd, + env: params.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + + const finish = (result: Omit) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + ...result, + durationMs: Date.now() - startedAt, + }); + }; + + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, params.timeoutMs); + + const appendOutput = (target: "stdout" | "stderr", chunk: Buffer) => { + const text = chunk.toString("utf8"); + + if (target === "stdout") { + stdout += text; + } else { + stderr += text; + } + + const total = stdout.length + stderr.length; + if (total > params.maxOutputChars) { + outputTruncated = true; + + stdout = stdout.slice(0, params.maxOutputChars); + stderr = stderr.slice(0, params.maxOutputChars); + + child.kill("SIGKILL"); + } + }; + + child.stdout.on("data", (chunk: Buffer) => appendOutput("stdout", chunk)); + child.stderr.on("data", (chunk: Buffer) => appendOutput("stderr", chunk)); + + child.on("error", (error) => { + finish({ + exitCode: null, + signal: null, + stdout, + stderr: stderr + `\n${errorToString(error)}`, + timedOut, + outputTruncated, + }); + }); + + child.on("close", (exitCode, signal) => { + finish({ + exitCode, + signal, + stdout, + stderr, + timedOut, + outputTruncated, + }); + }); + + child.stdin.end(params.input ?? ""); + }); +} + +function buildSafeEnv(tempDir?: string): NodeJS.ProcessEnv { + return { + PATH: process.env.PATH ?? "", + PATHEXT: process.env.PATHEXT ?? "", + SystemRoot: process.env.SystemRoot ?? "", + HOME: tempDir ?? os.tmpdir(), + USERPROFILE: tempDir ?? os.tmpdir(), + TEMP: tempDir ?? os.tmpdir(), + TMP: tempDir ?? os.tmpdir(), + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8", + }; +} + +function errorToString(error: Error | string | object | null | undefined): string { + if (error instanceof Error) { + return error.stack || error.message; + } + + return String(error); +} diff --git a/src/ai/tools/registry.ts b/src/ai/tools/registry.ts new file mode 100644 index 0000000..04c4c71 --- /dev/null +++ b/src/ai/tools/registry.ts @@ -0,0 +1,189 @@ +import {Environment} from "../../common/environment"; +import {AiTool} from "../tool-types"; +import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search"; +import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime"; +import {shellExecute, shellExecuteTool} from "./shell"; +import {ToolHandler} from "./types"; +import {getWeather, getWeatherTool} from "./weather"; +import { + GET_FINANCIAL_MARKET_DATA_TOOL_NAME, + getFinancialMarketData, + getFinancialMarketDataToolPrompt, + getMarketRates +} from "./market-rates"; +import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator"; +import { + beginFileWrite, + beginFileWriteTool, + cancelFileWrite, + cancelFileWriteTool, + copyPath, + copyPathTool, + createDirectory, + createDirectoryTool, + createFile, + createFileTool, + deletePath, + deletePathTool, + editFilePatch, + editFilePatchTool, + fileToolsToolPrompt, + finishFileWrite, + finishFileWriteTool, + listDirectory, + listDirectoryTool, + readFile, + readFileTool, + renamePath, + renamePathTool, + searchFiles, + searchFilesTool, + sendFileAsAttachment, + sendFileAsAttachmentTool, + updateFile, + updateFileTool, + writeFileChunk, + writeFileChunkTool +} from "./files"; + +export const defaultTools: AiTool[] = [ + getCurrentDateTimeTool, + getFinancialMarketData, +]; + +export const fileTools = [ + readFileTool, + listDirectoryTool, + searchFilesTool, + + createFileTool, + beginFileWriteTool, + writeFileChunkTool, + finishFileWriteTool, + cancelFileWriteTool, + + sendFileAsAttachmentTool, + + createDirectoryTool, + copyPathTool, + updateFileTool, + editFilePatchTool, + renamePathTool, + deletePathTool, +] satisfies AiTool[]; + +// export const notesFileTools: AiTool[] = [ +// createNoteTool, +// listNotesTool, +// getNoteContentTool, +// updateNoteContentTool, +// deleteNoteTool, +// sendNoteAsFileTool, +// searchNotesTool +// ] + +export const getTools = (forCreator?: boolean) => { + const tools: AiTool[] = [ + ...defaultTools, + // ...notesFileTools + ]; + + if (Environment.BRAVE_SEARCH_API_KEY) { + tools.push(webSearchTool); + } + + if (Environment.OPEN_WEATHER_MAP_API_KEY) { + tools.push(getWeatherTool); + } + + if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) { + tools.push(...fileTools); + } + + if (forCreator) { + if (Environment.ENABLE_PYTHON_INTERPRETER) { + tools.push(pythonInterpreterTool); + } + + if (Environment.ENABLE_UNSAFE_EVAL) { + tools.push(shellExecuteTool); + } + } + + return tools; +}; + +export const fileToolHandlers = { + read_file: readFile, + list_directory: listDirectory, + search_files: searchFiles, + + create_file: createFile, + begin_file_write: beginFileWrite, + write_file_chunk: writeFileChunk, + finish_file_write: finishFileWrite, + cancel_file_write: cancelFileWrite, + + send_file_as_attachment: sendFileAsAttachment, + + create_directory: createDirectory, + copy_path: copyPath, + update_file: updateFile, + edit_file_patch: editFilePatch, + rename_path: renamePath, + delete_path: deletePath, +}; + +export const getToolHandlers = () => { + let handlers: Record = { + get_datetime: getCurrentDateTime, + get_financial_market_data: getMarketRates, + + // create_note: createNote, + // list_notes: listNotes, + // get_note_content: getNoteContent, + // update_note_content: updateNoteContent, + // delete_note: deleteNote, + // send_note_as_file: sendNoteAsFile, + // search_notes: searchNotes, + + ...fileToolHandlers, + + + python_interpreter: runPythonInterpreter, + + shell_execute: shellExecute, + + web_search: webSearch, + + get_weather: getWeather, + + }; + + return handlers; +}; + +export function getToolPrompts(toolNames: string[]): string[] { + const prompts: string[] = []; + + for (const toolName of toolNames) { + if (!prompts.includes(fileToolsToolPrompt) && + fileTools.map(t => t.function.name).includes(toolName)) { + prompts.push(fileToolsToolPrompt); + continue; + } + + switch (toolName) { + case GET_FINANCIAL_MARKET_DATA_TOOL_NAME: + prompts.push(getFinancialMarketDataToolPrompt); + break; + case WEB_SEARCH_TOOL_NAME: + prompts.push(webSearchToolPrompt); + break; + default: + break; + } + } + + return prompts; +} \ No newline at end of file diff --git a/src/ai/tools/runtime.ts b/src/ai/tools/runtime.ts new file mode 100644 index 0000000..913e208 --- /dev/null +++ b/src/ai/tools/runtime.ts @@ -0,0 +1,61 @@ +import {getToolHandlers} from "./registry"; +import {normalizeToolArguments} from "./utils"; +import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator"; +import {toolsLogger} from "./tool-logger"; +import {AiJsonObject, AiJsonValue} from "../tool-types"; + +const logger = toolsLogger.child("runtime"); + +export type ToolRuntimeContext = { + pythonInputFiles?: PythonInterpreterInputFile[]; +}; + +function stringifyToolResult(result: AiJsonValue): string { + if (typeof result === "string") return result; + return JSON.stringify(result, null, 2); +} + +export async function executeToolCall( + userId: number | undefined | null, + name: string, + args?: string | AiJsonObject, + context: ToolRuntimeContext = {}, +): Promise { + const startedAt = Date.now(); + const handler = getToolHandlers()[name]; + logger.info("execute.start", {name, args}); + + if (!handler) { + return stringifyToolResult({ + error: `Unknown tool: ${name}`, + }); + } + + try { + if (name === PYTHON_INTERPRETER_TOOL_NAME) { + const result = await runPythonInterpreter(normalizeToolArguments(args, userId), { + executionTimeoutMs: 8_000, + syntaxTimeoutMs: 3_000, + maxCodeChars: 100_000, + maxOutputChars: 20_000, + inputFiles: context.pythonInputFiles, + }); + + const s = stringifyToolResult(result); + logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)}); + + return s; + } + + const arguments1 = normalizeToolArguments(args, userId); + const result = await handler(arguments1); + const s = stringifyToolResult(result); + logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)}); + return s; + } catch (error) { + logger.error("execute.failed", {name, duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); + return stringifyToolResult({ + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/ai/tools/search-notes.ts b/src/ai/tools/search-notes.ts new file mode 100644 index 0000000..b8da252 --- /dev/null +++ b/src/ai/tools/search-notes.ts @@ -0,0 +1,395 @@ +import {AiTool} from "../tool-types"; +import path from "node:path"; +import {readdir, readFile} from "node:fs/promises"; +import {notesDir, notesRootFile} from "../../index"; +import {asNonEmptyString} from "./utils"; +import {toolsLogger} from "./tool-logger"; +import {AiJsonObject, AiJsonValue} from "../tool-types"; + +const logger = toolsLogger.child("search-notes"); + +export type SearchNoteMatchedField = "file_name" | "title" | "content"; + +export type SearchNoteItem = { + fileName: string; + filePath: string; + relativePath: string; + title: string; + score: number; + matchedFields: SearchNoteMatchedField[]; + snippet?: string; +}; + +export type SearchNotesResult = + | { success: true; results: SearchNoteItem[] } + | { success: false; error: string }; + +export const searchNotesTool = { + type: "function", + function: { + name: "search_notes", + description: + "Search Markdown notes by file name, note title, and full note content. Supports fuzzy matching. Use this when the user refers to a note by title, topic, partial title, approximate name, keyword, or something written inside the note. Returns success=true and results[], where each result contains fileName, title, score, matchedFields, relativePath, and optional snippet. Later note tools should use results[0].fileName unless multiple results are ambiguous.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: + "Search query for finding notes by file name, title, topic, keywords, or content. Can be partial, approximate, or contain typos. Use a short clean phrase, not the full user sentence.", + }, + limit: { + type: "integer", + description: + "Maximum number of search results to return. Defaults to 3. Maximum is 10.", + minimum: 1, + maximum: 10, + default: 3, + }, + }, + required: ["query"], + }, + }, +} satisfies AiTool; + +export async function searchNotes( + args?: AiJsonObject, +): Promise { + const startedAt = Date.now(); + logger.debug("start", {args}); + + const query = asNonEmptyString(args?.query) ?? ""; + if (!query.trim().length) { + return {success: false, error: "No query provided"}; + } + + const limit = parseSearchLimit(args?.limit); + + try { + const entries = await readdir(notesDir, {withFileTypes: true}); + + const markdownFiles = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter((fileName) => fileName.endsWith(".md")); + + const notes = await Promise.all( + markdownFiles.map(async (fileName) => { + const filePath = path.join(notesDir, fileName); + const relativePath = path.relative(path.dirname(notesRootFile), filePath); + + let content = ""; + try { + content = await readFile(filePath, "utf-8"); + } catch { + // Ignore content read errors for individual files. + } + + const title = extractNoteTitle(fileName, content); + const fileNameWithoutExtension = path.basename(fileName, ".md"); + + const fileNameScore = calculateFuzzyScore(query, fileNameWithoutExtension); + const titleScore = calculateFuzzyScore(query, title); + const contentScore = calculateContentScore(query, content); + + const matchedFields: SearchNoteMatchedField[] = []; + + if (fileNameScore > 0) { + matchedFields.push("file_name"); + } + + if (titleScore > 0) { + matchedFields.push("title"); + } + + if (contentScore > 0) { + matchedFields.push("content"); + } + + const score = Math.max( + fileNameScore, + titleScore, + contentScore, + ); + + return { + fileName, + filePath, + relativePath, + title, + score, + matchedFields, + snippet: + contentScore > 0 + ? buildContentSnippet(query, content) + : undefined, + }; + }), + ); + + const results = notes + .filter((note) => note.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit); + + logger.debug("done", {query, limit, results: results.length, duration: logger.duration(startedAt)}); + return {success: true, results}; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return {success: false, error: `Failed to search notes: ${errorMessage}`}; + } +} + +function parseSearchLimit(value: AiJsonValue | undefined): number { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseInt(value, 10) + : 3; + + if (!Number.isFinite(parsed)) { + return 3; + } + + return Math.max(1, Math.min(10, Math.floor(parsed))); +} + +function extractNoteTitle(fileName: string, content: string): string { + const headingMatch = content.match(/^#\s+(.+)$/m); + const heading = headingMatch?.[1]?.trim(); + + if (heading) { + return heading; + } + + return path.basename(fileName, ".md"); +} + +function calculateFuzzyScore(query: string, value: string): number { + const normalizedQuery = normalizeSearchText(query); + const normalizedValue = normalizeSearchText(value); + + if (!normalizedQuery.length || !normalizedValue.length) { + return 0; + } + + if (normalizedValue === normalizedQuery) { + return 100; + } + + if (normalizedValue.startsWith(normalizedQuery)) { + return 90; + } + + if (normalizedValue.includes(normalizedQuery)) { + return 85; + } + + const queryWords = normalizedQuery.split(" ").filter(Boolean); + const valueWords = normalizedValue.split(" ").filter(Boolean); + + const wordMatchScore = calculateWordMatchScore(queryWords, valueWords); + const subsequenceScore = isSubsequence(normalizedQuery, normalizedValue) ? 55 : 0; + const distanceScore = calculateLevenshteinScore(normalizedQuery, normalizedValue); + + return Math.max(wordMatchScore, subsequenceScore, distanceScore); +} + +function calculateContentScore(query: string, content: string): number { + const normalizedQuery = normalizeSearchText(query); + const normalizedContent = normalizeSearchText(content); + + if (!normalizedQuery.length || !normalizedContent.length) { + return 0; + } + + if (normalizedContent.includes(normalizedQuery)) { + return 70; + } + + const queryWords = normalizedQuery.split(" ").filter(Boolean); + const contentWords = new Set(normalizedContent.split(" ").filter(Boolean)); + + if (!queryWords.length) { + return 0; + } + + let matchedWords = 0; + + for (const queryWord of queryWords) { + if (contentWords.has(queryWord)) { + matchedWords++; + continue; + } + + const hasPartialMatch = [...contentWords].some((contentWord) => { + if (contentWord.includes(queryWord) || queryWord.includes(contentWord)) { + return true; + } + + if (queryWord.length < 4 || contentWord.length < 4) { + return false; + } + + const distance = levenshteinDistance(queryWord, contentWord); + const maxLength = Math.max(queryWord.length, contentWord.length); + const similarity = 1 - distance / maxLength; + + return similarity >= 0.75; + }); + + if (hasPartialMatch) { + matchedWords += 0.75; + } + } + + const matchRatio = matchedWords / queryWords.length; + + if (matchRatio <= 0) { + return 0; + } + + return Math.round(matchRatio * 60); +} + +function normalizeSearchText(value: string): string { + return value + .toLowerCase() + .trim() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/ё/g, "е") + .replace(/[^a-zа-я0-9\s-]/gi, " ") + .replace(/[-_]+/g, " ") + .replace(/\s+/g, " "); +} + +function calculateWordMatchScore(queryWords: string[], valueWords: string[]): number { + if (!queryWords.length || !valueWords.length) { + return 0; + } + + let matchedWords = 0; + + for (const queryWord of queryWords) { + const bestWordScore = Math.max( + ...valueWords.map((valueWord) => { + if (valueWord === queryWord) { + return 1; + } + + if (valueWord.startsWith(queryWord) || valueWord.includes(queryWord)) { + return 0.85; + } + + const distance = levenshteinDistance(queryWord, valueWord); + const maxLength = Math.max(queryWord.length, valueWord.length); + const similarity = 1 - distance / maxLength; + + return similarity >= 0.7 ? similarity : 0; + }), + ); + + if (bestWordScore > 0) { + matchedWords += bestWordScore; + } + } + + const ratio = matchedWords / queryWords.length; + return Math.round(ratio * 75); +} + +function calculateLevenshteinScore(query: string, value: string): number { + const distance = levenshteinDistance(query, value); + const maxLength = Math.max(query.length, value.length); + + if (maxLength === 0) { + return 0; + } + + const similarity = 1 - distance / maxLength; + + if (similarity < 0.45) { + return 0; + } + + return Math.round(similarity * 65); +} + +function isSubsequence(query: string, value: string): boolean { + let queryIndex = 0; + + for (const valueChar of value) { + if (valueChar === query[queryIndex]) { + queryIndex++; + } + + if (queryIndex === query.length) { + return true; + } + } + + return false; +} + +function levenshteinDistance(a: string, b: string): number { + const matrix: number[][] = Array.from({length: a.length + 1}, () => + Array.from({length: b.length + 1}, () => 0), + ); + + for (let i = 0; i <= a.length; i++) { + matrix[i][0] = i; + } + + for (let j = 0; j <= b.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + cost, + ); + } + } + + return matrix[a.length][b.length]; +} + +function buildContentSnippet(query: string, content: string): string | undefined { + const normalizedQuery = query.trim().toLowerCase(); + const normalizedContent = content.toLowerCase(); + + let matchIndex = normalizedContent.indexOf(normalizedQuery); + + if (matchIndex < 0) { + const queryWords = normalizeSearchText(query) + .split(" ") + .filter((word) => word.length >= 3); + + for (const word of queryWords) { + matchIndex = normalizedContent.indexOf(word); + if (matchIndex >= 0) { + break; + } + } + } + + if (matchIndex < 0) { + return undefined; + } + + const snippetRadius = 120; + const start = Math.max(0, matchIndex - snippetRadius); + const end = Math.min(content.length, matchIndex + normalizedQuery.length + snippetRadius); + + const prefix = start > 0 ? "..." : ""; + const suffix = end < content.length ? "..." : ""; + + return `${prefix}${content.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`; +} diff --git a/src/ai/tools/shell.ts b/src/ai/tools/shell.ts new file mode 100644 index 0000000..cb56221 --- /dev/null +++ b/src/ai/tools/shell.ts @@ -0,0 +1,110 @@ +import {AiTool} from "../tool-types"; +import {runCommand} from "../../util/utils"; +import {asNonEmptyString} from "./utils"; +import {AiJsonObject} from "../tool-types"; + +export const shellExecuteTool = { + type: "function", + function: { + name: "shell_execute", + description: "Execute NON-Python command in a shell. Do not use if you intend to execute some python.", + parameters: { + type: "object", + properties: { + cmd: { + type: "string", + description: "Actual command to execute in a shell" + } + }, + required: ["cmd"] + } + } +} satisfies AiTool; + +export const shellExecuteToolPrompt = [ + "Shell tool rules:", + "- You have access to the `shell_execute` tool.", + "- `shell_execute` 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 unclear, 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"); + +export async function shellExecute(args?: AiJsonObject): Promise { + const cmd = asNonEmptyString(args?.cmd); + if (!cmd) return undefined; + + const {stdout, stderr} = await runCommand(cmd); + + return stdout ?? stderr; +} diff --git a/src/ai/tools/tool-logger.ts b/src/ai/tools/tool-logger.ts new file mode 100644 index 0000000..a056778 --- /dev/null +++ b/src/ai/tools/tool-logger.ts @@ -0,0 +1,3 @@ +import {appLogger} from "../../logging/logger"; + +export const toolsLogger = appLogger.child("ai-tools"); diff --git a/src/ai/tools/types.ts b/src/ai/tools/types.ts new file mode 100644 index 0000000..221bd20 --- /dev/null +++ b/src/ai/tools/types.ts @@ -0,0 +1,3 @@ +import {AiJsonObject, AiJsonValue} from "../tool-types"; + +export type ToolHandler = (args?: AiJsonObject) => Promise | AiJsonValue | string | null | undefined; diff --git a/src/ai/tools/utils.ts b/src/ai/tools/utils.ts new file mode 100644 index 0000000..e48332a --- /dev/null +++ b/src/ai/tools/utils.ts @@ -0,0 +1,113 @@ +import {Ollama} from "ollama"; +import {toolsLogger} from "./tool-logger"; +import {AiJsonObject, AiJsonValue} from "../tool-types"; +import type {BoundaryValue} from "../../common/boundary-types"; + +const logger = toolsLogger.child("utils"); + +export function asNonEmptyString(value: BoundaryValue): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +export function normalizeToolArguments(args: string | AiJsonObject | undefined, userId?: number | null): AiJsonObject { + if (!args) return {}; + + if (typeof args === "string") { + try { + const parsed = JSON.parse(args) as AiJsonValue; + + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as AiJsonObject; + } + } catch { + return { + raw: args, + }; + } + + return {}; + } + + if (typeof args === "object" && !Array.isArray(args)) { + const userIdObject = userId ? {"userId": userId} : {}; + return { + ...args, + ...userIdObject, + } as AiJsonObject; + } + + return {}; +} + +export function asBoolean(value: BoundaryValue, 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; +} + +export function asString(value: BoundaryValue, defaultValue = ""): string { + return typeof value === "string" ? value : defaultValue; +} + +export function asPositiveInt(value: BoundaryValue, 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); +} + +export async function unloadAllOllamaModels(ollama: Ollama, exceptFor?: string[]) { + try { + const runningModels = await ollama.ps(); + const modelsToUnload = runningModels.models + .filter(m => !exceptFor?.includes(m.model)); + + const unloadPromises = modelsToUnload + .map(model => + ollama.generate({ + model: model.name, + keep_alive: 0, + stream: false, + prompt: "" + }) + ); + + await Promise.all(unloadPromises); + logger.info("ollama.unload_all.done", {count: modelsToUnload.length, exceptFor}); + } catch (error) { + logger.error("ollama.unload_all.failed", {exceptFor, error: error instanceof Error ? error : String(error)}); + } +} + +export async function loadOllamaModel(model: string, ollama: Ollama, contextLength: number): Promise { + try { + logger.info("ollama.load.start", {model, contextLength}); + await ollama.generate({ + model: model, + stream: false, + prompt: "", + options: { + num_ctx: contextLength + } + }); + logger.info("ollama.load.done", {model, contextLength}); + return true; + } catch (error) { + logger.error("ollama.load.failed", {model, contextLength, error: error instanceof Error ? error : String(error)}); + return false; + } +} diff --git a/src/ai/tools/weather.ts b/src/ai/tools/weather.ts new file mode 100644 index 0000000..b4907d0 --- /dev/null +++ b/src/ai/tools/weather.ts @@ -0,0 +1,151 @@ +import axios from "axios"; +import {toolsLogger} from "./tool-logger"; + +const logger = toolsLogger.child("weather"); +import {Environment} from "../../common/environment"; +import {logError} from "../../util/utils"; +import {AiJsonObject, AiTool} from "../tool-types"; +import {asNonEmptyString} from "./utils"; + +export const getWeatherTool = { + type: "function", + function: { + name: "get_weather", + 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 AiTool; + +export const weatherToolPrompt = [ + "Weather tool rules:", + "- Use `get_weather` for current weather, current temperature, conditions, hot/cold/rainy/snowy questions, and weather follow-ups.", + "- Weather is live/current data. Never answer it from memory.", + "- A weather tool result is valid only for the exact city used in that tool call.", + "- If the user changes the city, call `get_weather` again.", + "- Follow-up questions like `what about Moscow?`, `and for Krasnodar?`, `what about there?`, `what about Berlin?` inherit the previous weather intent and require a new tool call for the new city.", + "", + "Arguments:", + "- `city`: the city from the latest user request or resolved from the follow-up context.", + "- `lang`: ISO 639-1 two-letter language code only: `ru`, `en`, `de`, etc.", + "", + "Do not guess, compare, or reuse weather from another city.", + "If the city is missing or unclear, ask the user to specify it.", +].join("\n"); + +export async function getWeather(args?: AiJsonObject): Promise { + const startedAt = Date.now(); + logger.info("start", {args}); + 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", { + params: { + q: city, + limit: 1, + appid: apiKey, + }, + })).data[0]; + logger.debug("geocode.done", {city, country: geocodeResponse?.country, hasResult: !!geocodeResponse, geocodeResponse}); + if (!geocodeResponse) { + return { + ok: false, + tool: "get_weather", + error: `City not found: ${city}`, + city, + lang, + }; + } + const lat = geocodeResponse.lat; + const lon = geocodeResponse.lon; + + const response = (await axios.get("https://api.openweathermap.org/data/2.5/weather", { + params: { + lat, + lon, + units: "metric", + appid: apiKey, + ...(lang ? {lang} : {}), + }, + })).data; + logger.debug("weather_api.done", {city, country: geocodeResponse.country, lang, units: "metric", hasResponse: !!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(":"); + + return { + ok: true, + tool: "get_weather", + scope: { + city, + lang, + validOnlyForExactCity: true, + liveData: true, + note: "If the user asks about another city, call get_weather again.", + }, + weather: { + main: weather.main, + description: weather.description, + temperature: main.temp, + temperatureMax: main.temp_max, + temperatureMin: main.temp_min, + feelsLike: main.feels_like, + humidity: main.humidity, + pressure: main.pressure, + seaLevel: main.sea_level ?? null, + groundLevel: main.grnd_level ?? null, + sunriseUtc: sunrise, + sunsetUtc: sunset, + windDegree: wind.deg, + windSpeed: wind.speed, + }, + }; + } catch (error) { + logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); + logError(error instanceof Error ? error : String(error)); + return null; + } finally { + logger.debug("done", {duration: logger.duration(startedAt)}); + } +} diff --git a/src/ai/tools/web-search.ts b/src/ai/tools/web-search.ts new file mode 100644 index 0000000..e50f5b2 --- /dev/null +++ b/src/ai/tools/web-search.ts @@ -0,0 +1,403 @@ +import axios from "axios"; +import {toolsLogger} from "./tool-logger"; + +const logger = toolsLogger.child("brave-search"); +import {Environment} from "../../common/environment"; +import {logError} from "../../util/utils"; +import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types"; +import {asBoolean, asNonEmptyString} from "./utils"; + +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?: AiJsonValue; + infobox?: AiJsonValue; + locations?: AiJsonValue; + mixed?: AiJsonValue; + summarizer?: AiJsonValue; +}; + +export const WEB_SEARCH_TOOL_NAME = "web_search"; + +export const webSearchTool = { + type: "function", + function: { + name: WEB_SEARCH_TOOL_NAME, + 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 AiTool; + +export const webSearchToolPrompt = [ + "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 `shell_execute` 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"); + +function asIntegerInRange( + value: AiJsonValue | undefined, + 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: AiJsonValue | undefined, + 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: AiJsonValue | undefined): 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: AiJsonValue | undefined): 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"; +} + +export async function webSearch(args?: AiJsonObject) { + const startedAt = Date.now(); + logger.info("start", {args}); + + 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 (error) { + logError(error instanceof Error ? error : String(error)); + + const status = axios.isAxiosError(error) ? error.response?.status : undefined; + const data = axios.isAxiosError(error) ? error.response?.data : undefined; + + return { + ok: false, + status: typeof status === "number" ? status : null, + error: error instanceof Error ? error.message : String(error), + response: data ?? null, + }; + } finally { + logger.debug("done", {duration: logger.duration(startedAt)}); + } +} + +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)) + : [], + }; +} diff --git a/src/ai/transcript-artifact-store.ts b/src/ai/transcript-artifact-store.ts new file mode 100644 index 0000000..af3ffdb --- /dev/null +++ b/src/ai/transcript-artifact-store.ts @@ -0,0 +1,42 @@ +import {AiProvider} from "../model/ai-provider"; +import type {StoredAttachment} from "../model/stored-attachment"; +import type {AiDownloadedFile} from "./telegram-attachments"; +import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store"; + +export async function persistTranscriptArtifactAttachment(params: { + provider: AiProvider; + transcript: string; + downloads: AiDownloadedFile[]; + chatId: number; + messageId: number; +}): Promise { + const text = params.transcript.trim(); + if (!text) return Promise.resolve(undefined); + + const sources = params.downloads + .filter(download => download.kind === "audio" || download.kind === "video-note") + .map(download => ({ + fileId: download.fileId, + fileName: download.fileName, + mimeType: download.mimeType, + sizeBytes: download.sizeBytes ?? download.buffer.length, + sha256: download.sha256, + })); + + return await persistInternalJsonArtifactAttachment({ + artifactKind: "transcript", + fileNamePrefix: "transcript", + chatId: params.chatId, + messageId: params.messageId, + payload: { + provider: params.provider, + transcript: text, + sources, + }, + metadata: { + provider: params.provider, + sourceFileNames: sources.map(source => source.fileName), + transcriptChars: text.length, + }, + }); +} diff --git a/src/ai/unified-ai-request-pipeline.ts b/src/ai/unified-ai-request-pipeline.ts new file mode 100644 index 0000000..333c4cd --- /dev/null +++ b/src/ai/unified-ai-request-pipeline.ts @@ -0,0 +1,319 @@ +import {AiProvider} from "../model/ai-provider"; +import {AI_VOICE_MODE_TRANSCRIPT, DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings"; +import {Environment} from "../common/environment"; +import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline"; +import type {AiDownloadedFile} from "./telegram-attachments"; +import type {TelegramStreamMessage} from "./telegram-stream-message"; +import type {ChatMessage} from "./chat-messages-types"; +import type {OpenAIChatMessage} from "./openai-chat-message"; +import type {MistralChatMessage} from "./mistral-chat-message"; +import type {PreparedDocumentRag} from "./document-rag-pipeline"; +import {prepareDocumentRag} from "./document-rag-pipeline"; +import {persistRagArtifactAttachment} from "./rag-artifact-store"; +import {persistTranscriptArtifactAttachment} from "./transcript-artifact-store"; +import type {ToolRuntimeContext} from "./tools/runtime"; +import { + appendTranscriptToChatMessages, + collectTextMessages, + initialStatus, + providerName, + RuntimeConfigSnapshot, + stripAudioFromRunnerMessages, + toolRuntimeContextFromDownloads, + transcribeAudioIfNeeded, + UnifiedRunOptions, +} from "./unified-ai-runner.shared"; +import {aiLog} from "../logging/ai-logger"; +import {isTranscribableAudioDownload} from "./speech-to-text"; + +export type PreparedUnifiedAiRequest = { + chatMessages: Array; + imageCount: number; + firstRoundStatus: string; + toolContext: ToolRuntimeContext; + preparedDocumentRag?: PreparedDocumentRag; + finishAfterTranscript: boolean; + cleanup: () => Promise; +}; + +type MutablePreparedContext = { + chatMessages: Array; + imageCount: number; + firstRoundStatus: string; + toolContext: ToolRuntimeContext; + transcript: string; + preparedDocumentRag?: PreparedDocumentRag; + finishAfterTranscript: boolean; +}; + +function nowIso(): string { + return new Date().toISOString(); +} + +function runtimeTargetFor(options: UnifiedRunOptions, config: RuntimeConfigSnapshot) { + return options.provider === AiProvider.OLLAMA + ? config.ollamaChatTarget + : options.provider === AiProvider.MISTRAL + ? config.mistralChatTarget + : config.openAiChatTarget; +} + +function createAiRequestPipelineState(options: UnifiedRunOptions): UserRequestPipelineState { + return { + requestId: `ai:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`, + chatId: options.msg.chat.id, + messageId: options.msg.message_id, + replyToMessageId: options.msg.reply_to_message?.message_id, + fromId: options.msg.from?.id ?? 0, + receivedAt: nowIso(), + text: options.text, + settings: { + provider: options.provider, + responseLanguage: options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE, + contextSize: options.contextSize, + voiceMode: options.voiceMode ?? "execute", + imageOutputMode: "photo", + }, + inputAttachments: [], + outputAttachments: [], + artifacts: [], + toolRankDecisions: [], + audit: [], + }; +} + +export async function prepareUnifiedAiRequestPipeline(params: { + options: UnifiedRunOptions; + config: RuntimeConfigSnapshot; + downloads: AiDownloadedFile[]; + streamMessage: TelegramStreamMessage; + controller: AbortController; +}): Promise { + const {options, config, downloads, streamMessage, controller} = params; + const prepared: MutablePreparedContext = { + chatMessages: [], + imageCount: 0, + firstRoundStatus: Environment.waitThinkText, + toolContext: {}, + transcript: "", + finishAfterTranscript: false, + }; + + const stages: UserRequestPipelineStage[] = [ + { + name: "audit_start", + async run() { + return { + stage: "audit_start", + status: "succeeded", + details: { + phase: "ai_request_prepare", + provider: options.provider, + downloads: downloads.map(download => ({ + kind: download.kind, + fileName: download.fileName, + mimeType: download.mimeType, + sizeBytes: download.sizeBytes ?? download.buffer.length, + })), + }, + }; + }, + }, + { + name: "collect_conversation_context", + async run() { + const collected = await collectTextMessages( + options.msg, + options.text, + options.provider, + downloads, + config, + runtimeTargetFor(options, config), + options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE, + ); + prepared.chatMessages = collected.chatMessages as typeof prepared.chatMessages; + prepared.imageCount = collected.imageCount; + prepared.firstRoundStatus = initialStatus(downloads, prepared.imageCount); + prepared.toolContext = toolRuntimeContextFromDownloads(downloads); + + return { + stage: "collect_conversation_context", + status: "succeeded", + }; + }, + }, + { + name: "prepare_text_context", + async run() { + streamMessage.setStatus(prepared.firstRoundStatus); + await streamMessage.flush(); + + return { + stage: "prepare_text_context", + status: "succeeded", + }; + }, + }, + { + name: "resolve_runtime", + async run() { + return { + stage: "resolve_runtime", + status: "succeeded", + }; + }, + }, + { + name: "speech_to_text", + async run() { + prepared.transcript = await transcribeAudioIfNeeded( + options.provider, + options.msg.from?.id, + downloads, + streamMessage, + controller.signal, + ).catch(error => { + if (downloads.some(isTranscribableAudioDownload)) throw error; + return ""; + }); + + const transcript = prepared.transcript.trim(); + if (!transcript) { + return { + stage: "speech_to_text", + status: "skipped", + }; + } + + const transcriptArtifact = await persistTranscriptArtifactAttachment({ + provider: options.provider, + transcript, + downloads, + chatId: options.msg.chat.id, + messageId: options.msg.message_id, + }); + if (transcriptArtifact) { + await streamMessage.storeInternalAttachment(transcriptArtifact); + } + + if (options.voiceMode === AI_VOICE_MODE_TRANSCRIPT) { + prepared.finishAfterTranscript = true; + streamMessage.replaceText(`[Расшифровка]\n${transcript}`); + await streamMessage.finish(); + return { + stage: "speech_to_text", + status: "succeeded", + fallbackAction: "continue_without_stage", + }; + } + + appendTranscriptToChatMessages(prepared.chatMessages, transcript); + stripAudioFromRunnerMessages(prepared.chatMessages); + aiLog("debug", "request.transcript.appended", { + provider: providerName(options.provider), + transcriptChars: transcript.length, + chatMessages: prepared.chatMessages.length, + }); + + return { + stage: "speech_to_text", + status: "succeeded", + }; + }, + }, + { + name: "document_rag", + async run() { + if (prepared.finishAfterTranscript) { + return { + stage: "document_rag", + status: "skipped", + }; + } + + prepared.preparedDocumentRag = await prepareDocumentRag( + options.provider, + downloads, + prepared.chatMessages, + streamMessage, + config, + controller.signal, + options.text, + ); + + const ragArtifact = await persistRagArtifactAttachment({ + provider: options.provider, + prepared: prepared.preparedDocumentRag, + downloads, + chatId: options.msg.chat.id, + messageId: options.msg.message_id, + details: prepared.preparedDocumentRag?.provider === AiProvider.OPENAI + ? {uploadedFileIds: prepared.preparedDocumentRag.uploadedFileIds} + : prepared.preparedDocumentRag?.provider === AiProvider.OLLAMA + ? { + embeddingModel: config.ollamaDocumentsTarget.model, + topK: config.ollamaRagTopK, + chunkSize: config.ollamaRagChunkSize, + chunkOverlap: config.ollamaRagChunkOverlap, + maxContextChars: config.ollamaRagMaxContextChars, + artifact: prepared.preparedDocumentRag.artifact, + } + : undefined, + }); + if (ragArtifact) { + await streamMessage.storeInternalAttachment(ragArtifact); + } + + return { + stage: "document_rag", + status: prepared.preparedDocumentRag ? "succeeded" : "skipped", + }; + }, + }, + { + name: "audit_finish", + async run() { + return { + stage: "audit_finish", + status: "succeeded", + details: { + phase: "ai_request_prepare", + chatMessages: prepared.chatMessages.length, + imageCount: prepared.imageCount, + hasTranscript: !!prepared.transcript.trim(), + hasDocumentRag: !!prepared.preparedDocumentRag, + finishAfterTranscript: prepared.finishAfterTranscript, + }, + }; + }, + }, + ]; + + const state = createAiRequestPipelineState(options); + const pipeline = new UserRequestPipeline({ + stages, + stageNames: [ + "audit_start", + "collect_conversation_context", + "prepare_text_context", + "resolve_runtime", + "speech_to_text", + "document_rag", + "audit_finish", + ], + }); + await pipeline.run(state, controller.signal); + await streamMessage.storePipelineAudit(state.audit); + + return { + chatMessages: prepared.chatMessages, + imageCount: prepared.imageCount, + firstRoundStatus: prepared.firstRoundStatus, + toolContext: prepared.toolContext, + preparedDocumentRag: prepared.preparedDocumentRag, + finishAfterTranscript: prepared.finishAfterTranscript, + cleanup: async () => { + await prepared.preparedDocumentRag?.cleanup(); + }, + }; +} diff --git a/src/ai/unified-ai-response-pipeline.ts b/src/ai/unified-ai-response-pipeline.ts new file mode 100644 index 0000000..f3a0d8c --- /dev/null +++ b/src/ai/unified-ai-response-pipeline.ts @@ -0,0 +1,355 @@ +import {AiProvider} from "../model/ai-provider"; +import {Environment} from "../common/environment"; +import {ifTrue, logError} from "../util/utils"; +import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline"; +import type {AiDownloadedFile} from "./telegram-attachments"; +import type {TelegramStreamMessage} from "./telegram-stream-message"; +import type {PreparedUnifiedAiRequest} from "./unified-ai-request-pipeline"; +import type {OpenAIChatMessage} from "./openai-chat-message"; +import type {MistralChatMessage} from "./mistral-chat-message"; +import type {ChatMessage} from "./chat-messages-types"; +import { + providerName, + RuntimeConfigSnapshot, + snapshotModel, + TELEGRAM_LIMIT, + UnifiedRunOptions, +} from "./unified-ai-runner.shared"; +import {runOpenAi} from "./unified-ai-runner.openai"; +import {runOllama} from "./unified-ai-runner.ollama"; +import {runMistral} from "./unified-ai-runner.mistral"; +import { + resolveTextToSpeechProviderForUser, + sendSynthesizedSpeech, + speechToOutputAttachmentRecord, + synthesizeSpeech +} from "./text-to-speech"; +import {persistFinalTextArtifactAttachment} from "./final-response-artifact-store"; +import {aiLog} from "../logging/ai-logger"; + +function nowIso(): string { + return new Date().toISOString(); +} + +function createResponsePipelineState(options: UnifiedRunOptions): UserRequestPipelineState { + return { + requestId: `ai-response:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`, + chatId: options.msg.chat.id, + messageId: options.msg.message_id, + replyToMessageId: options.msg.reply_to_message?.message_id, + fromId: options.msg.from?.id ?? 0, + receivedAt: nowIso(), + text: options.text, + settings: { + provider: options.provider, + responseLanguage: options.responseLanguage ?? "default", + contextSize: options.contextSize, + voiceMode: options.voiceMode ?? "execute", + imageOutputMode: "photo", + }, + inputAttachments: [], + outputAttachments: [], + artifacts: [], + toolRankDecisions: [], + audit: [], + }; +} + +async function runProviderModelCall(params: { + options: UnifiedRunOptions; + config: RuntimeConfigSnapshot; + downloads: AiDownloadedFile[]; + prepared: PreparedUnifiedAiRequest; + streamMessage: TelegramStreamMessage; + signal: AbortSignal; +}): Promise { + const {options, config, downloads, prepared, streamMessage, signal} = params; + const preparedDocumentRag = prepared.preparedDocumentRag; + const documents = preparedDocumentRag?.provider === AiProvider.MISTRAL ? preparedDocumentRag.documents : []; + + aiLog("info", "request.provider.dispatch", {provider: providerName(options.provider)}); + + switch (options.provider) { + case AiProvider.OPENAI: + await runOpenAi( + options.msg, + prepared.chatMessages as OpenAIChatMessage[], + streamMessage, + signal, + options.stream ?? true, + options.msg, + config, + prepared.toolContext, + downloads, + preparedDocumentRag?.provider === AiProvider.OPENAI ? preparedDocumentRag : undefined, + ); + return; + case AiProvider.OLLAMA: + if (config.ollamaChatTarget.model?.includes("gpt-oss") && options.think) { + options.think = "high"; + } + + await runOllama( + options.msg, + prepared.chatMessages as ChatMessage[], + streamMessage, + signal, + ifTrue(options.stream), + options.think ?? false, + prepared.firstRoundStatus, + config, + prepared.toolContext, + options.contextSize, + ); + return; + case AiProvider.MISTRAL: + await runMistral( + options.msg, + prepared.chatMessages as MistralChatMessage[], + documents, + streamMessage, + signal, + options.stream ?? true, + prepared.firstRoundStatus, + config, + prepared.toolContext, + ); + } +} + +async function synthesizeResponseIfRequested(params: { + options: UnifiedRunOptions; + config: RuntimeConfigSnapshot; + streamMessage: TelegramStreamMessage; +}): Promise<"succeeded" | "skipped" | "failed"> { + const {options, config, streamMessage} = params; + + if (!options.synthesizeSpeechResponse) return "skipped"; + const text = streamMessage.getText().trim(); + if (!text) return "skipped"; + + try { + if (!options.msg.from?.id) { + throw new Error(Environment.couldNotIdentifyUserForSpeechToTextText); + } + + const resolved = await resolveTextToSpeechProviderForUser(options.msg.from.id, options.provider) + .catch(() => resolveTextToSpeechProviderForUser(options.msg.from!.id)); + const speech = await synthesizeSpeech({provider: resolved.provider, text}); + const sent = await sendSynthesizedSpeech(options.msg, speech); + streamMessage.recordOutputAttachment(speechToOutputAttachmentRecord(speech, sent.message_id)); + return "succeeded"; + } catch (error) { + aiLog("error", "text_to_speech.failed", { + provider: providerName(options.provider), + model: snapshotModel(options.provider, config), + error: error instanceof Error ? error.message : String(error), + }); + return "failed"; + } +} + +export async function runUnifiedAiResponsePipeline(params: { + options: UnifiedRunOptions; + config: RuntimeConfigSnapshot; + downloads: AiDownloadedFile[]; + prepared: PreparedUnifiedAiRequest; + streamMessage: TelegramStreamMessage; + controller: AbortController; +}): Promise { + const {options, config, downloads, prepared, streamMessage, controller} = params; + const state = createResponsePipelineState(options); + + const stages: UserRequestPipelineStage[] = [ + { + name: "audit_start", + async run() { + return { + stage: "audit_start", + status: "succeeded", + details: { + phase: "ai_response", + provider: options.provider, + model: snapshotModel(options.provider, config), + chatMessages: prepared.chatMessages.length, + hasDocumentRag: !!prepared.preparedDocumentRag, + }, + }; + }, + }, + { + name: "model_call", + async run() { + await runProviderModelCall({ + options, + config, + downloads, + prepared, + streamMessage, + signal: controller.signal, + }); + + return { + stage: "model_call", + status: "succeeded", + }; + }, + }, + { + name: "tool_loop", + async run() { + const executions = streamMessage.getToolExecutions(); + return { + stage: "tool_loop", + status: executions.length ? "succeeded" : "skipped", + fallbackAction: executions.length ? undefined : "continue_without_stage", + details: { + count: executions.length, + tools: executions.map(execution => ({ + toolName: execution.toolName, + callId: execution.callId, + resultChars: execution.resultChars, + })), + }, + artifacts: executions.length ? [{ + kind: "tool_result", + stage: "tool_loop", + createdAt: new Date().toISOString(), + toolName: "summary", + callId: "tool_loop_summary", + resultText: JSON.stringify({ + count: executions.length, + tools: executions.map(execution => ({ + toolName: execution.toolName, + callId: execution.callId, + resultChars: execution.resultChars, + })), + }), + }] : undefined, + }; + }, + }, + { + name: "output_size_gate", + async run() { + const originalChars = streamMessage.getText().length; + if (originalChars > TELEGRAM_LIMIT) { + streamMessage.replaceText(streamMessage.getText().slice(0, TELEGRAM_LIMIT - 3) + "..."); + } + + return { + stage: "output_size_gate", + status: originalChars > TELEGRAM_LIMIT ? "fallback" : "succeeded", + fallbackAction: originalChars > TELEGRAM_LIMIT ? "notify_user" : undefined, + }; + }, + }, + { + name: "send_response", + async run() { + await streamMessage.finish(); + return { + stage: "send_response", + status: "succeeded", + }; + }, + }, + { + name: "persist_output_artifacts", + async run() { + const outputAttachments = streamMessage.getOutputAttachments(); + const artifact = await persistFinalTextArtifactAttachment({ + provider: options.provider, + model: snapshotModel(options.provider, config), + text: streamMessage.getText(), + chatId: options.msg.chat.id, + messageId: options.msg.message_id, + }); + + if (artifact) { + await streamMessage.storeInternalAttachment(artifact); + } + + return { + stage: "persist_output_artifacts", + status: artifact || outputAttachments.length ? "succeeded" : "skipped", + details: { + finalTextPersisted: !!artifact, + outputAttachments, + }, + }; + }, + }, + { + name: "text_to_speech", + async run() { + const status = await synthesizeResponseIfRequested({options, config, streamMessage}); + return { + stage: "text_to_speech", + status, + fallbackAction: status === "failed" ? "continue_without_stage" : undefined, + }; + }, + }, + { + name: "audit_finish", + async run() { + return { + stage: "audit_finish", + status: "succeeded", + details: { + phase: "ai_response", + textChars: streamMessage.getText().length, + toolExecutions: streamMessage.getToolExecutions().length, + outputAttachments: streamMessage.getOutputAttachments().length, + }, + }; + }, + }, + ]; + + const responsePipeline = new UserRequestPipeline({ + stages, + stageNames: [ + "audit_start", + "model_call", + "tool_loop", + "output_size_gate", + "send_response", + "text_to_speech", + "persist_output_artifacts", + "audit_finish", + ], + }); + + try { + await responsePipeline.run(state, controller.signal); + await streamMessage.storePipelineAudit(state.audit); + } catch (error) { + await streamMessage.storePipelineAudit(state.audit).catch(logError); + throw error; + } finally { + const cleanupState = createResponsePipelineState(options); + const cleanupPipeline = new UserRequestPipeline({ + stages: [{ + name: "cleanup", + async run() { + await prepared.cleanup(); + return { + stage: "cleanup", + status: "succeeded", + }; + }, + }], + stageNames: ["cleanup"], + }); + + try { + await cleanupPipeline.run(cleanupState, controller.signal); + await streamMessage.storePipelineAudit(cleanupState.audit); + } catch (error) { + await streamMessage.storePipelineAudit(cleanupState.audit).catch(logError); + logError(error instanceof Error ? error : String(error)); + } + } +} diff --git a/src/ai/unified-ai-runner.mistral.ts b/src/ai/unified-ai-runner.mistral.ts new file mode 100644 index 0000000..4de138d --- /dev/null +++ b/src/ai/unified-ai-runner.mistral.ts @@ -0,0 +1,191 @@ +import {Environment} from "../common/environment"; +import {getMistralTools} from "./tool-mappers"; +import {TelegramStreamMessage} from "./telegram-stream-message"; +import {ToolRuntimeContext} from "./tools/runtime"; +import {MistralChatMessage} from "./mistral-chat-message"; +import {createMistralClient} from "./ai-runtime-target"; +import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; +import {AiProvider} from "../model/ai-provider"; +import {ToolRanker} from "./unified-ai-runner.tool-ranker"; + +import { + contentFromMistralDelta, + executeToolBatch, + MAX_TOOL_ROUNDS, + MistralDeltaLike, + MistralDocumentReference, + mistralToolCalls, + normalizeMistralToolCalls, + roundStatus, + RuntimeConfigSnapshot, + StreamingToolCallAccumulator, + ToolCallData, + ToolExecutionMemory +} from "./unified-ai-runner.shared"; +import {Message} from "typescript-telegram-bot-api"; +import {filterRankedTools, latestUserTextFromMessages} from "./tool-ranker-pipeline"; +import {storeToolRankAudit} from "./tool-rank-audit"; + +export async function runMistral( + msg: Message, + messages: MistralChatMessage[], + documents: MistralDocumentReference[], + streamMessage: TelegramStreamMessage, + signal: AbortSignal, + stream: boolean, + firstRoundStatus: string, + config: RuntimeConfigSnapshot, + toolContext: ToolRuntimeContext, +): Promise { + const runnerStartedAt = Date.now(); + const mistralAi = createMistralClient(config.mistralChatTarget); + const toolRanker = new ToolRanker(config); + const availableTools = getMistralTools(msg.from?.id === Environment.CREATOR_ID); + aiLog("info", "mistral.run.start", { + stream, + target: aiLogProviderTarget(config.mistralChatTarget), + inputMessages: messages.length, + documents: documents.length, + hasToolInputFiles: !!toolContext.pythonInputFiles?.length, + }); + + const toolMemory: ToolExecutionMemory = new Map(); + + for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { + const roundStartedAt = Date.now(); + aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream}); + if (signal.aborted) throw new Error("Aborted"); + + streamMessage.setStatus(Environment.getSelectingToolsText()); + await streamMessage.flush(); + const toolRankStartedAt = Date.now(); + const toolRankStartedAtIso = new Date().toISOString(); + const rankerSelection = await toolRanker.selectTools({ + provider: AiProvider.MISTRAL, + userQuery: latestUserTextFromMessages(messages), + availableTools, + round, + signal, + }) + .catch(async error => { + streamMessage.clearStatus(); + await streamMessage.flush(); + await storeToolRankAudit({ + streamMessage, + provider: AiProvider.MISTRAL, + model: config.mistralChatTarget.model, + round, + startedAt: toolRankStartedAt, + startedAtIso: toolRankStartedAtIso, + error, + }); + throw error; + }); + streamMessage.clearStatus(); + await streamMessage.flush(); + await storeToolRankAudit({ + streamMessage, + provider: AiProvider.MISTRAL, + model: config.mistralChatTarget.model, + round, + startedAt: toolRankStartedAt, + startedAtIso: toolRankStartedAtIso, + selectedTools: rankerSelection.toolNames, + }); + const filteredTools = filterRankedTools(availableTools, rankerSelection.toolNames); + const requestTools = filteredTools.length ? filteredTools : undefined; + + streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? ""); + await streamMessage.flush(); + + if (!stream) { + const request = { + model: config.mistralChatTarget.model, + messages, + tools: requestTools, + documents: documents + } as Parameters[0]; + const response = await mistralAi.chat.complete(request, {signal}); + const message = response.choices?.[0]?.message; + const text = typeof message?.content === "string" ? message.content : JSON.stringify(message?.content ?? ""); + streamMessage.append(text); + const calls = normalizeMistralToolCalls(mistralToolCalls(message)); + aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", { + round, + duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), + textChars: text.length, + calls: calls.map(aiLogToolCall), + }); + if (!calls.length) return; + messages.push({ + role: "assistant", + content: text, + toolCalls: calls.map(call => ({ + id: call.id, + function: {name: call.name, arguments: call.argumentsText}, + })), + }); + const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory); + for (const [index, call] of calls.entries()) { + messages.push({ + role: "tool", + name: call.name, + toolCallId: call.id, + content: toolResults[index] ?? "", + }); + } + continue; + } + + const request = { + model: config.mistralChatTarget.model, + messages, + tools: requestTools, + documents: documents + } as Parameters[0]; + const streamResponse = await mistralAi.chat.stream(request, {signal}); + aiLog("debug", "mistral.stream.open", {round}); + let calls: ToolCallData[] = []; + const roundTextStart = streamMessage.getText().length; + const toolCallAccumulator = new StreamingToolCallAccumulator("mistral_stream", round); + + for await (const event of streamResponse) { + if (signal.aborted) throw new Error("Aborted"); + + const choice = event.data?.choices?.[0]; + const delta = choice?.delta; + const mistralDelta = delta as MistralDeltaLike; + + streamMessage.append(contentFromMistralDelta(mistralDelta)); + + const rawDeltaCalls = mistralToolCalls(mistralDelta); + if (rawDeltaCalls.length) { + calls = toolCallAccumulator.add(rawDeltaCalls); + streamMessage.setStatus(Environment.getUseToolText(calls)); + await streamMessage.flush(); + } + } + aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", { + round, + duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), + textChars: streamMessage.getText().slice(roundTextStart).length, + calls: calls.map(aiLogToolCall), + }); + if (!calls.length) return; + const roundText = streamMessage.getText().slice(roundTextStart); + messages.push({ + role: "assistant", + content: roundText, + toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}})) + }); + const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory); + for (const [index, call] of calls.entries()) { + messages.push({ + role: "tool", + name: call.name, + toolCallId: call.id, + content: toolResults[index] ?? "", + }); + } + } +} diff --git a/src/ai/unified-ai-runner.ollama.ts b/src/ai/unified-ai-runner.ollama.ts new file mode 100644 index 0000000..98997b3 --- /dev/null +++ b/src/ai/unified-ai-runner.ollama.ts @@ -0,0 +1,479 @@ +// Ollama provider runner extracted from unified-ai-runner.ts. +import * as fs from "node:fs"; +import path from "node:path"; +import {Environment} from "../common/environment"; +import type {BoundaryValue} from "../common/boundary-types"; +import {bot, notesDir} from "../index"; +import {clamp, logError} from "../util/utils"; +import {getOllamaTools} from "./tool-mappers"; +import {TelegramStreamMessage} from "./telegram-stream-message"; +import {ChatMessage} from "./chat-messages-types"; +import {ChatRequest, Tool} from "ollama"; +import {ToolRuntimeContext} from "./tools/runtime"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; +import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils"; +import {createOllamaClient} from "./ai-runtime-target"; +import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; + +import { + allToolSchemaNames, + appendOllamaToolResults, + dedupeToolCalls, + DEFAULT_OLLAMA_CONTEXT_SIZE, + executeToolBatch, + isOllamaModelActive, + isRecord, + MAX_OLLAMA_CONTEXT_SIZE, + MAX_TOOL_ROUNDS, + MIN_OLLAMA_CONTEXT_SIZE, + normalizeOllamaToolCalls, + OllamaToolCallLike, + roundStatus, + RuntimeConfigSnapshot, + safeJsonParseObject, + Think, + ToolCallData, + ToolExecutionMemory +} from "./unified-ai-runner.shared"; +import {ToolRanker} from "./unified-ai-runner.tool-ranker"; +import {getToolPrompts} from "./tools/registry"; +import {filterRankedTools, latestUserTextFromMessages} from "./tool-ranker-pipeline"; +import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/notes"; +import {getModelCapabilities} from "./provider-model-runtime"; +import {AiProvider} from "../model/ai-provider"; +import {Message} from "typescript-telegram-bot-api"; +import {storeToolRankAudit} from "./tool-rank-audit"; + +export async function runOllama( + msg: Message, + messages: ChatMessage[], + streamMessage: TelegramStreamMessage, + signal: AbortSignal, + stream: boolean, + think: Think, + firstRoundStatus: string, + config: RuntimeConfigSnapshot, + toolContext: ToolRuntimeContext, + contextSize?: number, +): Promise { + const runnerStartedAt = Date.now(); + + const audioCount = messages.reduce((sum, m) => sum + (m.audioParts?.length || m.audios?.length || 0), 0); + const videoNoteCount = messages.reduce((sum, m) => sum + (m.videoNotes?.length ?? 0), 0); + const imageCount = messages.reduce((sum, m) => sum + (m.imageParts?.length || m.images?.length || 0), 0); + + const target = (audioCount || videoNoteCount) ? config.ollamaAudioTarget : + imageCount ? config.ollamaVisionTarget : + think ? config.ollamaThinkingTarget : config.ollamaChatTarget; + const model = target.model; + aiLog("info", "ollama.run.start", { + stream, + think, + target: aiLogProviderTarget(target), + requestedContextSize: contextSize, + message: aiLogMessageIdentity(msg), + counts: {messages: messages.length, images: imageCount, audio: audioCount, videoNotes: videoNoteCount}, + hasToolInputFiles: !!toolContext.pythonInputFiles?.length, + }); + + const ollama = createOllamaClient(target); + const modelInfo = await ollama.show({model}); + const modelInfoMap: Record = isRecord(modelInfo.model_info) ? modelInfo.model_info : {}; + const contextKey = Object.keys(modelInfoMap).find(k => k.endsWith(".context_length")); + const rawMaxContextLength = contextKey ? modelInfoMap[contextKey] : undefined; + const parsedMaxContextLength = + typeof rawMaxContextLength === "number" + ? rawMaxContextLength + : typeof rawMaxContextLength === "string" + ? Number(rawMaxContextLength) + : DEFAULT_OLLAMA_CONTEXT_SIZE; + + const maxContextLength = Number.isFinite(parsedMaxContextLength) + ? parsedMaxContextLength + : DEFAULT_OLLAMA_CONTEXT_SIZE; + + const context = clamp( + contextSize === -1 ? MAX_OLLAMA_CONTEXT_SIZE : contextSize ?? DEFAULT_OLLAMA_CONTEXT_SIZE, + MIN_OLLAMA_CONTEXT_SIZE, + maxContextLength ?? DEFAULT_OLLAMA_CONTEXT_SIZE + ); + aiLog("debug", "ollama.context.resolved", {model, contextKey, maxContextLength, context}); + + const modelsToLoad = [model]; + + try { + const activeModels = (await ollama.ps()).models.map(m => m.model); + const oldSet = new Set(activeModels); + const newSet = new Set(modelsToLoad); + + const added = modelsToLoad.filter(m => !oldSet.has(m)); + const removed = activeModels.filter(m => !newSet.has(m)); + const diff = [...added, ...removed]; + aiLog("debug", "ollama.models.active", {activeModels, requiredModels: modelsToLoad, added, removed}); + if (diff.length) { + aiLog("info", "ollama.models.unload_extra", {keep: modelsToLoad, diff}); + await unloadAllOllamaModels(ollama, modelsToLoad); + } + } catch (e) { + logError(e instanceof Error ? e : String(e)); + } + + if (!(await isOllamaModelActive(ollama, target))) { + const loadStartedAt = Date.now(); + aiLog("info", "ollama.model.load.start", {model, context}); + const currentStatus = streamMessage.getStatus(); + streamMessage.setStatus(Environment.getLoadingModelText(model)); + await streamMessage.flush(); + if (await loadOllamaModel(model, ollama, context)) { + aiLog("success", "ollama.model.load.done", {model, duration: aiLogDuration(loadStartedAt)}); + streamMessage.setStatus(currentStatus ?? Environment.waitThinkText); + await streamMessage.flush(); + } + } else { + aiLog("debug", "ollama.model.already_loaded", {model}); + } + + let interval: ReturnType | null = null; + + if (!stream) { + let typingInFlight = false; + const applyTyping = async () => { + if (typingInFlight) return; + typingInFlight = true; + try { + await enqueueTelegramApiCall( + () => bot.sendChatAction({chat_id: msg.chat.id, action: "typing"}), + {method: "sendChatAction", chatId: msg.chat.id, chatType: msg.chat.type} + ).catch(logError); + } finally { + typingInFlight = false; + } + }; + + await applyTyping(); + interval = setInterval(() => { + applyTyping().catch(logError); + }, 5000); + } + + const toolMemory: ToolExecutionMemory = new Map(); + + try { + for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { + const roundStartedAt = Date.now(); + aiLog("debug", "ollama.round.start", { + round, + context, + messages: messages.length, + stream, + think: audioCount ? false : think, + }); + + const request: ChatRequest = { + model: model, + messages: messages, + think: audioCount ? false : think, + options: { + temperature: 0.7, + top_p: 0.9, + top_k: 40, + num_ctx: 16384 + } + }; + + let activeToolNames: string[] = []; + if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) { + const availableOllamaTools: Tool[] = getOllamaTools(msg.from?.id === Environment.CREATOR_ID) as Tool[]; + + aiLog("debug", "ollama.tools.available", { + round, + tools: allToolSchemaNames(availableOllamaTools), + rankerEnabled: !!config.ollamaToolRankerTarget, + }); + + streamMessage.setStatus(Environment.getSelectingToolsText()); + await streamMessage.flush(); + const toolRankStartedAt = Date.now(); + const toolRankStartedAtIso = new Date().toISOString(); + const rankerSelection = await new ToolRanker(config).selectTools({ + provider: AiProvider.OLLAMA, + userQuery: latestUserTextFromMessages(messages), + availableTools: availableOllamaTools, + round, + signal, + }) + .catch(async error => { + streamMessage.clearStatus(); + await streamMessage.flush(); + await storeToolRankAudit({ + streamMessage, + provider: AiProvider.OLLAMA, + model, + round, + startedAt: toolRankStartedAt, + startedAtIso: toolRankStartedAtIso, + error, + }); + throw error; + }); + streamMessage.clearStatus(); + await streamMessage.flush(); + await storeToolRankAudit({ + streamMessage, + provider: AiProvider.OLLAMA, + model, + round, + startedAt: toolRankStartedAt, + startedAtIso: toolRankStartedAtIso, + selectedTools: rankerSelection.toolNames, + }); + + const filteredTools = [...new Set(filterRankedTools(availableOllamaTools, rankerSelection.toolNames))]; + activeToolNames = filteredTools.map(t => t.function.name ?? ""); + if (filteredTools.length > 0) { + request.tools = [...filteredTools]; + request.options = { + ...request.options, + temperature: 0 + }; + + const newMessage = messages[messages.length - 1]; + if (newMessage) { + newMessage.content += "\n" + "Suggested tools to call: " + activeToolNames.join(", "); + } + + const systemMessage = messages.find(m => m.role === "system"); + if (systemMessage) { + systemMessage.content += "\n\n" + getToolPrompts(activeToolNames).join("\n\n"); + } + + request.model = config.ollamaToolTarget.model; + } else { + delete request.tools; + } + + aiLog("debug", "ollama.tools.selected", { + round, + tools: activeToolNames, + count: activeToolNames.length, + usedRanker: rankerSelection.usedRanker, + }); + } + + if (!stream) { + const response = await ollama.chat({ + ...request, + stream: false + }); + + const message = response.message; + const rawContent = message?.content ?? ""; + + const nativeCalls = dedupeToolCalls( + normalizeOllamaToolCalls( + message?.tool_calls as readonly OllamaToolCallLike[] | undefined, + round, + ), + ); + + const responseText = rawContent; + + // if (looksLikeToolRankerJson(responseText)) { + // aiLog("error", "ollama.response.looks_like_tool_ranker_json", { + // round, + // preview: responseText.slice(0, 800), + // target: aiLogProviderTarget(target), + // }); + // throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up."); + // } + + streamMessage.append(responseText); + + aiLog("debug", "ollama.response.received", { + round, + duration: aiLogDuration(roundStartedAt), + textChars: responseText.length, + nativeToolCallCount: nativeCalls.length, + }); + + if (!nativeCalls.length) { + aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)}); + break; + } + + const calls = nativeCalls; + + aiLog("info", "ollama.tool_calls", { + round, + calls: calls.map(aiLogToolCall), + }); + + messages.push({ + role: "assistant", + content: responseText, + tool_calls: calls.map(c => ({ + function: { + name: c.name, + arguments: safeJsonParseObject(c.argumentsText), + }, + })), + }); + + appendOllamaToolResults( + messages, + calls, + await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory), + ); + + continue; + } + + aiLog("debug", "ollama.stream.messages", { + round, + messageCount: request.messages?.length ?? 0, + }); + const response = await ollama.chat({ + ...request, + stream: true + }); + + aiLog("debug", "ollama.stream.open", {round}); + const calls: ToolCallData[] = []; + const roundTextStart = streamMessage.getText().length; + const abortOllamaResponse = () => response.abort?.(); + signal.addEventListener("abort", abortOllamaResponse, {once: true}); + if (signal.aborted) abortOllamaResponse(); + try { + for await (const chunk of response) { + aiLog("trace", "ollama.stream.chunk", { + round, + contentPreview: chunk.message.content?.slice(0, 240), + hasToolCalls: !!chunk.message.tool_calls?.length, + hasThinking: !!chunk.message.thinking, + }); + + const localToolCalls: ToolCallData[] = []; + + localToolCalls.push(...normalizeOllamaToolCalls( + chunk.message.tool_calls as readonly OllamaToolCallLike[] | undefined, + round, + )); + + const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking); + const previousStatus = streamMessage.getStatus(); + if (newStatus && newStatus !== Environment.waitThinkText) { + streamMessage.setStatus(newStatus); + } else { + streamMessage.clearStatus(); + } + + if (streamMessage.getStatus() !== previousStatus && previousStatus && newStatus !== Environment.waitThinkText) { + await streamMessage.flush(); + } + + if (signal.aborted) { + response.abort?.(); + throw new Error("Aborted"); + } + + if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) { + streamMessage.append(chunk.message?.content ?? ""); + } + + calls.push(...normalizeOllamaToolCalls( + chunk.message?.tool_calls as readonly OllamaToolCallLike[] | undefined, + round, + )); + + if (chunk.done) { + aiLog("debug", "ollama.stream.done", { + round, + duration: aiLogDuration(roundStartedAt), + textChars: streamMessage.getText().slice(roundTextStart).length, + toolCallCount: calls.length, + }); + await streamMessage.flush(streamMessage.regenerateKeyboard(), true); + } + } + } finally { + signal.removeEventListener("abort", abortOllamaResponse); + } + + // const streamedRoundText = streamMessage.getText().slice(roundTextStart); + // if (!calls.length && looksLikeToolRankerJson(streamedRoundText)) { + // streamMessage.replaceText(streamMessage.getText().slice(0, roundTextStart)); + // aiLog("error", "ollama.response.looks_like_tool_ranker_json", { + // round, + // preview: streamedRoundText.slice(0, 800), + // target: aiLogProviderTarget(target), + // }); + // throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up."); + // } + + if (!calls.length) { + aiLog("success", "ollama.run.done", { + round, + duration: aiLogDuration(runnerStartedAt), + }); + + break; + } + + calls.splice(0, calls.length, ...dedupeToolCalls(calls)); + + aiLog("info", "ollama.tool_calls", { + round, + calls: calls.map(aiLogToolCall), + }); + + const roundText = streamMessage.getText().slice(roundTextStart); + + messages.push({ + role: "assistant", + content: roundText, + tool_calls: calls.map(c => ({ + function: { + name: c.name, + arguments: safeJsonParseObject(c.argumentsText), + }, + })), + }); + + const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory); + + let successGetNoteFileResult: GetNoteFileResult | undefined = undefined; + + for (const toolResult of toolResults) { + try { + const raw = JSON.parse(toolResult); + const res = GetNoteFileResultSchema.safeParse(raw); + + if (res.success && res.data.success) { + successGetNoteFileResult = res.data; + } + } catch { + // Not every tool result is JSON. + } + } + + if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) { + const attachmentPath = path.join(notesDir, successGetNoteFileResult.attachment.relativePath); + if (!fs.existsSync(attachmentPath)) { + throw new Error(`Attachment file does not exist: ${attachmentPath}`); + } + + await bot.sendDocument({ + chat_id: msg.chat.id, + reply_parameters: { + message_id: msg.message_id, + }, + document: fs.createReadStream(attachmentPath), + }).catch(logError); + } + + appendOllamaToolResults(messages, calls, toolResults); + } + } finally { + if (interval) clearInterval(interval); + } +} diff --git a/src/ai/unified-ai-runner.openai.ts b/src/ai/unified-ai-runner.openai.ts new file mode 100644 index 0000000..096c278 --- /dev/null +++ b/src/ai/unified-ai-runner.openai.ts @@ -0,0 +1,615 @@ +import {Message} from "typescript-telegram-bot-api"; +import {OpenAI, toFile} from "openai"; +import {Environment} from "../common/environment"; +import {TelegramStreamMessage} from "./telegram-stream-message"; +import {ToolRuntimeContext} from "./tools/runtime"; +import {OpenAIChatMessage} from "./openai-chat-message"; +import type { + ResponseCreateParamsNonStreaming, + ResponseCreateParamsStreaming, + ResponseInputItem, + ResponseStreamEvent +} from "openai/resources/responses/responses"; +import {createOpenAiClient} from "./ai-runtime-target"; +import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; + +import { + AsyncIterableStream, + buildSystemInstruction, + collectOpenAiResponseCodeInterpreterCalls, + collectOpenAiResponseFunctionCalls, + collectOpenAiResponseImages, + collectOpenAiResponseText, + executeToolBatch, + getOpenAIResponsesToolsWithImage, + MAX_TOOL_ROUNDS, + OPENAI_IMAGE_PARTIALS, + openAiResponseItemCallId, + OpenAiResponseLike, + OpenAiResponseOutputItem, + RuntimeConfigSnapshot, + safeJsonParseObject, + showOpenAiGeneratedImage, + ToolCallData, + ToolExecutionMemory, + errorMessage, + allToolSchemaNames +} from "./unified-ai-runner.shared"; +import {bot} from "../index"; +import fs from "node:fs"; +import path from "node:path"; +import {logError} from "../util/utils"; +import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files"; +import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings"; +import {AiDownloadedFile} from "./telegram-attachments"; +import {ToolRanker} from "./unified-ai-runner.tool-ranker"; +import {AiProvider} from "../model/ai-provider"; +import {filterRankedTools, latestUserTextFromMessages} from "./tool-ranker-pipeline"; +import {storeToolRankAudit} from "./tool-rank-audit"; + +export async function runOpenAi( + msg: Message, + messages: OpenAIChatMessage[], + streamMessage: TelegramStreamMessage, + signal: AbortSignal, + stream: boolean, + sourceMessage: Message, + config: RuntimeConfigSnapshot, + toolContext: ToolRuntimeContext, + downloads: AiDownloadedFile[] = [], + documentRag?: OpenAiDocumentRagContext, +): Promise { + const runnerStartedAt = Date.now(); + let responseInput: Array = [...messages] as Array; + const openAi = createOpenAiClient(config.openAiChatTarget); + const ownsDocumentRag = !documentRag; + const preparedDocumentRag = documentRag ?? await prepareOpenAiDocumentRag(openAi, downloads.filter(download => download.kind === "document")); + const toolRanker = new ToolRanker(config); + const availableTools = getOpenAIResponsesToolsWithImage( + config, + msg.from?.id === Environment.CREATOR_ID, + preparedDocumentRag?.vectorStoreIds ?? [], + ); + + const systemPrompt = buildSystemInstruction( + config, + DEFAULT_AI_RESPONSE_LANGUAGE, + false, + config.openAiChatTarget.systemPromptAdditions, + ); + + aiLog("info", "openai.run.start", { + stream, + target: aiLogProviderTarget(config.openAiChatTarget), + imageTarget: aiLogProviderTarget(config.openAiImageTarget), + inputMessages: messages.length, + sourceMessage: aiLogMessageIdentity(sourceMessage), + hasToolInputFiles: !!toolContext.pythonInputFiles?.length, + }); + + const toolMemory: ToolExecutionMemory = new Map(); + + try { + for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { + const roundStartedAt = Date.now(); + aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream}); + streamMessage.setStatus(Environment.getSelectingToolsText()); + await streamMessage.flush(); + const toolRankStartedAt = Date.now(); + const toolRankStartedAtIso = new Date().toISOString(); + const rankerSelection = await toolRanker.selectTools({ + provider: AiProvider.OPENAI, + userQuery: latestUserTextFromMessages(messages), + availableTools, + round, + signal, + }) + .catch(async error => { + streamMessage.clearStatus(); + await streamMessage.flush(); + await storeToolRankAudit({ + streamMessage, + provider: AiProvider.OPENAI, + model: config.openAiChatTarget.model, + round, + startedAt: toolRankStartedAt, + startedAtIso: toolRankStartedAtIso, + error, + }); + throw error; + }); + streamMessage.clearStatus(); + await streamMessage.flush(); + await storeToolRankAudit({ + streamMessage, + provider: AiProvider.OPENAI, + model: config.openAiChatTarget.model, + round, + startedAt: toolRankStartedAt, + startedAtIso: toolRankStartedAtIso, + selectedTools: rankerSelection.toolNames, + }); + const filteredTools = filterRankedTools(availableTools, rankerSelection.toolNames); + const requestTools = preparedDocumentRag?.vectorStoreIds.length + ? (() => { + const tools = [...filteredTools]; + const hasFileSearch = allToolSchemaNames(tools).includes("file_search"); + if (!hasFileSearch) { + const fileSearchTool = availableTools.find(tool => allToolSchemaNames([tool]).includes("file_search")); + if (fileSearchTool) { + tools.unshift(fileSearchTool); + } + } + return tools.length ? tools : undefined; + })() + : (filteredTools.length ? filteredTools : undefined); + + if (!stream) { + const request: ResponseCreateParamsNonStreaming = { + model: config.openAiChatTarget.model, + input: responseInput as ResponseInputItem[], + tools: requestTools as ResponseCreateParamsNonStreaming["tools"], + instructions: systemPrompt, + }; + const response = await openAi.responses.create(request, {signal}) as OpenAiResponseLike; + + const responseText = collectOpenAiResponseText(response); + streamMessage.append(responseText); + aiLog("debug", "openai.response.received", { + round, + duration: aiLogDuration(roundStartedAt), + textChars: responseText.length, + outputItems: response?.output?.length ?? 0, + }); + const images = collectOpenAiResponseImages(response); + if (images.length) { + await showOpenAiGeneratedImage( + streamMessage, + sourceMessage, + images[images.length - 1], + `final_${round}`, + Environment.getImageGenDoneText(config.openAiImageTarget.model), + true, + ); + } + + const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(response); + if (codeInterpreterCalls.length) { + aiLog("info", "openai.code_interpreter_calls", { + round, + duration: aiLogDuration(roundStartedAt), + calls: codeInterpreterCalls.map(call => ({ + id: call.id, + status: call.status, + containerId: call.containerId, + codeChars: call.code?.length ?? 0, + outputItems: call.outputs.length, + })), + }); + } + + const calls = collectOpenAiResponseFunctionCalls(response); + aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", { + round, + duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), + calls: calls.map(call => ({ + id: call.callId, + name: call.name, + arguments: safeJsonParseObject(call.argumentsText) + })), + }); + if (!calls.length) return; + + const toolCalls = calls.map(call => ({ + id: call.callId, + name: call.name, + argumentsText: call.argumentsText, + })); + const toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory); + const toolOutputs = calls.map((call, index) => ({ + type: "function_call_output" as const, + call_id: call.callId, + output: toolResults[index] ?? "", + })); + + const uploadFilesResult = await tryToUploadFiles(msg, toolResults); + if (uploadFilesResult.found) { + if (!uploadFilesResult.uploaded) { + const old = toolOutputs[uploadFilesResult.toolIndex]; + const callId = old?.call_id; + if (uploadFilesResult.toolIndex >= 0) { + delete toolOutputs[uploadFilesResult.toolIndex]; + } + if (callId) { + toolOutputs.push({ + type: "function_call_output" as const, + call_id: callId, + output: "Error: " + uploadFilesResult.error + }); + } + } + } + + responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs]; + continue; + } + + let completedResponse: OpenAiResponseLike | null = null; + const request: ResponseCreateParamsStreaming = { + model: config.openAiChatTarget.model, + input: responseInput as ResponseInputItem[], + stream: true, + tools: requestTools as ResponseCreateParamsStreaming["tools"], + parallel_tool_calls: true, + instructions: systemPrompt + }; + const response = await openAi.responses.create(request, {signal}) as AsyncIterableStream; + + aiLog("debug", "openai.stream.open", {round}); + + let localToolCalls: ToolCallData[] = []; + for await (const event of response) { + if (signal.aborted) throw new Error("Aborted"); + + switch (event.type) { + case "response.output_text.delta": + streamMessage.append(event.delta ?? ""); + break; + case "response.image_generation_call.in_progress": + streamMessage.setStatus(Environment.startingImageGenText); + await streamMessage.flush(); + break; + case "response.image_generation_call.generating": + streamMessage.setStatus(Environment.imageGenText); + await streamMessage.flush(); + break; + case "response.image_generation_call.partial_image": { + const iteration = (event.partial_image_index ?? 0) + 1; + await showOpenAiGeneratedImage( + streamMessage, + sourceMessage, + event.partial_image_b64, + `partial_${round}_${iteration}`, + Environment.getPartialImageGenText(iteration, OPENAI_IMAGE_PARTIALS), + false, + ); + break; + } + case "response.image_generation_call.completed": + streamMessage.setStatus(Environment.finalizingImageGenText); + await streamMessage.flush(); + break; + case "response.file_search_call.in_progress": + case "response.file_search_call.searching": + streamMessage.setStatus(Environment.getUseToolText(["file_search"])); + await streamMessage.flush(); + break; + case "response.file_search_call.completed": + streamMessage.clearStatus(); + await streamMessage.flush(); + break; + case "response.code_interpreter_call.in_progress": + case "response.code_interpreter_call.interpreting": + streamMessage.setStatus(Environment.getUseToolText(["code_interpreter"])); + await streamMessage.flush(); + break; + case "response.code_interpreter_call.completed": + streamMessage.clearStatus(); + await streamMessage.flush(); + break; + case "response.code_interpreter_call_code.delta": + case "response.code_interpreter_call_code.done": + break; + case "response.output_item.added": + if (event.item.type === "function_call" && event.item.name) { + const item = event.item as OpenAiResponseOutputItem & { id?: string }; + localToolCalls.push({ + id: openAiResponseItemCallId(item), + name: item.name ?? "", + argumentsText: item.arguments ?? "{}", + }); + + aiLog("info", "openai.stream.tool_call.added", { + round, + toolCalls: localToolCalls.map(aiLogToolCall) + }); + streamMessage.setStatus(Environment.getUseToolText(localToolCalls)); + await streamMessage.flush(); + } + break; + case "response.output_item.done": + if (event.item.type === "function_call" && event.item.name) { + const item = event.item as OpenAiResponseOutputItem & { id?: string }; + const itemId = openAiResponseItemCallId(item); + const index = localToolCalls.findIndex(c => c.id === itemId); + if (index !== -1) { + localToolCalls.splice(index, 1); + if (localToolCalls.length === 0) { + streamMessage.clearStatus(); + } else { + streamMessage.setStatus(Environment.getUseToolText(localToolCalls)); + } + await streamMessage.flush(); + } + } + break; + case "response.function_call_arguments.delta": + break; + case "response.function_call_arguments.done": + break; + + case "response.completed": + completedResponse = event.response as OpenAiResponseLike; + break; + case "response.failed": + throw new Error(event.response?.error?.message ?? "OpenAI response failed"); + case "error": + throw new Error(event.message ?? event?.message ?? "OpenAI stream error"); + } + } + + if (!completedResponse) throw new Error("OpenAI did not return the final response.completed event."); + + aiLog("debug", "openai.stream.completed", { + round, + duration: aiLogDuration(roundStartedAt), + outputItems: completedResponse?.output?.length ?? 0, + }); + + const images = collectOpenAiResponseImages(completedResponse); + if (images.length) { + await showOpenAiGeneratedImage( + streamMessage, + sourceMessage, + images[images.length - 1], + `final_${round}`, + Environment.getImageGenDoneText(config.openAiImageTarget.model), + true, + ); + } + + const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(completedResponse); + if (codeInterpreterCalls.length) { + aiLog("info", "openai.code_interpreter_calls", { + round, + duration: aiLogDuration(roundStartedAt), + calls: codeInterpreterCalls.map(call => ({ + id: call.id, + status: call.status, + containerId: call.containerId, + codeChars: call.code?.length ?? 0, + outputItems: call.outputs.length, + })), + }); + } + + const calls = collectOpenAiResponseFunctionCalls(completedResponse); + aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", { + round, + duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), + calls: calls.map(call => ({ + id: call.callId, + name: call.name, + arguments: safeJsonParseObject(call.argumentsText) + })), + }); + if (!calls.length) return; + + const toolCalls = calls.map(call => ({ + id: call.callId, + name: call.name, + argumentsText: call.argumentsText, + })); + const toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory); + const toolOutputs = calls.map((call, index) => ({ + type: "function_call_output", + call_id: call.callId, + output: toolResults[index] ?? "", + })); + + const uploadFilesResult = await tryToUploadFiles(msg, toolResults); + if (uploadFilesResult.found) { + if (!uploadFilesResult.uploaded) { + const old = toolOutputs[uploadFilesResult.toolIndex]; + const callId = old?.call_id; + if (uploadFilesResult.toolIndex >= 0) { + delete toolOutputs[uploadFilesResult.toolIndex]; + } + if (callId) { + toolOutputs.push({ + type: "function_call_output" as const, + call_id: callId, + output: "Error: " + uploadFilesResult.error + }); + } + } + } + + responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs]; + } + } finally { + if (ownsDocumentRag) { + await preparedDocumentRag?.cleanup().catch(logError); + } + } +} + +export type OpenAiDocumentRagContext = { + vectorStoreIds: string[]; + uploadedFileIds: string[]; + cleanup: () => Promise; +}; + +export async function prepareOpenAiDocumentRag(openAi: OpenAI, downloads: AiDownloadedFile[]): Promise { + if (!downloads.length) return undefined; + + const vectorStore = await openAi.vectorStores.create({ + name: `tg-chat-bot-${Date.now()}`, + description: "Temporary document RAG for a single Telegram request.", + expires_after: { + anchor: "last_active_at", + days: 1, + }, + }); + + const uploadedFileIds: string[] = []; + + try { + for (const download of downloads) { + const uploaded = await openAi.files.create({ + file: await toFile(download.buffer, download.fileName, { + type: download.mimeType ?? "application/octet-stream", + }), + purpose: "user_data", + }); + uploadedFileIds.push(uploaded.id); + } + + const batch = await openAi.vectorStores.fileBatches.createAndPoll(vectorStore.id, { + file_ids: uploadedFileIds, + }); + + if (batch.file_counts.failed > 0) { + throw new Error(`OpenAI file_search failed to index ${batch.file_counts.failed} document(s).`); + } + + return { + vectorStoreIds: [vectorStore.id], + uploadedFileIds, + cleanup: async () => { + await cleanupOpenAiDocumentRag(openAi, vectorStore.id, uploadedFileIds); + }, + }; + } catch (error) { + await cleanupOpenAiDocumentRag(openAi, vectorStore.id, uploadedFileIds).catch(() => undefined); + throw error; + } +} + +async function cleanupOpenAiDocumentRag(openAi: OpenAI, vectorStoreId: string, fileIds: string[]): Promise { + await openAi.vectorStores.delete(vectorStoreId).catch(() => undefined); + for (const fileId of fileIds) { + await openAi.files.delete(fileId).catch(() => undefined); + } +} + +async function tryToUploadFiles( + msg: Message, + toolResults: string[] +): Promise< + | { found: false } + | { found: true, uploaded: true } + | { found: boolean, uploaded: false, error: string, toolIndex: number } +> { + let sendFileAttachment: { + result: SendFileAttachmentResult & { success: true }, + toolIndex: number + } | null = null; + + let found = false; + + try { + for (const [index, toolResult] of toolResults.entries()) { + const raw = JSON.parse(toolResult); + const res = SendFileAttachmentResultSchema.safeParse(raw); + + if (res.success) { + found = true; + + if (res.data.success) { + sendFileAttachment = {result: res.data, toolIndex: index}; + } + } + } + + if (!found) { + return {found: false}; + } + + const attachmentRoot = Environment.FILE_TOOLS_ROOT_DIR; + const attachmentPath = attachmentRoot + ? path.join( + attachmentRoot, + String(msg.from?.id), + sendFileAttachment?.result?.attachment?.relativePath ?? "", + ) + : ""; + + if (!fs.existsSync(attachmentPath)) { + throw new Error(`Attachment file does not exist: ${attachmentPath}`); + } + + await bot.sendDocument({ + chat_id: msg.chat.id, + reply_parameters: { + message_id: msg.message_id, + }, + document: fs.createReadStream(attachmentPath), + }); + + return {found: true, uploaded: true}; + } catch (e) { + logError(e instanceof Error ? e : String(e)); + return { + found: found, + uploaded: false, + error: errorMessage(e instanceof Error ? e : String(e)), + toolIndex: sendFileAttachment?.toolIndex ?? -1 + }; + } +} + +// function openAiResponseContentToText(content: string | readonly { text?: string; refusal?: string }[]): string { +// if (typeof content === "string") return content; +// if (!Array.isArray(content)) return ""; +// return content.map(part => isRecord(part) ? part.text ?? part.content ?? part.refusal ?? "" : "").join(""); +// function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAiCompatibleChatMessage[] { +// return messages.map((message): OpenAiCompatibleChatMessage => { +// if (message.role === "system" || message.role === "assistant") { +// return { +// role: message.role, +// content: openAiResponseContentToText(message.content), +// }; +// } +// +// const content = Array.isArray(message.content) +// ? message.content.map((part): OpenAiCompatibleContentPart => { +// if (isRecord(part) && part.type === "input_image") { +// return { +// type: "image_url", +// image_url: {url: String(part.image_url ?? "")}, +// }; +// } +// +// return { +// type: "text", +// text: isRecord(part) && typeof part.text === "string" ? part.text : "", +// }; +// }) +// : message.content; +// +// return {role: "user", content}; +// }); +// } +// function normalizeOpenAiChatToolCalls(toolCalls: OpenAiChatToolCallLike[] = []): ToolCallData[] { +// return toolCalls.map((call, i) => ({ +// id: call.id || `openai_chat_${Date.now()}_${i}`, +// name: call.function?.name || call.name || "", +// argumentsText: typeof call.function?.arguments === "string" +// ? call.function.arguments +// : JSON.stringify(call.function?.arguments ?? call.arguments ?? {}), +// })).filter(call => call.name); +// } +// async function appendOpenAiChatToolResults( +// messages: OpenAiCompatibleChatMessage[], +// calls: ToolCallData[], +// results: string[], +// ): Promise { +// for (const [index, call] of calls.entries()) { +// messages.push({ +// role: "tool", +// tool_call_id: call.id, +// content: results[index] ?? "", +// }); +// } +// } diff --git a/src/ai/unified-ai-runner.shared.ts b/src/ai/unified-ai-runner.shared.ts new file mode 100644 index 0000000..8c6c996 --- /dev/null +++ b/src/ai/unified-ai-runner.shared.ts @@ -0,0 +1,1559 @@ +import {Message} from "typescript-telegram-bot-api"; +import * as fs from "node:fs"; +import path from "node:path"; +import type {BoundaryValue} from "../common/boundary-types"; +import {AiProvider} from "../model/ai-provider"; +import {ToolRankerFallbackPolicy} from "../common/policies"; +import {Environment} from "../common/environment"; +import {photoGenDir} from "../index"; +import {delay, logError, replyToMessage} from "../util/utils"; +import {MessageStore} from "../common/message-store"; +import type {OpenAiResponseTool} from "./tool-mappers"; +import {AiProviderName, getOpenAICodeInterpreterTool, getOpenAIResponsesTools} from "./tool-mappers"; +import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message"; +import {AiDownloadedFile} from "./telegram-attachments"; +import {getRuntimeCapabilities} from "./provider-model-runtime"; +import {StoredAttachment} from "../model/stored-attachment"; +import {AiChatMessage, ChatMessage} from "./chat-messages-types"; +import {ListResponse, Ollama} from "ollama"; +import {executeToolCall, ToolRuntimeContext} from "./tools/runtime"; +import {MessageImagePart, MessagePart} from "../common/message-part"; +import {KeyedAsyncLock} from "../util/async-lock"; +import {type AiRequestQueueTarget} from "./provider-request-queue"; +import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator"; +import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings"; +import { + isTranscribableAudioDownload, + resolveSpeechToTextProviderForUser, + transcribeSpeechDownloads +} from "./speech-to-text"; +import type {ChatCompletionMessageParam} from "openai/resources/chat/completions"; +import {MistralChatMessage} from "./mistral-chat-message"; +import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer"; +import {AiRuntimeTarget, createMistralClient, resolveAiRuntimeTarget} from "./ai-runtime-target"; +import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; +import {buildConversationSnapshot, serializeConversationSnapshot} from "./conversation-pipeline"; +import type {ResponseInputMessageContentList} from "openai/resources/responses/responses"; +import {persistToolResultArtifactAttachment} from "./tool-result-artifact-store"; +import {filterUserVisibleStoredAttachments} from "../common/stored-attachment-utils"; + +export type {Message} from "typescript-telegram-bot-api"; +export type {AiRuntimeTarget} from "./ai-runtime-target"; +export type {AiDownloadedFile} from "./telegram-attachments"; +export type {StoredAttachment} from "../model/stored-attachment"; +export type {AiChatMessage, ChatMessage} from "./chat-messages-types"; +export type {ToolRuntimeContext} from "./tools/runtime"; +export type {MessageImagePart, MessagePart} from "../common/message-part"; +export type {OpenAIChatMessage} from "./openai-chat-message"; +export type {MistralChatMessage} from "./mistral-chat-message"; +export type {OllamaChatMessage} from "./ollama-chat-message"; +export type {TelegramArtifactFile} from "./telegram-stream-message"; +export {TelegramStreamMessage} from "./telegram-stream-message"; +export type {ChatRequest, ListResponse, Ollama, Tool} from "ollama"; +export type { + ResponseCreateParamsNonStreaming, + ResponseCreateParamsStreaming, + ResponseInputItem, + ResponseInputMessageContentList, + ResponseStreamEvent, +} from "openai/resources/responses/responses"; +export type { + ChatCompletionCreateParamsNonStreaming, + ChatCompletionCreateParamsStreaming, + ChatCompletionMessageParam, +} from "openai/resources/chat/completions"; +export const TELEGRAM_LIMIT = 4096; +export const MAX_TOOL_ROUNDS = 40; +export const MAX_IDENTICAL_TOOL_CALLS = 1; +export const OPENAI_IMAGE_PARTIALS = 3; +export const AI_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; +export const MIN_OLLAMA_CONTEXT_SIZE = 4096; +export const MAX_OLLAMA_CONTEXT_SIZE = 262144; +export const DEFAULT_OLLAMA_CONTEXT_SIZE = 32768; +export const toolResourceLocks = new KeyedAsyncLock(); + +export type UnifiedRunOptions = { + provider: AiProvider; + msg: Message; + isGuestMsg?: boolean; + text: string; + stream?: boolean; + think?: Think; + synthesizeSpeechResponse?: boolean; + responseLanguage?: UserAiResponseLanguage; + contextSize?: number; + voiceMode?: UserAiVoiceMode; + targetMessage?: Message; +}; + +export type ToolCallData = { + id: string; + name: string; + argumentsText: string; +}; + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; +export type JsonObject = { [key: string]: JsonValue }; + +// SDKs sometimes expose loose object-shaped payloads. Keep the looseness at the boundary, +// but do not spread it through the rest of the code. +export type LooseRecord = Record; + +export type OpenAiResponsesFunctionCall = { + callId: string; + name: string; + argumentsText: string; +}; + +export type MistralToolCallLike = { + id?: string; + index?: number; + name?: string; + function?: { + name?: string; + arguments?: string | JsonObject; + }; + arguments?: string | JsonObject; +}; + +export type MistralDeltaLike = { + content?: string | Array<{ text?: string }> | null; + toolCalls?: MistralToolCallLike[] | null; + tool_calls?: MistralToolCallLike[] | null; +}; + +export type MistralLibraryLike = { + id?: string; +}; + +export type MistralUploadedDocumentLike = { + id?: string; +}; + +export type MistralDocumentStatusLike = { + processingStatus?: string; +}; + +export type MistralDocumentReference = { + type: "document"; + documentId: string; +}; + +export type OllamaToolCallLike = { + function?: { + name?: string; + arguments?: JsonObject; + }; +}; + +export type OpenAiChatToolCallLike = { + id?: string; + type?: "function"; + index?: number; + name?: string; + function?: { + name?: string; + arguments?: string | JsonObject; + }; + arguments?: string | JsonObject; +}; + +export type OpenAiResponseOutputItem = { + type?: string; + id?: string; + call_id?: string; + name?: string; + arguments?: string; + result?: string; + content?: Array<{ text?: string; refusal?: string }>; + code?: string | null; + container_id?: string; + outputs?: Array<{ type?: "logs" | "image"; logs?: string; url?: string }> | null; + status?: string; +}; + +export type OpenAiResponseLike = { + id?: string; + output?: OpenAiResponseOutputItem[]; + output_text?: string; +}; + +export type OpenAiCompatibleContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } }; + +export type OpenAiCompatibleChatMessage = ChatCompletionMessageParam; + +export type OpenAiChatCompletionResponseLike = { + choices?: Array<{ message?: { content?: string | null; tool_calls?: OpenAiChatToolCallLike[] } }>; +}; + +export type OpenAiChatCompletionStreamChunkLike = { + choices?: Array<{ delta?: { content?: string | null; tool_calls?: OpenAiChatToolCallLike[] } }>; +}; + +export type AsyncIterableStream = AsyncIterable; + +export function isRecord(value: BoundaryValue): value is LooseRecord { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function toJsonValue(value: BoundaryValue): JsonValue | undefined { + if (value === null) return null; + + switch (typeof value) { + case "string": + case "boolean": + return value; + case "number": + return Number.isFinite(value) ? value : null; + case "object": + if (Array.isArray(value)) { + return value.map(item => toJsonValue(item) ?? null); + } + + if (!isRecord(value)) return undefined; + + return Object.fromEntries( + Object.entries(value) + .map(([key, item]) => [key, toJsonValue(item)] as const) + .filter((entry): entry is readonly [string, JsonValue] => entry[1] !== undefined), + ); + default: + return undefined; + } +} + +export function toJsonObject(value: BoundaryValue): JsonObject | undefined { + const json = toJsonValue(value); + return json !== null && typeof json === "object" && !Array.isArray(json) ? json : undefined; +} + +export function asOptionalString(value: BoundaryValue): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +export function isAbortError(error: BoundaryValue): boolean { + return error instanceof Error ? error.message.includes("Aborted") : String(error).includes("Aborted"); +} + +export type AttachmentKind = "image" | "document" | "audio" | "video" | "video-note"; + +export type Think = boolean | "high" | "medium" | "low"; + +export type RuntimeConfigSnapshot = { + useNamesInPrompt: boolean; + useSystemPrompt: boolean; + systemPrompt?: string; + rankerToolPrompt?: string; + toolRankerFallbackPolicy: ToolRankerFallbackPolicy; + + ollamaChatTarget: AiRuntimeTarget; + ollamaToolRankerTarget?: AiRuntimeTarget; + ollamaToolTarget: AiRuntimeTarget; + ollamaVisionTarget: AiRuntimeTarget; + ollamaThinkingTarget: AiRuntimeTarget; + ollamaAudioTarget: AiRuntimeTarget; + ollamaDocumentsTarget: AiRuntimeTarget; + ollamaRagChunkSize: number; + ollamaRagChunkOverlap: number; + ollamaRagTopK: number; + ollamaRagMaxContextChars: number; + ollamaRagMinScore: number; + ollamaRagMaxArchiveFiles: number; + ollamaRagMaxArchiveBytes: number; + ollamaRagMaxArchiveDepth: number; + mistralChatTarget: AiRuntimeTarget; + mistralToolRankerTarget?: AiRuntimeTarget; + + openAiChatTarget: AiRuntimeTarget; + openAiImageTarget: AiRuntimeTarget; + openAiToolRankerTarget?: AiRuntimeTarget; +}; + +export function snapshotRuntimeConfig(): RuntimeConfigSnapshot { + return { + useNamesInPrompt: Environment.USE_NAMES_IN_PROMPT, + useSystemPrompt: Environment.USE_SYSTEM_PROMPT, + + systemPrompt: Environment.SYSTEM_PROMPT, + rankerToolPrompt: Environment.RANKER_TOOL_PROMPT, + toolRankerFallbackPolicy: Environment.TOOL_RANKER_FALLBACK_POLICY, + + ollamaChatTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"), + ollamaToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "toolRank"), + ollamaToolTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "tools"), + ollamaVisionTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "vision"), + ollamaThinkingTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "thinking"), + ollamaAudioTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "audio"), + ollamaDocumentsTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "documents"), + ollamaRagChunkSize: Environment.OLLAMA_RAG_CHUNK_SIZE, + ollamaRagChunkOverlap: Environment.OLLAMA_RAG_CHUNK_OVERLAP, + ollamaRagTopK: Environment.OLLAMA_RAG_TOP_K, + ollamaRagMaxContextChars: Environment.OLLAMA_RAG_MAX_CONTEXT_CHARS, + ollamaRagMinScore: Environment.OLLAMA_RAG_MIN_SCORE, + ollamaRagMaxArchiveFiles: Environment.OLLAMA_RAG_MAX_ARCHIVE_FILES, + ollamaRagMaxArchiveBytes: Environment.OLLAMA_RAG_MAX_ARCHIVE_BYTES, + ollamaRagMaxArchiveDepth: Environment.OLLAMA_RAG_MAX_ARCHIVE_DEPTH, + + mistralChatTarget: resolveAiRuntimeTarget(AiProvider.MISTRAL, "chat"), + mistralToolRankerTarget: resolveAiRuntimeTarget(AiProvider.MISTRAL, "toolRank"), + + openAiChatTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "chat"), + openAiImageTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "outputImages"), + openAiToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "toolRank"), + }; +} + +export function getMessageImageParts(part: MessagePart): MessageImagePart[] { + if (part.imageParts?.length) return part.imageParts; + return (part.images ?? []).map(data => ({data, mimeType: "image/jpeg"})); +} + +export function openAiImageDataUrl(image: MessageImagePart): string { + return `data:${image.mimeType || "image/jpeg"};base64,${image.data}`; +} + +export function snapshotModel(provider: AiProvider, config: RuntimeConfigSnapshot): string { + switch (provider) { + case AiProvider.OLLAMA: + return config.ollamaChatTarget.model; + case AiProvider.MISTRAL: + return config.mistralChatTarget.model; + case AiProvider.OPENAI: + return config.openAiChatTarget.model; + } +} + +export function providerTargets(provider: AiProvider, config: RuntimeConfigSnapshot): AiRuntimeTarget[] { + switch (provider) { + case AiProvider.OLLAMA: + return [ + config.ollamaChatTarget, + config.ollamaToolRankerTarget, + config.ollamaToolTarget, + config.ollamaVisionTarget, + config.ollamaThinkingTarget, + config.ollamaAudioTarget, + config.ollamaDocumentsTarget + ].filter((target): target is AiRuntimeTarget => !!target); + case AiProvider.MISTRAL: + return [ + config.mistralChatTarget, + config.mistralToolRankerTarget, + ].filter((target): target is AiRuntimeTarget => !!target); + case AiProvider.OPENAI: + return [ + config.openAiChatTarget, + config.openAiToolRankerTarget, + ].filter((target): target is AiRuntimeTarget => !!target); + } +} + +export function providerChatTarget(provider: AiProvider, config: RuntimeConfigSnapshot): AiRuntimeTarget { + switch (provider) { + case AiProvider.OLLAMA: + return config.ollamaChatTarget; + case AiProvider.MISTRAL: + return config.mistralChatTarget; + case AiProvider.OPENAI: + return config.openAiChatTarget; + } +} + +export function providerName(provider: AiProvider): AiProviderName { + switch (provider) { + case AiProvider.OLLAMA: + return "ollama"; + case AiProvider.MISTRAL: + return "mistral"; + case AiProvider.OPENAI: + return "openai"; + } +} + +export function buildSystemInstruction( + config: RuntimeConfigSnapshot, + responseLanguage: UserAiResponseLanguage, + includePythonToolPrompt: boolean, + additions?: string | null, +): string { + return [ + config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null, + config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null, + additions?.trim() ? additions.trim() : null, + includePythonToolPrompt ? pythonInterpreterToolPrompt : null, + ].filter(Boolean).join("\n\n"); +} + +export function initialStatus(downloads: AiDownloadedFile[], messagePartsImages: number): string { + const documents = downloads.filter(d => d.kind === "document"); + const images = downloads.filter(d => d.kind === "image").length + messagePartsImages; + const audio = downloads.filter(isTranscribableAudioDownload).length; + + if (documents.length) return prepareTelegramMarkdownV2(Environment.getAnalyzingDocumentText(documents.map(d => d.fileName))); + if (audio) return Environment.transcribingAudioText; + if (images > 1) return Environment.analyzingPicturesText; + if (images === 1) return Environment.analyzingPictureText; + return Environment.waitThinkText; +} + +export function hasAudioAttachmentKind(kinds: Set): boolean { + return kinds.has("audio") || kinds.has("video-note"); +} + +export function resolveAiRequestQueueTarget( + options: Pick, + config: RuntimeConfigSnapshot, + requestedAttachmentKinds: Set, +): AiRequestQueueTarget { + switch (options.provider) { + case AiProvider.OLLAMA: + if (hasAudioAttachmentKind(requestedAttachmentKinds)) return config.ollamaAudioTarget; + if (requestedAttachmentKinds.has("image")) return config.ollamaVisionTarget; + return options.think ? config.ollamaThinkingTarget : config.ollamaChatTarget; + case AiProvider.MISTRAL: + return config.mistralChatTarget; + case AiProvider.OPENAI: + return config.openAiChatTarget; + } +} + +export function roundStatus(round: number, firstRoundStatus: string, content?: string, toolCalls?: ToolCallData[], thinking?: boolean): string | null { + if (content?.length && !toolCalls?.length && !thinking) { + return null; + } + + return toolCalls?.length ? Environment.getUseToolText(toolCalls) + : thinking ? Environment.reasoningText + : round === 0 ? firstRoundStatus + : Environment.waitThinkText; +} + +export function isPlainTextDocument(doc: AiDownloadedFile): boolean { + const ext = path.extname(doc.fileName).toLowerCase(); + const mime = (doc.mimeType ?? "").toLowerCase(); + + return mime.startsWith("text/") + || mime === "application/json" + || mime === "application/xml" + || [ + ".txt", + ".md", + ".markdown", + ".csv", + ".json", + ".jsonl", + ".xml", + ".yaml", + ".yml", + ".ini", + ".env", + ".log", + ".ps1", + ".sh", + ".bat", + ".cmd", + ".js", + ".jsx", + ".ts", + ".tsx", + ".py", + ".rb", + ".go", + ".java", + ".c", + ".cc", + ".cpp", + ".h", + ".hpp", + ".php", + ".sql", + ].includes(ext); +} + +export function decodeTextDocument(doc: AiDownloadedFile): string { + return doc.buffer.toString("utf8").replace(/\u0000/g, ""); +} + +export function downloadedFileAsBlob(doc: AiDownloadedFile): Blob { + const arrayBuffer = doc.buffer.buffer.slice( + doc.buffer.byteOffset, + doc.buffer.byteOffset + doc.buffer.byteLength, + ) as ArrayBuffer; + + return new Blob([arrayBuffer], { + type: doc.mimeType ?? "application/octet-stream", + }); +} + +export function ollamaModelNames(response: ListResponse): string[] { + return (response?.models ?? []) + .flatMap((model) => [model?.model, model?.name]) + .filter((value): value is string => typeof value === "string" && value.length > 0); +} + +export async function isOllamaModelActive(ollama: Ollama, target: AiRuntimeTarget): Promise { + const active = await ollama.ps(); + return ollamaModelNames(active).includes(target.model); +} + +export function addMessageAttachmentKinds(msg: Message | undefined, kinds: Set): void { + if (!msg) return; + + if (msg.photo?.length) kinds.add("image"); + if (msg.document) { + const mimeType = msg.document.mime_type; + kinds.add(mimeType?.startsWith("image/") ? "image" : mimeType?.startsWith("audio/") ? "audio" : "document"); + } + if (msg.voice || msg.audio) kinds.add("audio"); + if (msg.video_note) kinds.add("video-note"); + if (msg.video) kinds.add("video"); +} + +export async function collectStoredReplyChainAttachments(msg: Message, limit: number = 1): Promise { + const attachments: StoredAttachment[] = []; + const seen = new Set(); + let current = await MessageStore.get(msg.chat.id, msg.message_id); + + for (let i = 0; current && i < limit; i++) { + for (const attachment of filterUserVisibleStoredAttachments(current?.attachments ?? [])) { + const key = [ + attachment.kind, + attachment.fileUniqueId || attachment.fileId, + attachment.cachePath, + ].join(":"); + if (seen.has(key)) continue; + seen.add(key); + attachments.push(attachment); + } + current = await MessageStore.get(current.chatId, current.replyToMessageId); + } + + return attachments; +} + +export async function hasStoredReplyChainImage(msg: Message): Promise { + const attachments = await collectStoredReplyChainAttachments(msg); + if (attachments.some(attachment => attachment.kind === "image")) return true; + + return false; +} + +export async function collectRequestedAttachmentKinds(msg: Message): Promise> { + const kinds = new Set(); + + addMessageAttachmentKinds(msg, kinds); + addMessageAttachmentKinds(msg.reply_to_message, kinds); + + for (const attachment of await collectStoredReplyChainAttachments(msg)) { + kinds.add(attachment.kind); + } + + if (!kinds.has("image") && await hasStoredReplyChainImage(msg)) { + kinds.add("image"); + } + + return kinds; +} + +export function unsupportedAttachmentText(provider: AiProvider, model: string, kind: AttachmentKind): string { + const providerName = provider.toLowerCase(); + + switch (kind) { + case "audio": + return Environment.getCurrentModelUnsupportedInputText(model, providerName, "voice or audio messages"); + case "image": + return Environment.getCurrentModelUnsupportedInputText(model, providerName, "images"); + case "document": + return Environment.getCurrentModelUnsupportedInputText(model, providerName, "documents"); + case "video": + return Environment.getCurrentModelUnsupportedInputText(model, providerName, "video"); + case "video-note": + return Environment.getCurrentModelUnsupportedInputText(model, providerName, "video notes"); + } +} + +export async function rejectUnsupportedAttachments( + provider: AiProvider, + model: string, + msg: Message, + config: RuntimeConfigSnapshot, + requestedAttachmentKinds?: Set, +): Promise { + const kinds = requestedAttachmentKinds ?? await collectRequestedAttachmentKinds(msg); + let effectiveModel = model || snapshotModel(provider, config); + const hasAudio = hasAudioAttachmentKind(kinds); + + if (provider === AiProvider.OLLAMA) { + effectiveModel = hasAudio ? config.ollamaAudioTarget.model + : kinds.has("image") ? config.ollamaVisionTarget.model + : config.ollamaChatTarget.model; + } + + const caps = await getRuntimeCapabilities(provider, effectiveModel); + + let speechToTextSupported = !hasAudio; + if (hasAudio && msg.from?.id) { + speechToTextSupported = await resolveSpeechToTextProviderForUser(msg.from.id, provider) + .then(() => true) + .catch(() => false); + } + + const unsupported = + (hasAudio && !speechToTextSupported ? "audio" : null) ?? + (kinds.has("image") && !caps.vision?.supported ? "image" : null) ?? + (kinds.has("document") && !caps.documents?.supported ? "document" : null) ?? + (kinds.has("video") ? "video" : null); + + if (!unsupported) return false; + + if (!kinds.has("audio")) { + await replyToMessage({ + message: msg, + text: unsupportedAttachmentText(provider, effectiveModel, unsupported), + }).catch(logError); + } + + return true; +} + +export async function collectCachedMessageAttachments(msg: Message): Promise<{ + attachments: StoredAttachment[]; + missing: StoredAttachment[] +}> { + const attachments = await collectStoredReplyChainAttachments(msg); + return { + attachments, + missing: attachments.filter(attachment => !fs.existsSync(attachment.cachePath)), + }; +} + +export function safeJsonParseObject(value?: string): JsonObject { + if (!value?.trim()) return {}; + try { + const parsed = JSON.parse(value); + return toJsonObject(parsed) ?? {}; + } catch { + return {}; + } +} + +export type ToolArgumentsParseResult = + | { ok: true; args: JsonObject } + | { ok: false; message: string; raw: string }; + +export function parseToolArgumentsObject(argumentsText?: string): ToolArgumentsParseResult { + const raw = argumentsText ?? ""; + + if (!raw.trim()) { + return {ok: true, args: {}}; + } + + try { + const parsed = JSON.parse(raw); + const args = toJsonObject(parsed); + + if (!args) { + return { + ok: false, + raw, + message: "Tool arguments must be a JSON object.", + }; + } + + return {ok: true, args}; + } catch (error) { + return { + ok: false, + raw, + message: `Invalid JSON in tool arguments: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +export function errorMessage(error: BoundaryValue): string { + return error instanceof Error ? error.message : String(error); +} + +export function toolFailureResult(kind: string, message: string, extra?: JsonObject): string { + return JSON.stringify({ + success: false, + error: { + kind, + message, + ...(extra ?? {}), + }, + }); +} + +export function toolRuntimeContextFromDownloads(downloads: AiDownloadedFile[]): ToolRuntimeContext { + if (!downloads.length) return {}; + + return { + pythonInputFiles: downloads.map(download => ({ + kind: download.kind, + path: download.path, + fileName: download.fileName, + mimeType: download.mimeType, + })), + }; +} + +export function extractToolArtifacts(toolName: string, result: string): TelegramArtifactFile[] { + if (toolName !== PYTHON_INTERPRETER_TOOL_NAME) return []; + + try { + const parsed = JSON.parse(result); + const artifacts = isRecord(parsed) && Array.isArray(parsed.artifacts) ? parsed.artifacts : []; + + return artifacts + .map(artifact => artifact as Partial) + .filter((artifact): artifact is TelegramArtifactFile => { + return (artifact.kind === "image" || artifact.kind === "file") + && typeof artifact.path === "string" + && typeof artifact.fileName === "string" + && Number.isSafeInteger(artifact.sizeBytes); + }); + } catch { + return []; + } +} + +export async function sendToolArtifacts(toolCall: ToolCallData, result: string, message: TelegramStreamMessage): Promise { + const artifacts = extractToolArtifacts(toolCall.name, result); + for (const artifact of artifacts) { + await message.sendArtifact(artifact); + } +} + +export function stringifyToolArguments(value: string | JsonObject | undefined): string { + return typeof value === "string" ? value : JSON.stringify(value ?? {}); +} + +export function normalizeMistralToolCalls(calls: MistralToolCallLike[] = []): ToolCallData[] { + return calls.map((call, i) => ({ + id: call.id || `call_${Date.now()}_${i}`, + name: call.function?.name || call.name || "", + argumentsText: stringifyToolArguments(call.function?.arguments ?? call.arguments), + })).filter(c => c.name); +} + +export function mistralToolCalls(value: MistralDeltaLike | { + toolCalls?: MistralToolCallLike[] | null; + tool_calls?: MistralToolCallLike[] | null +} | null | undefined): MistralToolCallLike[] { + return value?.toolCalls ?? value?.tool_calls ?? []; +} + +export function contentFromMistralDelta(delta: MistralDeltaLike): string { + if (!delta.content) return ""; + if (typeof delta.content === "string") return delta.content; + if (Array.isArray(delta.content)) return delta.content.map(c => c.text ?? "").join(""); + return ""; +} + +export function normalizeOllamaToolCalls(calls: readonly OllamaToolCallLike[] = [], round: number): ToolCallData[] { + return calls + .map((call, i) => ({ + id: `ollama_${round}_${i}`, + name: call.function?.name ?? "", + argumentsText: JSON.stringify(call.function?.arguments ?? {}), + })) + .filter(call => !!call.name); +} + +export async function collectTextMessages( + msg: Message, + textOverride: string, + provider: AiProvider, + downloads: AiDownloadedFile[], + config: RuntimeConfigSnapshot, + runtimeTarget: AiRuntimeTarget, + responseLanguage: UserAiResponseLanguage, +): Promise<{ + chatMessages: AiChatMessage[]; + imageCount: number +}> { + const includePythonToolPrompt = Environment.ENABLE_PYTHON_INTERPRETER && msg.from?.id === Environment.CREATOR_ID; + const snapshot = await buildConversationSnapshot( + msg, + textOverride, + downloads, + config, + runtimeTarget, + responseLanguage, + includePythonToolPrompt, + ); + + return serializeConversationSnapshot(snapshot, provider, Environment.USE_NAMES_IN_PROMPT); +} + +export async function transcribeAudioIfNeeded(provider: AiProvider, userId: number | undefined, downloads: AiDownloadedFile[], message: TelegramStreamMessage, signal: AbortSignal): Promise { + const audioDownloads = downloads.filter(isTranscribableAudioDownload); + if (!audioDownloads.length) return ""; + if (!userId) throw new Error(Environment.couldNotIdentifyUserForSpeechToTextText); + if (signal.aborted) throw new Error("Aborted"); + + const startedAt = Date.now(); + aiLog("info", "speech_to_text.start", { + requestedProvider: providerName(provider), + userId, + files: audioDownloads.map(d => ({ + kind: d.kind, + fileName: d.fileName, + mimeType: d.mimeType, + sizeBytes: d.buffer.length + })), + }); + + message.setStatus(Environment.transcribingAudioText); + await message.flush(); + + try { + const resolved = await resolveSpeechToTextProviderForUser(userId, provider); + aiLog("debug", "speech_to_text.provider_resolved", {provider: String(resolved.provider)}); + + const transcript = await transcribeSpeechDownloads(resolved.provider, downloads, signal); + if (!transcript.trim()) { + throw new Error(Environment.speechToTextEmptyResultText); + } + + aiLog("success", "speech_to_text.done", { + duration: aiLogDuration(startedAt), + transcriptChars: transcript.length, + }); + return transcript; + } catch (e) { + aiLog("error", "speech_to_text.failed", {duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)}); + throw e; + } +} + +export function stripAudioFromRunnerMessages(parts: AiChatMessage[]): void { + for (const part of parts) { + if ("audios" in part) { + delete part.audios; + } + if ("audioParts" in part) { + delete part.audioParts; + } + + if ("videoNotes" in part) { + delete part.videoNotes; + } + } +} + +export function appendTranscriptToChatMessages( + chatMessages: AiChatMessage[], + transcript: string, +): void { + const lastUser = [...chatMessages].reverse().find(message => "role" in message && message.role === "user"); + if (!lastUser) return; + + const text = transcript.trim(); + if (!text) return; + + if (!("content" in lastUser)) return; + + if (typeof lastUser.content === "string") { + lastUser.content = [lastUser.content, text].filter((value: string) => value.trim()).join("\n\n"); + return; + } + + if (Array.isArray(lastUser.content)) { + const usesOpenAiResponsesParts = lastUser.content.some(part => { + if (!isRecord(part)) return false; + + // Do not read `part.type` directly here: for some providers TypeScript + // narrows it to the Chat Completions union (`text | image_url | thinking`), + // which makes comparisons with Responses parts (`input_text | input_image`) + // look impossible even though this is a runtime mixed-provider guard. + const partType = (part as {type?: string}).type; + + return partType === "input_text" || partType === "input_image"; + }); + + if (usesOpenAiResponsesParts) { + (lastUser.content as ResponseInputMessageContentList).push({type: "input_text", text}); + } else { + (lastUser.content as OpenAiCompatibleContentPart[]).push({type: "text", text}); + } + } +} + +export async function deleteMistralLibrary(libraryId: string | undefined, target: AiRuntimeTarget): Promise { + if (!libraryId) return; + + const startedAt = Date.now(); + aiLog("debug", "mistral.library.delete.start", {libraryId, target: aiLogProviderTarget(target)}); + try { + const mistralAi = createMistralClient(target); + await mistralAi.beta.libraries.delete({libraryId}); + aiLog("success", "mistral.library.delete.done", {libraryId, duration: aiLogDuration(startedAt)}); + } catch (e) { + aiLog("error", "mistral.library.delete.failed", {libraryId, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)}); + logError(e instanceof Error ? e : String(e)); + } +} + +export async function appendMistralTextDocument(doc: AiDownloadedFile, messages: MistralChatMessage[], message: TelegramStreamMessage): Promise { + const startedAt = Date.now(); + aiLog("info", "mistral.document.text.start", { + fileName: doc.fileName, + mimeType: doc.mimeType, + sizeBytes: doc.buffer.length, + }); + + message.setStatus(prepareTelegramMarkdownV2(Environment.getAnalyzingDocumentText([doc.fileName]))); + await message.flush(); + + const text = decodeTextDocument(doc).trim(); + if (!text) { + throw new Error(Environment.getDocumentIsEmptyText(doc.fileName)); + } + + messages.push({ + role: "user", + content: [ + { + type: "text", + text: Environment.getDocumentContentText(doc.fileName, text), + }, + ], + }); + + aiLog("success", "mistral.document.text.done", { + fileName: doc.fileName, + duration: aiLogDuration(startedAt), + chars: text.length, + }); +} + +export async function prepareMistralDocuments(downloads: AiDownloadedFile[], messages: MistralChatMessage[], message: TelegramStreamMessage, target: AiRuntimeTarget, signal: AbortSignal): Promise<{ + documents: MistralDocumentReference[]; + libraryId?: string +}> { + const docs = downloads.filter(d => d.kind === "document"); + const result: MistralDocumentReference[] = []; + if (!docs.length) return {documents: result}; + + const startedAt = Date.now(); + aiLog("info", "mistral.documents.prepare.start", { + target: aiLogProviderTarget(target), + count: docs.length, + documents: docs.map(d => ({fileName: d.fileName, mimeType: d.mimeType, sizeBytes: d.buffer.length})), + }); + + const mistralAi = createMistralClient(target); + const library = await mistralAi.beta.libraries.create({ + name: `tg-chat-bot-${Date.now()}`, + description: "Temporary library for document search", + }, {signal}); + const libraryId = (library as MistralLibraryLike).id; + if (!libraryId) { + throw new Error(Environment.mistralLibraryIdMissingText); + } + + aiLog("debug", "mistral.library.created", {libraryId}); + + try { + for (const doc of docs) { + if (signal.aborted) throw new Error("Aborted"); + + if (isPlainTextDocument(doc)) { + await appendMistralTextDocument(doc, messages, message); + continue; + } + + message.setStatus(prepareTelegramMarkdownV2(Environment.getAnalyzingDocumentText([doc.fileName]))); + await message.flush(); + + const documentStartedAt = Date.now(); + aiLog("info", "mistral.document.upload.start", { + libraryId, + fileName: doc.fileName, + mimeType: doc.mimeType, + sizeBytes: doc.buffer.length, + }); + + const uploaded = await mistralAi.beta.libraries.documents.upload({ + libraryId, + requestBody: { + file: downloadedFileAsBlob(doc), + }, + }, {signal}); + + const uploadedDocument = uploaded as MistralUploadedDocumentLike & { document_id?: string }; + const documentId = uploadedDocument.id ?? uploadedDocument.document_id; + if (!documentId) { + throw new Error(Environment.getMistralUploadedDocumentIdMissingText(doc.fileName)); + } + + aiLog("debug", "mistral.document.upload.done", { + libraryId, + documentId, + fileName: doc.fileName, + duration: aiLogDuration(documentStartedAt), + }); + + let processed = false; + for (let i = 0; i < 90; i++) { + const info = await mistralAi.beta.libraries.documents.status({libraryId, documentId}, {signal}); + const statusInfo = info as MistralDocumentStatusLike & { status?: string; process_status?: string }; + const status = statusInfo.status ?? statusInfo.process_status ?? statusInfo.processingStatus; + aiLog("debug", "mistral.document.status", { + libraryId, + documentId, + fileName: doc.fileName, + status, + attempt: i + 1 + }); + + if (status === "processed" || status === "Completed" || status === "done" || status === "Done") { + processed = true; + break; + } + if (status === "failed" || status === "error" || status === "Failed" || status === "Error" || status === "missing_content") { + throw new Error(Environment.getMistralDocumentProcessingFailedText(doc.fileName, status)); + } + await delay(2000, signal); + } + + if (!processed) { + throw new Error(Environment.getMistralDocumentProcessingTimedOutText(doc.fileName)); + } + + aiLog("success", "mistral.document.processed", { + libraryId, + documentId, + fileName: doc.fileName, + duration: aiLogDuration(documentStartedAt), + }); + + result.push({type: "document", documentId}); + } + + aiLog("success", "mistral.documents.prepare.done", { + libraryId, + count: docs.length, + duration: aiLogDuration(startedAt), + }); + return {documents: result, libraryId}; + } catch (e) { + aiLog("error", "mistral.documents.prepare.failed", { + libraryId, + duration: aiLogDuration(startedAt), + error: e instanceof Error ? e : String(e), + }); + await deleteMistralLibrary(libraryId, target); + throw e; + } +} + +export async function executeTool( + userId: number | undefined | null, + toolCall: ToolCallData, + message: TelegramStreamMessage, + context: ToolRuntimeContext, +): Promise { + const startedAt = Date.now(); + + aiLog("info", "tool.start", { + tool: aiLogToolCall(toolCall), + hasPythonInputFiles: !!context.pythonInputFiles?.length, + }); + + await message.flush(); + + const parsedArgs = parseToolArgumentsObject(toolCall.argumentsText); + if (!parsedArgs.ok) { + const result = toolFailureResult("invalid_arguments", parsedArgs.message, { + raw: parsedArgs.raw.slice(0, 4000), + }); + + aiLog("warn", "tool.arguments.invalid", { + tool: aiLogToolCall(toolCall), + duration: aiLogDuration(startedAt), + error: parsedArgs.message, + }); + + return result; + } + + try { + const rawResult = await executeToolCall(userId, toolCall.name, parsedArgs.args, context); + const result = stringifyToolExecutionResult(rawResult); + + await sendToolArtifacts(toolCall, result, message); + + aiLog("success", "tool.done", { + name: toolCall.name, + duration: aiLogDuration(startedAt), + result, + }); + + return result; + } catch (error) { + if (isAbortError(error instanceof Error ? error : String(error))) { + throw error; + } + + const result = toolFailureResult("execution_failed", errorMessage(error instanceof Error ? error : String(error))); + + aiLog("error", "tool.failed.returned_to_model", { + name: toolCall.name, + duration: aiLogDuration(startedAt), + error: error instanceof Error ? error : String(error), + }); + + return result; + } +} + +export function toolResourceKeys(toolCall: ToolCallData): string[] { + const args = safeJsonParseObject(toolCall.argumentsText); + const pathValue = typeof args.path === "string" ? args.path : undefined; + const sourcePath = typeof args.sourcePath === "string" ? args.sourcePath : undefined; + const targetPath = typeof args.targetPath === "string" ? args.targetPath : undefined; + + switch (toolCall.name) { + case "get_datetime": + case "web_search": + case "get_weather": + case "read_file": + case "list_directory": + return []; + case "create_file": + case "create_directory": + case "update_file": + case "delete_path": + return [`file:${pathValue ?? "*"}`]; + case "copy_path": + case "rename_path": + return [`file:${sourcePath ?? "*"}`, `file:${targetPath ?? "*"}`]; + case "shell_execute": + return ["shell:*"]; + default: + return [`tool:${toolCall.name}`]; + } +} + +export async function runWithToolLocks(keys: string[], task: () => Promise): Promise { + const uniqueKeys = [...new Set(keys)].sort(); + const run = (index: number): Promise => { + const key = uniqueKeys[index]; + if (!key) return task(); + return toolResourceLocks.runExclusive(key, () => run(index + 1)); + }; + + return run(0); +} + +export async function executeScheduledTool( + userId: number | undefined | null, + toolCall: ToolCallData, + message: TelegramStreamMessage, + context: ToolRuntimeContext, +): Promise { + const keys = toolResourceKeys(toolCall); + if (!keys.length) return executeTool(userId, toolCall, message, context); + return runWithToolLocks(keys, () => executeTool(userId, toolCall, message, context)); +} + +export async function executeToolBatch( + userId: number | undefined | null, + toolCalls: ToolCallData[], + message: TelegramStreamMessage, + context: ToolRuntimeContext, + memory: ToolExecutionMemory = new Map(), +): Promise { + if (!toolCalls.length) return []; + + const statusCalls = dedupeToolCalls(toolCalls); + + const startedAt = Date.now(); + + aiLog("info", "tool.batch.start", { + count: toolCalls.length, + uniqueCount: statusCalls.length, + tools: statusCalls.map(aiLogToolCall), + }); + + message.setStatus(Environment.getUseToolText(statusCalls)); + await message.flush(); + + const inBatch = new Map>(); + + const runOne = async (toolCall: ToolCallData): Promise => { + const signature = toolCallSignature(toolCall); + const previous = memory.get(signature); + + if (previous && previous.count >= MAX_IDENTICAL_TOOL_CALLS) { + const suppressed = duplicateToolCallSuppressedResult(toolCall, previous.result); + + aiLog("warn", "tool.duplicate.suppressed", { + tool: aiLogToolCall(toolCall), + previousCount: previous.count, + }); + + return suppressed; + } + + message.setStatus(Environment.getUseToolText(statusCalls)); + await message.flush(); + + const resultText = await executeScheduledTool(userId, toolCall, message, context); + + memory.set(signature, { + count: (previous?.count ?? 0) + 1, + result: resultText, + }); + + message.setStatus(Environment.getUseToolText(statusCalls)); + await message.flush(); + + return resultText; + }; + + try { + const results = await Promise.all(toolCalls.map(toolCall => { + const signature = toolCallSignature(toolCall); + const existing = inBatch.get(signature); + + if (existing) { + aiLog("warn", "tool.duplicate.in_batch_joined", { + tool: aiLogToolCall(toolCall), + }); + + return existing; + } + + const promise = runOne(toolCall); + inBatch.set(signature, promise); + return promise; + })); + + message.setStatus(Environment.getUseToolText(statusCalls)); + await message.flush(); + + const finishedAt = new Date().toISOString(); + await Promise.all(results.map(async (resultText, index) => { + const toolCall = toolCalls[index]; + if (!toolCall) return; + + message.recordToolExecution({ + toolName: toolCall.name, + callId: toolCall.id, + argumentsText: toolCall.argumentsText, + resultChars: resultText.length, + startedAt: new Date(startedAt).toISOString(), + finishedAt, + }); + + try { + const attachment = await persistToolResultArtifactAttachment({ + toolCall, + resultText, + chatId: message.sourceChatId(), + messageId: message.sourceMessageId(), + }); + await message.storeInternalAttachment(attachment); + } catch (error) { + logError(error instanceof Error ? error : String(error)); + } + })); + + aiLog("success", "tool.batch.done", { + count: toolCalls.length, + uniqueCount: statusCalls.length, + duration: aiLogDuration(startedAt), + }); + + return results; + } catch (e) { + aiLog("error", "tool.batch.failed", { + count: toolCalls.length, + duration: aiLogDuration(startedAt), + error: e instanceof Error ? e : String(e), + }); + + throw e; + } +} + +export function appendOllamaToolResults(messages: ChatMessage[], calls: ToolCallData[], results: string[]): void { + for (const [index, call] of calls.entries()) { + messages.push({ + role: "tool", + content: results[index] ?? "", + tool_name: call.name + }); + } +} + +export function stringifyToolExecutionResult(result: BoundaryValue): string { + if (typeof result === "string") return result; + const json = JSON.stringify(toJsonValue(result) ?? String(result)); + return json ?? String(result); +} + +export type ToolExecutionMemory = Map; + +export function stableJsonStringify(value: BoundaryValue): string { + if (Array.isArray(value)) { + return `[${value.map(stableJsonStringify).join(",")}]`; + } + + if (isRecord(value)) { + return `{${Object.keys(value) + .sort() + .map(key => `${JSON.stringify(key)}:${stableJsonStringify(value[key])}`) + .join(",")}}`; + } + + return JSON.stringify(value); +} + +export function toolCallSignature(toolCall: ToolCallData): string { + const parsed = parseToolArgumentsObject(toolCall.argumentsText); + const argsSignature = parsed.ok + ? stableJsonStringify(parsed.args) + : `invalid-json:${toolCall.argumentsText}`; + + return `${toolCall.name}\u0000${argsSignature}`; +} + +export type StreamingToolCallChunkLike = { + id?: string; + index?: number; + name?: string; + function?: { + name?: string; + arguments?: string | JsonObject; + }; + arguments?: string | JsonObject; +}; + +export class StreamingToolCallAccumulator { + private readonly byKey = new Map(); + + constructor( + private readonly prefix: string, + private readonly round: number, + ) { + } + + add(chunks: readonly StreamingToolCallChunkLike[]): ToolCallData[] { + for (const [fallbackIndex, chunk] of chunks.entries()) { + const key = typeof chunk.index === "number" + ? `index:${chunk.index}` + : chunk.id + ? `id:${chunk.id}` + : `fallback:${fallbackIndex}`; + + const existing = this.byKey.get(key) ?? { + id: chunk.id ?? `${this.prefix}_${this.round}_${fallbackIndex}`, + name: "", + argumentsText: "", + }; + + if (chunk.id) { + existing.id = chunk.id; + } + + const name = chunk.function?.name ?? chunk.name; + if (name) { + existing.name = name; + } + + const args = chunk.function?.arguments ?? chunk.arguments; + if (typeof args === "string") { + existing.argumentsText += args; + } else if (args !== undefined) { + existing.argumentsText = JSON.stringify(args); + } + + this.byKey.set(key, existing); + } + + return this.snapshot(); + } + + snapshot(): ToolCallData[] { + return [...this.byKey.values()].filter(call => call.name); + } +} + +export function duplicateToolCallSuppressedResult(toolCall: ToolCallData, previousResult: string): string { + return JSON.stringify({ + success: false, + skipped: true, + reason: `Identical tool call '${toolCall.name}' with the same arguments was already executed in this agentic loop. Use the previous result and answer the user instead of calling the tool again.`, + previousResult: previousResult.slice(0, 8000), + }); +} + +export function dedupeToolCalls(calls: ToolCallData[]): ToolCallData[] { + const bySignature = new Map(); + + for (const call of calls) { + bySignature.set(toolCallSignature(call), call); + } + + return [...bySignature.values()]; +} + +export type NormalizedRouterPlanStep = { + t: string[]; // Tool name + h: string; // Hint + from: string; +}; + +export type NormalizedRouterPlan = { + s: NormalizedRouterPlanStep[]; // Steps + m: string; // Missing +}; + +export function toolSchemaName(tool: BoundaryValue): string | undefined { + if (!isRecord(tool)) return undefined; + const fn = isRecord(tool.function) ? tool.function : undefined; + const directName = fn?.name ?? tool.name ?? (typeof tool.type === "string" && tool.type !== "function" ? tool.type : undefined); + return asOptionalString(directName); +} + +export function toolSchemaNames(tool: BoundaryValue): string[] { + if (!isRecord(tool)) return []; + + if (Array.isArray(tool.functionDeclarations)) { + return tool.functionDeclarations + .map(declaration => isRecord(declaration) ? asOptionalString(declaration.name) : undefined) + .filter((name): name is string => !!name); + } + + const name = toolSchemaName(tool); + return name ? [name] : []; +} + +export function allToolSchemaNames(tools: readonly BoundaryValue[]): string[] { + return [...new Set(tools.flatMap(toolSchemaNames))]; +} + +export function getOpenAIResponsesToolsWithImage( + config: RuntimeConfigSnapshot, + forCreator?: boolean, + vectorStoreIds: string[] = [], +): Array { + const tools: Array = [ + ...getOpenAIResponsesTools(forCreator), + getOpenAICodeInterpreterTool(), + { + type: "image_generation", + model: config.openAiImageTarget.model, + size: "auto", + moderation: "low", + output_format: "png", + partial_images: OPENAI_IMAGE_PARTIALS, + }, + {type: "web_search"}, + ]; + + if (vectorStoreIds.length) { + tools.unshift({ + type: "file_search", + vector_store_ids: vectorStoreIds, + }); + } + + return tools; +} + +export function collectOpenAiResponseText(response: OpenAiResponseLike): string { + if (typeof response.output_text === "string") return response.output_text; + + return (response.output ?? []) + .filter(item => item.type === "message") + .flatMap(item => item.content ?? []) + .map(content => content.text ?? content.refusal ?? "") + .join(""); +} + +export function collectOpenAiResponseFunctionCalls(response: OpenAiResponseLike): OpenAiResponsesFunctionCall[] { + return (response.output ?? []) + .filter(item => item.type === "function_call" && item.call_id && item.name) + .map(item => ({ + callId: item.call_id!, + name: item.name!, + argumentsText: item.arguments ?? "{}", + })); +} + +export type OpenAiCodeInterpreterCall = { + id: string; + code: string | null; + containerId: string; + status: string; + outputs: Array<{ type?: "logs" | "image"; logs?: string; url?: string }>; +}; + +export function collectOpenAiResponseCodeInterpreterCalls(response: OpenAiResponseLike): OpenAiCodeInterpreterCall[] { + return (response.output ?? []) + .filter(item => item.type === "code_interpreter_call" && item.id && item.container_id) + .map(item => ({ + id: item.id!, + code: item.code ?? null, + containerId: item.container_id!, + status: item.status ?? "unrecognized", + outputs: Array.isArray(item.outputs) ? item.outputs : [], + })); +} + +export function collectOpenAiResponseImages(response: OpenAiResponseLike): string[] { + return (response.output ?? []) + .filter(item => item.type === "image_generation_call" && typeof item.result === "string") + .map(item => item.result!); +} + +export function writeOpenAiGeneratedImage(sourceMessage: Message, b64: string, label: string): { + buffer: Buffer; + cachePath: string; + fileName: string; +} { + const buffer = Buffer.from(b64, "base64"); + const fileName = `${sourceMessage.chat.id}_${sourceMessage.message_id}_${Date.now()}_${label}.png`; + const cachePath = path.join(photoGenDir, fileName); + fs.writeFileSync(cachePath, buffer); + return {buffer, cachePath, fileName}; +} + +export async function showOpenAiGeneratedImage( + streamMessage: TelegramStreamMessage, + sourceMessage: Message, + b64: string, + label: string, + status: string, + final: boolean, +): Promise { + const image = writeOpenAiGeneratedImage(sourceMessage, b64, label); + const attachment: StoredAttachment = { + kind: "image", + fileId: image.cachePath, + fileName: image.fileName, + mimeType: "image/png", + cachePath: image.cachePath, + }; + if (final && !streamMessage.getText().trim()) { + streamMessage.replaceText(status); + streamMessage.clearStatus(); + } else { + streamMessage.setStatus(status); + } + await streamMessage.showImage(image.buffer, attachment); +} + +export function openAiResponseItemCallId(item: OpenAiResponseOutputItem & { id?: string }): string { + return item.call_id ?? item.id ?? `openai_response_${Date.now()}`; +} + diff --git a/src/ai/unified-ai-runner.tool-ranker.ts b/src/ai/unified-ai-runner.tool-ranker.ts new file mode 100644 index 0000000..4223978 --- /dev/null +++ b/src/ai/unified-ai-runner.tool-ranker.ts @@ -0,0 +1,238 @@ +import {ChatCompletionMessageParam} from "openai/resources/chat/completions"; +import {ChatRequest} from "ollama"; +import {BoundaryValue} from "../common/boundary-types"; +import {ToolRankerFallbackPolicy} from "../common/policies"; +import {AiProvider} from "../model/ai-provider"; +import {createMistralClient, createOllamaClient, createOpenAiClient, sameRuntimeEndpoint} from "./ai-runtime-target"; +import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger"; +import {providerChatTarget, RuntimeConfigSnapshot} from "./unified-ai-runner.shared"; +import { + buildRankerContext, + buildRankerTarget, + buildToolRankerPrompt, + filterRankedTools, + ToolRankerSelection, +} from "./tool-ranker-pipeline"; +import {allToolSchemaNames} from "./unified-ai-runner.shared"; +import {sanitizeToolRankerResult} from "./tool-ranker-metadata"; + +export class ToolRanker { + constructor(private readonly config: RuntimeConfigSnapshot) { + } + + async selectTools(args: { + provider: AiProvider; + userQuery: string; + availableTools: readonly BoundaryValue[]; + round: number; + signal: AbortSignal; + messages?: readonly { role?: string; content?: string | readonly { text?: string }[] }[]; + }): Promise { + const {availableTools, provider, round, signal, userQuery} = args; + const availableNames = allToolSchemaNames(availableTools); + const fallbackPolicy = this.config.toolRankerFallbackPolicy; + const configuredTarget = buildRankerTarget(this.config, provider); + const mainModelTarget = providerChatTarget(provider, this.config); + + if (!availableTools.length) { + return {toolNames: [], usedRanker: false}; + } + + const target = configuredTarget ?? (fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL ? mainModelTarget : undefined); + + if (!target) { + if (fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS) { + return {toolNames: [], usedRanker: false}; + } + + return {toolNames: availableNames, usedRanker: false}; + } + + const startedAt = Date.now(); + const ranker = buildToolRankerPrompt(buildRankerContext(this.config, provider, target, round, userQuery, availableTools)); + + aiLog("debug", "tool_ranker.start", { + provider, + round, + target: aiLogProviderTarget(target), + queryChars: userQuery.length, + availableTools: availableNames, + fallbackPolicy, + usedMainModelFallback: !configuredTarget && fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL, + }); + + try { + if (signal.aborted) throw new Error("Aborted"); + const raw = await this.runRanker(provider, target, ranker.prompt, userQuery); + if (signal.aborted) throw new Error("Aborted"); + const selectedNames = sanitizeToolRankerResult({ + raw, + availableToolNames: availableNames, + }); + const filtered = filterRankedTools(availableTools, selectedNames); + const toolNames = allToolSchemaNames(filtered); + + aiLog("debug", "tool_ranker.done", { + provider, + round, + duration: aiLogDuration(startedAt), + selectedNames, + selectedCount: toolNames.length, + rawPreview: raw.slice(0, 800), + }); + + return {toolNames, usedRanker: true}; + } catch (error) { + if (error instanceof Error && error.message.includes("Aborted")) throw error; + let failureMessage = error instanceof Error ? error.message : String(error); + + const canRetryOnMainModel = fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL + && ( + target.model !== mainModelTarget.model + || !sameRuntimeEndpoint(target, mainModelTarget) + ); + + if (canRetryOnMainModel) { + try { + aiLog("warn", "tool_ranker.failed.retry_main_model", { + provider, + round, + target: aiLogProviderTarget(target), + fallbackTarget: aiLogProviderTarget(mainModelTarget), + duration: aiLogDuration(startedAt), + error: failureMessage, + }); + + const fallbackRanker = buildToolRankerPrompt( + buildRankerContext(this.config, provider, mainModelTarget, round, userQuery, availableTools), + ); + const raw = await this.runRanker(provider, mainModelTarget, fallbackRanker.prompt, userQuery); + const selectedNames = sanitizeToolRankerResult({ + raw, + availableToolNames: availableNames, + }); + const filtered = filterRankedTools(availableTools, selectedNames); + const toolNames = allToolSchemaNames(filtered); + + aiLog("debug", "tool_ranker.done", { + provider, + round, + duration: aiLogDuration(startedAt), + selectedNames, + selectedCount: toolNames.length, + rawPreview: raw.slice(0, 800), + fallbackUsed: true, + }); + + return {toolNames, usedRanker: true}; + } catch (fallbackError) { + if (fallbackError instanceof Error && fallbackError.message.includes("Aborted")) throw fallbackError; + + const fallbackErrorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + aiLog("warn", "tool_ranker.failed.main_model_fallback_failed", { + provider, + round, + target: aiLogProviderTarget(target), + fallbackTarget: aiLogProviderTarget(mainModelTarget), + duration: aiLogDuration(startedAt), + error: fallbackErrorMessage, + }); + + failureMessage = fallbackErrorMessage; + } + } + + aiLog("warn", "tool_ranker.failed.fallback_all_allowed", { + provider, + round, + target: aiLogProviderTarget(target), + fallbackPolicy, + duration: aiLogDuration(startedAt), + error: failureMessage, + }); + + if (fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS) { + return {toolNames: [], usedRanker: false}; + } + + return { + toolNames: availableNames, + usedRanker: false, + }; + } + } + + private async runRanker( + provider: AiProvider, + target: NonNullable>, + prompt: string, + userQuery: string, + ): Promise { + switch (provider) { + case AiProvider.OLLAMA: { + const ollama = createOllamaClient(target); + const request = { + model: target.model, + messages: [ + {role: "system", content: prompt}, + {role: "user", content: userQuery}, + ], + stream: false as const, + think: false, + format: { + type: "object", + properties: { + toolNames: { + type: "array", + items: {type: "string"}, + }, + }, + required: ["toolNames"], + additionalProperties: false, + }, + options: { + temperature: 0, + top_p: 0.8, + top_k: 20, + repeat_penalty: 1.05, + num_ctx: 8192, + num_predict: 256, + }, + } satisfies ChatRequest & { stream: false }; + + const response = await ollama.chat(request); + return response.message?.content?.trim() ?? ""; + } + case AiProvider.MISTRAL: { + const mistral = createMistralClient(target); + const request: Parameters[0] = { + model: target.model, + messages: [ + {role: "system", content: prompt}, + {role: "user", content: userQuery}, + ], + temperature: 0, + }; + const response = await mistral.chat.complete(request); + const message = response.choices?.[0]?.message; + return typeof message?.content === "string" ? message.content.trim() : ""; + } + case AiProvider.OPENAI: { + const openAi = createOpenAiClient(target); + const messages = [ + {role: "system", content: prompt}, + {role: "user", content: userQuery}, + ] satisfies ChatCompletionMessageParam[]; + + // gpt-5 family ranker targets reject temperature=0; use the model default instead. + const response = await openAi.chat.completions.create({ + model: target.model, + messages, + response_format: {type: "json_object"}, + }); + + return response.choices[0]?.message?.content?.trim() ?? ""; + } + } + } +} diff --git a/src/ai/unified-ai-runner.ts b/src/ai/unified-ai-runner.ts new file mode 100644 index 0000000..f3811e8 --- /dev/null +++ b/src/ai/unified-ai-runner.ts @@ -0,0 +1,302 @@ +// Facade extracted from unified-ai-runner.ts. +import {AiProvider} from "../model/ai-provider"; +import {Environment} from "../common/environment"; +import {ifTrue, logError, replyToMessage} from "../util/utils"; +import {createAiCancelRequest, finishAiRequest, setAiCancelMessageId} from "./cancel-registry"; +import {TelegramStreamMessage} from "./telegram-stream-message"; +import {AiDownloadedFile, attachmentsToDownloadedFiles, cleanupDownloads} from "./telegram-attachments"; +import {aiProviderRequestQueue} from "./provider-request-queue"; +import { + AI_VOICE_MODE_TRANSCRIPT, + resolveAiContextSizeForUser, + resolveAiImageOutputModeForUser, + resolveAiResponseLanguageForUser, + resolveAiVoiceModeForUser +} from "../common/user-ai-settings"; +import {buildAiRegenerateCallbackData} from "./regenerate-callback"; +import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "../logging/ai-logger"; + +import { + AI_REQUEST_TIMEOUT_MS, + collectCachedMessageAttachments, + collectRequestedAttachmentKinds, + hasAudioAttachmentKind, + isAbortError, + providerName, + rejectUnsupportedAttachments, + resolveAiRequestQueueTarget, + RuntimeConfigSnapshot, + snapshotModel, + snapshotRuntimeConfig, + UnifiedRunOptions +} from "./unified-ai-runner.shared"; +import {prepareUnifiedAiRequestPipeline} from "./unified-ai-request-pipeline"; +import {persistErrorArtifactAttachment} from "./final-response-artifact-store"; +import {runUnifiedAiResponsePipeline} from "./unified-ai-response-pipeline"; +import {AiRequestStore} from "../common/ai-request-store"; +import type {StoredAiRequestStatus} from "../model/stored-ai-request"; + +export type {ToolCallData} from "./unified-ai-runner.shared"; +export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared"; + +async function executeUnifiedAiRequest( + options: UnifiedRunOptions, + config: RuntimeConfigSnapshot, + downloads: AiDownloadedFile[], + controller: AbortController, + streamMessage: TelegramStreamMessage, +): Promise { + const requestStartedAt = Date.now(); + let preparedRequest: Awaited> | undefined; + aiLog("info", "request.execute.start", { + provider: providerName(options.provider), + stream: options.stream ?? true, + think: options.think, + responseLanguage: options.responseLanguage, + contextSize: options.contextSize, + voiceMode: options.voiceMode, + message: aiLogMessageIdentity(options.msg), + downloads: downloads.map(d => ({ + kind: d.kind, + fileName: d.fileName, + mimeType: d.mimeType, + sizeBytes: d.buffer.length + })), + }); + + preparedRequest = await prepareUnifiedAiRequestPipeline({ + options, + config, + downloads, + streamMessage, + controller, + }); + if (preparedRequest.finishAfterTranscript) return; + + aiLog("debug", "request.messages.collected", { + provider: providerName(options.provider), + chatMessages: preparedRequest.chatMessages.length, + imageCount: preparedRequest.imageCount, + firstRoundStatus: preparedRequest.firstRoundStatus, + hasToolInputFiles: !!preparedRequest.toolContext.pythonInputFiles?.length, + }); + + try { + await runUnifiedAiResponsePipeline({ + options, + config, + downloads, + prepared: preparedRequest, + streamMessage, + controller, + }); + aiLog("success", "request.execute.done", { + provider: providerName(options.provider), + duration: aiLogDuration(requestStartedAt), + responseChars: streamMessage.getText().length, + mistralLibraryId: preparedRequest?.preparedDocumentRag?.provider === AiProvider.MISTRAL ? preparedRequest.preparedDocumentRag.libraryId : undefined, + }); + return; + } catch (e) { + aiLog("error", "request.execute.failed", { + provider: providerName(options.provider), + duration: aiLogDuration(requestStartedAt), + error: e instanceof Error ? e : String(e), + }); + throw e; + } +} + +export async function runUnifiedAi(options: UnifiedRunOptions): Promise { + const startedAt = Date.now(); + const config = snapshotRuntimeConfig(); + options.responseLanguage ??= await resolveAiResponseLanguageForUser(options.msg.from?.id); + options.contextSize ??= await resolveAiContextSizeForUser(options.msg.from?.id); + options.voiceMode ??= await resolveAiVoiceModeForUser(options.msg.from?.id); + const imageOutputMode = await resolveAiImageOutputModeForUser(options.msg.from?.id); + const requestedAttachmentKinds = await collectRequestedAttachmentKinds(options.msg); + + aiLog("info", "run.start", { + provider: providerName(options.provider), + model: snapshotModel(options.provider, config), + message: aiLogMessageIdentity(options.msg), + targetMessage: aiLogMessageIdentity(options.targetMessage), + isGuestMsg: options.isGuestMsg, + stream: options.stream, + think: options.think, + responseLanguage: options.responseLanguage, + contextSize: options.contextSize, + voiceMode: options.voiceMode, + requestedAttachmentKinds: [...requestedAttachmentKinds], + textChars: options.text.length, + }); + + if (await rejectUnsupportedAttachments(options.provider, snapshotModel(options.provider, config), options.msg, config, requestedAttachmentKinds)) { + aiLog("warn", "run.rejected.unsupported_attachment", { + provider: providerName(options.provider), + requestedAttachmentKinds: [...requestedAttachmentKinds], + }); + return; + } + + const cached = await collectCachedMessageAttachments(options.msg); + aiLog("debug", "run.attachments.cache", { + attachments: cached.attachments.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})), + missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})), + }); + if (cached.missing.length) { + await replyToMessage({ + message: options.msg, + text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName), + }).catch(logError); + aiLog("warn", "run.rejected.missing_attachment_cache", { + missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})), + }); + return; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS); + let aiRequestStatus: StoredAiRequestStatus = "running"; + let aiRequestError: string | undefined; + let responseMessageId: number | undefined; + const cancel = createAiCancelRequest({ + chatId: options.msg.chat.id, + fromId: options.msg.from?.id ?? 0, + provider: providerName(options.provider), + controller + }); + const streamMessage = new TelegramStreamMessage( + options.msg, + cancel.id, + ifTrue(options.stream), + options.voiceMode === AI_VOICE_MODE_TRANSCRIPT && hasAudioAttachmentKind(requestedAttachmentKinds) + ? undefined + : buildAiRegenerateCallbackData(options.provider, !!options.think), + options.targetMessage, + options.provider, + options.isGuestMsg, + imageOutputMode + ); + cancel.onCancel = () => streamMessage.cancel(cancel.provider); + const queueTarget = resolveAiRequestQueueTarget(options, config, requestedAttachmentKinds); + aiLog("debug", "run.queue.target", {target: aiLogProviderTarget(queueTarget), cancelId: cancel.id}); + const aiRequestStartedAt = new Date().toISOString(); + await AiRequestStore.put({ + requestId: cancel.id, + chatId: options.msg.chat.id, + messageId: options.msg.message_id, + fromId: options.msg.from?.id ?? 0, + provider: options.provider, + model: snapshotModel(options.provider, config), + status: "running", + startedAt: aiRequestStartedAt, + }).catch(logError); + + try { + const queueMessage = await streamMessage.start(Environment.waitThinkText); + responseMessageId = queueMessage.message_id; + await AiRequestStore.put({ + requestId: cancel.id, + chatId: options.msg.chat.id, + messageId: options.msg.message_id, + responseMessageId, + fromId: options.msg.from?.id ?? 0, + provider: options.provider, + model: snapshotModel(options.provider, config), + status: "running", + startedAt: aiRequestStartedAt, + }).catch(logError); + setAiCancelMessageId(cancel.id, queueMessage.message_id); + aiLog("info", "run.queue.enter", { + cancelId: cancel.id, + queueMessageId: queueMessage.message_id, + target: aiLogProviderTarget(queueTarget), + }); + + await aiProviderRequestQueue.enqueue(queueTarget, { + signal: controller.signal, + onPositionChange: async requestsBefore => { + aiLog("debug", "run.queue.position", {cancelId: cancel.id, requestsBefore}); + streamMessage.setStatus(Environment.getAiQueueText(options.provider, requestsBefore)); + await streamMessage.flush(); + }, + run: async (): Promise => { + const queueWaitFinishedAt = Date.now(); + aiLog("info", "run.queue.dequeued", {cancelId: cancel.id}); + const downloads = attachmentsToDownloadedFiles(cached.attachments); + aiLog("debug", "run.downloads.ready", { + count: downloads.length, + downloads: downloads.map(d => ({ + kind: d.kind, + fileName: d.fileName, + mimeType: d.mimeType, + path: d.path, + sizeBytes: d.buffer.length + })), + }); + try { + await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage); + aiRequestStatus = "succeeded"; + aiLog("success", "run.queue.task.done", { + cancelId: cancel.id, + duration: aiLogDuration(queueWaitFinishedAt), + }); + } finally { + cleanupDownloads(downloads); + aiLog("debug", "run.downloads.cleaned", {cancelId: cancel.id, count: downloads.length}); + } + return null; + }, + }); + } catch (e) { + if (controller.signal.aborted || isAbortError(e instanceof Error ? e : String(e))) { + aiRequestStatus = "aborted"; + aiRequestError = e instanceof Error ? e.message : String(e); + aiLog("warn", "run.aborted", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)}); + streamMessage.replaceText(streamMessage.getText()); + await streamMessage.finish(); + } else { + aiRequestStatus = "failed"; + aiRequestError = e instanceof Error ? e.message : String(e); + aiLog("error", "run.failed", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)}); + const errorMessage = e instanceof Error ? e.message : String(e); + await streamMessage.fail(e instanceof Error ? e : String(e)); + try { + await streamMessage.storeInternalAttachment(await persistErrorArtifactAttachment({ + provider: options.provider, + model: snapshotModel(options.provider, config), + message: errorMessage, + recoverable: false, + chatId: options.msg.chat.id, + messageId: options.msg.message_id, + })); + } catch (artifactError) { + logError(artifactError instanceof Error ? artifactError : String(artifactError)); + } + logError(errorMessage); + } + } finally { + clearTimeout(timeout); + await AiRequestStore.put({ + requestId: cancel.id, + chatId: options.msg.chat.id, + messageId: options.msg.message_id, + responseMessageId, + fromId: options.msg.from?.id ?? 0, + provider: options.provider, + model: snapshotModel(options.provider, config), + status: aiRequestStatus, + startedAt: aiRequestStartedAt, + finishedAt: new Date().toISOString(), + error: aiRequestError, + }).catch(logError); + finishAiRequest(cancel.id); + aiLog("success", "run.finished", { + cancelId: cancel.id, + provider: providerName(options.provider), + duration: aiLogDuration(startedAt), + aborted: controller.signal.aborted, + }); + } +} diff --git a/src/ai/user-request-pipeline/blueprint.ts b/src/ai/user-request-pipeline/blueprint.ts new file mode 100644 index 0000000..d923258 --- /dev/null +++ b/src/ai/user-request-pipeline/blueprint.ts @@ -0,0 +1,73 @@ +import type {PipelineFallbackPolicy, PipelineStageName} from "./types.js"; +import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./types.js"; + +export const USER_REQUEST_PIPELINE_STAGES: readonly PipelineStageName[] = [ + "receive_request", + "audit_start", + "load_user_settings", + "collect_conversation_context", + "input_size_gate", + "download_attachments", + "normalize_attachments", + "persist_input_attachments", + "prepare_text_context", + "build_system_prompt", + "resolve_runtime", + "speech_to_text", + "document_rag", + "map_provider_messages", + "tool_rank", + "filter_tools", + "model_call", + "tool_loop", + "persist_output_artifacts", + "output_size_gate", + "text_to_speech", + "send_response", + "cleanup", + "audit_finish", +]; + +export const USER_REQUEST_ATTACHMENT_LIMIT_BYTES = PIPELINE_ATTACHMENT_LIMIT_BYTES; + +export const DEFAULT_PIPELINE_FALLBACK_POLICIES: readonly PipelineFallbackPolicy[] = [ + { + stage: "input_size_gate", + onUnavailable: "fail_request", + onFailed: "notify_user", + }, + { + stage: "speech_to_text", + onUnavailable: "continue_without_stage", + onFailed: "continue_without_stage", + }, + { + stage: "document_rag", + onUnavailable: "continue_without_stage", + onFailed: "notify_user", + }, + { + stage: "tool_rank", + onUnavailable: "use_alternate_target", + onFailed: "use_alternate_target", + }, + { + stage: "tool_loop", + onUnavailable: "continue_without_stage", + onFailed: "notify_user", + }, + { + stage: "output_size_gate", + onUnavailable: "fail_request", + onFailed: "notify_user", + }, + { + stage: "text_to_speech", + onUnavailable: "continue_without_stage", + onFailed: "continue_without_stage", + }, +]; + +export function isPipelineStageName(value: string): value is PipelineStageName { + return (USER_REQUEST_PIPELINE_STAGES as readonly string[]).includes(value); +} diff --git a/src/ai/user-request-pipeline/fallback-executor.ts b/src/ai/user-request-pipeline/fallback-executor.ts new file mode 100644 index 0000000..e21b8f6 --- /dev/null +++ b/src/ai/user-request-pipeline/fallback-executor.ts @@ -0,0 +1,61 @@ +import type { + PipelineFallbackAction, + PipelineFallbackPolicy, + PipelineStageName, + PipelineStageStatus, +} from "./types.js"; + +export type PipelineFallbackReason = "unavailable" | "failed"; + +export type PipelineFallbackDecision = { + stage: PipelineStageName; + reason: PipelineFallbackReason; + action: PipelineFallbackAction; + shouldContinue: boolean; + shouldNotifyUser: boolean; + shouldFailRequest: boolean; +}; + +const DEFAULT_ACTION_BY_REASON: Record = { + unavailable: "continue_without_stage", + failed: "fail_request", +}; + +export function resolvePipelineFallbackAction(params: { + stage: PipelineStageName; + reason: PipelineFallbackReason; + policies: readonly PipelineFallbackPolicy[]; +}): PipelineFallbackAction { + const policy = params.policies.find(item => item.stage === params.stage); + if (!policy) return DEFAULT_ACTION_BY_REASON[params.reason]; + + return params.reason === "unavailable" + ? policy.onUnavailable + : policy.onFailed; +} + +export function decidePipelineFallback(params: { + stage: PipelineStageName; + reason: PipelineFallbackReason; + policies: readonly PipelineFallbackPolicy[]; +}): PipelineFallbackDecision { + const action = resolvePipelineFallbackAction(params); + + return { + stage: params.stage, + reason: params.reason, + action, + shouldContinue: action === "ignore" + || action === "continue_without_stage" + || action === "notify_user" + || action === "use_alternate_target", + shouldNotifyUser: action === "notify_user", + shouldFailRequest: action === "fail_request", + }; +} + +export function fallbackReasonFromStageStatus(status: PipelineStageStatus): PipelineFallbackReason | undefined { + if (status === "skipped") return "unavailable"; + if (status === "failed") return "failed"; + return undefined; +} diff --git a/src/ai/user-request-pipeline/index.ts b/src/ai/user-request-pipeline/index.ts new file mode 100644 index 0000000..2357f67 --- /dev/null +++ b/src/ai/user-request-pipeline/index.ts @@ -0,0 +1,6 @@ +export * from "./blueprint.js"; +export * from "./fallback-executor.js"; +export * from "./pipeline.js"; +export * from "./size-gate.js"; +export * from "./telegram-message-stages.js"; +export * from "./types.js"; diff --git a/src/ai/user-request-pipeline/pipeline.ts b/src/ai/user-request-pipeline/pipeline.ts new file mode 100644 index 0000000..df56104 --- /dev/null +++ b/src/ai/user-request-pipeline/pipeline.ts @@ -0,0 +1,133 @@ +import {DEFAULT_PIPELINE_FALLBACK_POLICIES, USER_REQUEST_PIPELINE_STAGES} from "./blueprint.js"; +import {decidePipelineFallback, type PipelineFallbackDecision} from "./fallback-executor.js"; +import type { + PipelineAuditEvent, + PipelineFallbackPolicy, + PipelineStageName, + PipelineStageResult, + UserRequestPipelineStage, + UserRequestPipelineState, +} from "./types.js"; + +export type UserRequestPipelineOptions = { + stages: UserRequestPipelineStage[]; + stageNames?: readonly PipelineStageName[]; + fallbackPolicies?: readonly PipelineFallbackPolicy[]; + onFallback?: (decision: PipelineFallbackDecision) => Promise | void; +}; + +function nowIso(): string { + return new Date().toISOString(); +} + +function durationMs(startedAt: number): number { + return Date.now() - startedAt; +} + +function stageEvent(event: PipelineAuditEvent): PipelineAuditEvent { + return event; +} + +export class UserRequestPipeline { + private readonly stages = new Map(); + private readonly stageNames: readonly PipelineStageName[]; + private readonly fallbackPolicies: readonly PipelineFallbackPolicy[]; + private readonly onFallback?: (decision: PipelineFallbackDecision) => Promise | void; + + constructor(options: UserRequestPipelineOptions) { + for (const stage of options.stages) { + this.stages.set(stage.name, stage); + } + this.stageNames = options.stageNames ?? USER_REQUEST_PIPELINE_STAGES; + this.fallbackPolicies = options.fallbackPolicies ?? DEFAULT_PIPELINE_FALLBACK_POLICIES; + this.onFallback = options.onFallback; + } + + async run(state: UserRequestPipelineState, signal: AbortSignal): Promise { + for (const stageName of this.stageNames) { + if (signal.aborted) throw new Error("Aborted"); + + const stage = this.stages.get(stageName); + if (!stage) { + const decision = decidePipelineFallback({ + stage: stageName, + reason: "unavailable", + policies: this.fallbackPolicies, + }); + await this.onFallback?.(decision); + state.audit.push(stageEvent({ + stage: stageName, + status: "skipped", + startedAt: nowIso(), + finishedAt: nowIso(), + details: { + reason: "stage_not_registered", + fallbackAction: decision.action, + }, + })); + if (decision.shouldFailRequest) { + throw new Error(`Required pipeline stage is not registered: ${stageName}`); + } + continue; + } + + const startedAtMs = Date.now(); + const startedAt = nowIso(); + state.audit.push(stageEvent({ + stage: stageName, + status: "running", + startedAt, + })); + + try { + const result = await stage.run(state, signal); + this.applyStageResult(state, result); + state.audit.push(stageEvent({ + stage: stageName, + status: result.status, + startedAt, + finishedAt: nowIso(), + durationMs: durationMs(startedAtMs), + details: result.fallbackAction || result.details + ? { + ...(result.details ?? {}), + ...(result.fallbackAction ? {fallbackAction: result.fallbackAction} : {}), + } + : undefined, + })); + } catch (error) { + const decision = decidePipelineFallback({ + stage: stageName, + reason: "failed", + policies: this.fallbackPolicies, + }); + await this.onFallback?.(decision); + state.audit.push(stageEvent({ + stage: stageName, + status: "failed", + startedAt, + finishedAt: nowIso(), + durationMs: durationMs(startedAtMs), + details: {fallbackAction: decision.action}, + error: error instanceof Error ? error.message : String(error), + })); + if (decision.shouldFailRequest) { + throw error; + } + } + } + + return state; + } + + private applyStageResult(state: UserRequestPipelineState, result: PipelineStageResult): void { + if (result.artifacts?.length) { + state.artifacts.push(...result.artifacts); + } + + if (result.attachments?.length) { + state.outputAttachments.push(...result.attachments.filter(attachment => attachment.direction === "output")); + state.inputAttachments.push(...result.attachments.filter(attachment => attachment.direction === "input")); + } + } +} diff --git a/src/ai/user-request-pipeline/size-gate.ts b/src/ai/user-request-pipeline/size-gate.ts new file mode 100644 index 0000000..8389b59 --- /dev/null +++ b/src/ai/user-request-pipeline/size-gate.ts @@ -0,0 +1,51 @@ +import {PIPELINE_ATTACHMENT_LIMIT_BYTES, type PersistentAttachment} from "./types.js"; + +export type AttachmentSizeGateResult = + | { + ok: true; + attachment: PersistentAttachment; + } + | { + ok: false; + attachment: PersistentAttachment; + limitBytes: number; + reason: string; + }; + +export function validateAttachmentSize( + attachment: PersistentAttachment, + limitBytes: number = PIPELINE_ATTACHMENT_LIMIT_BYTES, +): AttachmentSizeGateResult { + if (attachment.sizeBytes <= limitBytes) { + return {ok: true, attachment}; + } + + return { + ok: false, + attachment, + limitBytes, + reason: `Attachment ${attachment.fileName} is larger than ${limitBytes} bytes.`, + }; +} + +export function splitAttachmentsBySize( + attachments: readonly PersistentAttachment[], + limitBytes: number = PIPELINE_ATTACHMENT_LIMIT_BYTES, +): { + accepted: PersistentAttachment[]; + rejected: AttachmentSizeGateResult[]; +} { + const accepted: PersistentAttachment[] = []; + const rejected: AttachmentSizeGateResult[] = []; + + for (const attachment of attachments) { + const result = validateAttachmentSize(attachment, limitBytes); + if (result.ok) { + accepted.push(result.attachment); + } else { + rejected.push(result); + } + } + + return {accepted, rejected}; +} diff --git a/src/ai/user-request-pipeline/telegram-message-stages.ts b/src/ai/user-request-pipeline/telegram-message-stages.ts new file mode 100644 index 0000000..6b32083 --- /dev/null +++ b/src/ai/user-request-pipeline/telegram-message-stages.ts @@ -0,0 +1,189 @@ +import type {Message} from "typescript-telegram-bot-api"; +import {AiProvider} from "../../model/ai-provider"; +import type {StoredMessage} from "../../model/stored-message"; +import type {StoredAttachment} from "../../model/stored-attachment"; +import {MessageStore} from "../../common/message-store"; +import {Environment} from "../../common/environment"; +import { + DEFAULT_AI_IMAGE_OUTPUT_MODE, + DEFAULT_AI_RESPONSE_LANGUAGE, + DEFAULT_AI_VOICE_MODE, +} from "../../common/user-ai-settings"; +import { + cacheMessageAttachmentsWithRejections, + collectTelegramAttachmentDescriptors, + type RejectedTelegramAttachment, +} from "../telegram-attachments"; +import {PIPELINE_ATTACHMENT_LIMIT_BYTES, type PersistentAttachment, type UserRequestPipelineState} from "./types"; +import {UserRequestPipeline} from "./pipeline"; +import type {UserRequestPipelineStage} from "./types"; + +type TelegramMessageAttachmentPipelineResult = { + state: UserRequestPipelineState; + storedMessage: StoredMessage; + attachments: StoredAttachment[]; + rejected: RejectedTelegramAttachment[]; +}; + +function nowIso(): string { + return new Date().toISOString(); +} + +function requestIdFor(msg: Message): string { + return `telegram:${msg.chat.id}:${msg.message_id}:${Date.now()}`; +} + +function rejectedKey(attachment: Pick): string { + return `${attachment.fileId}:${attachment.fileName}`; +} + +function dedupeRejected(attachments: RejectedTelegramAttachment[]): RejectedTelegramAttachment[] { + const seen = new Set(); + const result: RejectedTelegramAttachment[] = []; + + for (const attachment of attachments) { + const key = rejectedKey(attachment); + if (seen.has(key)) continue; + seen.add(key); + result.push(attachment); + } + + return result; +} + +function storedToPersistentAttachment(msg: Message, attachment: StoredAttachment): PersistentAttachment { + return { + direction: "input", + kind: attachment.kind, + fileId: attachment.fileId, + fileUniqueId: attachment.fileUniqueId, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes ?? 0, + cachePath: attachment.cachePath, + sha256: attachment.sha256, + sourceChatId: msg.chat.id, + sourceMessageId: msg.message_id, + }; +} + +export async function runTelegramMessageAttachmentPipeline( + msg: Message, + storedMessage: StoredMessage, +): Promise { + let downloadedAttachments: StoredAttachment[] = []; + let rejectedAttachments: RejectedTelegramAttachment[] = []; + let persistedMessage = storedMessage; + + const state: UserRequestPipelineState = { + requestId: requestIdFor(msg), + chatId: msg.chat.id, + messageId: msg.message_id, + replyToMessageId: msg.reply_to_message?.message_id, + fromId: msg.from?.id ?? storedMessage.fromId, + receivedAt: nowIso(), + text: storedMessage.text ?? msg.text ?? msg.caption ?? "", + settings: { + provider: Environment.DEFAULT_AI_PROVIDER ?? AiProvider.OLLAMA, + responseLanguage: DEFAULT_AI_RESPONSE_LANGUAGE, + voiceMode: DEFAULT_AI_VOICE_MODE, + imageOutputMode: DEFAULT_AI_IMAGE_OUTPUT_MODE, + }, + inputAttachments: [], + outputAttachments: [], + artifacts: [], + toolRankDecisions: [], + audit: [], + }; + + const stages: UserRequestPipelineStage[] = [ + { + name: "input_size_gate", + async run() { + rejectedAttachments = dedupeRejected([ + ...rejectedAttachments, + ...collectTelegramAttachmentDescriptors(msg) + .filter(attachment => (attachment.sizeBytes ?? 0) > PIPELINE_ATTACHMENT_LIMIT_BYTES) + .map(attachment => ({ + kind: attachment.kind, + fileId: attachment.fileId, + fileUniqueId: attachment.fileUniqueId, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes ?? 0, + limitBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES, + reason: "too_large" as const, + })), + ]); + + return { + stage: "input_size_gate", + status: rejectedAttachments.length ? "fallback" : "succeeded", + fallbackAction: rejectedAttachments.length ? "notify_user" : undefined, + }; + }, + }, + { + name: "download_attachments", + async run() { + const result = await cacheMessageAttachmentsWithRejections(msg); + downloadedAttachments = result.attachments; + rejectedAttachments = dedupeRejected([...rejectedAttachments, ...result.rejected]); + + return { + stage: "download_attachments", + status: "succeeded", + }; + }, + }, + { + name: "normalize_attachments", + async run() { + return { + stage: "normalize_attachments", + status: "succeeded", + attachments: downloadedAttachments.map(attachment => storedToPersistentAttachment(msg, attachment)), + }; + }, + }, + { + name: "persist_input_attachments", + async run() { + if (downloadedAttachments.length) { + persistedMessage = { + ...persistedMessage, + attachments: downloadedAttachments, + }; + persistedMessage = await MessageStore.put(persistedMessage); + } + + return { + stage: "persist_input_attachments", + status: "succeeded", + }; + }, + }, + ]; + + const pipeline = new UserRequestPipeline({ + stages, + stageNames: [ + "input_size_gate", + "download_attachments", + "normalize_attachments", + "persist_input_attachments", + ], + }); + await pipeline.run(state, new AbortController().signal); + persistedMessage = await MessageStore.put({ + ...persistedMessage, + pipelineAudit: state.audit, + }); + + return { + state, + storedMessage: persistedMessage, + attachments: downloadedAttachments, + rejected: rejectedAttachments, + }; +} diff --git a/src/ai/user-request-pipeline/types.ts b/src/ai/user-request-pipeline/types.ts new file mode 100644 index 0000000..b70d82f --- /dev/null +++ b/src/ai/user-request-pipeline/types.ts @@ -0,0 +1,233 @@ +import type {AiProvider} from "../../model/ai-provider"; +import type {StoredAttachmentKind} from "../../model/stored-attachment"; +import type { + UserAiImageOutputMode, + UserAiResponseLanguage, + UserAiVoiceMode, +} from "../../common/user-ai-settings"; + +export const PIPELINE_ATTACHMENT_LIMIT_BYTES = 50 * 1024 * 1024; + +export type PipelineStageName = + | "receive_request" + | "audit_start" + | "load_user_settings" + | "collect_conversation_context" + | "input_size_gate" + | "download_attachments" + | "normalize_attachments" + | "persist_input_attachments" + | "prepare_text_context" + | "build_system_prompt" + | "resolve_runtime" + | "speech_to_text" + | "document_rag" + | "map_provider_messages" + | "tool_rank" + | "filter_tools" + | "model_call" + | "tool_loop" + | "persist_output_artifacts" + | "output_size_gate" + | "text_to_speech" + | "send_response" + | "cleanup" + | "audit_finish"; + +export type PipelineStageStatus = + | "pending" + | "running" + | "succeeded" + | "skipped" + | "failed" + | "fallback"; + +export type PipelineFallbackAction = + | "ignore" + | "notify_user" + | "continue_without_stage" + | "use_alternate_target" + | "fail_request"; + +export type PipelineFallbackPolicy = { + stage: PipelineStageName; + onUnavailable: PipelineFallbackAction; + onFailed: PipelineFallbackAction; +}; + +export type PipelineUserSettings = { + provider: AiProvider; + responseLanguage: UserAiResponseLanguage; + contextSize?: number; + voiceMode: UserAiVoiceMode; + imageOutputMode: UserAiImageOutputMode; +}; + +export type PipelineRuntimeTarget = { + provider: AiProvider; + purpose: + | "chat" + | "toolRank" + | "documents" + | "speechToText" + | "textToSpeech" + | "outputImages" + | "tools"; + model: string; + baseUrl?: string; +}; + +export type PipelineRuntimePlan = { + chat: PipelineRuntimeTarget; + toolRank?: PipelineRuntimeTarget; + documents?: PipelineRuntimeTarget; + speechToText?: PipelineRuntimeTarget; + textToSpeech?: PipelineRuntimeTarget; + outputImages?: PipelineRuntimeTarget; + tools?: PipelineRuntimeTarget; +}; + +export type PipelineAttachmentDirection = "input" | "output"; + +export type PersistentAttachment = { + id?: string; + direction: PipelineAttachmentDirection; + kind: StoredAttachmentKind | "file"; + fileId?: string; + fileUniqueId?: string; + fileName: string; + mimeType?: string; + sizeBytes: number; + cachePath?: string; + sha256?: string; + sourceChatId?: number; + sourceMessageId?: number; +}; + +export type PipelineArtifactKind = + | "transcript" + | "rag" + | "tool_result" + | "generated_file" + | "tts_audio" + | "final_text" + | "error"; + +export type PipelineArtifactBase = { + id?: string; + kind: PipelineArtifactKind; + stage: PipelineStageName; + requestId?: string; + messageChatId?: number; + messageId?: number; + createdAt: string; +}; + +export type TranscriptArtifact = PipelineArtifactBase & { + kind: "transcript"; + text: string; + sourceAttachmentIds: string[]; + model?: string; +}; + +export type RagArtifact = PipelineArtifactBase & { + kind: "rag"; + sourceAttachmentIds: string[]; + provider: AiProvider; + extractedText?: string; + chunks?: Array<{ + id: string; + sourceName: string; + text: string; + score?: number; + }>; + providerState?: { + vectorStoreIds?: string[]; + libraryId?: string; + documentIds?: string[]; + }; +}; + +export type ToolResultArtifact = PipelineArtifactBase & { + kind: "tool_result"; + toolName: string; + callId: string; + resultText: string; + outputAttachmentIds?: string[]; +}; + +export type GeneratedFileArtifact = PipelineArtifactBase & { + kind: "generated_file" | "tts_audio"; + attachmentId: string; +}; + +export type FinalTextArtifact = PipelineArtifactBase & { + kind: "final_text"; + text: string; +}; + +export type ErrorArtifact = PipelineArtifactBase & { + kind: "error"; + errorCode?: string; + message: string; + recoverable: boolean; +}; + +export type PipelineArtifact = + | TranscriptArtifact + | RagArtifact + | ToolResultArtifact + | GeneratedFileArtifact + | FinalTextArtifact + | ErrorArtifact; + +export type PipelineAuditEvent = { + stage: PipelineStageName; + status: PipelineStageStatus; + startedAt?: string; + finishedAt?: string; + durationMs?: number; + provider?: AiProvider; + model?: string; + details?: Record; + error?: string; +}; + +export type ToolRankDecision = { + provider: AiProvider; + round: number; + availableTools: string[]; + selectedTools: string[]; + usedRanker: boolean; +}; + +export type UserRequestPipelineState = { + requestId: string; + chatId: number; + messageId: number; + replyToMessageId?: number; + fromId: number; + receivedAt: string; + text: string; + settings: PipelineUserSettings; + runtime?: PipelineRuntimePlan; + inputAttachments: PersistentAttachment[]; + outputAttachments: PersistentAttachment[]; + artifacts: PipelineArtifact[]; + toolRankDecisions: ToolRankDecision[]; + audit: PipelineAuditEvent[]; +}; + +export type PipelineStageResult = { + stage: PipelineStageName; + status: PipelineStageStatus; + artifacts?: PipelineArtifact[]; + attachments?: PersistentAttachment[]; + details?: Record; + fallbackAction?: PipelineFallbackAction; +}; + +export interface UserRequestPipelineStage { + readonly name: PipelineStageName; + run(state: UserRequestPipelineState, signal: AbortSignal): Promise; +} diff --git a/src/base/callback-command.ts b/src/base/callback-command.ts index 0f3852a..17a9c38 100644 --- a/src/base/callback-command.ts +++ b/src/base/callback-command.ts @@ -3,17 +3,18 @@ import {CallbackQuery, InlineKeyboardButton} from "typescript-telegram-bot-api"; import {Requirements} from "./requirements"; import {bot} from "../index"; import {logError} from "../util/utils"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; export abstract class CallbackCommand { abstract text: string; abstract data: string; - requirements?: Requirements = null; + requirements?: Requirements | null = null; abstract execute(query: CallbackQuery): Promise; // eslint-disable-next-line @typescript-eslint/no-unused-vars - afterExecute(query: CallbackQuery): Promise { + afterExecute(_query: CallbackQuery): Promise { return Promise.resolve(); } @@ -23,7 +24,10 @@ export abstract class CallbackCommand { } async answerCallbackQuery(query: CallbackQuery): Promise { - bot.answerCallbackQuery(this.getOptions(query)).catch(logError); + enqueueTelegramApiCall( + () => bot.answerCallbackQuery(this.getOptions(query)), + {method: "answerCallbackQuery", skipPerChatLimit: true} + ).catch(logError); } asButton(): InlineKeyboardButton { @@ -40,4 +44,4 @@ export interface AnswerCallbackQueryOptions { show_alert?: boolean; url?: string; cache_time?: number; -} \ No newline at end of file +} diff --git a/src/base/command.ts b/src/base/command.ts index b7a5301..6cf6a86 100644 --- a/src/base/command.ts +++ b/src/base/command.ts @@ -9,7 +9,7 @@ export abstract class Command { command?: string | string[]; argsMode: ArgsMode = "none"; - requirements?: Requirements = null; + requirements?: Requirements | null = null; title?: string; description?: string; @@ -24,7 +24,7 @@ export abstract class Command { abstract execute( msg: Message, - match?: RegExpExecArray + match?: RegExpExecArray | null ): Promise; } @@ -51,8 +51,8 @@ export function createCommandRegExp( argsMode === "none" ? "\\s*$" : argsMode === "required" - ? "\\s+([\\s\\S]+)\\s*$" // (3)=args обязателен - : "(?:\\s+([\\s\\S]+))?\\s*$"; // (3)=args опционален + ? "\\s+([\\s\\S]+)\\s*$" // (3)=args required + : "(?:\\s+([\\s\\S]+))?\\s*$"; // (3)=args optional return new RegExp(base + tail, "i"); } diff --git a/src/base/dao.ts b/src/base/dao.ts index 9e1801b..2d96e77 100644 --- a/src/base/dao.ts +++ b/src/base/dao.ts @@ -1,9 +1,9 @@ -export abstract class Dao { +export abstract class Dao { abstract getAll(): Promise; - abstract getById(params: never): Promise + abstract getById(params: GetByIdParams): Promise; - abstract getByIds(params: never): Promise + abstract getByIds(params: GetByIdsParams): Promise; - abstract insert(items: never[]): Promise -} \ No newline at end of file + abstract insert(items: InsertParams): Promise; +} diff --git a/src/base/requirements.ts b/src/base/requirements.ts index 00c1ff3..c0831e1 100644 --- a/src/base/requirements.ts +++ b/src/base/requirements.ts @@ -4,7 +4,7 @@ export class Requirements { requirements: Requirement[] = []; private constructor(requirements?: Requirement[]) { - this.requirements = requirements; + this.requirements = requirements || []; } static Build(...requirements: Requirement[]): Requirements { diff --git a/src/callback_commands/ai-cancel.ts b/src/callback_commands/ai-cancel.ts new file mode 100644 index 0000000..0264c20 --- /dev/null +++ b/src/callback_commands/ai-cancel.ts @@ -0,0 +1,137 @@ +import {CallbackCommand} from "../base/callback-command"; +import {CallbackQuery, InlineKeyboardMarkup, Message} from "typescript-telegram-bot-api"; +import {abortAiRequest, getAiCancelRequest} from "../ai/cancel-registry"; +import {Requirements} from "../base/requirements"; +import {Requirement} from "../base/requirement"; +import {Environment} from "../common/environment"; +import {AiProvider} from "../model/ai-provider"; +import {MessageStore} from "../common/message-store"; +import {bot} from "../index"; +import {buildCancelledGenerationText, logError} from "../util/utils"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; +import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer"; +import {buildAiRegenerateCallbackData} from "../ai/regenerate-callback"; +import {isAiProviderConfigured, resolveEffectiveAiProviderForUser} from "../common/user-ai-settings"; + +const TELEGRAM_TEXT_LIMIT = 4096; +const TELEGRAM_CAPTION_LIMIT = 1024; + +export class AiCancel extends CallbackCommand { + data = "/cancel_ai"; + text = Environment.aiCancelCallbackText; + + requirements = Requirements.Build(Requirement.SAME_USER); + + async execute(query: CallbackQuery): Promise { + if (!query.message || !query.data) return; + + const parsed = this.parseCallbackData(query.data); + if (!parsed) return; + + const request = getAiCancelRequest(parsed.requestId); + if (!request) { + await this.markMessageAsCancelled(query, parsed.provider); + return; + } + if (request.fromId !== query.from.id && query.from.id !== Environment.CREATOR_ID) return; + + const cancelled = await abortAiRequest(parsed.requestId); + if (!cancelled) return; + } + + private parseCallbackData(data: string): { requestId: string; provider?: AiProvider } | null { + const [, requestId, provider] = data.split(/\s+/); + if (!requestId) return null; + + return { + requestId, + provider: Object.values(AiProvider).includes(provider as AiProvider) ? provider as AiProvider : undefined, + }; + } + + private async markMessageAsCancelled(query: CallbackQuery, providerFromCallback?: AiProvider): Promise { + const callbackMessage = query.message; + if (!callbackMessage || callbackMessage.date === 0) return; + const message = callbackMessage as Message; + + const stored = await MessageStore.get(message.chat.id, message.message_id).catch(e => { + logError(e); + return null; + }); + const sourceFromId = await this.resolveSourceFromId(message, stored).catch(e => { + logError(e); + return undefined; + }); + const regenerateProvider = providerFromCallback && isAiProviderConfigured(providerFromCallback) + ? providerFromCallback + : await resolveEffectiveAiProviderForUser(sourceFromId ?? query.from.id); + const providerName = (providerFromCallback ?? regenerateProvider).toLowerCase(); + const isCaption = this.isCaptionMessage(message); + const limit = isCaption ? TELEGRAM_CAPTION_LIMIT : TELEGRAM_TEXT_LIMIT; + const baseText = stored?.text ?? message.text ?? message.caption ?? ""; + const cancelledText = buildCancelledGenerationText(baseText, providerName, limit); + const replyMarkup = this.regenerateKeyboard(regenerateProvider); + const formatted = prepareTelegramMarkdownV2(cancelledText, {mode: "final"}); + const deletedByBotAt = Math.floor(Date.now() / 1000); + + try { + await enqueueTelegramApiCall( + () => isCaption + ? bot.editMessageCaption({ + chat_id: message.chat.id, + message_id: message.message_id, + caption: formatted, + parse_mode: "MarkdownV2", + reply_markup: replyMarkup, + }) + : bot.editMessageText({ + chat_id: message.chat.id, + message_id: message.message_id, + text: formatted, + parse_mode: "MarkdownV2", + reply_markup: replyMarkup, + }), + {method: isCaption ? "editMessageCaption" : "editMessageText", chatId: message.chat.id, chatType: message.chat.type} + ); + + await MessageStore.put({ + chatId: message.chat.id, + id: message.message_id, + replyToMessageId: stored?.replyToMessageId ?? this.replyToMessageId(message), + fromId: message.from?.id ?? stored?.fromId ?? 0, + text: cancelledText, + quoteText: stored?.quoteText, + date: message.date ?? stored?.date ?? deletedByBotAt, + deletedByBotAt, + attachments: stored?.attachments, + }); + } catch (e) { + logError(e instanceof Error ? e : String(e)); + } + } + + private regenerateKeyboard(provider: AiProvider): InlineKeyboardMarkup { + return { + inline_keyboard: [[{ + text: Environment.regenerateText, + callback_data: buildAiRegenerateCallbackData(provider), + }]], + }; + } + + private async resolveSourceFromId(message: Message, stored: Awaited>): Promise { + const reply = "reply_to_message" in message ? message.reply_to_message : undefined; + if (reply?.from?.id) return reply.from.id; + + const source = await MessageStore.get(message.chat.id, stored?.replyToMessageId); + return source?.fromId; + } + + private replyToMessageId(message: Message): number | undefined { + return "reply_to_message" in message ? message.reply_to_message?.message_id : undefined; + } + + private isCaptionMessage(message: Message): boolean { + return message.caption !== undefined; + } +} diff --git a/src/callback_commands/ai-regenerate.ts b/src/callback_commands/ai-regenerate.ts new file mode 100644 index 0000000..d77ca27 --- /dev/null +++ b/src/callback_commands/ai-regenerate.ts @@ -0,0 +1,85 @@ +import {CallbackQuery, Message} from "typescript-telegram-bot-api"; +import {CallbackCommand} from "../base/callback-command"; +import {Requirements} from "../base/requirements"; +import {Requirement} from "../base/requirement"; +import {MessageStore} from "../common/message-store"; +import {StoredMessage} from "../model/stored-message"; +import {cutPrefixes, logError} from "../util/utils"; +import {runUnifiedAi} from "../ai/unified-ai-runner"; +import {AI_REGENERATE_CALLBACK, parseAiRegenerateCallbackData} from "../ai/regenerate-callback"; +import {resolveEffectiveAiProviderForUser} from "../common/user-ai-settings"; +import {Environment} from "../common/environment"; + +export class AiRegenerate extends CallbackCommand { + data = AI_REGENERATE_CALLBACK; + text = Environment.aiRegenerateCallbackText; + + requirements = Requirements.Build(Requirement.SAME_USER); + + async execute(query: CallbackQuery): Promise { + if (!query.message || !query.data) return; + + const parsed = parseAiRegenerateCallbackData(query.data); + if (!parsed) return; + + const source = await this.resolveSourceMessage(query); + if (!source) return; + + const sourceFromId = source.stored?.fromId ?? source.message.from?.id; + if (!sourceFromId || (sourceFromId !== query.from.id && query.from.id !== Environment.CREATOR_ID)) return; + + const provider = + // isAiProviderConfigured(parsed.provider) + // ? parsed.provider + // : + await resolveEffectiveAiProviderForUser(source.message.from?.id ?? query.from.id); + const text = cutPrefixes(source.stored ?? source.message) ?? ""; + + runUnifiedAi({ + provider, + msg: source.message, + text, + stream: true, + think: parsed.think, + targetMessage: query.message, + }).catch(logError); + } + + private async resolveSourceMessage(query: CallbackQuery): Promise<{ + message: Message; + stored: StoredMessage | null; + } | null> { + const responseMessage = query.message; + if (!responseMessage) return null; + + const directSource = "reply_to_message" in responseMessage ? responseMessage.reply_to_message : undefined; + if (directSource) { + const stored = await MessageStore.put(directSource).catch(e => { + logError(e); + return null; + }); + return {message: directSource, stored}; + } + + const storedResponse = await MessageStore.get(responseMessage.chat.id, responseMessage.message_id); + const storedSource = await MessageStore.get(responseMessage.chat.id, storedResponse?.replyToMessageId); + if (!storedSource) return null; + + return { + message: this.storedToMessage(storedSource, responseMessage, query), + stored: storedSource, + }; + } + + private storedToMessage(stored: StoredMessage, responseMessage: Message, query: CallbackQuery): Message { + return { + message_id: stored.id, + chat: responseMessage.chat, + date: stored.date, + from: query.from.id === stored.fromId + ? query.from + : {id: stored.fromId, is_bot: false, first_name: ""}, + text: stored.text ?? undefined, + } as Message; + } +} diff --git a/src/callback_commands/cancel.ts b/src/callback_commands/cancel.ts index 69e2eb2..11deecc 100644 --- a/src/callback_commands/cancel.ts +++ b/src/callback_commands/cancel.ts @@ -1,9 +1,10 @@ import {CallbackCommand} from "../base/callback-command"; +import {Environment} from "../common/environment"; export class Cancel extends CallbackCommand { - text = "❌ Отменить"; - data = null; + text = Environment.cancelText; + data = ""; constructor(text?: string, data?: string) { super(); @@ -13,10 +14,10 @@ export class Cancel extends CallbackCommand { } static withData(data?: string): Cancel { - return new Cancel(null, data); + return new Cancel(undefined, data); } async execute(): Promise { return Promise.resolve(); } -} \ No newline at end of file +} diff --git a/src/callback_commands/download-yt-video.ts b/src/callback_commands/download-yt-video.ts deleted file mode 100644 index d867540..0000000 --- a/src/callback_commands/download-yt-video.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {CallbackCommand} from "../base/callback-command"; -import {CallbackQuery} from "typescript-telegram-bot-api"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {commands} from "../index"; -import {YouTubeDownload} from "../commands/youtube-download"; - -const downloadText = " 📥 Скачать"; -const getFromCacheText = "📥 Загрузить из кэша"; - -export class DownloadYtVideo extends CallbackCommand { - data = "/ytdl"; - text = " 📥 Скачать"; - - requirements = Requirements.Build(Requirement.SAME_USER); - - constructor(text?: string, data?: string) { - super(); - - this.text = text || this.text; - this.data = data || this.data; - } - - static withData(inCache?: boolean, data?: string): DownloadYtVideo { - return new DownloadYtVideo(inCache ? getFromCacheText : downloadText, data); - } - - async execute(query: CallbackQuery): Promise { - const videoId = query.data.split(" ")[1]; - if (!videoId) return; - - const yt = commands.find(c => c instanceof YouTubeDownload); - if (!yt) return; - await yt.downloadYouTubeVideo(query.message, {videoId: videoId}); - } -} \ No newline at end of file diff --git a/src/callback_commands/ollama-cancel.ts b/src/callback_commands/ollama-cancel.ts deleted file mode 100644 index 93b238a..0000000 --- a/src/callback_commands/ollama-cancel.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 {MessageStore} from "../common/message-store"; -import {StoredMessage} from "../model/stored-message"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {Environment} from "../common/environment"; - -export class OllamaCancel extends CallbackCommand { - - data = "/cancel_ollama"; - text = "Cancel Ollama generation"; - - requirements = Requirements.Build(Requirement.SAME_USER); - - async execute(query: CallbackQuery): Promise { - const chatId = query.message.chat.id; - const fromId = query.from.id; - const messageId = query.message.message_id; - - const uuid = query.data.split(" ")[1]; - if (!uuid) return; - - const request = getOllamaRequest(uuid); - if (request) { - if (request.fromId !== fromId && fromId !== Environment.CREATOR_ID) return; - - const aborted = abortOllamaRequest(uuid); - console.log(`aborted request ${uuid}:`, aborted); - } else { - console.log(`no request with uuid "${uuid}" found`); - } - - let msg: StoredMessage | null = null; - try { - msg = await MessageStore.get(chatId, messageId); - } catch (e) { - logError(e); - } - - console.log(`Message for ${chatId}-${messageId}:`, msg); - - let content: string | null = null; - - if (msg?.text?.trim()?.length > 0) { - content = msg?.text.trim(); - if (content.length + Environment.ollamaCancelledText.length > 4096) { - content = content.substring(0, 4096 - Environment.ollamaCancelledText.length - 2) + "\n"; - } - } - - const newText = `${content ? content : ""}${Environment.ollamaCancelledText}`; - - try { - await bot.editMessageText({ - chat_id: chatId, - message_id: messageId, - text: newText, - parse_mode: "Markdown", - reply_markup: {inline_keyboard: []}, - }); - - if (msg) { - await MessageStore.put(msg); - } - } catch (e) { - logError(e); - } - } -} \ No newline at end of file diff --git a/src/callback_commands/try-again.ts b/src/callback_commands/try-again.ts deleted file mode 100644 index 29af38a..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(null, data); - } - - async execute(): Promise { - return Promise.resolve(); - } -} \ No newline at end of file diff --git a/src/callback_commands/user-settings.ts b/src/callback_commands/user-settings.ts new file mode 100644 index 0000000..ef6f4f2 --- /dev/null +++ b/src/callback_commands/user-settings.ts @@ -0,0 +1,110 @@ +import {CallbackQuery} from "typescript-telegram-bot-api"; +import {CallbackCommand} from "../base/callback-command"; +import {UserStore} from "../common/user-store"; +import { + ensureValidUserAiSettings, + normalizeAiContextSizeChoice, + normalizeAiImageOutputMode, + normalizeAiProviderChoice, + normalizeAiResponseLanguage, + normalizeAiVoiceMode, + normalizeInterfaceLanguage, + resolveInterfaceLocaleForUser, + setUserAiContextSizeChoice, + setUserAiImageOutputMode, + setUserAiProviderChoice, + setUserAiResponseLanguage, + setUserAiVoiceMode, + setUserInterfaceLanguage, +} from "../common/user-ai-settings"; +import { + buildUserSettingsKeyboard, + formatUserSettingsText, + parseUserSettingsCallbackData, + USER_SETTINGS_CALLBACK_PREFIX, + UserSettingsScreen, +} from "../common/user-settings-view"; +import {editMessageText, ignoreIfNotChanged, logError} from "../util/utils"; +import {Environment} from "../common/environment"; +import {Localization} from "../common/localization"; + +export class UserSettingsCallback extends CallbackCommand { + data = USER_SETTINGS_CALLBACK_PREFIX; + text = Environment.userSettingsCallbackText; + + async execute(query: CallbackQuery): Promise { + if (!query.message || !query.data) return; + + const message = query.message; + const parsed = parseUserSettingsCallbackData(query.data); + if (!parsed || parsed.userId !== query.from.id) return; + + await UserStore.put(query.from); + + let screen: UserSettingsScreen = parsed.screen; + let settings = await ensureValidUserAiSettings(query.from.id); + + if (parsed.screen === "provider" && parsed.providerChoice) { + const choice = normalizeAiProviderChoice(parsed.providerChoice); + if (choice) { + const result = await setUserAiProviderChoice(query.from.id, choice); + settings = result.settings; + } + screen = "provider"; + } + + if (parsed.screen === "interfaceLanguage" && parsed.interfaceLanguage) { + const language = normalizeInterfaceLanguage(parsed.interfaceLanguage); + if (language) { + const result = await setUserInterfaceLanguage(query.from.id, language); + settings = result.settings; + } + screen = "interfaceLanguage"; + } + + if (parsed.screen === "responseLanguage" && parsed.responseLanguage) { + const language = normalizeAiResponseLanguage(parsed.responseLanguage); + if (language) { + const result = await setUserAiResponseLanguage(query.from.id, language); + settings = result.settings; + } + screen = "responseLanguage"; + } + + if (parsed.screen === "contextSize" && parsed.contextSizeChoice) { + const choice = normalizeAiContextSizeChoice(parsed.contextSizeChoice); + if (choice || choice === -1) { + const result = await setUserAiContextSizeChoice(query.from.id, choice); + settings = result.settings; + } + screen = "contextSize"; + } + + if (parsed.screen === "voiceMode" && parsed.voiceMode) { + const mode = normalizeAiVoiceMode(parsed.voiceMode); + if (mode) { + const result = await setUserAiVoiceMode(query.from.id, mode); + settings = result.settings; + } + screen = "voiceMode"; + } + + if (parsed.screen === "imageOutput" && parsed.imageOutputMode) { + const mode = normalizeAiImageOutputMode(parsed.imageOutputMode); + if (mode) { + const result = await setUserAiImageOutputMode(query.from.id, mode); + settings = result.settings; + } + screen = "imageOutput"; + } + + const locale = await resolveInterfaceLocaleForUser(query.from.id, query.from.language_code); + + await Localization.runWithLocale(locale, () => editMessageText({ + chat_id: message.chat.id, + message_id: message.message_id, + text: formatUserSettingsText(settings, screen), + reply_markup: buildUserSettingsKeyboard(settings, screen), + })).catch(ignoreIfNotChanged).catch(logError); + } +} diff --git a/src/callback_commands/yt-info.ts b/src/callback_commands/yt-info.ts deleted file mode 100644 index 8a676d7..0000000 --- a/src/callback_commands/yt-info.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {CallbackCommand} from "../base/callback-command"; -import {CallbackQuery} from "typescript-telegram-bot-api"; -import {processYouTubeLink} from "../util/utils"; - -export class YtInfo extends CallbackCommand { - data = "/ytinfo"; - text: string; - - async execute(query: CallbackQuery): Promise { - const videoId = query.data.split(" ")[1]; - if (!videoId) return; - - await processYouTubeLink(query.message, null, videoId); - } -} \ No newline at end of file diff --git a/src/commands/admins-add.ts b/src/commands/admins-add.ts index e33272a..b99901e 100644 --- a/src/commands/admins-add.ts +++ b/src/commands/admins-add.ts @@ -8,8 +8,8 @@ import {botUser} from "../index"; export class AdminsAdd extends Command { command = "addAdmin"; - title = "/addAdmin"; - description = "Add user to admins"; + title = Environment.commandTitles.adminsAdd; + description = Environment.commandDescriptions.adminsAdd; requirements = Requirements.Build( Requirement.BOT_CREATOR, @@ -18,25 +18,25 @@ export class AdminsAdd extends Command { ); async execute(msg: Message): Promise { - if (!msg.reply_to_message) return; + if (!msg.reply_to_message || !msg.reply_to_message.from) return; const id = msg.reply_to_message.from.id; const text = fullName(msg.reply_to_message.from); if (id === botUser.id) { - await oldSendMessage(msg, "Бот не может сам себя сделать админом").catch(logError); + await oldSendMessage(msg, Environment.botCannotMakeItselfAdminText).catch(logError); return; } if (id === Environment.CREATOR_ID) { - await oldSendMessage(msg, "Создатель бота и так является админом").catch(logError); + await oldSendMessage(msg, Environment.botCreatorAlreadyAdminText).catch(logError); return; } if (await Environment.addAdmin(id)) { - await oldSendMessage(msg, text + " теперь админ!").catch(logError); + await oldSendMessage(msg, Environment.getUserIsNowAdminText(text)).catch(logError); } else { - await oldSendMessage(msg, text + " и так уже админ 🤔").catch(logError); + await oldSendMessage(msg, Environment.getUserAlreadyAdminText(text)).catch(logError); } } -} \ No newline at end of file +} diff --git a/src/commands/admins-list.ts b/src/commands/admins-list.ts index e94a8ac..bea36e0 100644 --- a/src/commands/admins-list.ts +++ b/src/commands/admins-list.ts @@ -3,7 +3,7 @@ import {Message} from "typescript-telegram-bot-api"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; import {Environment} from "../common/environment"; -import {fullName, logError, replyToMessage, sendErrorPlaceholder} from "../util/utils"; +import {escapePlainMarkdownV2, fullName, logError, replyToMessage, sendErrorPlaceholder} from "../util/utils"; import {StoredUser} from "../model/stored-user"; import {UserStore} from "../common/user-store"; @@ -29,14 +29,14 @@ export class AdminsList extends Command { } } - let text = "*Администраторы*:\n\n"; + let text = Environment.administratorsHeaderText; users.forEach(user => { text += "\\* "; if (user) { - text += `[${fullName(user)}](tg://user?id=${user.id})`; + text += `[${escapePlainMarkdownV2(fullName(user))}](tg://user?id=${user.id})`; } else { - text += "Нет информации о пользователе"; + text += Environment.noUserInfoText; } text += "\n"; @@ -48,8 +48,8 @@ export class AdminsList extends Command { parse_mode: "MarkdownV2" }); } catch (e) { - logError(e); + logError(e instanceof Error ? e : String(e)); await sendErrorPlaceholder(msg).catch(logError); } } -} \ No newline at end of file +} diff --git a/src/commands/admins-remove.ts b/src/commands/admins-remove.ts index f50560d..1b14629 100644 --- a/src/commands/admins-remove.ts +++ b/src/commands/admins-remove.ts @@ -8,8 +8,8 @@ import {botUser} from "../index"; export class AdminsRemove extends Command { command = "removeAdmin"; - title = "/removeAdmin"; - description = "Remove user from admins"; + title = Environment.commandTitles.adminsRemove; + description = Environment.commandDescriptions.adminsRemove; requirements = Requirements.Build( Requirement.BOT_CREATOR, @@ -18,25 +18,25 @@ export class AdminsRemove extends Command { ); async execute(msg: Message): Promise { - if (!msg.reply_to_message) return; + if (!msg.reply_to_message || !msg.reply_to_message.from) return; const id = msg.reply_to_message.from.id; const text = fullName(msg.reply_to_message.from); if (id === botUser.id) { - await oldSendMessage(msg, "Бот не может сам себя убрать из админов").catch(logError); + await oldSendMessage(msg, Environment.botCannotRemoveItselfFromAdminsText).catch(logError); return; } if (id === Environment.CREATOR_ID) { - await oldSendMessage(msg, "Создатель бота не может перестать быть админом").catch(logError); + await oldSendMessage(msg, Environment.botCreatorCannotStopBeingAdminText).catch(logError); return; } if (await Environment.removeAdmin(id)) { - await oldSendMessage(msg, text + " больше не админ!").catch(logError); + await oldSendMessage(msg, Environment.getUserNoLongerAdminText(text)).catch(logError); } else { - await oldSendMessage(msg, text + " и так не был админом 🤔").catch(logError); + await oldSendMessage(msg, Environment.getUserWasNotAdminText(text)).catch(logError); } } -} \ No newline at end of file +} diff --git a/src/commands/ae.ts b/src/commands/ae.ts index 927bf2c..a7438e5 100644 --- a/src/commands/ae.ts +++ b/src/commands/ae.ts @@ -3,36 +3,59 @@ import {Message} from "typescript-telegram-bot-api"; import {errorPlaceholder, logError, oldSendMessage} from "../util/utils"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; +import {Environment} from "../common/environment"; export class Ae extends Command { argsMode = "required" as const; - title = "/ae"; - description = "evaluation"; + command = ["ae"]; + + title = Environment.commandTitles.ae; + description = Environment.commandDescriptions.ae; requirements = Requirements.Build(Requirement.BOT_CREATOR); async execute(msg: Message, params?: RegExpExecArray) { - const match = params?.[3]; + 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)); - } catch (e) { - const text = e.message.toString(); + let result = this.executeEvaluation(match); + await oldSendMessage(msg, result).catch(async () => await errorPlaceholder(msg)); + } catch (error) { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + const text = normalizedError.message.toString(); if (text.includes("is not defined")) { - await oldSendMessage(msg, "variable is not defined").catch(logError); + await oldSendMessage(msg, Environment.variableNotDefinedText).catch(logError); return; } logError(`${text} - * Stacktrace: ${e.stack}`); + * Stacktrace: ${normalizedError.stack}`); await oldSendMessage(msg, text).catch(logError); } } -} \ No newline at end of file + + executeEvaluation(evaluation: string): string { + try { + let e = eval(evaluation); + + e = ((typeof e == "string") ? e : JSON.stringify(e)); + + return e; + } catch (error) { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + const text = normalizedError.message.toString(); + + if (text.includes("is not defined")) { + return Environment.evaluationVariableNotDefinedText; + } + + logError(`${text} + * Stacktrace: ${normalizedError.stack}`); + + return text; + } + } +} diff --git a/src/commands/ban.ts b/src/commands/ban.ts index 269f45d..cdd1663 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -5,10 +5,11 @@ import {Message} from "typescript-telegram-bot-api"; import {bot, botUser} from "../index"; import {fullName, logError, oldSendMessage, oldReplyToMessage} from "../util/utils"; import {Environment} from "../common/environment"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; export class Ban extends Command { - title = "/ban [reply]"; - description = "ban user from chat"; + title = Environment.commandTitles.ban; + description = Environment.commandDescriptions.ban; requirements = Requirements.Build( Requirement.BOT_ADMIN, @@ -19,32 +20,35 @@ export class Ban extends Command { ); async execute(msg: Message) { - if (!msg.reply_to_message) return; + if (!msg.reply_to_message || !msg.from || ! msg.reply_to_message.from) return; const user = msg.reply_to_message.from; const userId = user.id; if (userId === botUser.id) { - await oldReplyToMessage(msg, "Используй /leave").catch(logError); + await oldReplyToMessage(msg, Environment.useLeaveCommandText).catch(logError); return; } if (userId === Environment.CREATOR_ID) { - await oldReplyToMessage(msg, "Бот не будет банить своего создателя.").catch(logError); + await oldReplyToMessage(msg, Environment.botWillNotBanCreatorText).catch(logError); return; } if (msg.from.id !== Environment.CREATOR_ID && Environment.ADMIN_IDS.has(userId)) { - await oldReplyToMessage(msg, "Бот не будет банить своих администраторов.").catch(logError); + await oldReplyToMessage(msg, Environment.botWillNotBanAdminsText).catch(logError); return; } - bot.banChatMember({chat_id: msg.chat.id, user_id: userId}) + enqueueTelegramApiCall( + () => bot.banChatMember({chat_id: msg.chat.id, user_id: userId}), + {method: "banChatMember", chatId: msg.chat.id, chatType: msg.chat.type} + ) .then(async () => { - await oldSendMessage(msg, `${fullName(user)} забанен 🚫`).catch(logError); + await oldSendMessage(msg, Environment.getUserBannedText(fullName(user))).catch(logError); }) .catch(async () => { - await oldSendMessage(msg, `Не смог забанить ${fullName(user)} ☹️`).catch(logError); + await oldSendMessage(msg, Environment.getUserBanFailedText(fullName(user))).catch(logError); }); } -} \ No newline at end of file +} diff --git a/src/commands/choice.ts b/src/commands/choice.ts index 7b3ed61..6feb606 100644 --- a/src/commands/choice.ts +++ b/src/commands/choice.ts @@ -1,18 +1,23 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; import {logError, oldReplyToMessage, randomValue} from "../util/utils"; +import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer"; +import {Environment} from "../common/environment"; +import {appLogger} from "../logging/logger"; + +const logger = appLogger.child("command:choice"); export class Choice extends Command { command = "choice"; argsMode = "required" as const; - title = "/choice a, b, ..., c"; - description = "Выбор случайного значения"; + title = Environment.commandTitles.choice; + description = Environment.commandDescriptions.choice; async execute(msg: Message, match?: RegExpExecArray): Promise { - console.log("match", match); + logger.debug("execute", {chatId: msg.chat?.id, messageId: msg.message_id, match}); - const payload = match[3]; + const payload = match?.[3] || ""; const re = /\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^,]+?))\s*(?:,|$)/g; @@ -33,7 +38,15 @@ export class Choice extends Command { } const random = randomValue(out); + if (!random) { + await oldReplyToMessage(msg, Environment.noChoicesText).catch(logError); + return; + } - await oldReplyToMessage(msg, `Выбрал *${random}*`, "Markdown").catch(logError); + await oldReplyToMessage( + msg, + Environment.getChoiceText(prepareTelegramMarkdownV2(random, {mode: "final"})), + "MarkdownV2" + ).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/coin.ts b/src/commands/coin.ts index a71efb0..5f9d7ba 100644 --- a/src/commands/coin.ts +++ b/src/commands/coin.ts @@ -1,13 +1,15 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; import {getRangedRandomInt, logError, oldReplyToMessage} from "../util/utils"; +import {Environment} from "../common/environment"; export class Coin extends Command { - title = "/coin"; - description = "Heads or tails"; + title = Environment.commandTitles.coin; + description = Environment.commandDescriptions.coin; async execute(msg: Message): Promise { const random = getRangedRandomInt(0, 2); - const headsOrTails = random === 1 ? "Выпал *Орёл* 🪙" : "Выпала *Решка* 🪙"; - await oldReplyToMessage(msg, headsOrTails, "Markdown").catch(logError); } -} \ No newline at end of file + const headsOrTails = Environment.getCoinResultText(random === 1 ? Environment.coinHeadsText : Environment.coinTailsText) + " 🪙"; + await oldReplyToMessage(msg, headsOrTails, "Markdown").catch(logError); + } +} diff --git a/src/commands/debug.ts b/src/commands/debug.ts index d0cce74..1260d5c 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -3,10 +3,11 @@ import {Message} from "typescript-telegram-bot-api"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; import {logError, replyToMessage} from "../util/utils"; +import {Environment} from "../common/environment"; export class Debug extends Command { - title = "/debug"; - description = "Returns msg (or reply) as json"; + title = Environment.commandTitles.debug; + description = Environment.commandDescriptions.debug; requirements = Requirements.Build(Requirement.BOT_ADMIN); @@ -17,4 +18,4 @@ export class Debug extends Command { const text = `\`\`\`json\n${json}\n\`\`\``; await replyToMessage({message: msg, text: text, parse_mode: "Markdown"}).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/dice.ts b/src/commands/dice.ts index 813f41c..77e40e7 100644 --- a/src/commands/dice.ts +++ b/src/commands/dice.ts @@ -2,26 +2,31 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; import {logError, randomValue} from "../util/utils"; import {bot} from "../index"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; +import {Environment} from "../common/environment"; type DiceEmoji = "🎲" | "🎯" | "🏀" | "⚽" | "🎳" | "🎰"; -const emojis = ["🎲", "🎯", "🏀", "⚽", "🎳", "🎰"]; +const emojis: readonly DiceEmoji[] = ["🎲", "🎯", "🏀", "⚽", "🎳", "🎰"]; export class Dice extends Command { - title = "/dice"; - description = "Sends random or specific dice"; + title = Environment.commandTitles.dice; + description = Environment.commandDescriptions.dice; async execute(msg: Message): Promise { - const split = msg.text.split("/dice "); - const secondPart = split[1]?.trim(); - const emojiIndex = emojis.indexOf(secondPart); - const emojiToDice: DiceEmoji = (emojiIndex >= 0 ? emojis[emojiIndex] : randomValue(emojis)) as DiceEmoji; + const split = msg.text?.split("/dice "); + const secondPart = split?.[1]?.trim() || ""; + const requestedEmoji = secondPart as DiceEmoji; + const emojiToDice = emojis.includes(requestedEmoji) ? requestedEmoji : randomValue(emojis) ?? "🎲"; - await bot.sendDice({ - chat_id: msg.chat.id, - emoji: emojiToDice, - reply_parameters: { - message_id: msg.message_id - } - }).catch(logError); + await enqueueTelegramApiCall( + () => bot.sendDice({ + chat_id: msg.chat.id, + emoji: emojiToDice, + reply_parameters: { + message_id: msg.message_id + } + }), + {method: "sendDice", chatId: msg.chat.id, chatType: msg.chat.type} + ).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/distort.ts b/src/commands/distort.ts index 36e3061..35bcd98 100644 --- a/src/commands/distort.ts +++ b/src/commands/distort.ts @@ -2,13 +2,15 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; import {downloadTelegramFile, extractImageFileId, logError, oldReplyToMessage, waveDistortSharp} from "../util/utils"; import {bot} from "../index"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; +import {Environment} from "../common/environment"; export class Distort extends Command { command = "distort"; argsMode = "optional" as const; - title = "/distort [amp] [wavelength]"; - description = "Distortion of picture"; + title = Environment.commandTitles.distort; + description = Environment.commandDescriptions.distort; async execute(msg: Message, match?: RegExpExecArray): Promise { const chatId = msg.chat.id; @@ -17,7 +19,7 @@ export class Distort extends Command { if (!reply) { await oldReplyToMessage( msg, - "Ответь командой /distort на сообщение с картинкой (фото, документ или стикер).\n" + "Пример: /distort 16 80" + Environment.distortReplyInstructionText ); return; } @@ -26,7 +28,7 @@ export class Distort extends Command { if (!fileId) { await oldReplyToMessage( msg, - "В реплае не вижу картинку. Пришли фото или файл-изображение." + Environment.distortMissingImageText ); return; } @@ -37,7 +39,10 @@ export class Distort extends Command { const wavelength = b ? Number(b) : 72; try { - await bot.sendChatAction({chat_id: chatId, action: "upload_photo"}); + await enqueueTelegramApiCall( + () => bot.sendChatAction({chat_id: chatId, action: "upload_photo"}), + {method: "sendChatAction", chatId, chatType: msg.chat.type} + ); const file = await bot.getFile({file_id: fileId}); if (!file.file_path) { @@ -47,17 +52,20 @@ export class Distort extends Command { const inputBuf = await downloadTelegramFile(file.file_path); - const outBuf = await waveDistortSharp(inputBuf, amp, wavelength); + const outBuf = await waveDistortSharp(inputBuf, amp, wavelength); - await bot.sendPhoto({ - chat_id: chatId, - photo: outBuf, - caption: `Искажение готово ✅ (amp=${amp}, wavelength=${wavelength})`, - }); - } catch (e) { + await enqueueTelegramApiCall( + () => bot.sendPhoto({ + chat_id: chatId, + photo: outBuf, + caption: Environment.getDistortionReadyCaption(amp, wavelength), + }), + {method: "sendPhoto", chatId, chatType: msg.chat.type} + ); + } catch (error) { await oldReplyToMessage( - msg, `Не получилось исказить изображение: ${e?.message ?? String(e)}` + msg, Environment.getDistortFailedText(error instanceof Error ? error : String(error)) ).catch(logError); } } -} \ No newline at end of file +} diff --git a/src/commands/export-db.ts b/src/commands/export-db.ts index 83cf2ad..1687155 100644 --- a/src/commands/export-db.ts +++ b/src/commands/export-db.ts @@ -6,6 +6,8 @@ import {Environment} from "../common/environment"; import fs from "node:fs"; import {logError, replyToMessage, sendErrorPlaceholder} from "../util/utils"; import {bot} from "../index"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; +import {DatabaseManager, type DatabaseBackupArtifact} from "../db/database-manager"; export class ExportDb extends Command { @@ -16,27 +18,37 @@ export class ExportDb extends Command { requirements = Requirements.Build(Requirement.BOT_CREATOR); async execute(msg: Message): Promise { - const fullPath = Environment.DB_PATH.substring(5); - if (!fs.existsSync(fullPath)) { - await sendErrorPlaceholder(msg); - return; - } - + let backups: DatabaseBackupArtifact[] = []; try { - const buffer = fs.readFileSync(fullPath); + backups = await DatabaseManager.exportBackupArtifacts(); + if (!backups.length) { + throw new Error("Database backup artifacts were not created."); + } - await bot.sendDocument({ - chat_id: Environment.CREATOR_ID, - document: new FileOptions(buffer, {filename: "database.db", contentType: "application/sql"}), - caption: "Бэкап базы данных", - }); + for (const backup of backups) { + await enqueueTelegramApiCall( + () => bot.sendDocument({ + chat_id: Environment.CREATOR_ID, + document: new FileOptions( + fs.createReadStream(backup.filePath), + {filename: backup.fileName, contentType: backup.contentType}, + ), + caption: Environment.databaseBackupCaption, + }), + {method: "sendDocument", chatId: Environment.CREATOR_ID, chatType: "private"} + ); + } if (msg.chat.id !== Environment.CREATOR_ID) { - await replyToMessage({message: msg, text: "Успешно отправлено в ЛС создателю!"}); + await replyToMessage({message: msg, text: Environment.databaseBackupSentText}); } } catch (e) { - logError(e); + logError(e instanceof Error ? e : String(e)); await sendErrorPlaceholder(msg); + } finally { + for (const backup of backups) { + await backup.cleanup(); + } } } -} \ No newline at end of file +} diff --git a/src/commands/gemini-chat.ts b/src/commands/gemini-chat.ts deleted file mode 100644 index 9a0b53f..0000000 --- a/src/commands/gemini-chat.ts +++ /dev/null @@ -1,188 +0,0 @@ -import {Message} from "typescript-telegram-bot-api"; -import {Environment} from "../common/environment"; -import {bot, googleAi} from "../index"; -import {MessageStore} from "../common/message-store"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {ApiError} from "@google/genai"; -import { - collectReplyChainText, - escapeMarkdownV2Text, - logError, - oldReplyToMessage, replyToMessage, - startIntervalEditor -} from "../util/utils"; -import {ChatCommand} from "../base/chat-command"; - -export class GeminiChat extends ChatCommand { - command = "gemini"; - argsMode = "required" as const; - - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - title = "/gemini"; - description = "Chat with AI (Gemini)"; - - async execute(msg: Message, match?: RegExpExecArray): Promise { - console.log("match", match); - return this.executeGemini(msg, match?.[3]); - } - - async executeGemini(msg: Message, text: string): Promise { - if (!text || text.trim().length === 0) return; - - const chatId = msg.chat.id; - - const storedMsg = await MessageStore.get(chatId, msg.message_id); - const messageParts = await collectReplyChainText(storedMsg); - console.log("MESSAGE PARTS", messageParts); - - const chatMessages = messageParts.map(part => { - return { - role: part.bot ? "assistant" : "user", - content: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER "${part.name}":\n` : "") + part.content - }; - }); - chatMessages.reverse(); - - if (Environment.SYSTEM_PROMPT) { - chatMessages.unshift({role: "system", content: Environment.SYSTEM_PROMPT}); - } - - let chatContent = ""; - for (const part of chatMessages) { - chatContent += `${part.role.toUpperCase()}:\n${part.content}\n\n`; - } - - chatContent = chatContent.trim(); - - const input = []; - input.push( - { - type: "text", - text: chatContent - } - ); - - // TODO: 12/02/2026, Danil Nikolaev: support for multiple images - if (messageParts.some(p => p.images?.length)) { - const firstImages = messageParts.find(p => p.images?.length)?.images ?? []; - firstImages.forEach(image => { - input.push({ - type: "image", - data: image, - mime_type: "image/png" - }); - }); - } - - let waitMessage: Message; - - const startTime = Date.now(); - - try { - waitMessage = await bot.sendMessage({ - chat_id: chatId, - text: Environment.waitThinkText, - reply_parameters: { - chat_id: chatId, - message_id: msg.message_id - } - }); - - const stream = await googleAi.interactions.create({ - model: Environment.GEMINI_MODEL, - input: input, - stream: true - }); - - let currentText = ""; - let shouldBreak = false; - - const editor = startIntervalEditor({ - intervalMs: 4500, - getText: () => currentText, - editFn: async (text) => { - await bot.editMessageText( - { - chat_id: chatId, - message_id: waitMessage.message_id, - text: escapeMarkdownV2Text(text), - parse_mode: "MarkdownV2" - } - ).catch(logError); - - console.log("editMessageText", text); - - waitMessage.reply_to_message = msg; - waitMessage.text = text; - await MessageStore.put(waitMessage); - }, - onStop: async () => { - } - }); - await editor.tick(); - - try { - for await (const event of stream) { - switch (event.event_type) { - case "content.delta": - switch (event.delta?.type) { - case "text": { - const text = event.delta.text; - currentText += text; - - if (currentText.length > 4096) { - currentText = currentText.slice(0, 4093) + "..."; - shouldBreak = true; - } - - console.log("messageText", currentText); - console.log("length", currentText.length); - - if (shouldBreak) { - console.log("break", true); - break; - } - break; - } - case "image": { - const image = event.delta.data; - console.log("image", image); - } - } - } - } - } finally { - await editor.tick(); - await editor.stop(); - - if (!shouldBreak) { - console.log("ended", true); - } - - const diff = Math.abs(Date.now() - startTime) / 1000.0; - console.log("time", diff); - - waitMessage.reply_to_message = msg; - waitMessage.text = currentText; - await MessageStore.put(waitMessage); - - if (Environment.SEND_TIME_TOOK) { - await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`}); - } - } - } catch (error) { - logError(error); - - if (error instanceof ApiError) { - if (error.status === 429) { - await oldReplyToMessage(waitMessage, "На сегодня всё, лимиты закончились.").catch(logError); - return; - } - } - - await oldReplyToMessage(waitMessage, `Произошла ошибка!\n${error.toString()}`).catch(logError); - } - } -} \ No newline at end of file diff --git a/src/commands/gemini-generate-image.ts b/src/commands/gemini-generate-image.ts deleted file mode 100644 index 68932a1..0000000 --- a/src/commands/gemini-generate-image.ts +++ /dev/null @@ -1,60 +0,0 @@ -import {Command} from "../base/command"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {Message} from "typescript-telegram-bot-api"; -import {googleAi} from "../index"; -import {logError, replyToMessage} from "../util/utils"; -import {Environment} from "../common/environment"; - -export class GeminiGenerateImage extends Command { - command = "geminiGenImage"; - argsMode = "required" as const; - - title = "/geminiGenImage"; - description = "Generate image with Gemini"; - - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - async execute(msg: Message, match?: RegExpExecArray): Promise { - console.log("match", match); - - const prompt = match?.[3]; - return this.executeGenImage(msg, prompt); - } - - async executeGenImage(msg: Message, text: string): Promise { - if (!text || text.trim().length === 0) return; - - let waitMessage: Message; - - try { - waitMessage = await replyToMessage({ - message: msg, - text: Environment.genImageText, - }); - - const interaction = await googleAi.interactions.create({ - model: Environment.GEMINI_IMAGE_MODEL, - response_modalities: ["image"], - input: text, - }); - - interaction.outputs?.forEach((output, index) => { - if (output.type === "image") { - // const image = output.data; - console.log(`Image output ${index + 1}:`, output); - } else { - console.log(`Output ${index + 1}: ${output}`); - } - }); - } catch (e) { - logError(e); - - await replyToMessage({ - message: waitMessage, - text: `Произошла ошибка!\n${e.toString()}`, - link_preview_options: {is_disabled: true} - }).catch(logError); - } - } -} \ No newline at end of file diff --git a/src/commands/gemini-get-model.ts b/src/commands/gemini-get-model.ts deleted file mode 100644 index 38a053e..0000000 --- a/src/commands/gemini-get-model.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {Command} from "../base/command"; -import {Message} from "typescript-telegram-bot-api"; -import {logError, replyToMessage} from "../util/utils"; -import {Environment} from "../common/environment"; -import {googleAi} from "../index"; -import {AiModelCapabilities} from "../model/ai-model-capabilities"; - -export class GeminiGetModel extends Command { - title = "/geminiGetModel"; - description = "Get current Gemini model"; - - async execute(msg: Message): Promise { - await replyToMessage({message: msg, text: `Текущая модель: "${Environment.GEMINI_MODEL}"`}).catch(logError); - } - - async getModelCapabilities(): Promise { - try { - const info = await googleAi.models.get({model: Environment.GEMINI_MODEL}); - console.log(info); - - return { - vision: {supported: true}, - ocr: null, - thinking: {supported: info.thinking}, - tools: null - }; - } catch (e) { - logError(e); - return null; - } - } -} \ No newline at end of file diff --git a/src/commands/gemini-list-models.ts b/src/commands/gemini-list-models.ts deleted file mode 100644 index 2d01b1a..0000000 --- a/src/commands/gemini-list-models.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {Command} from "../base/command"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {Message} from "typescript-telegram-bot-api"; -import {googleAi} from "../index"; -import {logError, replyToMessage} from "../util/utils"; - -export class GeminiListModels extends Command { - title = "/geminiListModels"; - description = "List all Gemini models"; - - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - async execute(msg: Message): Promise { - try { - const listResponse = await googleAi.models.list(); - console.log(listResponse); - - const modelsString = listResponse.page - .sort((a, b) => a.name.localeCompare(b.name)) - .map(e => `${e.name}`) - .join("\n"); - - const text = "Доступные модели:\n\n" + "
" + modelsString + "
"; - - await replyToMessage({ - message: msg, - text: text, - parse_mode: "HTML" - }); - } catch (e) { - logError(e); - await replyToMessage({message: msg, text: "Не получилось загрузить список моделей"}).catch(logError); - } - } -} \ No newline at end of file diff --git a/src/commands/gemini-set-model.ts b/src/commands/gemini-set-model.ts deleted file mode 100644 index 512d252..0000000 --- a/src/commands/gemini-set-model.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {Command} from "../base/command"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {Message} from "typescript-telegram-bot-api"; -import {Environment} from "../common/environment"; -import {logError, replyToMessage} from "../util/utils"; - -export class GeminiSetModel extends Command { - argsMode = "required" as const; - - title = "/geminiSetModel"; - description = "Set Gemini model"; - - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - async execute(msg: Message, match?: RegExpExecArray | null): Promise { - const newModel = match?.[3]; - Environment.setGeminiModel(newModel || Environment.GEMINI_MODEL); - - const text = newModel ? `Выбрана модель "${newModel}"` - : `Модель не задана. Будет использоваться стандартная модель "${Environment.GEMINI_MODEL}".`; - - await replyToMessage({message: msg, text: text}).catch(logError); - } -} \ No newline at end of file diff --git a/src/commands/help.ts b/src/commands/help.ts index f9a53f1..16572eb 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -3,15 +3,17 @@ import {chatCommandToString, delay, logError, sendMessage} from "../util/utils"; import {Command} from "../base/command"; import {commands} from "../index"; import {TelegramError} from "typescript-telegram-bot-api/dist/errors"; +import {Environment} from "../common/environment"; export class Help extends Command { command = ["h", "help"]; - title = "/help"; - description = "Show list of commands"; + title = Environment.commandTitles.help; + description = Environment.commandDescriptions.help; async execute(msg: Message) { - let text = "Commands:\n\n"; + if (!msg.from) return; + let text = Environment.commandsHeaderText; commands.forEach(c => { text += `${chatCommandToString(c)}\n`; @@ -20,7 +22,7 @@ export class Help extends Command { await sendMessage({chat_id: msg.from.id, text: text}) .then(async () => { if (msg.chat.type !== "private") { - await sendMessage({message: msg, text: "Отправил команды в ЛС 😎"}).catch(logError); + await sendMessage({message: msg, text: Environment.sentCommandsInDmText}).catch(logError); } }) .catch(async (e) => { @@ -28,7 +30,7 @@ export class Help extends Command { if (e.response?.error_code === 403) { await sendMessage({ message: msg, - text: "Не смог отправить команды в ЛС ☹️\nТогда отправлю сюда" + text: Environment.couldNotSendCommandsInDmText }).catch(logError); await delay(1000); @@ -37,4 +39,4 @@ export class Help extends Command { } }); } -} \ No newline at end of file +} diff --git a/src/commands/id.ts b/src/commands/id.ts index 5bba0f9..b3eb296 100644 --- a/src/commands/id.ts +++ b/src/commands/id.ts @@ -1,17 +1,17 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; import {logError, oldReplyToMessage} from "../util/utils"; +import {Environment} from "../common/environment"; export class Id extends Command { - title = "/id"; - description = "ID of chat, user and reply (if replied to any message)"; + title = Environment.commandTitles.id; + description = Environment.commandDescriptions.id; async execute(msg: Message): Promise { - let text = `chat id: \n\`\`\`${msg.chat.id}\`\`\` \nfrom id: \n\`\`\`${msg.from.id}\`\`\``; - if (msg.reply_to_message) { - text += ` \nreply id: \n\`\`\`${msg.reply_to_message.from.id}\`\`\``; - } - - await oldReplyToMessage(msg, text, "MarkdownV2").catch(logError); + await oldReplyToMessage( + msg, + Environment.getIdText(msg.chat.id, msg.from?.id, msg.reply_to_message?.from?.id), + "MarkdownV2", + ).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/ignore.ts b/src/commands/ignore.ts index ae9339e..2a6edf5 100644 --- a/src/commands/ignore.ts +++ b/src/commands/ignore.ts @@ -7,8 +7,8 @@ import {botUser} from "../index"; import {Environment} from "../common/environment"; export class Ignore extends Command { - title = "/ignore"; - description = "Bot will ignore user"; + title = Environment.commandTitles.ignore; + description = Environment.commandDescriptions.ignore; requirements = Requirements.Build( Requirement.BOT_ADMIN, @@ -19,25 +19,25 @@ export class Ignore extends Command { ); async execute(msg: Message) { - if (!msg.reply_to_message) return; + if (!msg.reply_to_message || !msg.reply_to_message.from) return; const id = msg.reply_to_message.from.id; const text = fullName(msg.reply_to_message.from); if (id === botUser.id) { - await oldSendMessage(msg, "Бот не может сам себя игнорировать").catch(logError); + await oldSendMessage(msg, Environment.botWillNotIgnoreItselfText).catch(logError); return; } if (id === Environment.CREATOR_ID) { - await oldSendMessage(msg, "Бот не будет игнорировать своего создателя").catch(logError); + await oldSendMessage(msg, Environment.botWillNotIgnoreCreatorText).catch(logError); return; } if (await Environment.addMute(id)) { - await oldSendMessage(msg, text + " в муте! 🔇").catch(logError); + await oldSendMessage(msg, Environment.getUserIgnoredText(text)).catch(logError); } else { - await oldSendMessage(msg, text + " уже в муте 🤔").catch(logError); + await oldSendMessage(msg, Environment.getUserAlreadyIgnoredText(text)).catch(logError); } } -} \ No newline at end of file +} diff --git a/src/commands/import-db.ts b/src/commands/import-db.ts new file mode 100644 index 0000000..ef49588 --- /dev/null +++ b/src/commands/import-db.ts @@ -0,0 +1,54 @@ +import {Command} from "../base/command"; +import {Message} from "typescript-telegram-bot-api"; +import {Requirements} from "../base/requirements"; +import {Requirement} from "../base/requirement"; +import {bot} from "../index"; +import {DatabaseManager, type DatabaseBackupPayload} from "../db/database-manager"; +import {downloadTelegramFile, logError, replyToMessage, sendErrorPlaceholder} from "../util/utils"; +import {Environment} from "../common/environment"; +import {MessageStore} from "../common/message-store"; +import {UserStore} from "../common/user-store"; + +export class ImportDb extends Command { + command = ["importdb"]; + + argsMode = "optional" as const; + + requirements = Requirements.Build(Requirement.BOT_CREATOR); + + async execute(msg: Message, match?: RegExpExecArray | null): Promise { + try { + const payloadText = await this.resolvePayloadText(msg, match); + if (!payloadText) { + await replyToMessage({message: msg, text: Environment.databaseImportNeedJsonText}); + return; + } + + const payload = JSON.parse(payloadText) as DatabaseBackupPayload; + const result = await DatabaseManager.importBackupFromJsonPayload(payload); + + MessageStore.clear(); + UserStore.clear(); + + await replyToMessage({ + message: msg, + text: `${Environment.databaseImportDoneText} Users: ${result.users}, messages: ${result.messages}.`, + }); + } catch (error) { + logError(error instanceof Error ? error : String(error)); + await sendErrorPlaceholder(msg); + } + } + + private async resolvePayloadText(msg: Message, match?: RegExpExecArray | null): Promise { + const argText = match?.[3]?.trim(); + if (argText) return argText; + + const document = msg.document ?? msg.reply_to_message?.document; + if (!document) return null; + + const file = await bot.getFile({file_id: document.file_id}); + const buffer = await downloadTelegramFile(file.file_path); + return buffer ? buffer.toString("utf8").trim() : null; + } +} diff --git a/src/commands/info.ts b/src/commands/info.ts index e89bb25..437cda8 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -2,66 +2,70 @@ import {ChatCommand} from "../base/chat-command"; import {Message} from "typescript-telegram-bot-api"; import {callbackCommands, commands} from "../index"; import {Environment} from "../common/environment"; -import {boolToEmoji, getCurrentModel, getCurrentModelCapabilities, logError, replyToMessage} from "../util/utils"; -import {AiModelCapabilities} from "../model/ai-model-capabilities"; +import {logError, replyToMessage} from "../util/utils"; import {AiProvider} from "../model/ai-provider"; import {Command} from "../base/command"; +import {getProviderTools} from "../ai/tool-mappers"; +import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer"; +import {resolveEffectiveAiProviderForUser} from "../common/user-ai-settings"; +import {getFormattedCapabilities} from "../ai/provider-model-runtime"; export class Info extends Command { command = ["info", "v"]; - title = "/info"; - description = "Info about bot"; + title = Environment.commandTitles.info; + description = Environment.commandDescriptions.info; async execute(msg: Message): Promise { - const aiProvider = Environment.DEFAULT_AI_PROVIDER; - const aiModel = getCurrentModel(); - let aiModelCapabilities: AiModelCapabilities = {}; + if (!msg.from) return; - try { - aiModelCapabilities = await getCurrentModelCapabilities(); - } catch (e) { - logError(e); - await replyToMessage({message: msg, text: `Произошла ошибка: ${e}`}).catch(logError); - return; - } + const getToolsInfo = async () => { + const tools = getProviderTools(provider); + return Environment.getInfoToolsBlockText(tools.map(t => t.function.name)); + }; + + const getCommandsInfo = async () => { + const cmds = commands.filter(c => !(c instanceof ChatCommand)); + const chatCmds = commands.filter(c => c instanceof ChatCommand); + const callbackCmds = callbackCommands; + const publicCmdsLength = cmds.filter(c => c.requirements?.isPublic()).length; + const privateCmdsLength = cmds.length - publicCmdsLength; + const chatCmdsLength = chatCmds.length; + const callbackCmdsLength = callbackCmds.length; + + return Environment.getInfoCommandsBlockText({ + publicCommands: publicCmdsLength, + privateCommands: privateCmdsLength, + chatCommands: chatCmdsLength, + callbackCommands: callbackCmdsLength, + }); + }; + + const provider = await resolveEffectiveAiProviderForUser(msg.from.id); + // const aiProvidersLength = Object.keys(AiProvider).filter(key => isNaN(Number(key))).length; + const aiProviders = Object.keys(AiProvider).map(p => p.toLowerCase()); + + const finalText = [ + `\`\`\`${Environment.runtimeProviderLabelText}`, + `${Environment.infoSupportedProvidersLabelText}: ${aiProviders.join(", ")}`, + `${Environment.runtimeProviderCurrentLabelText}: ${provider.toLowerCase()}`, + "```", + "", + + `\`\`\`${Environment.runtimeCapabilitiesLabelText}`, + (await getFormattedCapabilities(provider)).join("\n"), + "```", + "", + + await getToolsInfo(), + await getCommandsInfo() + ].join("\n"); - const aiInfo = "```" + - "AI\n" + - `supported providers: ${Object.keys(AiProvider).filter(key => isNaN(Number(key))).length}\n\n` + - - `provider: ${aiProvider.toLowerCase()}\n` + - `model: ${aiModel}\n\n` + - `vision${aiModelCapabilities.vision?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities.vision?.supported)}\n` + - `ocr${aiModelCapabilities.ocr?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities.ocr?.supported)}\n` + - `thinking${aiModelCapabilities.thinking?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities.thinking?.supported)}\n` + - `tools${aiModelCapabilities.tools?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities.tools?.supported)}` + - "```"; - - const cmds = commands.filter(c => !(c instanceof ChatCommand)); - const chatCmds = commands.filter(c => c instanceof ChatCommand); - const callbackCmds = callbackCommands; - - const publicCmdsLength = cmds.filter(c => c.requirements?.isPublic()).length; - const privateCmdsLength = cmds.length - publicCmdsLength; - - const chatCmdsLength = chatCmds.length; - - const callbackCmdsLength = callbackCmds.length; - - const text = - aiInfo + "\n\n" + - - "```" + - "Commands\n" + - `Public: ${publicCmdsLength}\n` + - `Private: ${privateCmdsLength}\n` + - `Chat: ${chatCmdsLength}\n` + - `Callback: ${callbackCmdsLength}\n` + - "```" - ; - - await replyToMessage({message: msg, text: text, parse_mode: "Markdown"}).catch(logError); + await replyToMessage({ + message: msg, + text: prepareTelegramMarkdownV2(finalText, {mode: "final"}), + parse_mode: "MarkdownV2" + }).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/leave.ts b/src/commands/leave.ts index 40446aa..a8cfd1c 100644 --- a/src/commands/leave.ts +++ b/src/commands/leave.ts @@ -3,10 +3,12 @@ import {Message} from "typescript-telegram-bot-api"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; import {bot} from "../index"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; +import {Environment} from "../common/environment"; export class Leave extends Command { - title = "/leave"; - description = "Bot will leave current chat"; + title = Environment.commandTitles.leave; + description = Environment.commandDescriptions.leave; requirements = Requirements.Build( Requirement.BOT_ADMIN, @@ -14,6 +16,9 @@ export class Leave extends Command { ); async execute(msg: Message): Promise { - await bot.leaveChat({chat_id: msg.chat.id}); + await enqueueTelegramApiCall( + () => bot.leaveChat({chat_id: msg.chat.id}), + {method: "leaveChat", chatId: msg.chat.id, chatType: msg.chat.type} + ); } -} \ No newline at end of file +} diff --git a/src/commands/mistral-chat.ts b/src/commands/mistral-chat.ts index 4a49523..236ef6a 100644 --- a/src/commands/mistral-chat.ts +++ b/src/commands/mistral-chat.ts @@ -1,180 +1,28 @@ +import {Message} from "typescript-telegram-bot-api"; +import {ChatCommand} from "../base/chat-command"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; -import {Message} from "typescript-telegram-bot-api"; -import { - collectReplyChainText, - escapeMarkdownV2Text, - logError, - oldReplyToMessage, - replyToMessage, - startIntervalEditor -} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {runUnifiedAi} from "../ai/unified-ai-runner"; import {Environment} from "../common/environment"; -import {bot, commands, mistralAi} from "../index"; -import {MessageStore} from "../common/message-store"; -import {ChatCommand} from "../base/chat-command"; -import {MistralGetModel} from "./mistral-get-model"; export class MistralChat extends ChatCommand { - command = "mistral"; + command = ["mistral", "mistral-chat", "mistral-voice"]; argsMode = "required" as const; requirements = Requirements.Build(Requirement.BOT_CREATOR); - title = "/mistral"; - description = "Chat with AI (Mistral)"; + title = Environment.commandTitles.mistralChat; + description = Environment.commandDescriptions.mistralChat; async execute(msg: Message, match?: RegExpExecArray): Promise { - console.log("match", match); - return this.executeMistral(msg, match?.[3]); - } - - async executeMistral(msg: Message, text: string): Promise { - if (!text || text.trim().length === 0) return; - - const chatId = msg.chat.id; - - const storedMsg = await MessageStore.get(chatId, msg.message_id); - const messageParts = await collectReplyChainText(storedMsg); - console.log("MESSAGE PARTS", messageParts); - - const chatMessages = messageParts.map(part => { - const content = []; - content.push({ - type: "text", - text: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER "${part.name}":\n` : "") + part.content, - }); - - for (const image of part.images) { - content.push({ - type: "image_url", - imageUrl: "data:image/jpeg;base64," + image - }); - } - - return { - role: part.bot ? "assistant" : "user", - content: content, - }; + const command = match?.[1]?.toLowerCase() ?? ""; + await runUnifiedAi({ + provider: AiProvider.MISTRAL, + msg: msg, + text: match?.[3] ?? "", + stream: true, + synthesizeSpeechResponse: command.endsWith("-voice"), }); - chatMessages.reverse(); - - if (Environment.SYSTEM_PROMPT) { - chatMessages.unshift({role: "system", content: [{type: "text", text: Environment.SYSTEM_PROMPT}]}); - } - - let waitMessage: Message; - - const startTime = Date.now(); - - try { - const imagesCount = chatMessages.reduce((total, curr) => { - return total + (curr.content.filter(c => c.type === "image_url")?.length ?? 0); - }, 0); - - if (imagesCount) { - try { - const modelInfo = await commands.find(c => c instanceof MistralGetModel).getModelCapabilities(); - if (modelInfo) { - if (!modelInfo.vision?.supported) { - await replyToMessage({ - message: msg, - text: "Моя текущая модель не умеет анализировать изображения 🥹" - }); - return; - } - } - } catch (e) { - logError(e); - } - } - - waitMessage = await bot.sendMessage({ - chat_id: chatId, - text: imagesCount ? - imagesCount > 1 ? Environment.analyzingPicturesText : Environment.analyzingPictureText - : Environment.waitThinkText, - - reply_parameters: { - chat_id: chatId, - message_id: msg.message_id - } - }); - - const stream = await mistralAi.chat.stream({ - model: Environment.MISTRAL_MODEL, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - messages: chatMessages as any - }); - - let currentText = ""; - let shouldBreak = false; - - const editor = startIntervalEditor({ - intervalMs: 4500, - getText: () => currentText, - editFn: async (text) => { - await bot.editMessageText( - { - chat_id: chatId, - message_id: waitMessage.message_id, - text: escapeMarkdownV2Text(text), - parse_mode: "MarkdownV2" - } - ).catch(logError); - - console.log("editMessageText", text); - - waitMessage.reply_to_message = msg; - waitMessage.text = text; - await MessageStore.put(waitMessage); - }, - onStop: async () => { - } - }); - await editor.tick(); - - try { - for await (const chunk of stream) { - console.log("chunk", chunk); - - const text = chunk.data.choices[0].delta.content; - currentText += text; - - if (currentText.length > 4096) { - currentText = currentText.slice(0, 4093) + "..."; - shouldBreak = true; - } - - console.log("messageText", currentText); - console.log("length", currentText.length); - - if (shouldBreak) { - console.log("break", true); - break; - } - } - } finally { - await editor.tick(); - await editor.stop(); - - if (!shouldBreak) { - console.log("ended", true); - } - - const diff = Math.abs(Date.now() - startTime) / 1000.0; - console.log("time", diff); - - waitMessage.reply_to_message = msg; - waitMessage.text = currentText; - await MessageStore.put(waitMessage); - if (Environment.SEND_TIME_TOOK) { - await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`}); - } - } - } catch (error) { - logError(error); - await oldReplyToMessage(waitMessage, `Произошла ошибка!\n${error.toString()}`).catch(logError); - } } -} \ No newline at end of file +} diff --git a/src/commands/mistral-get-model.ts b/src/commands/mistral-get-model.ts index 39de831..01b982a 100644 --- a/src/commands/mistral-get-model.ts +++ b/src/commands/mistral-get-model.ts @@ -1,36 +1,13 @@ -import {Command} from "../base/command"; -import {Message} from "typescript-telegram-bot-api"; -import {logError, replyToMessage} from "../util/utils"; import {Environment} from "../common/environment"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {mistralAi} from "../index"; -import {AiModelCapabilities} from "../model/ai-model-capabilities"; +import {AiProvider} from "../model/ai-provider"; +import {ProviderGetModelCommand} from "./provider-model-command"; -export class MistralGetModel extends Command { - title = "/mistralGetModel"; - description = "Get current Mistral model"; - - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - async execute(msg: Message): Promise { - await replyToMessage({message: msg, text: `Текущая модель: "${Environment.MISTRAL_MODEL}"`}).catch(logError); +export class MistralGetModel extends ProviderGetModelCommand { + constructor() { + super({ + provider: AiProvider.MISTRAL, + title: Environment.commandTitles.mistralGetModel, + description: Environment.commandDescriptions.mistralGetModel, + }); } - - async getModelCapabilities(): Promise { - try { - const info = await mistralAi.models.retrieve({modelId: Environment.MISTRAL_MODEL}); - console.log(info); - - return { - vision: {supported: info.capabilities.vision}, - ocr: {supported: info.capabilities.ocr}, - thinking: null, - tools: {supported: info.capabilities.functionCalling} - }; - } catch (e) { - logError(e); - return null; - } - } -} \ No newline at end of file +} diff --git a/src/commands/mistral-list-models.ts b/src/commands/mistral-list-models.ts index 200a824..3b4a655 100644 --- a/src/commands/mistral-list-models.ts +++ b/src/commands/mistral-list-models.ts @@ -1,36 +1,13 @@ -import {Command} from "../base/command"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {Message} from "typescript-telegram-bot-api"; -import {mistralAi} from "../index"; -import {logError, oldReplyToMessage, replyToMessage} from "../util/utils"; +import {Environment} from "../common/environment"; +import {AiProvider} from "../model/ai-provider"; +import {ProviderListModelsCommand} from "./provider-model-command"; -export class MistralListModels extends Command { - title = "/mistralListModels"; - description = "List all Mistral models"; - - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - async execute(msg: Message): Promise { - try { - const listResponse = await mistralAi.models.list(); - console.log(listResponse); - - const modelsString = listResponse.data - .sort((a, b) => a.name.localeCompare(b.name)) - .map(e => `${e.id}`) - .join("\n"); - - const text = "Доступные модели:\n\n" + "
" + modelsString + "
"; - - await replyToMessage({ - message: msg, - text: text, - parse_mode: "HTML" - }); - } catch (e) { - logError(e); - await oldReplyToMessage(msg, "Не получилось загрузить список моделей").catch(logError); - } +export class MistralListModels extends ProviderListModelsCommand { + constructor() { + super({ + provider: AiProvider.MISTRAL, + title: Environment.commandTitles.mistralListModels, + description: Environment.commandDescriptions.mistralListModels, + }); } -} \ No newline at end of file +} diff --git a/src/commands/mistral-set-model.ts b/src/commands/mistral-set-model.ts index 432d57f..d7755a8 100644 --- a/src/commands/mistral-set-model.ts +++ b/src/commands/mistral-set-model.ts @@ -1,25 +1,13 @@ -import {Command} from "../base/command"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {Message} from "typescript-telegram-bot-api"; import {Environment} from "../common/environment"; -import {logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {ProviderSetModelCommand} from "./provider-model-command"; -export class MistralSetModel extends Command { - argsMode = "required" as const; - - title = "/mistralSetModel"; - description = "Set Mistral model"; - - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - async execute(msg: Message, match?: RegExpExecArray | null): Promise { - const newModel = match?.[3]; - Environment.setMistralModel(newModel || Environment.MISTRAL_MODEL); - - const text = newModel ? `Выбрана модель "${newModel}"` - : `Модель не задана. Будет использоваться стандартная модель "${Environment.MISTRAL_MODEL}".`; - - await replyToMessage({message: msg, text: text}).catch(logError); +export class MistralSetModel extends ProviderSetModelCommand { + constructor() { + super({ + provider: AiProvider.MISTRAL, + title: Environment.commandTitles.mistralSetModel, + description: Environment.commandDescriptions.mistralSetModel, + }); } -} \ No newline at end of file +} diff --git a/src/commands/ollama-chat.ts b/src/commands/ollama-chat.ts index 8f0962d..1cd3839 100644 --- a/src/commands/ollama-chat.ts +++ b/src/commands/ollama-chat.ts @@ -1,250 +1,29 @@ import {Message} from "typescript-telegram-bot-api"; -import {abortOllamaRequest, bot, commands, getOllamaRequest, ollama, ollamaRequests} from "../index"; -import { - collectReplyChainText, - escapeMarkdownV2Text, - logError, - oldReplyToMessage, - replyToMessage, - startIntervalEditor -} from "../util/utils"; -import {Environment} from "../common/environment"; -import {MessageStore} from "../common/message-store"; -import {Cancel} from "../callback_commands/cancel"; -import {OllamaCancel} from "../callback_commands/ollama-cancel"; -import {OllamaGetModel} from "./ollama-get-model"; import {ChatCommand} from "../base/chat-command"; +import {Requirements} from "../base/requirements"; +import {Requirement} from "../base/requirement"; +import {AiProvider} from "../model/ai-provider"; +import {runUnifiedAi} from "../ai/unified-ai-runner"; +import {Environment} from "../common/environment"; export class OllamaChat extends ChatCommand { - command = ["ollamaThink", "ollama"]; + command = ["ollama", "ollama-chat", "ollama-voice", "think", "think-voice"]; argsMode = "required" as const; - title = "/ollama"; - description = "Chat with AI (Ollama)"; + requirements = Requirements.Build(Requirement.BOT_CREATOR); - async execute(msg: Message, match?: RegExpExecArray | null): Promise { - console.log("match", match); - return this.executeOllama(msg, match?.[3], match?.[1]?.toLowerCase()?.startsWith("ollamathink")); - } + title = Environment.commandTitles.ollamaChat; + description = Environment.commandDescriptions.ollamaChat; - async executeOllama(msg: Message, text: string, think: boolean = false): Promise { - if (!text || text.trim().length === 0) return; - - const chatId = msg.chat.id; - - const storedMsg = await MessageStore.get(chatId, msg.message_id); - const messageParts = await collectReplyChainText(storedMsg); - console.log("MESSAGE PARTS", messageParts); - - const chatMessages = messageParts.map(part => { - return { - role: part.bot ? "assistant" : "user", - content: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER "${part.name}":\n` : "") + part.content, - images: part.images - }; + async execute(msg: Message, match?: RegExpExecArray): Promise { + const command = match?.[1]?.toLowerCase() ?? ""; + await runUnifiedAi({ + provider: AiProvider.OLLAMA, + msg: msg, + text: match?.[3] ?? "", + stream: true, + think: command.startsWith("think"), + synthesizeSpeechResponse: command.endsWith("-voice"), }); - chatMessages.reverse(); - - if (Environment.SYSTEM_PROMPT) { - chatMessages.unshift({role: "system", content: Environment.SYSTEM_PROMPT, images: []}); - } - - let waitMessage: Message; - - const startTime = Date.now(); - - try { - const imagesCount = chatMessages.reduce((total, curr) => { - return total + (curr.images?.length ?? 0); - }, 0); - - if (!think && imagesCount) { - try { - const modelInfo = await commands.find(c => c instanceof OllamaGetModel).loadImageModelInfo(); - if (modelInfo) { - if (!modelInfo.vision?.supported) { - await replyToMessage({ - message: msg, - text: "Моя текущая модель не умеет анализировать изображения 🥹" - }); - return; - } - } - } catch (e) { - logError(e); - } - } - - if (think) { - try { - const modelInfo = await commands.find(c => c instanceof OllamaGetModel).loadThinkModelInfo(); - if (modelInfo) { - if (!modelInfo.thinking?.supported) { - await replyToMessage({ - message: msg, - text: "Моя текущая модель не умеет размышлять 🥹" - }); - return; - } - } - } catch (e) { - logError(e); - } - } - - const uuid = crypto.randomUUID(); - const cancelMarkup = {inline_keyboard: [[Cancel.withData(new OllamaCancel().data + " " + uuid).asButton()]]}; - - waitMessage = await replyToMessage({ - message: msg, - text: (!think && imagesCount) ? - imagesCount > 1 ? Environment.analyzingPicturesText : Environment.analyzingPictureText - : Environment.waitThinkText - }); - - 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, - }); - - const newRequest = { - uuid: uuid, - stream: stream, - done: false, - fromId: msg.from.id, - chatId: msg.chat.id, - }; - - console.log("Pushing new request", newRequest); - ollamaRequests.push(newRequest); - - await bot.editMessageReplyMarkup( - { - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: cancelMarkup - } - ).catch(logError); - - let currentText = ""; - let shouldBreak = false; - - const editor = startIntervalEditor({ - uuid: uuid, - intervalMs: 4500, - getText: () => currentText, - editFn: async (text) => { - if (getOllamaRequest(uuid)?.done) return; - - try { - await bot.editMessageText({ - chat_id: chatId, - message_id: waitMessage.message_id, - text: escapeMarkdownV2Text(text), - parse_mode: "MarkdownV2", - reply_markup: cancelMarkup - }).catch(logError); - - console.log("editMessageText", text); - - waitMessage.reply_to_message = msg; - waitMessage.text = text; - await MessageStore.put(waitMessage); - } catch (e) { - logError(e); - } - } - }); - await editor.tick(); - - try { - let isThinking = false; - - for await (const chunk of stream) { - const content = chunk.message.content; - - if (content === "" || chunk.message.thinking) { - if (!isThinking) { - await bot.editMessageText({ - chat_id: chatId, - message_id: waitMessage.message_id, - text: "🤔 Размышляю...", - parse_mode: "Markdown", - reply_markup: cancelMarkup - }).catch(logError); - } - - 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); - } - - const diff = Math.abs(Date.now() - startTime) / 1000; - - await editor.tick(); - await editor.stop(); - - console.log(`aborted request ${uuid}:`, abortOllamaRequest(uuid)); - - waitMessage.reply_to_message = msg; - waitMessage.text = currentText; - await MessageStore.put(waitMessage); - - if (Environment.SEND_TIME_TOOK) { - await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`}); - } - break; - } - } - } finally { - await bot.editMessageReplyMarkup({ - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: {inline_keyboard: []} - }).catch(logError); - } - } catch (error) { - if (error.message.toLowerCase().includes("aborted")) return; - - await bot.editMessageReplyMarkup({ - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: {inline_keyboard: []} - }).catch(logError); - - logError(error); - await oldReplyToMessage(waitMessage, `Произошла ошибка!\n${error.toString()}`).catch(logError); - } } -} \ No newline at end of file +} diff --git a/src/commands/ollama-get-model.ts b/src/commands/ollama-get-model.ts index a334a38..91490d8 100644 --- a/src/commands/ollama-get-model.ts +++ b/src/commands/ollama-get-model.ts @@ -1,112 +1,13 @@ -import {Command} from "../base/command"; -import {Message} from "typescript-telegram-bot-api"; -import {boolToEmoji, logError, replyToMessage} from "../util/utils"; import {Environment} from "../common/environment"; -import {ollama} from "../index"; -import {AiModelCapabilities} from "../model/ai-model-capabilities"; +import {AiProvider} from "../model/ai-provider"; +import {ProviderGetModelCommand} from "./provider-model-command"; -export class OllamaGetModel extends Command { - title = "/ollamaGetModel"; - description = "Ollama model info"; - - async execute(msg: Message): Promise { - try { - const model = Environment.OLLAMA_MODEL; - const imageModel = Environment.OLLAMA_IMAGE_MODEL; - const thinkModel = Environment.OLLAMA_THINK_MODEL; - - const promises: (Promise | null)[] = [this.getModelCapabilities()]; - - if (imageModel && imageModel !== model) { - promises.push(this.loadImageModelInfo()); - } else { - promises.push(null); - } - - if (thinkModel && thinkModel !== model) { - promises.push(this.loadThinkModelInfo()); - } else { - promises.push(null); - } - - const infos = await Promise.all(promises); - - let modelInfo = infos[0]; - const modelText = "```Text\n" + this.getModelText(model, modelInfo) + "```"; - - modelInfo = infos[1]; - const imageModelText = modelInfo ? - "```Image\n" + this.getModelText(imageModel, modelInfo) + "```" : null; - - modelInfo = infos[2]; - const thinkModelText = modelInfo ? - "```Think\n" + this.getModelText(thinkModel, modelInfo) + "```" : null; - - const modelInfos = [modelText]; - if (imageModelText) { - modelInfos.push(imageModelText); - } - if (thinkModelText) { - modelInfos.push(thinkModelText); - } - - await replyToMessage({ - message: msg, - text: modelInfos.join("\n\n"), - parse_mode: "Markdown" - }).catch(logError); - - } catch (e) { - logError(e); - await replyToMessage({message: msg, text: e.toString()}).catch(logError); - } +export class OllamaGetModel extends ProviderGetModelCommand { + constructor() { + super({ + provider: AiProvider.OLLAMA, + title: Environment.commandTitles.ollamaGetModel, + description: Environment.commandDescriptions.ollamaGetModel, + }); } - - private getModelText(model: string, info: AiModelCapabilities): string { - return `model: ${model}\n\n` + - `vision: ${boolToEmoji(info.vision?.supported)}\n` + - `thinking: ${boolToEmoji(info.thinking?.supported)}\n` + - `tools: ${boolToEmoji(info.tools?.supported)}`; - } - - async getModelCapabilities(model: string = Environment.OLLAMA_MODEL): Promise { - try { - const info = await ollama.show({model: model}); - console.log(info); - - return { - vision: { - supported: info.capabilities.includes("vision"), - external: model !== Environment.OLLAMA_MODEL, - model: model - }, - ocr: { - supported: info.capabilities.includes("ocr"), - external: model !== Environment.OLLAMA_MODEL, - model: model - }, - thinking: { - supported: info.capabilities.includes("thinking"), - external: model !== Environment.OLLAMA_MODEL, - model: model - }, - tools: { - supported: info.capabilities.includes("tools"), - external: model !== Environment.OLLAMA_MODEL, - model: model - }, - }; - } catch (e) { - logError(e); - return null; - } - } - - async loadImageModelInfo(): Promise { - return this.getModelCapabilities(Environment.OLLAMA_IMAGE_MODEL); - } - - async loadThinkModelInfo(): Promise { - return this.getModelCapabilities(Environment.OLLAMA_THINK_MODEL); - } -} \ No newline at end of file +} diff --git a/src/commands/ollama-list-models.ts b/src/commands/ollama-list-models.ts index cc3e974..ea6d3de 100644 --- a/src/commands/ollama-list-models.ts +++ b/src/commands/ollama-list-models.ts @@ -1,36 +1,13 @@ -import {Command} from "../base/command"; -import {Message} from "typescript-telegram-bot-api"; -import {ollama} from "../index"; -import {logError, oldReplyToMessage, replyToMessage} from "../util/utils"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; +import {Environment} from "../common/environment"; +import {AiProvider} from "../model/ai-provider"; +import {ProviderListModelsCommand} from "./provider-model-command"; -export class OllamaListModels extends Command { - title = "/ollamaListModels"; - description = "List all Ollama models"; - - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - async execute(msg: Message): Promise { - try { - const listResponse = await ollama.list(); - console.log(listResponse); - - const modelsString = listResponse.models - .sort((a, b) => a.name.localeCompare(b.name)) - .map(e => `${e.model}`) - .join("\n"); - - const text = "Доступные модели:\n\n" + "
" + modelsString + "
"; - - await replyToMessage({ - message: msg, - text: text, - parse_mode: "HTML" - }); - } catch (e) { - logError(e); - await oldReplyToMessage(msg, "Не получилось загрузить список моделей").catch(logError); - } +export class OllamaListModels extends ProviderListModelsCommand { + constructor() { + super({ + provider: AiProvider.OLLAMA, + title: Environment.commandTitles.ollamaListModels, + description: Environment.commandDescriptions.ollamaListModels, + }); } -} \ No newline at end of file +} diff --git a/src/commands/ollama-prompt.ts b/src/commands/ollama-prompt.ts deleted file mode 100644 index 8a1cd0f..0000000 --- a/src/commands/ollama-prompt.ts +++ /dev/null @@ -1,189 +0,0 @@ -import {Command} from "../base/command"; -import {Message} from "typescript-telegram-bot-api"; -import {abortOllamaRequest, bot, getOllamaRequest, ollama, ollamaRequests} from "../index"; -import {escapeMarkdownV2Text, logError, oldReplyToMessage, startIntervalEditor} from "../util/utils"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {Environment} from "../common/environment"; -import {Cancel} from "../callback_commands/cancel"; -import {OllamaCancel} from "../callback_commands/ollama-cancel"; -import {MessageStore} from "../common/message-store"; - -export class OllamaPrompt extends Command { - command = "ollamaPrompt"; - argsMode = "required" as const; - - title = "/ollamaPrompt"; - description = "Custom prompt for AI (Ollama)"; - - requirements = Requirements.Build(Requirement.BOT_ADMIN); - - async execute(msg: Message, match?: RegExpExecArray): Promise { - console.log("match", match); - return this.executeOllama(msg, match?.[3]); - } - - async executeOllama(msg: Message, text: string): Promise { - if (!text || text.trim().length === 0) return; - const chatId = msg.chat.id; - - let waitMessage: Message; - - const startTime = Date.now(); - - try { - const uuid = crypto.randomUUID(); - const cancelMarkup = {inline_keyboard: [[Cancel.withData(new OllamaCancel().data + " " + uuid).asButton()]]}; - - waitMessage = await bot.sendMessage({ - chat_id: chatId, - text: Environment.waitThinkText, - reply_parameters: { - chat_id: chatId, - message_id: msg.message_id - } - }); - - const stream = await ollama.generate({ - model: Environment.OLLAMA_MODEL, - stream: true, - think: false, - prompt: text - }); - - const newRequest = { - uuid: uuid, - stream: stream, - done: false, - fromId: msg.from.id, - chatId: msg.chat.id, - }; - - console.log("Pushing new request", newRequest); - ollamaRequests.push(newRequest); - - await bot.editMessageReplyMarkup( - { - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: cancelMarkup - } - ).catch(logError); - - let currentText = ""; - let shouldBreak = false; - - const editor = startIntervalEditor({ - uuid: uuid, - intervalMs: 4500, - getText: () => currentText, - editFn: async (text) => { - if (getOllamaRequest(uuid)?.done) return; - - try { - await bot.editMessageText({ - chat_id: chatId, - message_id: waitMessage.message_id, - text: escapeMarkdownV2Text(text), - parse_mode: "Markdown", - reply_markup: cancelMarkup - }).catch(logError); - - console.log("editMessageText", text); - - waitMessage.reply_to_message = msg; - waitMessage.text = text; - await MessageStore.put(waitMessage); - } catch (e) { - logError(e); - } - } - }); - await editor.tick(); - - try { - let isThinking = false; - - for await (const chunk of stream) { - - const content = chunk.response; - - if (content === "" || chunk.thinking) { - if (!isThinking) { - await bot.editMessageText({ - chat_id: chatId, - message_id: waitMessage.message_id, - text: "🤔 Размышляю...", - parse_mode: "Markdown", - }).catch(logError); - } - - isThinking = true; - } - - if (!isThinking) { - currentText += content; - } - - if (isThinking && !chunk.thinking) { - currentText += content; - } - - if (content === "" || !chunk.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); - } - - const diff = Math.abs(Date.now() - startTime) / 1000; - - await editor.tick(); - await editor.stop(); - - console.log(`aborted request ${uuid}:`, abortOllamaRequest(uuid)); - - waitMessage.reply_to_message = msg; - waitMessage.text = currentText; - await MessageStore.put(waitMessage); - await oldReplyToMessage(waitMessage, `⏱️ ${diff}s`); - break; - } - } - } finally { - await bot.editMessageReplyMarkup({ - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: {inline_keyboard: []} - }).catch(logError); - } - } catch (error) { - if (error.message.toLowerCase().includes("aborted")) return; - - await bot.editMessageReplyMarkup({ - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: {inline_keyboard: []} - }).catch(logError); - - logError(error); - await oldReplyToMessage(waitMessage, `Произошла ошибка!\n${error.toString()}`).catch(logError); - } - } -} \ No newline at end of file diff --git a/src/commands/ollama-search.ts b/src/commands/ollama-search.ts index c3a5634..6bb1673 100644 --- a/src/commands/ollama-search.ts +++ b/src/commands/ollama-search.ts @@ -2,48 +2,39 @@ import {Command} from "../base/command"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; import {Message} from "typescript-telegram-bot-api"; -import {bot, ollama} from "../index"; -import {WebSearchResponse} from "../model/web-search-response"; -import {oldEditMessageText, logError} from "../util/utils"; +import {escapeHtml, logError, replyToMessage} from "../util/utils"; import {Environment} from "../common/environment"; +import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target"; +import {AiProvider} from "../model/ai-provider"; export class OllamaSearch extends Command { command = ["s", "search"]; argsMode = "required" as const; - title = "/search"; - description = "Web search via Ollama"; + title = Environment.commandTitles.ollamaSearch; + description = Environment.commandDescriptions.ollamaSearch; override requirements = Requirements.Build(Requirement.BOT_ADMIN); async execute(msg: Message, match?: RegExpExecArray | null): Promise { - console.log("match", match); - const chatId = msg.chat.id; + const query = match?.[3] || ""; + if (!query || !query.length) return; try { - const wait = await bot.sendMessage({ - chat_id: chatId, - text: Environment.waitThinkText, - reply_parameters: { - chat_id: chatId, - message_id: msg.message_id - }, - parse_mode: "Markdown" + const target = resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"); + const result = await createOllamaClient(target).webSearch({query, maxResults: 10}); + const body = (result.results ?? []) + .map((item, index) => `${index + 1}. ${item.content}`) + .join("\n\n"); + + await replyToMessage({ + message: msg, + text: Environment.searchResultsHeaderText + "
" + escapeHtml(body) + "
", + parse_mode: "HTML", }); - - const results = await ollama.webSearch({query: match?.[3]}); - console.log("results", results); - - let message = "Результаты:\n\n"; - results.results.forEach((result, index) => { - const r = result as WebSearchResponse; - message += `${index + 1}. ${r.url}\n`; - }); - - await oldEditMessageText(chatId, wait.message_id, message); } catch (error) { - logError(error); + logError(error instanceof Error ? error : String(error)); + await replyToMessage({message: msg, text: Environment.errorText}).catch(logError); } - return Promise.resolve(); } -} \ No newline at end of file +} diff --git a/src/commands/ollama-set-model.ts b/src/commands/ollama-set-model.ts index 42ea5de..7050525 100644 --- a/src/commands/ollama-set-model.ts +++ b/src/commands/ollama-set-model.ts @@ -1,34 +1,13 @@ -import {Message} from "typescript-telegram-bot-api"; -import {Command} from "../base/command"; import {Environment} from "../common/environment"; -import {logError, replyToMessage} from "../util/utils"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {ollama} from "../index"; +import {AiProvider} from "../model/ai-provider"; +import {ProviderSetModelCommand} from "./provider-model-command"; -export class OllamaSetModel extends Command { - argsMode = "required" as const; - - title = "/ollamaSetModel"; - description = "Set Ollama model"; - - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - async execute(msg: Message, match?: RegExpExecArray | null): Promise { - const newModel = match?.[3]; - - try { - await ollama.show({model: newModel}); - - Environment.setOllamaModel(newModel || Environment.OLLAMA_MODEL); - - const text = newModel ? `Выбрана модель "${newModel}"` - : `Модель не задана. Будет использоваться стандартная модель "${Environment.OLLAMA_MODEL}".`; - - await replyToMessage({message: msg, text: text}).catch(logError); - } catch (e) { - logError(e); - await replyToMessage({message: msg, text: e.toString()}).catch(logError); - } +export class OllamaSetModel extends ProviderSetModelCommand { + constructor() { + super({ + provider: AiProvider.OLLAMA, + title: Environment.commandTitles.ollamaSetModel, + description: Environment.commandDescriptions.ollamaSetModel, + }); } -} \ No newline at end of file +} diff --git a/src/commands/openai-chat.ts b/src/commands/openai-chat.ts index 17b2ae9..bf2339d 100644 --- a/src/commands/openai-chat.ts +++ b/src/commands/openai-chat.ts @@ -1,167 +1,29 @@ import {Message} from "typescript-telegram-bot-api"; -import {MessageStore} from "../common/message-store"; -import { - collectReplyChainText, - escapeMarkdownV2Text, - logError, - replyToMessage, - startIntervalEditor -} from "../util/utils"; -import {Environment} from "../common/environment"; -import {bot, openAi} from "../index"; +import {ChatCommand} from "../base/chat-command"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; -import {ChatCommand} from "../base/chat-command"; +import {AiProvider} from "../model/ai-provider"; +import {runUnifiedAi} from "../ai/unified-ai-runner"; +import {Environment} from "../common/environment"; export class OpenAIChat extends ChatCommand { - command = ["openai", "chatgpt"]; + command = ["openai", "chatgpt", "openai-voice", "chatgpt-voice"]; argsMode = "required" as const; requirements = Requirements.Build(Requirement.BOT_CREATOR); - title = "/openAI"; - description = "Chat with AI (OpenAI)"; + title = Environment.commandTitles.openAiChat; + description = Environment.commandDescriptions.openAiChat; async execute(msg: Message, match?: RegExpExecArray): Promise { - console.log("OpenAI Chat: ", match); - return this.executeOpenAI(msg, match?.[3]); - } - - async executeOpenAI(msg: Message, text: string): Promise { - if (!text || text.trim().length === 0) return; - - const chatId = msg.chat.id; - - const storedMsg = await MessageStore.get(chatId, msg.message_id); - const messageParts = await collectReplyChainText(storedMsg); - console.log("MESSAGE PARTS", messageParts); - - const chatMessages = messageParts.map(part => { - const content = []; - content.push({ - type: part.bot ? "output_text" : "input_text", - text: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER "${part.name}":\n` : "") + part.content, - }); - - // TODO: 03/02/2026, Danil Nikolaev: upload file then add here - // for (const image of part.images) { - // content.push({ - // type: "image_url", - // imageUrl: "data:image/jpeg;base64," + image - // }); - // } - - return { - role: part.bot ? "assistant" : "user", - content: content, - type: "message", - }; + const command = match?.[1]?.toLowerCase() ?? ""; + await runUnifiedAi({ + provider: AiProvider.OPENAI, + msg: msg, + text: match?.[3] ?? "", + stream: true, + think: true, + synthesizeSpeechResponse: command.endsWith("-voice"), }); - chatMessages.reverse(); - - if (Environment.SYSTEM_PROMPT) { - chatMessages.unshift({ - role: "system", - content: [{type: "input_text", text: Environment.SYSTEM_PROMPT}], - type: "message" - }); - } - - let waitMessage: Message; - - const startTime = Date.now(); - - try { - waitMessage = await bot.sendMessage({ - chat_id: chatId, - text: Environment.waitThinkText, - reply_parameters: { - chat_id: chatId, - message_id: msg.message_id - } - }); - - const stream = await openAi.responses.create({ - model: Environment.OPENAI_MODEL, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - input: chatMessages as any, - stream: true - }); - - let currentText = ""; - let shouldBreak = false; - - const editor = startIntervalEditor({ - intervalMs: 4500, - getText: () => currentText, - editFn: async (text) => { - await bot.editMessageText( - { - chat_id: chatId, - message_id: waitMessage.message_id, - text: escapeMarkdownV2Text(text), - parse_mode: "MarkdownV2" - } - ).catch(logError); - - console.log("editMessageText", text); - - waitMessage.reply_to_message = msg; - waitMessage.text = text; - await MessageStore.put(waitMessage); - }, - onStop: async () => { - } - }); - await editor.tick(); - - try { - for await (const chunk of stream) { - console.log("chunk", chunk); - - if (chunk.type === "response.output_text.delta") { - const text = chunk.delta; - currentText += text; - - if (currentText.length > 4096) { - currentText = currentText.slice(0, 4093) + "..."; - shouldBreak = true; - } - - console.log("messageText", currentText); - console.log("length", currentText.length); - - if (shouldBreak) { - console.log("break", true); - break; - } - } - } - } finally { - await editor.tick(); - await editor.stop(); - - if (!shouldBreak) { - console.log("ended", true); - } - - const diff = Math.abs(Date.now() - startTime) / 1000.0; - console.log("time", diff); - - waitMessage.reply_to_message = msg; - waitMessage.text = currentText; - await MessageStore.put(waitMessage); - - if (Environment.SEND_TIME_TOOK) { - await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`}); - } - } - } catch (error) { - logError(error); - await replyToMessage({ - message: waitMessage, - text: `Произошла ошибка!\n${error.toString()}` - }).catch(logError); - } } -} \ No newline at end of file +} diff --git a/src/commands/openai-gen-image.ts b/src/commands/openai-gen-image.ts deleted file mode 100644 index b559115..0000000 --- a/src/commands/openai-gen-image.ts +++ /dev/null @@ -1,117 +0,0 @@ -import {ChatCommand} from "../base/chat-command"; -import {Message} from "typescript-telegram-bot-api"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {bot, openAi, photoGenDir} from "../index"; -import fs from "node:fs"; -import path from "node:path"; -import {oldEditMessageText, logError, replyToMessage} from "../util/utils"; -import {Environment} from "../common/environment"; -import {APIError} from "openai"; - -export class OpenAIGenImage extends ChatCommand { - command = ["openAiGenImage", "chatGPTGenImage", "imgen"]; - - title = "/openAIGenImage"; - description = "Generate image from OpenAI"; - - argsMode = "required" as const; - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - async execute(msg: Message, match?: RegExpExecArray): Promise { - const prompt = match?.[3]?.trim(); - if (!prompt?.length) return; - - let waitMessage: Message | null = null; - - try { - const totalParts = 3; - const model = Environment.OPENAI_IMAGE_MODEL; - const fileFullName = `${msg.chat.id}_${msg.message_id}.png`; - const getFileLocation = (fn: string) => { - return path.join(photoGenDir, fn); - }; - - waitMessage = await replyToMessage({message: msg, text: "🌈 Генерирую изображение..."}); - - const stream = await openAi.images.generate({ - model: model, - prompt: prompt, - n: 1, - size: "auto", - stream: true, - partial_images: totalParts, - moderation: "low", - output_format: "png", - }); - - const then = Date.now(); - - for await (const event of stream) { - switch (event.type) { - case "image_generation.partial_image": { - console.log(` Partial image ${event.partial_image_index + 1}/3 received`); - console.log(` Size: ${event.b64_json.length} characters (base64)`); - - const fileName = `partial_${event.partial_image_index + 1}_${fileFullName}`; - const imageBuffer = Buffer.from(event.b64_json, "base64"); - const fileLocation = getFileLocation(fileName); - fs.writeFileSync(fileLocation, imageBuffer); - console.log(` 💾 Saved to: ${path.resolve(fileLocation)}`); - - await bot.editMessageMedia({ - chat_id: msg.chat.id, - message_id: waitMessage.message_id, - media: { - type: "photo", - media: imageBuffer, - caption: `🌈 Генерирую изображение (${(event.partial_image_index + 1)}/${totalParts})...` - } - }); - break; - } - case "image_generation.completed": { - console.log("\n✅ Final image completed!"); - console.log(` Size: ${event.b64_json.length} characters (base64)`); - - const imageBuffer = Buffer.from(event.b64_json, "base64"); - const fileLocation = getFileLocation(fileFullName); - fs.writeFileSync(fileLocation, imageBuffer); - console.log(` Saved to: ${path.resolve(fileLocation)}`); - - const diff = Date.now() - then; - await bot.editMessageMedia({ - chat_id: msg.chat.id, - message_id: waitMessage.message_id, - media: { - type: "photo", - media: imageBuffer, - caption: `🌈 Изображение по запросу "${prompt}" сгенерировано моделью "${model}" размеров ${event.size} за ${diff}ms` - } - }); - break; - } - default: - console.log(`❓ Unknown event: ${event}`); - } - } - } catch (e) { - logError(e); - - if (e instanceof APIError && e.error.code === "moderation_blocked") { - const text = "❌ Мне запрещено такое генерировать 😠"; - - if (waitMessage) { - await oldEditMessageText(msg.chat.id, waitMessage.message_id, text).catch(logError); - } else { - await replyToMessage({message: msg, text: text}).catch(logError); - } - } else { - await replyToMessage({ - message: waitMessage ? waitMessage : msg, - text: `Произошла ошибка: ${e}` - }).catch(logError); - } - } - } -} \ No newline at end of file diff --git a/src/commands/openai-get-model.ts b/src/commands/openai-get-model.ts index aa0f42f..a5b1b01 100644 --- a/src/commands/openai-get-model.ts +++ b/src/commands/openai-get-model.ts @@ -1,29 +1,13 @@ -import {Command} from "../base/command"; -import {Message} from "typescript-telegram-bot-api"; -import {logError, replyToMessage} from "../util/utils"; import {Environment} from "../common/environment"; -import {AiModelCapabilities} from "../model/ai-model-capabilities"; +import {AiProvider} from "../model/ai-provider"; +import {ProviderGetModelCommand} from "./provider-model-command"; -export class OpenAIGetModel extends Command { - title = "/openAIGetModel"; - description = "Get current OpenAI model"; - - async execute(msg: Message): Promise { - await replyToMessage({message: msg, text: `Текущая модель: "${Environment.OPENAI_MODEL}"`}).catch(logError); +export class OpenAIGetModel extends ProviderGetModelCommand { + constructor() { + super({ + provider: AiProvider.OPENAI, + title: Environment.commandTitles.openAiGetModel, + description: Environment.commandDescriptions.openAiGetModel, + }); } - - async getModelCapabilities(): Promise { - // TODO: 12/02/2026, Danil Nikolaev: find solution - try { - return { - vision: {supported: true}, - ocr: null, - thinking: {supported: true}, - tools: {supported: true}, - }; - } catch (e) { - logError(e); - return null; - } - } -} \ No newline at end of file +} diff --git a/src/commands/openai-list-models.ts b/src/commands/openai-list-models.ts index 1760204..1490aa0 100644 --- a/src/commands/openai-list-models.ts +++ b/src/commands/openai-list-models.ts @@ -1,37 +1,13 @@ -import {Command} from "../base/command"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {Message} from "typescript-telegram-bot-api"; -import {openAi} from "../index"; -import {logError, replyToMessage} from "../util/utils"; +import {Environment} from "../common/environment"; +import {AiProvider} from "../model/ai-provider"; +import {ProviderListModelsCommand} from "./provider-model-command"; -export class OpenAIListModels extends Command { - title = "/openAIListModels"; - description = "List all OpenAI models"; - - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - async execute(msg: Message): Promise { - try { - const listResponse = await openAi.models.list(); - console.log(listResponse); - - const modelsString = listResponse.data - .map(e => `${e.id}`) - .sort((a, b) => a.localeCompare(b)) - .join("\n") - .substring(0, 4000); - - const text = "Доступные модели:\n\n" + "
" + modelsString + "
"; - - await replyToMessage({ - message: msg, - text: text, - parse_mode: "HTML" - }); - } catch (e) { - logError(e); - await replyToMessage({message: msg, text: "Не получилось загрузить список моделей"}).catch(logError); - } +export class OpenAIListModels extends ProviderListModelsCommand { + constructor() { + super({ + provider: AiProvider.OPENAI, + title: Environment.commandTitles.openAiListModels, + description: Environment.commandDescriptions.openAiListModels, + }); } -} \ No newline at end of file +} diff --git a/src/commands/openai-set-model.ts b/src/commands/openai-set-model.ts index e2ba962..8946862 100644 --- a/src/commands/openai-set-model.ts +++ b/src/commands/openai-set-model.ts @@ -1,25 +1,13 @@ -import {Command} from "../base/command"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {Message} from "typescript-telegram-bot-api"; import {Environment} from "../common/environment"; -import {logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {ProviderSetModelCommand} from "./provider-model-command"; -export class OpenAISetModel extends Command { - argsMode = "required" as const; - - title = "/openAISetModel"; - description = "Set OpenAI model"; - - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - async execute(msg: Message, match?: RegExpExecArray | null): Promise { - const newModel = match?.[3]; - Environment.setOpenAIModel(newModel || Environment.OPENAI_MODEL); - - const text = newModel ? `Выбрана модель "${newModel}"` - : `Модель не задана. Будет использоваться стандартная модель "${Environment.OPENAI_MODEL}".`; - - await replyToMessage({message: msg, text: text}).catch(logError); +export class OpenAISetModel extends ProviderSetModelCommand { + constructor() { + super({ + provider: AiProvider.OPENAI, + title: Environment.commandTitles.openAiSetModel, + description: Environment.commandDescriptions.openAiSetModel, + }); } -} \ No newline at end of file +} diff --git a/src/commands/ping.ts b/src/commands/ping.ts index ed453c6..5504086 100644 --- a/src/commands/ping.ts +++ b/src/commands/ping.ts @@ -1,46 +1,38 @@ import {logError, sendMessage} from "../util/utils"; import {Message} from "typescript-telegram-bot-api"; import {Command} from "../base/command"; +import {Environment} from "../common/environment"; export class Ping extends Command { - title = "/ping"; - description = "Ping between received and sent message"; + title = Environment.commandTitles.ping; + description = Environment.commandDescriptions.ping; async execute(msg: Message) { let d = new Date(); const u = (n: number): string => n > 9 ? n.toString() : `0${n}`; - const date = `${u(d.getDay())}.${u(d.getMonth() + 1)}.${d.getFullYear()}`; + const date = `${u(d.getDate())}.${u(d.getMonth() + 1)}.${d.getFullYear()}`; const time = `${u(d.getHours())}:${u(d.getMinutes())}:${u(d.getSeconds())}:${u(d.getMilliseconds())}`; const mDate = msg.date; const nowDate = new Date().getTime() / 1000; const diff = nowDate - mDate; - const tgPing = diff.toFixed(2); + const tgPing = (diff * 1000).toFixed(0); d = new Date(mDate * 1000); - const msgDate = `${u(d.getDay())}.${u(d.getMonth() + 1)}.${d.getFullYear()}`; + const msgDate = `${u(d.getDate())}.${u(d.getMonth() + 1)}.${d.getFullYear()}`; const msgTime = `${u(d.getHours())}:${u(d.getMinutes())}:${u(d.getSeconds())}:${u(d.getMilliseconds())}`; const then = Date.now(); - await sendMessage({message: msg, text: "pong"}).catch(logError); + await sendMessage({message: msg, text: Environment.pongText}).catch(logError); const now = Date.now(); const msgSendDiff = (now - then).toFixed(2); await sendMessage( { message: msg, - text: - "```ping\n" + - `TG: ${tgPing}ms\n` + - `API ${msgSendDiff}ms\n\n` + - - `🗓️ Message date: ${msgDate}\n` + - `🕒 Message time: ${msgTime}\n\n` + - `🗓️ Local date : ${date}\n` + - `🕒 Local time: ${time}` + - "```", + text: Environment.getPingReportText(tgPing, msgSendDiff, msgDate, msgTime, date, time), parse_mode: "Markdown" } ).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/prefix-response.ts b/src/commands/prefix-response.ts index f4f7c3b..5ebf860 100644 --- a/src/commands/prefix-response.ts +++ b/src/commands/prefix-response.ts @@ -5,6 +5,6 @@ import {Environment} from "../common/environment"; export class PrefixResponse extends Command { async execute(msg: Message): Promise { - await replyToMessage({message: msg, text: randomValue(Environment.ANSWERS.prefix)}).catch(logError); + await replyToMessage({message: msg, text: randomValue(Environment.ANSWERS.prefix) ?? Environment.prefixFallbackText}).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/provider-model-command.ts b/src/commands/provider-model-command.ts new file mode 100644 index 0000000..73bde78 --- /dev/null +++ b/src/commands/provider-model-command.ts @@ -0,0 +1,98 @@ +import {Message} from "typescript-telegram-bot-api"; +import {Command} from "../base/command"; +import {Requirement} from "../base/requirement"; +import {Requirements} from "../base/requirements"; +import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target"; +import {formatRuntimeModelInfo, getRuntimeModel, listProviderModels, setRuntimeModel} from "../ai/provider-model-runtime"; +import {Environment} from "../common/environment"; +import {AiProvider} from "../model/ai-provider"; +import {appLogger} from "../logging/logger"; +import {escapeHtml, logError, replyToMessage} from "../util/utils"; + +const logger = appLogger.child("commands:models"); + +type ProviderModelCommandOptions = { + provider: AiProvider; + title: string; + description: string; +}; + +export abstract class ProviderModelCommand extends Command { + protected readonly provider: AiProvider; + + title: string; + description: string; + + protected constructor(options: ProviderModelCommandOptions) { + super(); + this.provider = options.provider; + this.title = options.title; + this.description = options.description; + } +} + +export class ProviderGetModelCommand extends ProviderModelCommand { + async execute(msg: Message): Promise { + logger.debug("get_model", {provider: this.provider, chatId: msg.chat?.id, messageId: msg.message_id}); + await replyToMessage({message: msg, text: await formatRuntimeModelInfo(this.provider)}).catch(logError); + } +} + +export class ProviderSetModelCommand extends ProviderModelCommand { + argsMode = "required" as const; + requirements = Requirements.Build(Requirement.BOT_CREATOR); + + async execute(msg: Message, match?: RegExpExecArray | null): Promise { + const newModel = match?.[3]?.trim(); + logger.info("set_model.request", {provider: this.provider, hasModel: !!newModel, chatId: msg.chat?.id, messageId: msg.message_id}); + + if (newModel) setRuntimeModel(this.provider, newModel); + + const model = getRuntimeModel(this.provider); + const text = newModel + ? Environment.getSelectedModelWithInfoText(model, await formatRuntimeModelInfo(this.provider)) + : Environment.getModelIsNotSetCurrentText(model); + + logger.debug("set_model.reply", {provider: this.provider, model}); + await replyToMessage({message: msg, text}).catch(logError); + } +} + +export class ProviderListModelsCommand extends ProviderModelCommand { + requirements = Requirements.Build(Requirement.BOT_CREATOR); + + async execute(msg: Message): Promise { + try { + logger.info("list_models.request", {provider: this.provider, chatId: msg.chat?.id, messageId: msg.message_id}); + const models = (await listProviderModels(this.provider)).sort((a, b) => a.localeCompare(b)); + const modelsString = escapeHtml(models.join("\n").substring(0, 4000)); + const text = await this.buildListText(modelsString); + + logger.debug("list_models.reply", {provider: this.provider, count: models.length, textChars: text.length}); + await replyToMessage({message: msg, text, parse_mode: "HTML"}); + } catch (e) { + logger.error("list_models.failed", {provider: this.provider, error: e instanceof Error ? e : String(e)}); + logError(e instanceof Error ? e : String(e)); + await replyToMessage({message: msg, text: Environment.modelListLoadFailedText}).catch(logError); + } + } + + private async buildListText(modelsString: string): Promise { + if (this.provider !== AiProvider.OLLAMA) { + return Environment.modelListHeaderText + "
" + modelsString + "
"; + } + + const target = resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"); + const loadedModels = ((await createOllamaClient(target).ps())?.models ?? []) + .map(model => model.model || model.name) + .filter((model): model is string => !!model); + + logger.debug("list_models.loaded", {provider: this.provider, loaded: loadedModels.length}); + return Environment.getLoadedModelsText(loadedModels) + + "\n\n" + + Environment.modelListHeaderText + + "
" + + modelsString + + "
"; + } +} diff --git a/src/commands/qr.ts b/src/commands/qr.ts index 3488373..fc038d1 100644 --- a/src/commands/qr.ts +++ b/src/commands/qr.ts @@ -1,15 +1,17 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; -import {extractMessagePayload, logError, replyToMessage} from "../util/utils"; +import {escapeHtml, extractMessagePayload, logError, replyToMessage} from "../util/utils"; import {bot, botUser} from "../index"; import QRCode from "qrcode"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; +import {Environment} from "../common/environment"; export class Qr extends Command { argsMode = "optional" as const; - title = "/qr"; - description = "Generates QR-code from text you sent or replied to."; + title = Environment.commandTitles.qr; + description = Environment.commandDescriptions.qr; async execute(msg: Message, match?: RegExpExecArray): Promise { const chatId = msg.chat.id; @@ -19,27 +21,29 @@ export class Qr extends Command { await replyToMessage( { message: msg, - text: "Не найден текст для генерации QR-кода." + text: Environment.qrCodeMissingTextText } ); return; } - // TODO: 16/02/2026, Danil Nikolaev: escape html symbols in payload - - if (payload.length > 1500) { - payload = payload.slice(0, 1500); + const maxQrPayloadLength = 1500; + if (payload.length > maxQrPayloadLength) { + payload = payload.slice(0, maxQrPayloadLength); await replyToMessage( { message: msg, - text: `Слишком длинный текст для QR (${payload.length} символов). Текст будет обрезан до 1500 символов.` + text: Environment.getQrCodeTextTooLongText(payload.length, maxQrPayloadLength) } ); } try { - await bot.sendChatAction({chat_id: chatId, action: "upload_photo"}); + await enqueueTelegramApiCall( + () => bot.sendChatAction({chat_id: chatId, action: "upload_photo"}), + {method: "sendChatAction", chatId, chatType: msg.chat.type} + ); const pngBuffer = await QRCode.toBuffer(payload, { type: "png", @@ -49,23 +53,27 @@ export class Qr extends Command { }); const maxCaptionLength = botUser.is_premium ? 4096 : 1024; + const visiblePayload = payload.length > maxCaptionLength - 80 + ? payload.slice(0, maxCaptionLength - 83) + "..." + : payload; - await bot.sendPhoto({ - chat_id: chatId, - photo: pngBuffer, - caption: "QR-код готов ✅\nСодержимое:\n
" + - `${payload.length > maxCaptionLength ? payload.slice(0, maxCaptionLength - 40) + "..." : payload}` + - "
", - reply_parameters: { - message_id: msg.message_id, - }, - parse_mode: "HTML" - }); - } catch (e) { + await enqueueTelegramApiCall( + () => bot.sendPhoto({ + chat_id: chatId, + photo: pngBuffer, + caption: Environment.getQrCodeReadyText(escapeHtml(visiblePayload)), + reply_parameters: { + message_id: msg.message_id, + }, + parse_mode: "HTML" + }), + {method: "sendPhoto", chatId, chatType: msg.chat.type} + ); + } catch (error) { await replyToMessage({ message: msg, - text: `Не получилось сгенерировать QR: ${e?.message ?? String(e)}` + text: Environment.getQrCodeFailedText(error instanceof Error ? error : String(error)) }).catch(logError); } } -} \ No newline at end of file +} diff --git a/src/commands/quote.ts b/src/commands/quote.ts index a71a118..a15fc4b 100644 --- a/src/commands/quote.ts +++ b/src/commands/quote.ts @@ -17,9 +17,15 @@ import { import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; import twemoji from "twemoji"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; +import {AsyncSemaphore} from "../util/async-lock"; +import {Environment} from "../common/environment"; +import {getLruMapValue, setLruMapValue} from "../util/lru-map"; +import {appLogger} from "../logging/logger"; + +const logger = appLogger.child("command:quote"); try { - GlobalFonts.registerFromPath("./assets/Inter_18pt-ExtraThin.ttf", "InterExtraThin"); GlobalFonts.registerFromPath("./assets/Inter_18pt-Thin.ttf", "InterThin"); GlobalFonts.registerFromPath("./assets/Inter_18pt-Light.ttf", "InterLight"); GlobalFonts.registerFromPath("./assets/Inter_18pt-Regular.ttf", "Inter"); @@ -33,50 +39,60 @@ try { GlobalFonts.registerFromPath("./assets/JetBrainsMono-Italic.ttf", "JetBrainsMonoItalic"); GlobalFonts.registerFromPath("./assets/JetBrainsMono-Regular.ttf", "JetBrainsMonoRegular"); } catch (e) { - logError(e); + logError(e instanceof Error ? e : String(e)); } export class Quote extends Command { command = ["cit", "citation", "q", "quote"]; argsMode = "none" as const; - title = "/quote"; - description = "Make quote from text (or quote)"; + title = Environment.commandTitles.quote; + description = Environment.commandDescriptions.quote; requirements = Requirements.Build(Requirement.REPLY); async execute(msg: Message): Promise { const chatId = msg.chat.id; const reply = msg.reply_to_message; + if (!reply) return; try { + const startedAt = Date.now(); + logger.debug("execute.start", {chatId, messageId: msg.message_id, replyMessageId: reply.message_id}); const quoteRaw = (msg.quote?.text ?? reply.text ?? reply.caption ?? "").trim(); if (quoteRaw.length === 0) { - await replyToMessage({message: msg, text: "Не нашёл в сообщении текста 😢"}).catch(logError); + await replyToMessage({message: msg, text: Environment.quoteMissingTextText}).catch(logError); return; } const quote = quoteRaw.length ? quoteRaw : "…"; - const entities = msg.quote ? msg.quote.entities : reply.entities ?? reply.caption_entities ?? []; + const entities = msg.quote ? msg.quote.entities ?? [] : reply.entities ?? reply.caption_entities ?? []; - const png = await renderQuoteCard(msg, quote, reply, entities); - await bot.sendPhoto({ - chat_id: chatId, - photo: png, - reply_parameters: { - message_id: msg.message_id, - }, - }).catch(logError); + const png = await quoteRenderSemaphore.runExclusive(() => renderQuoteCard(msg, quote, reply, entities)); + await enqueueTelegramApiCall( + () => bot.sendPhoto({ + chat_id: chatId, + photo: png, + reply_parameters: { + message_id: msg.message_id, + }, + }), + {method: "sendPhoto", chatId, chatType: msg.chat.type} + ); + logger.debug("execute.done", {chatId, messageId: msg.message_id, bytes: png.length, duration: logger.duration(startedAt)}); } catch (e) { - logError(e); - await replyToMessage({message: msg, text: "Не смог собрать цитату 😢"}).catch(logError); + logError(e instanceof Error ? e : String(e)); + await replyToMessage({message: msg, text: Environment.quoteBuildFailedText}).catch(logError); } } } const emojiCache = new Map(); const customEmojiCache = new Map(); +const quoteRenderSemaphore = new AsyncSemaphore(2); +const EMOJI_CACHE_MAX_ENTRIES = 256; +const CUSTOM_EMOJI_CACHE_MAX_ENTRIES = 512; function appleEmojiUrl(emoji: string): string { const codePoints = [...emoji] @@ -97,17 +113,19 @@ function twemojiUrl(emoji: string) { return `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/${code}.png`; } -async function loadEmoji(emoji: string): Promise { +async function loadEmoji(emoji: string | undefined): Promise { + if (!emoji) return null; + const downloadAndCache = async (url: string): Promise => { const res = await axios.get(url, {responseType: "arraybuffer"}); const img = await loadImage(Buffer.from(res.data)); - emojiCache.set(url, img); + setLruMapValue(emojiCache, url, img, EMOJI_CACHE_MAX_ENTRIES); return img; }; const checkIfCached = async (emoji: string, emojiToUrl: (emoji: string) => string): Promise => { const url = emojiToUrl(emoji); - const cached = emojiCache.get(url); + const cached = getLruMapValue(emojiCache, url); if (cached) return cached; return await downloadAndCache(emojiToUrl(emoji)); }; @@ -117,7 +135,7 @@ async function loadEmoji(emoji: string): Promise { try { return await checkIfCached(emoji, source); } catch (e) { - logError(e); + logError(e instanceof Error ? e : String(e)); } } @@ -125,7 +143,7 @@ async function loadEmoji(emoji: string): Promise { } async function loadCustomEmoji(customEmojiId: string): Promise { - const cached = customEmojiCache.get(customEmojiId); + const cached = getLruMapValue(customEmojiCache, customEmojiId); if (cached) return cached; try { @@ -134,14 +152,14 @@ async function loadCustomEmoji(customEmojiId: string): Promiseimg, cx, y, emojiSize, emojiSize); } catch (e) { - logError(e); + logError(e instanceof Error ? e : String(e)); ctx.fillText(seg.v, cx, baselineY); } cx += emojiSize; @@ -506,17 +524,17 @@ async function drawLine(ctx: SKRSContext2D, line: Segment[], x: number, baseline } else { const img = await loadEmoji("😥"); const y = baselineY - emojiSize + Math.round(fontSize * 0.2); - ctx.drawImage(img, cx, y, emojiSize, emojiSize); + ctx.drawImage(img, cx, y, emojiSize, emojiSize); } } catch (e) { - console.warn("Failed to draw custom emoji:", e); + logger.warn("custom_emoji.draw_failed", {error: e instanceof Error ? e : String(e)}); try { const img = await loadEmoji("😥"); const y = baselineY - emojiSize + Math.round(fontSize * 0.2); - ctx.drawImage(img, cx, y, emojiSize, emojiSize); + ctx.drawImage(img, cx, y, emojiSize, emojiSize); } catch (e) { - logError(e); + logError(e instanceof Error ? e : String(e)); ctx.fillText(":-(", cx, baselineY); } @@ -742,4 +760,4 @@ function getQuoteAuthor(reply: Message): QuoteAuthor { const u = reply.from!; const name = [u.first_name, u.last_name].filter(Boolean).join(" ") || u.username || "Unknown"; return {name, username: u.username, userId: u.id}; -} \ No newline at end of file +} diff --git a/src/commands/random-int.ts b/src/commands/random-int.ts index 2bac8ee..50e9cd1 100644 --- a/src/commands/random-int.ts +++ b/src/commands/random-int.ts @@ -1,25 +1,36 @@ import {Command} from "../base/command"; -import {getRandomInt, getRangedRandomInt, logError, oldSendMessage} from "../util/utils"; +import {getRandomInt, logError, oldSendMessage} from "../util/utils"; import {Message} from "typescript-telegram-bot-api"; +import {Environment} from "../common/environment"; export class RandomInt extends Command { argsMode = "optional" as const; - title = "/randomInt"; - description = "Ranged random integer from parameters"; + title = Environment.commandTitles.randomInt; + description = Environment.commandDescriptions.randomInt; async execute(msg: Message) { - const split = msg.text.split(" "); - const min = parseInt(split[1]); - const max = parseInt(split[2]); + if (!msg.text) return; - const good = max > min; - const sufficient = !!(min && max) && good; + const args = msg.text.trim().split(/\s+/).slice(1); + const values = args + .map(value => Number(value)) + .filter(value => Number.isSafeInteger(value)); + const min = values.length === 1 ? 1 : values[0]; + const max = values.length === 1 ? values[0] : values[1]; - const random = !sufficient ? getRandomInt(Math.pow(2, 60)) : getRangedRandomInt(min, max); + const sufficient = Number.isSafeInteger(min) && Number.isSafeInteger(max); + if (sufficient && min === max) { + await oldSendMessage(msg, Environment.getRandomIntRangeText(min, max, min)).catch(logError); + return; + } - const randomText = !sufficient ? random.toString() : `[${min}; ${max}]: ${random}`; + const from = sufficient ? Math.min(min, max) : 0; + const to = sufficient ? Math.max(min, max) : 1_000_000_000; + const random = getRandomInt(to - from + 1) + from; + + const randomText = !sufficient ? random.toString() : Environment.getRandomIntRangeText(from, to, random); await oldSendMessage(msg, randomText).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/random-string.ts b/src/commands/random-string.ts index 2a7f78c..174a077 100644 --- a/src/commands/random-string.ts +++ b/src/commands/random-string.ts @@ -1,31 +1,35 @@ import {Command} from "../base/command"; import {getRandomInt, logError, replyToMessage} from "../util/utils"; import {Message} from "typescript-telegram-bot-api"; +import {Environment} from "../common/environment"; export class RandomString extends Command { argsMode = "optional" as const; - title = "/randomString"; - description = "literally random string (up to 4096 symbols)"; + title = Environment.commandTitles.randomString; + description = Environment.commandDescriptions.randomString; async execute(msg: Message) { - const split = msg.text.split(" "); - const l = parseInt(split.length > 1 ? split[1] : "1"); + if (!msg.text) return; - const length = (l <= 0 || l > 4096) ? 1 : l; + const [, lengthArg] = msg.text.trim().split(/\s+/); + const requestedLength = Number(lengthArg ?? 1); + const length = Number.isSafeInteger(requestedLength) + ? Math.min(4096, Math.max(1, requestedLength)) + : 1; + + const characters = Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя0123456789"); let result = ""; - const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя0123456789"; - for (let i = 0; i < length; i++) { - result += characters.charAt(getRandomInt(characters.length)); + result += characters[getRandomInt(characters.length)]; } await replyToMessage({ message: msg, - text: "
" + result + "
", + text: Environment.getExpandableBlockquoteText(result), parse_mode: "HTML" }).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/settings.ts b/src/commands/settings.ts new file mode 100644 index 0000000..606f3a0 --- /dev/null +++ b/src/commands/settings.ts @@ -0,0 +1,67 @@ +import {Message} from "typescript-telegram-bot-api"; +import {Command} from "../base/command"; +import {UserStore} from "../common/user-store"; +import { + ensureValidUserAiSettings, + normalizeAiContextSizeChoice, + normalizeAiImageOutputMode, + normalizeAiVoiceMode, + setUserAiContextSizeChoice, + setUserAiImageOutputMode, + setUserAiVoiceMode, +} from "../common/user-ai-settings"; +import {buildUserSettingsKeyboard, formatUserSettingsText} from "../common/user-settings-view"; +import {logError, replyToMessage} from "../util/utils"; +import {Environment} from "../common/environment"; + +export class Settings extends Command { + command = ["settings", "config"]; + argsMode = "optional" as const; + + title = Environment.commandTitles.settings; + description = Environment.commandDescriptions.settings; + + async execute(msg: Message, match?: RegExpExecArray | null): Promise { + if (!msg.from) return; + + await UserStore.put(msg.from); + const args = match?.[3]?.trim(); + let settings = await ensureValidUserAiSettings(msg.from.id); + let screen: Parameters[1] = "main"; + + if (args) { + const [name, ...rest] = args.split(/\s+/); + const value = rest.join(" "); + + if (name?.toLowerCase() === "context" || name?.toLowerCase() === "ctx") { + const choice = normalizeAiContextSizeChoice(value); + if (choice) { + settings = (await setUserAiContextSizeChoice(msg.from.id, choice)).settings; + screen = "contextSize"; + } + } + + if (name?.toLowerCase() === "voice" || name?.toLowerCase() === "audio") { + const mode = normalizeAiVoiceMode(value); + if (mode) { + settings = (await setUserAiVoiceMode(msg.from.id, mode)).settings; + screen = "voiceMode"; + } + } + + if (name?.toLowerCase() === "image" || name?.toLowerCase() === "images" || name?.toLowerCase() === "output") { + const mode = normalizeAiImageOutputMode(value || name); + if (mode) { + settings = (await setUserAiImageOutputMode(msg.from.id, mode)).settings; + screen = "imageOutput"; + } + } + } + + await replyToMessage({ + message: msg, + text: formatUserSettingsText(settings, screen), + reply_markup: buildUserSettingsKeyboard(settings, screen), + }).catch(logError); + } +} diff --git a/src/commands/shutdown.ts b/src/commands/shutdown.ts index ca57c7a..27cccb4 100644 --- a/src/commands/shutdown.ts +++ b/src/commands/shutdown.ts @@ -2,47 +2,50 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; -import {bot} from "../index"; +import {bot, shutdown as shutdownApp} from "../index"; import {delay, logError, randomValue} from "../util/utils"; - -const texts = [ - "ну что-же, господа", - "приятно было с вами пообщаться", - "но мне пора на покой", - "всего хорошего" -]; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; +import {Environment} from "../common/environment"; const timings = [1500, 2500]; const timer = [3, 2, 1]; export class Shutdown extends Command { - title = "/shutdown"; - description = "Self-destruction sequence for bot (shutdown)"; + title = Environment.commandTitles.shutdown; + description = Environment.commandDescriptions.shutdown; argsMode = "optional" as const; requirements = Requirements.Build(Requirement.BOT_CREATOR); async execute(msg: Message, match?: RegExpExecArray): Promise { - await bot.sendMessage({chat_id: msg.chat.id, text: "..."}).catch(logError); + const send = async (text: string) => { + await enqueueTelegramApiCall( + () => bot.sendMessage({chat_id: msg.chat.id, text}), + {method: "sendMessage", chatId: msg.chat.id, chatType: msg.chat.type} + ).catch(logError); + }; + + await send(Environment.shutdownFallbackText); const now = match?.[3]?.toLowerCase() === "now"; if (msg.chat.type !== "private" && !now) { - for (const text of texts) { - await delay(randomValue(timings)); - await bot.sendMessage({chat_id: msg.chat.id, text: text}).catch(logError); + for (const text of Environment.shutdownSequenceTexts) { + await delay(randomValue(timings) ?? 1500); + await send(text); } - await delay(randomValue(timings)); + await delay(randomValue(timings) ?? 1500); for (const t of timer) { - await bot.sendMessage({chat_id: msg.chat.id, text: `${t}`}).catch(logError); + await send(`${t}`); await delay(1000); } } - await bot.sendMessage({chat_id: msg.chat.id, text: "*R.I.P*"}).catch(logError); + await send(Environment.shutdownDoneText); - delay(2000).then(() => process.exit(0)); + await delay(2000); + await shutdownApp("manual"); } -} \ No newline at end of file +} diff --git a/src/commands/speech-to-text.ts b/src/commands/speech-to-text.ts new file mode 100644 index 0000000..024730c --- /dev/null +++ b/src/commands/speech-to-text.ts @@ -0,0 +1,80 @@ +import {Message} from "typescript-telegram-bot-api"; +import {Command} from "../base/command"; +import {isTranscribableAudioDownload, resolveSpeechToTextProviderForUser, transcribeSpeechDownloads} from "../ai/speech-to-text"; +import {attachmentsToDownloadedFiles, cacheMessageAttachments} from "../ai/telegram-attachments"; +import {MessageStore} from "../common/message-store"; +import {StoredAttachment} from "../model/stored-attachment"; +import {logError, replyToMessage} from "../util/utils"; +import {Environment} from "../common/environment"; +import {parseProviderToken} from "../ai/provider-aliases"; + +const TELEGRAM_LIMIT = 4096; + +async function collectStoredAttachments(msg: Message | undefined): Promise { + if (!msg) return []; + + const stored = await MessageStore.get(msg.chat.id, msg.message_id); + if (stored?.attachments?.length) return stored.attachments; + + return cacheMessageAttachments(msg); +} + +async function collectAudioDownloads(msg: Message) { + const attachments = [ + ...await collectStoredAttachments(msg), + ...await collectStoredAttachments(msg.reply_to_message), + ]; + const seen = new Set(); + + return attachmentsToDownloadedFiles(attachments) + .filter(isTranscribableAudioDownload) + .filter(download => { + const key = `${download.fileId}:${download.path}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export class SpeechToText extends Command { + command = ["stt", "transcribe"]; + argsMode = "optional" as const; + + title = Environment.commandTitles.speechToText; + description = Environment.commandDescriptions.speechToText; + + async execute(msg: Message, match?: RegExpExecArray | null): Promise { + if (!msg.from) return; + + const args = match?.[3]?.trim() ?? ""; + const explicitProvider = parseProviderToken(args.split(/\s+/)[0]); + const downloads = await collectAudioDownloads(msg); + + if (!downloads.length) { + await replyToMessage({ + message: msg, + text: Environment.speechToTextInstructionText, + }).catch(logError); + return; + } + + try { + const resolved = await resolveSpeechToTextProviderForUser(msg.from.id, explicitProvider, { + allowFallback: !explicitProvider, + }); + const transcript = await transcribeSpeechDownloads(resolved.provider, downloads); + const text = transcript.trim() || Environment.speechToTextEmptyResultText; + + await replyToMessage({ + message: msg, + text: text.length > TELEGRAM_LIMIT ? text.slice(0, TELEGRAM_LIMIT - 3) + "..." : text, + }).catch(logError); + } catch (e) { + logError(e instanceof Error ? e : String(e)); + await replyToMessage({ + message: msg, + text: e instanceof Error ? e.message : String(e), + }).catch(logError); + } + } +} diff --git a/src/commands/start.ts b/src/commands/start.ts index b0fa959..0c4d679 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -2,12 +2,13 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; import {commands} from "../index"; import {Help} from "./help"; +import {Environment} from "../common/environment"; export class Start extends Command { - title = "/start"; - description = "Start the bot"; + title = Environment.commandTitles.start; + description = Environment.commandDescriptions.start; async execute(msg: Message): Promise { - await commands.find(e => e instanceof Help).execute(msg); + await commands.find(e => e instanceof Help)?.execute(msg); } -} \ No newline at end of file +} diff --git a/src/commands/system-info.ts b/src/commands/system-info.ts index 53a7809..237e87f 100644 --- a/src/commands/system-info.ts +++ b/src/commands/system-info.ts @@ -1,18 +1,33 @@ import {Command} from "../base/command"; import {logError, replyToMessage} from "../util/utils"; import {Message} from "typescript-telegram-bot-api"; +import {Environment} from "../common/environment"; +import {ShellCommandRunner} from "../util/shell-command-runner"; export class SystemInfo extends Command { - title = "/systemInfo"; - description = "System information"; + title = Environment.commandTitles.systemInfo; + description = Environment.commandDescriptions.systemInfo; - private static systemInfoText: string; + private static systemInfoParams: Parameters[0] | null = null; - static setSystemInfo(info: string) { - SystemInfo.systemInfoText = info; + static setSystemInfo(params: Parameters[0]) { + SystemInfo.systemInfoParams = params; } async execute(msg: Message) { - await replyToMessage({message: msg, text: SystemInfo.systemInfoText}).catch(logError); + if (!SystemInfo.systemInfoParams) return; + + const loadAverageResult = await ShellCommandRunner.run("awk '{printf \"%.2f;%.2f;%.2f\\n\", $1, $2, $3}' /proc/loadavg"); + const split = loadAverageResult.stdout?.split(";").map(s => parseFloat(s)) ?? []; + const loadAverageText = split.length + ? `LOAD_AVERAGE: ${split.map(value => value.toFixed(2)).join(", ")}` + : null; + + const finalText = [ + Environment.getSystemSpecsText(SystemInfo.systemInfoParams), + loadAverageText, + ].filter(Boolean).join("\n"); + + await replyToMessage({message: msg, text: finalText}).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/test.ts b/src/commands/test.ts index ff9eddb..a720203 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -5,10 +5,10 @@ import {Environment} from "../common/environment"; export class Test extends Command { regexp = /^(test|тест|еуые|ntcn|инноке(нтий|ш|нтич))$/i; - title = "тест"; - description = "System functionality check"; + title = Environment.commandTitles.test; + description = Environment.commandDescriptions.test; async execute(msg: Message) { - await oldReplyToMessage(msg, randomValue(Environment.ANSWERS.test) || "а").catch(logError); + await oldReplyToMessage(msg, randomValue(Environment.ANSWERS.test) || Environment.defaultTestAnswerText).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/text-to-speech.ts b/src/commands/text-to-speech.ts new file mode 100644 index 0000000..679d685 --- /dev/null +++ b/src/commands/text-to-speech.ts @@ -0,0 +1,50 @@ +import {Message} from "typescript-telegram-bot-api"; +import {Command} from "../base/command"; +import {parseProviderToken} from "../ai/provider-aliases"; +import { + resolveTextToSpeechProviderForUser, + sendSynthesizedSpeech, + synthesizeSpeech, +} from "../ai/text-to-speech"; +import {logError, replyToMessage} from "../util/utils"; +import {Environment} from "../common/environment"; + +export class TextToSpeech extends Command { + command = ["tts", "say", "voice"]; + argsMode = "optional" as const; + + title = Environment.commandTitles.textToSpeech; + description = Environment.commandDescriptions.textToSpeech; + + async execute(msg: Message, match?: RegExpExecArray | null): Promise { + if (!msg.from) return; + + const args = match?.[3]?.trim() ?? ""; + const replyText = (msg.reply_to_message?.text ?? msg.reply_to_message?.caption ?? "").trim(); + const [firstToken = "", ...restTokens] = args.split(/\s+/); + const explicitProvider = parseProviderToken(firstToken); + const text = explicitProvider + ? (restTokens.join(" ").trim() || replyText) + : (args || replyText); + + if (!text.trim()) { + await replyToMessage({ + message: msg, + text: Environment.textToSpeechInstructionText, + }).catch(error => logError(error instanceof Error ? error : String(error))); + return; + } + + try { + const resolved = await resolveTextToSpeechProviderForUser(msg.from.id, explicitProvider); + const speech = await synthesizeSpeech({provider: resolved.provider, text}); + await sendSynthesizedSpeech(msg, speech); + } catch (e) { + logError(e instanceof Error ? e : String(e)); + await replyToMessage({ + message: msg, + text: e instanceof Error ? e.message : String(e), + }).catch(logError); + } + } +} diff --git a/src/commands/title.ts b/src/commands/title.ts index 2b76b1a..49dc4ac 100644 --- a/src/commands/title.ts +++ b/src/commands/title.ts @@ -4,13 +4,15 @@ import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; import {logError, oldReplyToMessage} from "../util/utils"; import {bot} from "../index"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; +import {Environment} from "../common/environment"; export class Title extends Command { command = "title"; argsMode = "required" as const; - title = "/title"; - description = "Change group title"; + title = Environment.commandTitles.title; + description = Environment.commandDescriptions.title; requirements = Requirements.Build( Requirement.BOT_ADMIN, @@ -22,10 +24,13 @@ export class Title extends Command { async execute(msg: Message, match?: RegExpExecArray): Promise { const title = (match?.[3] ?? "").trim(); if (title.length === 0) { - await oldReplyToMessage(msg, "Не нашёл название...").catch(logError); + await oldReplyToMessage(msg, Environment.titleMissingText).catch(logError); return; } - await bot.setChatTitle({chat_id: msg.chat.id, title: title}).catch(logError); + await enqueueTelegramApiCall( + () => bot.setChatTitle({chat_id: msg.chat.id, title: title}), + {method: "setChatTitle", chatId: msg.chat.id, chatType: msg.chat.type} + ).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/transliteration.ts b/src/commands/transliteration.ts index ab4e564..1bd48cc 100644 --- a/src/commands/transliteration.ts +++ b/src/commands/transliteration.ts @@ -1,6 +1,7 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; import {logError, oldReplyToMessage, randomValue} from "../util/utils"; +import {Environment} from "../common/environment"; const EN = "`qwertyuiop[]asdfghjkl;'zxcvbnm,./" + @@ -38,7 +39,7 @@ export const toEnLayout = (text: string) => swapLayout(text, ruToEn); const reCyr = /\p{Script=Cyrillic}/u; const reLat = /\p{Script=Latin}/u; -export type ScriptGuess = "ru" | "en" | "mixed" | "unknown"; +export type ScriptGuess = "ru" | "en" | "mixed" | "other"; export function detectScript(text: string): ScriptGuess { let cyr = 0, lat = 0; @@ -48,7 +49,7 @@ export function detectScript(text: string): ScriptGuess { else if (reLat.test(ch)) lat++; } - if (cyr === 0 && lat === 0) return "unknown"; + if (cyr === 0 && lat === 0) return "other"; if (cyr === lat) return "mixed"; return cyr > lat ? "ru" : "en"; } @@ -60,7 +61,7 @@ export function fixLayoutAuto( ): string { let guess = detectScript(text); if (guess === "mixed") { - guess = randomValue([true, false]) ? "ru" : "en"; + guess = (randomValue([true, false]) ?? false) ? "ru" : "en"; } if (guess === "en") { @@ -77,16 +78,18 @@ export function fixLayoutAuto( export class Transliteration extends Command { command = ["transliteration", "tr"]; - title = "/tr [text or reply]"; - description = "Transliteration EN <--> RU"; + title = Environment.commandTitles.transliteration; + description = Environment.commandDescriptions.transliteration; async execute(msg: Message): Promise { + if (!msg.text && !msg.caption) return; + let text: string = ""; if (msg.reply_to_message) { text = (msg.reply_to_message.text || msg.reply_to_message.caption || ""); } else { - const split = (msg.text || msg.caption).split("/tr "); + const split = ((msg.text || msg.caption)).split("/tr "); if (split.length > 1) { text = split[1].trim(); } @@ -100,4 +103,4 @@ export class Transliteration extends Command { await oldReplyToMessage(msg, newText).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/unban.ts b/src/commands/unban.ts index 978069c..f985816 100644 --- a/src/commands/unban.ts +++ b/src/commands/unban.ts @@ -5,10 +5,11 @@ import {Message} from "typescript-telegram-bot-api"; import {bot, botUser} from "../index"; import {fullName, logError, oldReplyToMessage, oldSendMessage} from "../util/utils"; import {Environment} from "../common/environment"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; export class Unban extends Command { - title = "/unban [reply]"; - description = "unban user from chat"; + title = Environment.commandTitles.unban; + description = Environment.commandDescriptions.unban; requirements = Requirements.Build( Requirement.BOT_ADMIN, @@ -19,32 +20,35 @@ export class Unban extends Command { ); async execute(msg: Message) { - if (!msg.reply_to_message) return; + if (!msg.reply_to_message || !msg.reply_to_message.from) return; const user = msg.reply_to_message.from; const userId = user.id; if (userId === botUser.id) { - await oldReplyToMessage(msg, "Бот и так не в бане сам у себя.").catch(logError); + await oldReplyToMessage(msg, Environment.botIsNotBannedByItselfText).catch(logError); return; } if (userId === Environment.CREATOR_ID) { - await oldReplyToMessage(msg, "Создатель бота и так не в бане и никогда не будет.").catch(logError); + await oldReplyToMessage(msg, Environment.botCreatorNeverBannedText).catch(logError); return; } - if (msg.from.id !== Environment.CREATOR_ID && Environment.ADMIN_IDS.has(userId)) { - await oldReplyToMessage(msg, "Админимтраторы бота и так не в бане.").catch(logError); + if (msg.from?.id !== Environment.CREATOR_ID && Environment.ADMIN_IDS.has(userId)) { + await oldReplyToMessage(msg, Environment.botAdminsNotBannedText).catch(logError); return; } - bot.unbanChatMember({chat_id: msg.chat.id, user_id: userId}) + enqueueTelegramApiCall( + () => bot.unbanChatMember({chat_id: msg.chat.id, user_id: userId}), + {method: "unbanChatMember", chatId: msg.chat.id, chatType: msg.chat.type} + ) .then(async () => { - await oldSendMessage(msg, `${fullName(user)} разбанен ⛓️‍💥`).catch(logError); + await oldSendMessage(msg, Environment.getUserUnbannedText(fullName(user))).catch(logError); }) .catch(async () => { - await oldSendMessage(msg, `Не смог разбанить ${fullName(user)} ☹️`).catch(logError); + await oldSendMessage(msg, Environment.getUserUnbanFailedText(fullName(user))).catch(logError); }); } -} \ No newline at end of file +} diff --git a/src/commands/unignore.ts b/src/commands/unignore.ts index 7c6025e..ba47a88 100644 --- a/src/commands/unignore.ts +++ b/src/commands/unignore.ts @@ -7,8 +7,8 @@ import {botUser} from "../index"; import {Environment} from "../common/environment"; export class Unignore extends Command { - title = "/unignore"; - description = "Bot will start responding to the user"; + title = Environment.commandTitles.unignore; + description = Environment.commandDescriptions.unignore; requirements = Requirements.Build( Requirement.BOT_ADMIN, Requirement.CHAT, @@ -18,25 +18,25 @@ export class Unignore extends Command { ); async execute(msg: Message) { - if (!msg.reply_to_message) return; + if (!msg.reply_to_message || !msg.reply_to_message.from) return; const id = msg.reply_to_message.from.id; const text = fullName(msg.reply_to_message.from); if (id === botUser.id) { - await oldSendMessage(msg, "Бот и так всегда к себе прислушивается").catch(logError); + await oldSendMessage(msg, Environment.botAlreadyAlwaysListensToItselfText).catch(logError); return; } if (id === Environment.CREATOR_ID) { - await oldSendMessage(msg, "Бот всегда слушает своего создателя").catch(logError); + await oldSendMessage(msg, Environment.botAlwaysListensToCreatorText).catch(logError); return; } if (await Environment.removeMute(id)) { - await oldSendMessage(msg, text + " больше не в муте! 🔈").catch(logError); + await oldSendMessage(msg, Environment.getUserUnignoredText(text)).catch(logError); } else { - await oldSendMessage(msg, text + " не был в муте 🤔").catch(logError); + await oldSendMessage(msg, Environment.getUserWasNotIgnoredText(text)).catch(logError); } } -} \ No newline at end of file +} diff --git a/src/commands/uptime.ts b/src/commands/uptime.ts index c5aa0ea..403c8a3 100644 --- a/src/commands/uptime.ts +++ b/src/commands/uptime.ts @@ -1,12 +1,13 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; import {getUptime, logError, oldSendMessage} from "../util/utils"; +import {Environment} from "../common/environment"; export class Uptime extends Command { - title = "/uptime"; - description = "Bot's uptime"; + title = Environment.commandTitles.uptime; + description = Environment.commandDescriptions.uptime; async execute(msg: Message): Promise { await oldSendMessage(msg, getUptime()).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/what-better.ts b/src/commands/what-better.ts index e559c1a..642a910 100644 --- a/src/commands/what-better.ts +++ b/src/commands/what-better.ts @@ -7,8 +7,8 @@ export class WhatBetter extends Command { command = ["what", "что"]; argsMode = "required" as const; - title = "/what better [a] or [b]"; - description = "either a or b randomly (50% chance)"; + title = Environment.commandTitles.whatBetter; + description = Environment.commandDescriptions.whatBetter; private argsRe = /^(better|лучше)\s+([\s\S]+?)\s+(or|или)\s+([\s\S]+)$/i; @@ -19,8 +19,8 @@ export class WhatBetter extends Command { const a = m[2].trim(); const b = m[4].trim(); - const text = `${randomValue(Environment.ANSWERS.better)} ${randomValue([a, b])}`; + const text = `${randomValue(Environment.ANSWERS.better) ?? Environment.betterFallbackText} ${randomValue([a, b]) ?? a}`; await oldSendMessage(msg, text).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/when.ts b/src/commands/when.ts index 9a43523..45a39fa 100644 --- a/src/commands/when.ts +++ b/src/commands/when.ts @@ -1,92 +1,60 @@ import {Command} from "../base/command"; import {getRandomInt, getRangedRandomInt, logError, oldReplyToMessage} from "../util/utils"; import {Message} from "typescript-telegram-bot-api"; +import {Environment} from "../common/environment"; export class When extends Command { command = ["when", "когда"]; argsMode = "required" as const; - title = "/when [value]"; - description = "random date"; + title = Environment.commandTitles.when; + description = Environment.commandDescriptions.when; async execute(msg: Message) { - let text = "через "; + let text = Environment.getWhenPrefixText(); const type = getRandomInt(8); switch (type) { case 0: - text = "сейчас"; + text = Environment.whenNowText; break; case 1: - text = "никогда"; + text = Environment.whenNeverText; break; case 2: //seconds { const seconds = getRangedRandomInt(1, 60); - - text += `${seconds} `; - - text += ( - (seconds == 1 || seconds % 10 == 1) ? "секунду" : - ((seconds > 1 && seconds < 5) || (seconds % 10 > 1 && seconds % 10 < 5)) ? "секунды" : "секунд" - ); + text = Environment.getWhenDurationText(seconds, Environment.whenSecondUnitText); break; } case 3: { const minutes = getRangedRandomInt(1, 60); - - text += `${minutes} `; - - text += ( - (minutes == 1 || minutes % 10 == 1) ? "минуту" : - ((minutes > 1 && minutes < 5) || (minutes % 10 > 1 && minutes % 10 < 5)) ? "минуты" : "минут" - ); + text = Environment.getWhenDurationText(minutes, Environment.whenMinuteUnitText); break; } case 4: { const hours = getRangedRandomInt(1, 24); - - text += `${hours} `; - - text += ( - (hours == 1 || hours % 10 == 1) ? "час" : - ((hours > 1 && hours < 5) || (hours % 10 > 1 && hours % 10 < 5)) ? "часа" : "часов" - ); + text = Environment.getWhenDurationText(hours, Environment.whenHourUnitText); break; } case 5: { const weeks = getRangedRandomInt(1, 4); - - text += `${weeks} `; - - text += (weeks == 1 ? "неделю" : "недель"); + text = Environment.getWhenDurationText(weeks, Environment.whenWeekUnitText); break; } case 6: { const months = getRandomInt(12); - - text += `${months} `; - - text += ( - (months == 1 || months % 10 == 1) ? "месяц" : - ((months > 1 && months < 5) || (months % 10 > 1 && months % 10 < 5)) ? "месяца" : "месяцев" - ); + text = Environment.getWhenDurationText(months, Environment.whenMonthUnitText); break; } case 7: { const years = getRangedRandomInt(1, 100); - - text += `${years} `; - - text += ( - (years == 1 || years % 10 == 1) ? "год" : - ((years > 1 && years < 5) || (years % 10 > 1 && years % 10 < 5)) ? "года" : "лет" - ); + text = Environment.getWhenDurationText(years, Environment.whenYearUnitText); break; } } await oldReplyToMessage(msg, text).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/youtube-download.ts b/src/commands/youtube-download.ts deleted file mode 100644 index 39714a7..0000000 --- a/src/commands/youtube-download.ts +++ /dev/null @@ -1,66 +0,0 @@ -import {Command} from "../base/command"; -import {Message} from "typescript-telegram-bot-api"; -import {editMessageText, logError, replyToMessage} from "../util/utils"; -import {bot, botUser} from "../index"; -import {DownloadOptions, downloadVideoFromYouTube, getYouTubeVideoId} from "../util/ytdl"; -import {Environment} from "../common/environment"; -import {TryAgain} from "../callback_commands/try-again"; - -export class YouTubeDownload extends Command { - command = ["ytdl", "youtube"]; - argsMode = "required" as const; - - async execute(msg: Message, match?: RegExpExecArray): Promise { - const url = match?.[3]; - return this.downloadYouTubeVideo(msg, {url: url}); - } - - async downloadYouTubeVideo(msg: Message, options: DownloadOptions): Promise { - // TODO: 02.03.2026, Danil Nikolaev: add check for date - let waitMessage: Message | null = (msg.from.id === botUser.id) ? msg : null; - const videoId = "videoId" in options ? options.videoId : getYouTubeVideoId(options.url); - - try { - if (!waitMessage) { - waitMessage = await replyToMessage({message: msg, text: "⏳ Скачиваю видео..."}); - } else { - await editMessageText({message: msg, text: "⏳ Скачиваю видео..."}); - } - - const {time, exists, buffer} = await downloadVideoFromYouTube({videoId: videoId}); - if (buffer) { - const start = Date.now(); - waitMessage = await bot.editMessageMedia({ - chat_id: msg.chat.id, - message_id: waitMessage.message_id, - media: { - type: "video", - media: buffer - } - }) as Message; - - const diff = Date.now() - start; - waitMessage = await bot.editMessageCaption({ - chat_id: msg.chat.id, - message_id: waitMessage.message_id, - caption: "✅ Видео" + (exists ? " загружено из кэша" : " успешно скачано") + " за " + (time + diff) + "мс", - }) as Message; - } - } catch (e) { - logError(e); - - if (waitMessage && "text" in waitMessage) { - await bot.editMessageText({ - chat_id: msg.chat.id, - message_id: waitMessage.message_id, - text: Environment.errorText, - reply_markup: { - inline_keyboard: [[ - TryAgain.withData("/ytdl " + videoId).asButton() - ]] - } - }); - } - } - } -} \ No newline at end of file diff --git a/src/common/ai-request-store.ts b/src/common/ai-request-store.ts new file mode 100644 index 0000000..c4f4e76 --- /dev/null +++ b/src/common/ai-request-store.ts @@ -0,0 +1,25 @@ +import {DatabaseManager} from "../db/database-manager"; +import type {AiRequestDbRow} from "../db/db-types"; +import type {StoredAiRequest} from "../model/stored-ai-request"; + +function toDbRow(request: StoredAiRequest): AiRequestDbRow { + return { + requestId: request.requestId, + chatId: request.chatId, + messageId: request.messageId, + responseMessageId: request.responseMessageId ?? null, + fromId: request.fromId, + provider: request.provider, + model: request.model, + status: request.status, + startedAt: request.startedAt, + finishedAt: request.finishedAt ?? null, + error: request.error ?? null, + }; +} + +export class AiRequestStore { + static async put(request: StoredAiRequest): Promise { + await DatabaseManager.upsertAiRequests([toDbRow(request)]); + } +} diff --git a/src/common/artifact-store.ts b/src/common/artifact-store.ts new file mode 100644 index 0000000..db323fd --- /dev/null +++ b/src/common/artifact-store.ts @@ -0,0 +1,178 @@ +import {createHash} from "node:crypto"; +import {DatabaseManager} from "../db/database-manager"; +import type {ArtifactDbRow} from "../db/db-types"; +import type {StoredAttachment} from "../model/stored-attachment"; +import type {PipelineArtifactKind} from "../ai/user-request-pipeline"; + +export type StoredArtifactRecord = { + id: string; + requestId: string; + messageChatId: number; + messageId: number; + kind: string; + stage: string; + attachmentId: string | null; + payload: Record; + createdAt: string; + attachment?: StoredAttachment; +}; + +function hashId(parts: Array): string { + return createHash("sha256").update(parts.map(part => part === null || part === undefined ? "" : String(part)).join("\u0000")).digest("hex"); +} + +function parsePayload(value: string): Record { + try { + const parsed = JSON.parse(value); + return typeof parsed === "object" && parsed !== null ? parsed as Record : {}; + } catch { + return {}; + } +} + +function isPipelineArtifactKind(value: unknown): value is PipelineArtifactKind { + return value === "rag" + || value === "transcript" + || value === "tool_result" + || value === "generated_file" + || value === "tts_audio" + || value === "final_text" + || value === "error"; +} + +function storedAttachmentFromPayload(payload: Record): StoredAttachment | undefined { + const kind = payload.kind; + const fileId = payload.fileId; + const fileName = payload.fileName; + const cachePath = payload.cachePath; + + if (typeof kind !== "string" || typeof fileId !== "string" || typeof fileName !== "string" || typeof cachePath !== "string") { + return undefined; + } + + return { + kind: "document", + fileId, + fileUniqueId: typeof payload.fileUniqueId === "string" ? payload.fileUniqueId : undefined, + fileName, + mimeType: typeof payload.mimeType === "string" ? payload.mimeType : undefined, + cachePath, + sizeBytes: typeof payload.sizeBytes === "number" ? payload.sizeBytes : undefined, + sha256: typeof payload.sha256 === "string" ? payload.sha256 : undefined, + scope: typeof payload.scope === "string" ? payload.scope as StoredAttachment["scope"] : undefined, + artifactKind: isPipelineArtifactKind(kind) ? kind : undefined, + metadata: typeof payload.metadata === "object" && payload.metadata !== null ? payload.metadata as Record : undefined, + }; +} + +function toStoredArtifact(row: ArtifactDbRow): StoredArtifactRecord { + const payload = parsePayload(row.payload); + return { + id: row.id, + requestId: row.requestId, + messageChatId: row.messageChatId, + messageId: row.messageId, + kind: row.kind, + stage: row.stage, + attachmentId: row.attachmentId, + payload, + createdAt: row.createdAt, + attachment: storedAttachmentFromPayload(payload), + }; +} + +function toArtifactDbRow(record: StoredArtifactRecord): ArtifactDbRow { + return { + id: record.id || hashId([record.requestId, record.messageChatId, record.messageId, record.kind, record.attachmentId ?? "", record.createdAt]), + requestId: record.requestId, + messageChatId: record.messageChatId, + messageId: record.messageId, + kind: record.kind, + stage: record.stage, + attachmentId: record.attachmentId, + payload: JSON.stringify(record.payload), + createdAt: record.createdAt, + }; +} + +export class ArtifactStore { + static async put(record: StoredArtifactRecord | StoredArtifactRecord[]): Promise { + const rows = Array.isArray(record) ? record.map(toArtifactDbRow) : [toArtifactDbRow(record)]; + await DatabaseManager.upsertArtifacts(rows); + } + + static async putMessageArtifacts(params: { + requestId: string; + messageChatId: number; + messageId: number; + attachments: StoredAttachment[]; + stage?: string; + createdAt?: string; + }): Promise { + const createdAt = params.createdAt ?? new Date().toISOString(); + const rows = params.attachments + .filter(attachment => Boolean(attachment.artifactKind)) + .map((attachment, index) => ({ + id: hashId([ + params.requestId, + params.messageChatId, + params.messageId, + attachment.artifactKind ?? "unknown", + attachment.fileUniqueId ?? attachment.fileId, + createdAt, + index, + ]), + requestId: params.requestId, + messageChatId: params.messageChatId, + messageId: params.messageId, + kind: attachment.artifactKind ?? "unknown", + stage: params.stage ?? attachment.artifactKind ?? "unknown", + attachmentId: attachment.fileUniqueId ?? attachment.fileId, + payload: { + kind: attachment.artifactKind ?? "unknown", + fileId: attachment.fileId, + fileUniqueId: attachment.fileUniqueId ?? null, + fileName: attachment.fileName, + mimeType: attachment.mimeType ?? null, + cachePath: attachment.cachePath, + sizeBytes: attachment.sizeBytes ?? null, + sha256: attachment.sha256 ?? null, + scope: attachment.scope ?? null, + metadata: attachment.metadata ?? null, + createdAt, + }, + createdAt, + })); + + await ArtifactStore.put(rows); + } + + static async getByRequestId(requestId: string): Promise { + const rows = await DatabaseManager.getArtifactsByRequestId(requestId); + return rows.map(toStoredArtifact); + } + + static async getByMessage(chatId: number, messageId: number): Promise { + const rows = await DatabaseManager.getArtifactsByMessage(chatId, messageId); + return rows.map(toStoredArtifact); + } + + static async getLatestRagForReplyChain(chatId: number, messageId: number): Promise { + let current = await DatabaseManager.getMessageById(chatId, messageId); + + while (current) { + const artifacts = await ArtifactStore.getByMessage(current.chatId, current.id); + const rag = artifacts.filter(artifact => artifact.kind === "rag").sort((a, b) => a.createdAt.localeCompare(b.createdAt)).at(-1); + if (rag) return rag; + if (!current.replyToMessageId) break; + current = await DatabaseManager.getMessageById(chatId, current.replyToMessageId); + } + + return null; + } + + static async getTranscriptForMessage(chatId: number, messageId: number): Promise { + const artifacts = await ArtifactStore.getByMessage(chatId, messageId); + return artifacts.find(artifact => artifact.kind === "transcript") ?? null; + } +} diff --git a/src/common/attachment-store.ts b/src/common/attachment-store.ts new file mode 100644 index 0000000..270c161 --- /dev/null +++ b/src/common/attachment-store.ts @@ -0,0 +1,66 @@ +import {DatabaseManager} from "../db/database-manager"; +import type {AttachmentDbRow} from "../db/db-types"; +import type {StoredAttachment} from "../model/stored-attachment"; + +function toAttachmentRow(input: { + messageChatId: number; + messageId: number; + attachment: StoredAttachment; + direction: string; + createdAt: string; + ordinal: number; +}): AttachmentDbRow { + const attachment = input.attachment; + const idSource = [ + input.messageChatId, + input.messageId, + input.direction, + attachment.scope ?? "user_input", + attachment.kind, + attachment.fileUniqueId ?? attachment.fileId, + attachment.fileName, + attachment.cachePath, + attachment.artifactKind ?? "", + input.ordinal, + ].join(":"); + + return { + id: idSource, + messageChatId: input.messageChatId, + messageId: input.messageId, + direction: input.direction, + scope: attachment.scope ?? "user_input", + kind: attachment.kind, + artifactKind: attachment.artifactKind ?? null, + fileId: attachment.fileId, + fileUniqueId: attachment.fileUniqueId ?? null, + fileName: attachment.fileName, + mimeType: attachment.mimeType ?? null, + cachePath: attachment.cachePath, + sizeBytes: attachment.sizeBytes ?? null, + sha256: attachment.sha256 ?? null, + metadata: attachment.metadata ? JSON.stringify(attachment.metadata) : null, + createdAt: input.createdAt, + }; +} + +export class AttachmentStore { + static async putMessageAttachments(params: { + messageChatId: number; + messageId: number; + attachments: StoredAttachment[]; + direction?: string; + createdAt?: string; + }): Promise { + const rows = params.attachments.map((attachment, ordinal) => toAttachmentRow({ + messageChatId: params.messageChatId, + messageId: params.messageId, + attachment, + direction: params.direction ?? (attachment.scope === "bot_output" ? "output" : "input"), + createdAt: params.createdAt ?? new Date().toISOString(), + ordinal, + })); + + await DatabaseManager.upsertAttachments(rows); + } +} diff --git a/src/common/boundary-types.ts b/src/common/boundary-types.ts new file mode 100644 index 0000000..0dfa88d --- /dev/null +++ b/src/common/boundary-types.ts @@ -0,0 +1,9 @@ +export type BoundaryPrimitive = string | number | boolean | bigint | null | undefined; + +export type BoundaryValue = BoundaryPrimitive | object | readonly BoundaryValue[]; + +export interface BoundaryRecord { + readonly [key: string]: BoundaryValue; +} + +export type ErrorLike = Error | string | BoundaryRecord; diff --git a/src/common/environment.ts b/src/common/environment.ts index 452cb15..143bbf1 100644 --- a/src/common/environment.ts +++ b/src/common/environment.ts @@ -1,149 +1,1942 @@ +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; +import {parse as parseDotEnv} from "dotenv"; +import {z} from "zod"; +import {appLogger} from "../logging/logger"; +import type {BoundaryValue, ErrorLike} from "./boundary-types"; + import {saveData} from "../db/database"; import {Answers} from "../model/answers"; import {ifTrue} from "../util/utils"; import {AiProvider} from "../model/ai-provider"; import {ImageHandleFallbackPolicy, ImageHandlePolicy, RateLimitFallbackPolicy} from "./policies"; +import {ToolRankerFallbackPolicy} from "./policies"; +import type {ToolCallData} from "../ai/unified-ai-runner"; +import {PYTHON_INTERPRETER_TOOL_NAME} from "../ai/tools/python-interpretator"; +import {Localization, type LocalizationParams} from "./localization"; + +type EnvRecord = Record; +type StringEnumLike = Record; +type StringEnumValue = T[keyof T]; + +function normalizeString(value: BoundaryValue): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +const optionalStringSchema = z + .preprocess(normalizeString, z.string().optional()) + .optional() + .catch(undefined); + +function stringWithDefaultSchema(defaultValue: string) { + return z + .preprocess(value => { + const normalized = normalizeString(value as BoundaryValue); + return normalized ?? defaultValue; + }, z.string()) + .default(defaultValue) + .catch(defaultValue); +} + +function booleanWithDefaultSchema(defaultValue: boolean) { + return z + .preprocess(value => { + const normalized = normalizeString(value as BoundaryValue); + + if (normalized === undefined) { + return defaultValue; + } + + return ifTrue(normalized); + }, z.boolean()) + .default(defaultValue) + .catch(defaultValue); +} + +const optionalBooleanSchema = z + .preprocess(value => { + const normalized = normalizeString(value as BoundaryValue); + return normalized === undefined ? undefined : ifTrue(normalized); + }, z.boolean().optional()) + .optional() + .catch(undefined); + +function requiredStringSchema() { + return z + .preprocess(normalizeString, z.string().min(1)); +} + +function requiredPositiveIntSchema() { + return z + .preprocess(value => { + const normalized = normalizeString(value as BoundaryValue); + + if (normalized === undefined) { + return undefined; + } + + const number = Number(normalized); + + if (!Number.isSafeInteger(number) || number <= 0) { + return undefined; + } + + return number; + }, z.number().int().positive()); +} + +function numberWithDefaultSchema(defaultValue: number) { + return z + .preprocess(value => { + const normalized = normalizeString(value as BoundaryValue); + + if (normalized === undefined) { + return defaultValue; + } + + const number = Number(normalized); + return Number.isFinite(number) ? number : defaultValue; + }, z.number()) + .default(defaultValue) + .catch(defaultValue); +} + +function positiveIntWithDefaultSchema(defaultValue: number) { + return z + .preprocess(value => { + const normalized = normalizeString(value as BoundaryValue); + + if (normalized === undefined) { + return defaultValue; + } + + const number = Number(normalized); + + if (!Number.isSafeInteger(number) || number <= 0) { + return defaultValue; + } + + return number; + }, z.number().int().positive()) + .default(defaultValue) + .catch(defaultValue); +} + +function enumWithDefaultSchema( + enumObject: T, + defaultValue: StringEnumValue, +) { + const values = Object.values(enumObject) as StringEnumValue[]; + + return z + .preprocess(value => { + const normalized = normalizeString(value as BoundaryValue); + + if (normalized === undefined) { + return defaultValue; + } + + return values.includes(normalized as StringEnumValue) + ? normalized + : defaultValue; + }, z.custom>((value): value is StringEnumValue => { + return typeof value === "string" + && values.includes(value as StringEnumValue); + })) + .default(defaultValue) + .catch(defaultValue); +} + +const StartupEnvSchema = z.object({ + BOT_TOKEN: requiredStringSchema(), + DATABASE_URL: optionalStringSchema, + DB_PATH: optionalStringSchema, + DATA_PATH: optionalStringSchema, + TEST_ENVIRONMENT: booleanWithDefaultSchema(false), + IS_DOCKER: optionalBooleanSchema, +}); + +const RuntimeEnvSchema = z.object({ + CREATOR_ID: requiredPositiveIntSchema(), + BOT_PREFIX: stringWithDefaultSchema(""), + CHAT_IDS_WHITELIST: optionalStringSchema, + ONLY_FOR_CREATOR_MODE: booleanWithDefaultSchema(false), + ENABLE_UNSAFE_EVAL: booleanWithDefaultSchema(false), + MAX_PHOTO_SIZE: positiveIntWithDefaultSchema(1280), + PROCESS_LINKS: booleanWithDefaultSchema(false), + LOCALES_DIR: stringWithDefaultSchema("locales"), + + RATE_LIMIT_FALLBACK_POLICY: enumWithDefaultSchema( + RateLimitFallbackPolicy, + RateLimitFallbackPolicy.NOTIFY_USER, + ), + + IMAGE_HANDLE_POLICY: enumWithDefaultSchema( + ImageHandlePolicy, + ImageHandlePolicy.HANDLE_IF_CAPABLE, + ), + + IMAGE_HANDLE_FALLBACK_POLICY: enumWithDefaultSchema( + ImageHandleFallbackPolicy, + ImageHandleFallbackPolicy.NOTIFY_USER, + ), + + TOOL_RANKER_FALLBACK_POLICY: enumWithDefaultSchema( + ToolRankerFallbackPolicy, + ToolRankerFallbackPolicy.ALL_TOOLS, + ), + + BRAVE_SEARCH_API_KEY: optionalStringSchema, + OPEN_WEATHER_MAP_API_KEY: optionalStringSchema, + + FILE_TOOLS_ROOT_DIR: optionalStringSchema, + ENABLE_FS_TOOLS: optionalBooleanSchema, + + DEFAULT_AI_PROVIDER: enumWithDefaultSchema( + AiProvider, + AiProvider.OLLAMA, + ), + + SYSTEM_PROMPT: optionalStringSchema, + RANKER_TOOL_PROMPT: optionalStringSchema, + USE_NAMES_IN_PROMPT: booleanWithDefaultSchema(false), + USE_SYSTEM_PROMPT: booleanWithDefaultSchema(true), + + SEND_TIME_TOOK: optionalBooleanSchema, + + ENABLE_PYTHON_INTERPRETER: optionalBooleanSchema, + + OLLAMA_API_KEY: optionalStringSchema, + OLLAMA_ADDRESS: optionalStringSchema, + OLLAMA_CHAT_MODEL: stringWithDefaultSchema("gemma4:e4b"), + OLLAMA_IMAGE_MODEL: optionalStringSchema, + OLLAMA_THINK_MODEL: optionalStringSchema, + OLLAMA_AUDIO_MODEL: optionalStringSchema, + OLLAMA_EMBEDDING_MODEL: stringWithDefaultSchema("nomic-embed-text:latest"), + OLLAMA_RAG_CHUNK_SIZE: positiveIntWithDefaultSchema(1400), + OLLAMA_RAG_CHUNK_OVERLAP: positiveIntWithDefaultSchema(220), + OLLAMA_RAG_TOP_K: positiveIntWithDefaultSchema(8), + OLLAMA_RAG_MAX_CONTEXT_CHARS: positiveIntWithDefaultSchema(14000), + OLLAMA_RAG_MIN_SCORE: numberWithDefaultSchema(0.12), + OLLAMA_RAG_MAX_ARCHIVE_FILES: positiveIntWithDefaultSchema(200), + OLLAMA_RAG_MAX_ARCHIVE_BYTES: positiveIntWithDefaultSchema(50 * 1024 * 1024), + OLLAMA_RAG_MAX_ARCHIVE_DEPTH: positiveIntWithDefaultSchema(2), + OLLAMA_MAX_CONCURRENT_REQUESTS: positiveIntWithDefaultSchema(1), + + MISTRAL_API_KEY: optionalStringSchema, + MISTRAL_MODEL: stringWithDefaultSchema("mistral-tiny-latest"), + MISTRAL_TRANSCRIPTION_MODEL: stringWithDefaultSchema("voxtral-mini-latest"), + MISTRAL_TTS_MODEL: stringWithDefaultSchema("voxtral-mini-tts-latest"), + MISTRAL_TTS_VOICE_ID: stringWithDefaultSchema("cb891218-482c-4392-9878-91e8d999d57a"), + MISTRAL_MAX_CONCURRENT_REQUESTS: positiveIntWithDefaultSchema(3), + + OPENAI_BASE_URL: optionalStringSchema, + OPENAI_API_KEY: optionalStringSchema, + OPENAI_MODEL: stringWithDefaultSchema("gpt-4.1-nano"), + OPENAI_IMAGE_MODEL: stringWithDefaultSchema("gpt-image-1-mini"), + OPENAI_TRANSCRIPTION_MODEL: stringWithDefaultSchema("gpt-4o-mini-transcribe"), + OPENAI_TTS_MODEL: stringWithDefaultSchema("gpt-4o-mini-tts"), + OPENAI_TTS_VOICE: stringWithDefaultSchema("alloy"), + OPENAI_TTS_INSTRUCTIONS: optionalStringSchema, + OPENAI_MAX_CONCURRENT_REQUESTS: positiveIntWithDefaultSchema(3), +}); + +type StartupEnv = z.infer; +type RuntimeEnv = z.infer; export class Environment { - static BOT_TOKEN: string; - static TEST_ENVIRONMENT: boolean; + private static readonly ENV_FILE_PATH = path.resolve(".env"); + + private static lastEnvMtimeMs: number | undefined; + private static lastSystemPromptMtimeMs: number | undefined; + private static lastRankerToolPromptMtimeMs: number | undefined; + private static envSystemPrompt: string | undefined; + private static envRankerToolPrompt: string | undefined; + + static BOT_TOKEN: string = ""; + static TEST_ENVIRONMENT: boolean = false; static ADMIN_IDS: Set = new Set(); static MUTED_IDS: Set = new Set(); static CHAT_IDS_WHITELIST: Set = new Set(); - static BOT_PREFIX: string; - static CREATOR_ID: number; - static IS_DOCKER: boolean; - static DATA_PATH: string; + static BOT_PREFIX: string = ""; + static CREATOR_ID: number = 0; + static IS_DOCKER: boolean = false; + static DATA_PATH: string = "data"; static DB_FILE_NAME: string = "database.db"; - static DB_PATH: string; + static DB_PATH: string = "file:" + path.join(Environment.DATA_PATH, Environment.DB_FILE_NAME); + static DB_FILE_PATH?: string; + static DB_KIND: "sqlite" | "postgres" = "sqlite"; - static ONLY_FOR_CREATOR_MODE: boolean; + static ONLY_FOR_CREATOR_MODE: boolean = false; - static ENABLE_UNSAFE_EVAL: boolean; + static ENABLE_UNSAFE_EVAL: boolean = false; static ANSWERS: Answers; - static USE_NAMES_IN_PROMPT: boolean; + static MAX_PHOTO_SIZE: number = 0; - static MAX_PHOTO_SIZE: number; + static PROCESS_LINKS: boolean = false; + static LOCALES_DIR: string = "locales"; - static PROCESS_LINKS: boolean; + static RATE_LIMIT_FALLBACK_POLICY: RateLimitFallbackPolicy = RateLimitFallbackPolicy.NOTIFY_USER; + static IMAGE_HANDLE_POLICY: ImageHandlePolicy = ImageHandlePolicy.HANDLE_IF_CAPABLE; + static IMAGE_HANDLE_FALLBACK_POLICY: ImageHandleFallbackPolicy = ImageHandleFallbackPolicy.NOTIFY_USER; + static TOOL_RANKER_FALLBACK_POLICY: ToolRankerFallbackPolicy = ToolRankerFallbackPolicy.ALL_TOOLS; - static DEFAULT_AI_PROVIDER: AiProvider; + static BRAVE_SEARCH_API_KEY?: string; + static OPEN_WEATHER_MAP_API_KEY?: string; - static RATE_LIMIT_FALLBACK_POLICY: RateLimitFallbackPolicy; - static IMAGE_HANDLE_POLICY: ImageHandlePolicy; - static IMAGE_HANDLE_FALLBACK_POLICY: ImageHandleFallbackPolicy; + static FILE_TOOLS_ROOT_DIR?: string; + static ENABLE_FS_TOOLS: boolean = false; + + // AI Stuff + static DEFAULT_AI_PROVIDER: AiProvider = AiProvider.OLLAMA; static SYSTEM_PROMPT?: string; - static SEND_TIME_TOOK: boolean; + static RANKER_TOOL_PROMPT?: string; + static USE_NAMES_IN_PROMPT: boolean = false; + static USE_SYSTEM_PROMPT: boolean = true; + static SEND_TIME_TOOK: boolean = false; + + static ENABLE_PYTHON_INTERPRETER: boolean = false; - static OLLAMA_ADDRESS?: string; - static OLLAMA_MODEL?: string; - static OLLAMA_IMAGE_MODEL?: string; - static OLLAMA_THINK_MODEL?: string; static OLLAMA_API_KEY?: string; - - static GEMINI_API_KEY?: string; - static GEMINI_MODEL: string; - static GEMINI_IMAGE_MODEL: string; + static OLLAMA_ADDRESS?: string; + static OLLAMA_CHAT_MODEL: string = ""; + static OLLAMA_IMAGE_MODEL: string = Environment.OLLAMA_CHAT_MODEL; + static OLLAMA_THINK_MODEL: string = Environment.OLLAMA_CHAT_MODEL; + static OLLAMA_AUDIO_MODEL: string = Environment.OLLAMA_CHAT_MODEL; + static OLLAMA_EMBEDDING_MODEL: string = ""; + static OLLAMA_RAG_CHUNK_SIZE: number = 0; + static OLLAMA_RAG_CHUNK_OVERLAP: number = 0; + static OLLAMA_RAG_TOP_K: number = 0; + static OLLAMA_RAG_MAX_CONTEXT_CHARS: number = 0; + static OLLAMA_RAG_MIN_SCORE: number = 0.0; + static OLLAMA_RAG_MAX_ARCHIVE_FILES: number = 0; + static OLLAMA_RAG_MAX_ARCHIVE_BYTES: number = 0; + static OLLAMA_RAG_MAX_ARCHIVE_DEPTH: number = 0; + static OLLAMA_MAX_CONCURRENT_REQUESTS: number = 0; static MISTRAL_API_KEY?: string; - static MISTRAL_MODEL: string; + static MISTRAL_MODEL: string = ""; + static MISTRAL_TRANSCRIPTION_MODEL: string = ""; + static MISTRAL_TTS_MODEL: string = ""; + static MISTRAL_TTS_VOICE_ID: string = ""; + static MISTRAL_MAX_CONCURRENT_REQUESTS: number = 0; static OPENAI_BASE_URL?: string; static OPENAI_API_KEY?: string; - static OPENAI_MODEL: string; - static OPENAI_IMAGE_MODEL: string; + static OPENAI_MODEL: string = ""; + static OPENAI_IMAGE_MODEL: string = ""; + static OPENAI_TRANSCRIPTION_MODEL: string = ""; + static OPENAI_TTS_MODEL: string = ""; + static OPENAI_TTS_VOICE: string = ""; + static OPENAI_TTS_INSTRUCTIONS?: string; + static OPENAI_MAX_CONCURRENT_REQUESTS: number = 0; - static errorText = "⚠️ Произошла ошибка."; - static waitText = "⏳ Секунду..."; - static waitThinkText = "⏳ Дайте-ка подумать..."; - static analyzingPictureText = "🔍 Внимательно изучаю изображение..."; - static analyzingPicturesText = "🔍 Внимательно изучаю изображения..."; - static genImageText = "👨‍🎨 Генерирую изображение..."; - static ollamaCancelledText = "```Ollama\n❌ Отменено```"; - - static load() { - Environment.BOT_TOKEN = process.env.BOT_TOKEN; - Environment.TEST_ENVIRONMENT = ifTrue(process.env.TEST_ENVIRONMENT); - Environment.CHAT_IDS_WHITELIST = new Set(process.env.CHAT_IDS_WHITELIST?.split(",")?.map(e => parseInt(e.trim(), 10)) || []); - Environment.BOT_PREFIX = process.env.BOT_PREFIX || ""; - Environment.CREATOR_ID = parseInt(process.env.CREATOR_ID || ""); - Environment.IS_DOCKER = ifTrue(process.env.IS_DOCKER); - Environment.DATA_PATH = Environment.IS_DOCKER ? "/" + path.join("config", "data") : "data"; - Environment.DB_PATH = "file:" + path.join(Environment.DATA_PATH, Environment.DB_FILE_NAME); - - Environment.ONLY_FOR_CREATOR_MODE = ifTrue(process.env.ONLY_FOR_CREATOR_MODE); - - Environment.ENABLE_UNSAFE_EVAL = ifTrue(process.env.ENABLE_UNSAFE_EVAL); - - Environment.USE_NAMES_IN_PROMPT = ifTrue(process.env.USE_NAMES_IN_PROMPT); - - Environment.MAX_PHOTO_SIZE = Number(process.env.MAX_PHOTO_SIZE || "1280"); - - 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; + static get databaseSummaryText(): string { + if (this.DB_KIND === "postgres") { + return "postgres"; } - 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; - } else { - Environment.RATE_LIMIT_FALLBACK_POLICY = RateLimitFallbackPolicy.NOTIFY_USER; + if (this.DB_FILE_PATH) { + return `sqlite:${this.DB_FILE_PATH}`; } - const imageHandlePolicy = process.env.IMAGE_HANDLE_POLICY || "HANDLE_IF_CAPABLE"; - if (Object.values(ImageHandlePolicy).includes(imageHandlePolicy as ImageHandlePolicy)) { - Environment.IMAGE_HANDLE_POLICY = imageHandlePolicy as ImageHandlePolicy; - } else { - Environment.IMAGE_HANDLE_POLICY = ImageHandlePolicy.HANDLE_IF_CAPABLE; + if (this.DB_PATH === ":memory:") { + return "sqlite:memory"; } - const imageHandleFallbackPolicy = process.env.IMAGE_HANDLE_FALLBACK_POLICY || "NOTIFY_USER"; - if (Object.values(ImageHandleFallbackPolicy).includes(imageHandleFallbackPolicy as ImageHandleFallbackPolicy)) { - Environment.IMAGE_HANDLE_FALLBACK_POLICY = imageHandleFallbackPolicy as ImageHandleFallbackPolicy; - } else { - Environment.IMAGE_HANDLE_FALLBACK_POLICY = ImageHandleFallbackPolicy.NOTIFY_USER; - } - - Environment.SEND_TIME_TOOK = ifTrue(process.env.SEND_TOOK_TIME || false); - - Environment.OLLAMA_ADDRESS = process.env.OLLAMA_ADDRESS; - Environment.OLLAMA_MODEL = process.env.OLLAMA_MODEL || "gemma3:4b"; - Environment.OLLAMA_IMAGE_MODEL = process.env.OLLAMA_IMAGE_MODEL || Environment.OLLAMA_MODEL; - Environment.OLLAMA_THINK_MODEL = process.env.OLLAMA_THINK_MODEL || Environment.OLLAMA_MODEL; - Environment.OLLAMA_API_KEY = process.env.OLLAMA_API_KEY; - - Environment.GEMINI_API_KEY = process.env.GEMINI_API_KEY; - Environment.GEMINI_MODEL = process.env.GEMINI_MODEL || "gemini-2.5-flash-lite"; - Environment.GEMINI_IMAGE_MODEL = process.env.GEMINI_IMAGE_MODEL || "gemini-2.5-flash-image"; - - Environment.MISTRAL_API_KEY = process.env.MISTRAL_API_KEY; - Environment.MISTRAL_MODEL = process.env.MISTRAL_MODEL || "mistral-tiny-latest"; - - Environment.OPENAI_BASE_URL = process.env.OPENAI_BASE_URL; - Environment.OPENAI_API_KEY = process.env.OPENAI_API_KEY; - Environment.OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-4.1-nano"; - Environment.OPENAI_IMAGE_MODEL = process.env.OPENAI_IMAGE_MODEL || "gpt-image-1-mini"; + return this.DB_PATH.startsWith("file:") ? "sqlite:file" : "sqlite"; } - static setSystemPrompt(prompt: string) { + private static text(key: string, fallback: string, params: LocalizationParams = {}): string { + return Localization.text(key, params, fallback); + } + + private static textArray(key: string, fallback: string[], params: LocalizationParams = {}): string[] { + return Localization.textArray(key, params, fallback); + } + + static get errorText() { + return this.text("errorText", "⚠️ An error occurred."); + } + + static get waitThinkText() { + return this.text("waitThinkText", "⏳ Let me think..."); + } + + static get analyzingPictureText() { + return this.text("analyzingPictureText", "🔍 Analyzing the image..."); + } + + static get analyzingPicturesText() { + return this.text("analyzingPicturesText", "🔍 Analyzing the images..."); + } + + static get reasoningText() { + return this.text("reasoningText", "🤔 Reasoning..."); + } + + static get transcribingAudioText() { + return this.text("transcribingAudioText", "🦻 Transcribing audio..."); + } + + static get genImageText() { + return this.text("genImageText", "👨‍🎨 Generating an image..."); + } + + static get cancelText() { + return this.text("cancelText", "❌ Cancel"); + } + + static get regenerateText() { + return this.text("regenerateText", "🔄 Regenerate"); + } + + static get aiCancelCallbackText() { + return this.text("aiCancelCallbackText", "Cancel AI generation"); + } + + static get aiRegenerateCallbackText() { + return this.text("aiRegenerateCallbackText", "Regenerate AI response"); + } + + static get userSettingsCallbackText() { + return this.text("userSettingsCallbackText", "User settings"); + } + + static get noAccessText() { + return this.text("noAccessText", "No access"); + } + + static get notBotCreatorText() { + return this.text("notBotCreatorText", "You are not the bot creator."); + } + + static get notBotAdministratorText() { + return this.text("notBotAdministratorText", "You are not a bot administrator."); + } + + static get notAChatText() { + return this.text("notAChatText", "This is not a chat."); + } + + static get notChatAdministratorText() { + return this.text("notChatAdministratorText", "You are not a chat administrator."); + } + + static get botNotChatAdministratorText() { + return this.text("botNotChatAdministratorText", "The bot is not a chat administrator."); + } + + static get replyRequiredText() { + return this.text("replyRequiredText", "A reply to a message is required."); + } + + static get onlyOriginalAuthorText() { + return this.text("onlyOriginalAuthorText", "Only the author of the original message can perform this action."); + } + + static get dockerContainerLabelText() { + return this.text("dockerContainerLabelText", "Docker container"); + } + + static get processLabelText() { + return this.text("processLabelText", "Process"); + } + + static get systemLabelText() { + return this.text("systemLabelText", "System"); + } + + static get systemInfoOsLabelText() { + return this.text("systemInfoOsLabelText", "OS"); + } + + static get systemInfoRuntimeLabelText() { + return this.text("systemInfoRuntimeLabelText", "RUNTIME"); + } + + static get systemInfoDockerLabelText() { + return this.text("systemInfoDockerLabelText", "DOCKER"); + } + + static get systemInfoCpuLabelText() { + return this.text("systemInfoCpuLabelText", "CPU"); + } + + static get systemInfoRamLabelText() { + return this.text("systemInfoRamLabelText", "RAM"); + } + + static get systemInfoCpuCoresText() { + return this.text("systemInfoCpuCoresText", "cores"); + } + + static get systemInfoCpuThreadsText() { + return this.text("systemInfoCpuThreadsText", "threads"); + } + + static get idChatLabelText() { + return this.text("idChatLabelText", "chat id"); + } + + static get idFromLabelText() { + return this.text("idFromLabelText", "from id"); + } + + static get idReplyLabelText() { + return this.text("idReplyLabelText", "reply id"); + } + + static get runtimeProviderLabelText() { + return this.text("runtimeProviderLabelText", "provider"); + } + + static get runtimeProviderCurrentLabelText() { + return this.text("runtimeProviderCurrentLabelText", "current"); + } + + static get runtimeModelLabelText() { + return this.text("runtimeModelLabelText", "model"); + } + + static get runtimeCapabilitiesLabelText() { + return this.text("runtimeCapabilitiesLabelText", "capabilities"); + } + + static get runtimeExternalLabelText() { + return this.text("runtimeExternalLabelText", "external"); + } + + static get runtimeCapabilityChatText() { + return this.text("runtimeCapabilityChatText", "chat"); + } + + static get runtimeCapabilityVisionText() { + return this.text("runtimeCapabilityVisionText", "vision / image input"); + } + + static get runtimeCapabilityOcrText() { + return this.text("runtimeCapabilityOcrText", "ocr"); + } + + static get runtimeCapabilityThinkingText() { + return this.text("runtimeCapabilityThinkingText", "thinking / reasoning"); + } + + static get runtimeCapabilityExtendedThinkingText() { + return this.text("runtimeCapabilityExtendedThinkingText", "leveled thinking / reasoning"); + } + + static get runtimeCapabilityToolsText() { + return this.text("runtimeCapabilityToolsText", "tools / function calling"); + } + + static get runtimeCapabilityAudioText() { + return this.text("runtimeCapabilityAudioText", "audio input"); + } + + static get runtimeCapabilitySpeechToTextText() { + return this.text("runtimeCapabilitySpeechToTextText", "speech-to-text"); + } + + static get runtimeCapabilityTextToSpeechText() { + return this.text("runtimeCapabilityTextToSpeechText", "text-to-speech"); + } + + static get runtimeCapabilityDocumentsText() { + return this.text("runtimeCapabilityDocumentsText", "documents / rag"); + } + + static get runtimeCapabilityOutputImagesText() { + return this.text("runtimeCapabilityOutputImagesText", "image gen / image output"); + } + + static get infoAiBlockLabelText() { + return this.text("infoAiBlockLabelText", "AI"); + } + + static get infoSupportedProvidersLabelText() { + return this.text("infoSupportedProvidersLabelText", "providers"); + } + + static get infoToolsBlockLabelText() { + return this.text("infoToolsBlockLabelText", "tools"); + } + + static get infoCommandsBlockLabelText() { + return this.text("infoCommandsBlockLabelText", "commands"); + } + + static get infoPublicLabelText() { + return this.text("infoPublicLabelText", "public"); + } + + static get infoPrivateLabelText() { + return this.text("infoPrivateLabelText", "private"); + } + + static get infoChatLabelText() { + return this.text("infoChatLabelText", "chat"); + } + + static get infoCallbackLabelText() { + return this.text("infoCallbackLabelText", "callback"); + } + + static get commandsHeaderText() { + return this.text("commandsHeaderText", "Commands:\n\n"); + } + + static get sentCommandsInDmText() { + return this.text("sentCommandsInDmText", "Sent commands in DM 😎"); + } + + static get couldNotSendCommandsInDmText() { + return this.text("couldNotSendCommandsInDmText", "Could not send commands in DM ☹️\nSending them here instead"); + } + + static get administratorsHeaderText() { + return this.text("administratorsHeaderText", "*Administrators*:\n\n"); + } + + static get noUserInfoText() { + return this.text("noUserInfoText", "No user information"); + } + + static get useLeaveCommandText() { + return this.text("useLeaveCommandText", "Use /leave"); + } + + static get databaseBackupCaption() { + return this.text("databaseBackupCaption", "Database backup"); + } + + static get databaseBackupSentText() { + return this.text("databaseBackupSentText", "Successfully sent to the creator in DM!"); + } + + static get databaseImportDoneText() { + return this.text("databaseImportDoneText", "Database imported successfully."); + } + + static get databaseImportNeedJsonText() { + return this.text("databaseImportNeedJsonText", "Send a JSON backup file or pass JSON after /importdb."); + } + + static get noChoicesText() { + return this.text("noChoicesText", "Nothing to choose from"); + } + + static get qrCodeMissingTextText() { + return this.text("qrCodeMissingTextText", "No text found for QR code generation."); + } + + static get quoteMissingTextText() { + return this.text("quoteMissingTextText", "Could not find text in the message 😢"); + } + + static get quoteBuildFailedText() { + return this.text("quoteBuildFailedText", "Could not build the quote 😢"); + } + + static get speechToTextInstructionText() { + return this.text("speechToTextInstructionText", "Send audio/voice/video-note or reply with /stt to a message containing audio."); + } + + static get speechToTextEmptyResultText() { + return this.text("speechToTextEmptyResultText", "Speech-to-text did not return transcription text."); + } + + static get textToSpeechInstructionText() { + return this.text("textToSpeechInstructionText", "Send text after the command or reply with /tts to a message containing text."); + } + + static get titleMissingText() { + return this.text("titleMissingText", "Could not find a title..."); + } + + static get betterFallbackText() { + return this.text("betterFallbackText", "Better"); + } + + static get pongText() { + return this.text("pongText", "pong"); + } + + static get variableNotDefinedText() { + return this.text("variableNotDefinedText", "variable is not defined"); + } + + static get evaluationVariableNotDefinedText() { + return this.text("evaluationVariableNotDefinedText", "Variable not defined"); + } + + static get defaultTestAnswerText() { + return this.text("defaultTestAnswerText", "a"); + } + + static get prefixFallbackText() { + return this.text("prefixFallbackText", "?"); + } + + static get searchResultsHeaderText() { + return this.text("searchResultsHeaderText", "Results:\n\n"); + } + + static get modelListHeaderText() { + return this.text("modelListHeaderText", "Available models:\n\n"); + } + + static get modelListLoadFailedText() { + return this.text("modelListLoadFailedText", "Could not load the model list"); + } + + static get noCurrentModelText() { + return this.text("noCurrentModelText", "Model is not set. Use one of the listed values."); + } + + static get unsupportedAttachmentText() { + return this.text("unsupportedAttachmentText", "This attachment type is not supported."); + } + + static get attachmentMissingFromCacheText() { + return this.text("attachmentMissingFromCacheText", "Attachment file is missing from cache."); + } + + static get couldNotIdentifyUserForSpeechToTextText() { + return this.text("couldNotIdentifyUserForSpeechToTextText", "Could not identify the user for speech-to-text."); + } + + static get missingTranscriptionFileText() { + return this.text("missingTranscriptionFileText", "Unable to prepare the audio file for transcription."); + } + + static get transcriptionFailedText() { + return this.text("transcriptionFailedText", "Could not transcribe the audio."); + } + + static get imageGenUnsupportedFilesText() { + return this.text("imageGenUnsupportedFilesText", "Image generation does not support files in this mode."); + } + + static get unsupportedDocumentProviderText() { + return this.text("unsupportedDocumentProviderText", "This provider does not support attached documents."); + } + + static get mistralPdfOnlyText() { + return this.text("mistralPdfOnlyText", "Mistral currently supports only PDF documents."); + } + + static get mistralDocumentUploadFailedText() { + return this.text("mistralDocumentUploadFailedText", "Could not upload the document to Mistral."); + } + + static get documentContentLabelText() { + return this.text("documentContentLabelText", "Document content"); + } + + static get mistralLibraryIdMissingText() { + return this.text("mistralLibraryIdMissingText", "Mistral did not return a temporary document library id."); + } + + static get documentsUnifiedRunnerUnsupportedText() { + return this.text("documentsUnifiedRunnerUnsupportedText", "Documents in the unified runner are currently handled only by Ollama RAG and Mistral."); + } + + static get zipCentralDirectoryNotFoundText() { + return this.text("zipCentralDirectoryNotFoundText", "ZIP archive is corrupted: central directory was not found."); + } + + static get zipInvalidCentralDirectoryText() { + return this.text("zipInvalidCentralDirectoryText", "ZIP archive is corrupted: invalid central directory."); + } + + static get tarFileTooLargeText() { + return this.text("tarFileTooLargeText", "TAR contains a file that is too large."); + } + + static get tarInvalidEntrySizeText() { + return this.text("tarInvalidEntrySizeText", "TAR archive is corrupted: invalid entry size."); + } + + static get tarEntryExceedsBoundsText() { + return this.text("tarEntryExceedsBoundsText", "TAR archive is corrupted: entry exceeds file bounds."); + } + + static get docxDocumentXmlMissingText() { + return this.text("docxDocumentXmlMissingText", "DOCX does not contain word/document.xml."); + } + + static get localRagEmbeddingModelRequiredText() { + return this.text("localRagEmbeddingModelRequiredText", "Local RAG requires OLLAMA_EMBEDDING_MODEL, for example nomic-embed-text."); + } + + static get localRagChunksBuildFailedText() { + return this.text("localRagChunksBuildFailedText", "Could not build chunks for local RAG."); + } + + static get localRagNoSuitableFragmentsText() { + return this.text("localRagNoSuitableFragmentsText", "Local RAG did not find suitable document fragments."); + } + + static get unsupportedAiProviderText() { + return this.text("unsupportedAiProviderText", "Unsupported AI provider."); + } + + static get noSupportedTranscriptionProviderText() { + return this.text("noSupportedTranscriptionProviderText", "No supported speech-to-text provider is configured."); + } + + static get noSupportedTextToSpeechProviderText() { + return this.text("noSupportedTextToSpeechProviderText", "No supported text-to-speech provider is configured."); + } + + static get noSpeechToTextProviderForAccessText() { + return this.text("noSpeechToTextProviderForAccessText", "No speech-to-text providers are configured for your access level."); + } + + static get noTextToSpeechProviderForAccessText() { + return this.text("noTextToSpeechProviderForAccessText", "No text-to-speech providers are configured for your access level."); + } + + static get ollamaTextToSpeechUnsupportedText() { + return this.text("ollamaTextToSpeechUnsupportedText", "Ollama does not support text-to-speech right now."); + } + + static get ollamaSpeechToTextModelRequiredText() { + return this.text("ollamaSpeechToTextModelRequiredText", "Ollama speech-to-text requires OLLAMA_AUDIO_MODEL=gemma4:e2b or OLLAMA_AUDIO_MODEL=gemma4:e4b."); + } + + static get noTextToSynthesizeText() { + return this.text("noTextToSynthesizeText", "No text to synthesize."); + } + + static get mistralTtsNoAudioDataText() { + return this.text("mistralTtsNoAudioDataText", "Mistral TTS did not return audioData."); + } + + static get speechFileTooLargeText() { + return this.text("speechFileTooLargeText", "The speech file is larger than 50 MB and cannot be sent."); + } + + static get userSettingsTitle() { + return this.text("userSettingsTitle", "User Settings"); + } + + static get userSettingsAiProviderSelectionTitle() { + return this.text("userSettingsAiProviderSelectionTitle", "AI Provider Selection"); + } + + static get userSettingsInterfaceLanguageSelectionTitle() { + return this.text("userSettingsInterfaceLanguageSelectionTitle", "Interface Language Selection"); + } + + static get userSettingsResponseLanguageSelectionTitle() { + return this.text("userSettingsResponseLanguageSelectionTitle", "Response Language Selection"); + } + + static get userSettingsContextSizeSelectionTitle() { + return this.text("userSettingsContextSizeSelectionTitle", "Context Size Selection"); + } + + static get userSettingsVoiceModeSelectionTitle() { + return this.text("userSettingsVoiceModeSelectionTitle", "Voice Message Mode Selection"); + } + + static get userSettingsImageOutputSelectionTitle() { + return this.text("userSettingsImageOutputSelectionTitle", "Image Output Mode Selection"); + } + + static get userSettingsTierLabel() { + return this.text("userSettingsTierLabel", "Tier"); + } + + static get userSettingsAiProviderLabel() { + return this.text("userSettingsAiProviderLabel", "AI provider"); + } + + static get userSettingsInterfaceLanguageLabel() { + return this.text("userSettingsInterfaceLanguageLabel", "Interface language"); + } + + static get userSettingsResponseLanguageLabel() { + return this.text("userSettingsResponseLanguageLabel", "LLM response language"); + } + + static get userSettingsContextSizeLabel() { + return this.text("userSettingsContextSizeLabel", "Context size"); + } + + static get userSettingsVoiceModeLabel() { + return this.text("userSettingsVoiceModeLabel", "Voice messages"); + } + + static get userSettingsImageOutputLabel() { + return this.text("userSettingsImageOutputLabel", "Image output"); + } + + static get userSettingsBackButtonText() { + return this.text("userSettingsBackButtonText", "Back"); + } + + static get userSettingsAiProviderButtonPrefix() { + return this.text("userSettingsAiProviderButtonPrefix", "AI provider"); + } + + static get userSettingsInterfaceLanguageButtonPrefix() { + return this.text("userSettingsInterfaceLanguageButtonPrefix", "Interface language"); + } + + static get userSettingsResponseLanguageButtonPrefix() { + return this.text("userSettingsResponseLanguageButtonPrefix", "Response language"); + } + + static get userSettingsContextSizeButtonPrefix() { + return this.text("userSettingsContextSizeButtonPrefix", "Context size"); + } + + static get userSettingsVoiceModeButtonPrefix() { + return this.text("userSettingsVoiceModeButtonPrefix", "Voice messages"); + } + + static get userSettingsImageOutputButtonPrefix() { + return this.text("userSettingsImageOutputButtonPrefix", "Image output"); + } + + static get userSettingsCreatorTierText() { + return this.text("userSettingsCreatorTierText", "Creator"); + } + + static get userSettingsAdminTierText() { + return this.text("userSettingsAdminTierText", "Admin"); + } + + static get userSettingsUserTierText() { + return this.text("userSettingsUserTierText", "User"); + } + + static get userSettingsSelectedPrefix() { + return this.text("userSettingsSelectedPrefix", "✓ "); + } + + static get userSettingsContextSizeDefaultText() { + return this.text("userSettingsContextSizeDefaultText", "Default"); + } + + static get userSettingsContextSizeMaxText() { + return this.text("userSettingsContextSizeMaxText", "Max"); + } + + static get userSettingsVoiceModeExecuteText() { + return this.text("userSettingsVoiceModeExecuteText", "Run through AI"); + } + + static get userSettingsVoiceModeTranscriptText() { + return this.text("userSettingsVoiceModeTranscriptText", "Show transcript only"); + } + + static get userSettingsImageOutputPhotoText() { + return this.text("userSettingsImageOutputPhotoText", "As photo"); + } + + static get userSettingsImageOutputDocumentText() { + return this.text("userSettingsImageOutputDocumentText", "As document"); + } + + static commandTitles = { + ae: "/ae", + adminsAdd: "/addAdmin", + adminsRemove: "/removeAdmin", + ban: "/ban [reply]", + choice: "/choice a, b, ..., c", + coin: "/coin", + debug: "/debug", + dice: "/dice", + distort: "/distort [amp] [wavelength]", + help: "/help", + id: "/id", + ignore: "/ignore", + info: "/info", + leave: "/leave", + mistralChat: "/mistral", + mistralGetModel: "/MistralGetModel", + mistralListModels: "/MistralListModels", + mistralSetModel: "/MistralSetModel", + ollamaChat: "/ollama", + ollamaGetModel: "/OllamaGetModel", + ollamaListModels: "/OllamaListModels", + ollamaSearch: "/search", + ollamaSetModel: "/OllamaSetModel", + openAiChat: "/openAI", + openAiGetModel: "/OpenAIGetModel", + openAiListModels: "/OpenAIListModels", + openAiSetModel: "/OpenAISetModel", + ping: "/ping", + qr: "/qr", + quote: "/quote", + randomInt: "/randomInt", + randomString: "/randomString", + settings: "/settings", + shutdown: "/shutdown", + speechToText: "/stt", + start: "/start", + systemInfo: "/systemInfo", + textToSpeech: "/tts", + title: "/title", + test: "test", + transliteration: "/tr [text or reply]", + unban: "/unban [reply]", + unignore: "/unignore", + uptime: "/uptime", + whatBetter: "/what better [a] or [b]", + when: "/when [value]", + } as const; + + static get commandDescriptions() { + return { + ae: this.text("commandDescriptions.ae", "evaluation"), + adminsAdd: this.text("commandDescriptions.adminsAdd", "Add user to admins"), + adminsRemove: this.text("commandDescriptions.adminsRemove", "Remove user from admins"), + ban: this.text("commandDescriptions.ban", "ban user from chat"), + choice: this.text("commandDescriptions.choice", "Choose a random value"), + coin: this.text("commandDescriptions.coin", "Heads or tails"), + debug: this.text("commandDescriptions.debug", "Returns msg (or reply) as json"), + dice: this.text("commandDescriptions.dice", "Sends random or specific dice"), + distort: this.text("commandDescriptions.distort", "Distortion of picture"), + help: this.text("commandDescriptions.help", "Show list of commands"), + id: this.text("commandDescriptions.id", "ID of chat, user and reply (if replied to any message)"), + ignore: this.text("commandDescriptions.ignore", "Bot will ignore user"), + info: this.text("commandDescriptions.info", "Info about bot"), + leave: this.text("commandDescriptions.leave", "Bot will leave current chat"), + mistralChat: this.text("commandDescriptions.mistralChat", "Chat with AI (Mistral)"), + mistralGetModel: this.text("commandDescriptions.mistralGetModel", "Get current Mistral model"), + mistralListModels: this.text("commandDescriptions.mistralListModels", "List all Mistral models"), + mistralSetModel: this.text("commandDescriptions.mistralSetModel", "Set Mistral model"), + ollamaChat: this.text("commandDescriptions.ollamaChat", "Chat with AI (Ollama)"), + ollamaGetModel: this.text("commandDescriptions.ollamaGetModel", "Get current Ollama model"), + ollamaListModels: this.text("commandDescriptions.ollamaListModels", "List all Ollama models"), + ollamaSearch: this.text("commandDescriptions.ollamaSearch", "Web search via Ollama"), + ollamaSetModel: this.text("commandDescriptions.ollamaSetModel", "Set Ollama model"), + openAiChat: this.text("commandDescriptions.openAiChat", "Chat with AI (OpenAI)"), + openAiGetModel: this.text("commandDescriptions.openAiGetModel", "Get current OpenAI model"), + openAiListModels: this.text("commandDescriptions.openAiListModels", "List all OpenAI models"), + openAiSetModel: this.text("commandDescriptions.openAiSetModel", "Set OpenAI model"), + ping: this.text("commandDescriptions.ping", "Ping between received and sent message"), + qr: this.text("commandDescriptions.qr", "Generates QR-code from text you sent or replied to."), + quote: this.text("commandDescriptions.quote", "Make quote from text (or quote)"), + randomInt: this.text("commandDescriptions.randomInt", "Ranged random integer from parameters"), + randomString: this.text("commandDescriptions.randomString", "literally random string (up to 4096 symbols)"), + settings: this.text("commandDescriptions.settings", "User settings"), + shutdown: this.text("commandDescriptions.shutdown", "Self-destruction sequence for bot (shutdown)"), + speechToText: this.text("commandDescriptions.speechToText", "Transcribe speech to text"), + start: this.text("commandDescriptions.start", "Start the bot"), + systemInfo: this.text("commandDescriptions.systemInfo", "System information"), + textToSpeech: this.text("commandDescriptions.textToSpeech", "Generate speech from text"), + title: this.text("commandDescriptions.title", "Change group title"), + test: this.text("commandDescriptions.test", "System functionality check"), + transliteration: this.text("commandDescriptions.transliteration", "Transliteration EN <--> RU"), + unban: this.text("commandDescriptions.unban", "unban user from chat"), + unignore: this.text("commandDescriptions.unignore", "Bot will start responding to the user"), + uptime: this.text("commandDescriptions.uptime", "Bot's uptime"), + whatBetter: this.text("commandDescriptions.whatBetter", "either a or b randomly (50% chance)"), + when: this.text("commandDescriptions.when", "random date"), + } as const; + } + + static getUserSettingsTitle(screen: string): string { + if (screen === "provider") return this.userSettingsAiProviderSelectionTitle; + if (screen === "interfaceLanguage") return this.userSettingsInterfaceLanguageSelectionTitle; + if (screen === "responseLanguage" || screen === "language") return this.userSettingsResponseLanguageSelectionTitle; + if (screen === "contextSize") return this.userSettingsContextSizeSelectionTitle; + if (screen === "voiceMode") return this.userSettingsVoiceModeSelectionTitle; + if (screen === "imageOutput") return this.userSettingsImageOutputSelectionTitle; + return this.userSettingsTitle; + } + + static getUserSettingsFieldText(label: string, value: string): string { + return `${label}: ${value}`; + } + + static getUserSettingsSelectedText(text: string): string { + return `${this.userSettingsSelectedPrefix}${text}`; + } + + static getUserSettingsContextSizeText(size: number): string { + return this.text("getUserSettingsContextSizeText", "{size} tokens", {size}); + } + + static getCancelledText(provider: string): string { + return this.text("getCancelledText", "{provider}\n❌ Generation cancelled.", {provider}); + } + + static get startingImageGenText() { + return this.text("startingImageGenText", "🌈 Starting image generation..."); + } + + static get imageGenText() { + return this.text("imageGenText", "🌈 Generating image..."); + } + + static get finalizingImageGenText() { + return this.text("finalizingImageGenText", "🌈 Finalizing image generation..."); + } + + static getPartialImageGenText(iteration: number, total: number): string { + return this.text("getPartialImageGenText", "🌈 Generating image ({iteration}/{total})...", {iteration, total}); + } + + static getImageGenDoneText(model?: string): string { + return model + ? this.text("getImageGenDoneText.withModel", "👨‍🎨 Image generated. Model: `{model}`.", {model}) + : this.text("getImageGenDoneText.default", "👨‍🎨 Image generated."); + } + + static getErrorText(error?: ErrorLike | BoundaryValue | null | undefined): string { + if (!error) return this.errorText; + + const reason = error instanceof Error ? error.message : String(error); + return this.text("getErrorText.withReason", "{errorText} Reason:\n{reason}", { + errorText: this.errorText, + reason, + }); + } + + static getUptimeText(processUptime: string, osUptime: string): string { + return `${Environment.IS_DOCKER ? this.dockerContainerLabelText : this.processLabelText}:\n${processUptime}\n\n${this.systemLabelText}:\n${osUptime}`; + } + + static getExpandableBlockquoteText(content: string): string { + return `
${content}
`; + } + + static getSystemSpecsText(params: { + os: string; + runtime: string; + docker: boolean; + cpu: string; + ramGb: string; + }): string { + return [ + `${this.systemInfoOsLabelText}: ${params.os}`, + `${this.systemInfoRuntimeLabelText}: ${params.runtime}`, + `${this.systemInfoDockerLabelText}: ${params.docker}`, + `${this.systemInfoCpuLabelText}: ${params.cpu}`, + `${this.systemInfoRamLabelText}: ${params.ramGb} GB`, + ].join("\n"); + } + + static getIdText(chatId: number | string, fromId: number | string | undefined, replyId?: number | string): string { + let text = `${this.idChatLabelText}: \n\`\`\`${chatId}\`\`\` \n${this.idFromLabelText}: \n\`\`\`${fromId}\`\`\``; + if (replyId !== undefined) { + text += ` \n${this.idReplyLabelText}: \n\`\`\`${replyId}\`\`\``; + } + return text; + } + + static getRandomIntRangeText(min: number, max: number, value: number): string { + return this.text("getRandomIntRangeText", "[{min}; {max}]: {value}", {min, max, value}); + } + + static getRuntimeCapabilityLineText(params: { + state: string; + title: string; + model?: string; + endpointBaseUrl?: string; + external?: boolean; + }): string { + const modelPart = params.model ? ` (${params.model})` : ""; + const endpointPart = params.endpointBaseUrl ? ` @ ${params.endpointBaseUrl}` : ""; + const externalPart = params.external ? ` ${this.runtimeExternalLabelText}` : ""; + return this.text("getRuntimeCapabilityLineText", "{state} {title}{modelPart}{endpointPart}{externalPart}", { + state: params.state, + title: params.title, + modelPart, + endpointPart, + externalPart, + }); + } + + static getRuntimeModelInfoText(provider: string, model: string | undefined, capabilities: string[]): string { + return [ + `${this.runtimeProviderLabelText}: ${provider}`, + `${this.runtimeModelLabelText}: ${model}`, + "", + `${this.runtimeCapabilitiesLabelText}:`, + ...capabilities, + ].join("\n"); + } + + static getInfoToolsBlockText(toolNames: string[]): string { + return [ + `\`\`\`${this.infoToolsBlockLabelText}`, + toolNames.map(name => `- ${name}`).join("\n"), + "```", + ].join("\n"); + } + + static getInfoCommandsBlockText(params: { + publicCommands: number; + privateCommands: number; + chatCommands: number; + callbackCommands: number; + }): string { + return [ + `\`\`\`${this.infoCommandsBlockLabelText}`, + `${this.infoPublicLabelText}: ${params.publicCommands}`, + `${this.infoPrivateLabelText}: ${params.privateCommands}`, + `${this.infoChatLabelText}: ${params.chatCommands}`, + `${this.infoCallbackLabelText}: ${params.callbackCommands}`, + "```", + ].join("\n"); + } + + static getUseToolText(toolCalls: ToolCallData[] | string[]): string { + const isString = (toolCall: ToolCallData | string) => { + return typeof toolCall === "string"; + }; + + return toolCalls.map(toolCall => { + const name = isString(toolCall) ? toolCall : toolCall.name; + return name === PYTHON_INTERPRETER_TOOL_NAME + ? this.text("getUseToolText.python", "👨‍💻 Running `Python`") + : name === "code_interpreter" + ? this.text("getUseToolText.codeInterpreter", "👨‍💻 Running `Code Interpreter`") + : this.text("getUseToolText.default", "🔧 Using tool `{name}`", {name}); + }).join("\n"); + } + + static getAnalyzingDocumentText(documentNames?: string[]): string { + if (!documentNames) return this.text("getAnalyzingDocumentText.default", "🔍 Analyzing the document..."); + if (documentNames.length === 1) { + return this.text("getAnalyzingDocumentText.single", "🔍 Analyzing document: `{name}`", {name: documentNames[0]}); + } + + return this.text("getAnalyzingDocumentText.many", "🔍 Analyzing documents: {names}", { + names: documentNames.map(n => `\`${n}\``).join(", "), + }); + } + + static getPreparingRAGText(documentNames?: string[]): string { + if (!documentNames) return this.text("getPreparingRAGText.default", "🔍 Preparing RAG for the document..."); + if (documentNames.length === 1) { + return this.text("getPreparingRAGText.single", "🔍 Preparing RAG for document: `{name}`", {name: documentNames[0]}); + } + + return this.text("getPreparingRAGText.many", "🔍 Preparing RAG for documents: {names}", { + names: documentNames.map(n => `\`${n}\``).join(", "), + }); + } + + static getSelectingToolsText(): string { + return this.text("getSelectingToolsText", "🧩 Выбираю подходящие инструменты..."); + } + + static getBuildingRAGIndexText(modelName?: string): string { + return modelName + ? this.text("getBuildingRAGIndexText.withModel", "🧠 Building RAG index: `{modelName}`.", {modelName}) + : this.text("getBuildingRAGIndexText.default", "🧠 Building RAG index..."); + } + + static getAiQueueText(provider: AiProvider, requestsBefore: number): string { + const count = Math.max(0, requestsBefore); + const beforeText = count === 0 ? this.text("queueNoneText", "none") : count.toString(); + return [ + this.text("getAiQueueText.queued", "⏳ Request to {provider} is queued.", {provider: provider.toString().toLowerCase()}), + this.text("getAiQueueText.ahead", "Requests ahead: {count}.", {count: beforeText}), + ].join("\n"); + } + + static getTelegramFileTooLargeText(fileName: string, maxSizeMb: number): string { + return this.text("getTelegramFileTooLargeText", "File {fileName} is larger than {maxSizeMb} MB and cannot be sent.", { + fileName, + maxSizeMb + }); + } + + static getUserIsNowAdminText(name: string): string { + return this.text("getUserIsNowAdminText", "{name} is now an admin!", {name}); + } + + static getUserAlreadyAdminText(name: string): string { + return this.text("getUserAlreadyAdminText", "{name} is already an admin 🤔", {name}); + } + + static getUserNoLongerAdminText(name: string): string { + return this.text("getUserNoLongerAdminText", "{name} is no longer an admin!", {name}); + } + + static getUserWasNotAdminText(name: string): string { + return this.text("getUserWasNotAdminText", "{name} was not an admin 🤔", {name}); + } + + static get botCannotMakeItselfAdminText() { + return this.text("botCannotMakeItselfAdminText", "The bot cannot make itself an admin"); + } + + static get botCreatorAlreadyAdminText() { + return this.text("botCreatorAlreadyAdminText", "The bot creator is already an admin"); + } + + static get botCannotRemoveItselfFromAdminsText() { + return this.text("botCannotRemoveItselfFromAdminsText", "The bot cannot remove itself from admins"); + } + + static get botCreatorCannotStopBeingAdminText() { + return this.text("botCreatorCannotStopBeingAdminText", "The bot creator cannot stop being an admin"); + } + + static get botWillNotBanCreatorText() { + return this.text("botWillNotBanCreatorText", "The bot will not ban its creator."); + } + + static get botWillNotBanAdminsText() { + return this.text("botWillNotBanAdminsText", "The bot will not ban its administrators."); + } + + static get botIsNotBannedByItselfText() { + return this.text("botIsNotBannedByItselfText", "The bot is not banned by itself anyway."); + } + + static get botCreatorNeverBannedText() { + return this.text("botCreatorNeverBannedText", "The bot creator is not banned and never will be."); + } + + static get botAdminsNotBannedText() { + return this.text("botAdminsNotBannedText", "Bot administrators are not banned anyway."); + } + + static get botWillNotIgnoreItselfText() { + return this.text("botWillNotIgnoreItselfText", "The bot will not ignore itself."); + } + + static get botWillNotIgnoreCreatorText() { + return this.text("botWillNotIgnoreCreatorText", "The bot will not ignore its creator."); + } + + static get botWillNotIgnoreAdminsText() { + return this.text("botWillNotIgnoreAdminsText", "The bot will not ignore its administrators."); + } + + static get botIsNotIgnoredByItselfText() { + return this.text("botIsNotIgnoredByItselfText", "The bot is not ignored by itself anyway."); + } + + static get botCreatorNotIgnoredText() { + return this.text("botCreatorNotIgnoredText", "The bot creator is not ignored and never will be."); + } + + static get botAdminsNotIgnoredText() { + return this.text("botAdminsNotIgnoredText", "Bot administrators are not ignored anyway."); + } + + static get botAlreadyAlwaysListensToItselfText() { + return this.text("botAlreadyAlwaysListensToItselfText", "The bot already always listens to itself"); + } + + static get botAlwaysListensToCreatorText() { + return this.text("botAlwaysListensToCreatorText", "The bot always listens to its creator"); + } + + static getUserBannedText(name: string): string { + return this.text("getUserBannedText", "{name} banned 🚫", {name}); + } + + static getUserBanFailedText(name: string): string { + return this.text("getUserBanFailedText", "Could not ban {name} ☹️", {name}); + } + + static getUserUnbannedText(name: string): string { + return this.text("getUserUnbannedText", "{name} unbanned ⛓️‍💥", {name}); + } + + static getUserUnbanFailedText(name: string): string { + return this.text("getUserUnbanFailedText", "Could not unban {name} ☹️", {name}); + } + + static getUserIgnoredText(name: string): string { + return this.text("getUserIgnoredText", "{name} is muted! 🔇", {name}); + } + + static getUserAlreadyIgnoredText(name: string): string { + return this.text("getUserAlreadyIgnoredText", "{name} is already muted 🤔", {name}); + } + + static getUserIgnoreFailedText(name: string): string { + return this.text("getUserIgnoreFailedText", "Could not mute {name} ☹️", {name}); + } + + static getUserUnignoredText(name: string): string { + return this.text("getUserUnignoredText", "{name} is no longer muted! 🔈", {name}); + } + + static getUserWasNotIgnoredText(name: string): string { + return this.text("getUserWasNotIgnoredText", "{name} was not muted 🤔", {name}); + } + + static getUserUnignoreFailedText(name: string): string { + return this.text("getUserUnignoreFailedText", "Could not unmute {name} ☹️", {name}); + } + + static getChoiceText(choice: string): string { + return this.text("getChoiceText", "Chose *{choice}*", {choice}); + } + + static getCoinResultText(result: string): string { + return this.text("getCoinResultText", "It landed on *{result}*", {result}); + } + + static get coinHeadsText() { + return this.text("coinHeadsText", "Heads"); + } + + static get coinTailsText() { + return this.text("coinTailsText", "Tails"); + } + + static get distortReplyInstructionText() { + return this.text("distortReplyInstructionText", "Reply with /distort to a message containing an image (photo, document, or sticker).\nExample: /distort 16 80"); + } + + static get distortMissingImageText() { + return this.text("distortMissingImageText", "I do not see an image in the reply. Send a photo or image file."); + } + + static getDistortionReadyCaption(amp: number, wavelength: number): string { + return this.text("getDistortionReadyCaption", "Distortion ready ✅ (amp={amp}, wavelength={wavelength})", { + amp, + wavelength + }); + } + + static getDistortFailedText(error: ErrorLike | BoundaryValue | null | undefined): string { + return this.text("getDistortFailedText", "Could not distort image: {reason}", { + reason: error instanceof Error ? error.message : String(error), + }); + } + + static getLoadedModelsText(modelNames: string[]): string { + return this.text("getLoadedModelsText", "Loaded models: {models}", {models: modelNames.join(", ")}); + } + + static getSelectedModelText(model: string): string { + return this.text("getSelectedModelText", "Selected model: `{model}`", {model}); + } + + static getSelectedModelWithInfoText(model: string, info: string): string { + return this.text("getSelectedModelWithInfoText", "Selected model \"{model}\"\n\n{info}", {model, info}); + } + + static getModelIsNotSetCurrentText(model: string): string { + return this.text("getModelIsNotSetCurrentText", "Model is not set. Current model: \"{model}\"", {model}); + } + + static getCurrentModelText(model: string): string { + return this.text("getCurrentModelText", "Current model: `{model}`", {model}); + } + + static getLoadingModelText(model: string): string { + return this.text("getLoadingModelText", "Loading model `{model}`...", {model}); + } + + static getCurrentModelUnsupportedInputText(model: string, providerName: string, inputKind: string): string { + return this.text("getCurrentModelUnsupportedInputText", "⚠️ Current model `{model}` ({providerName}) does not support {inputKind}.", { + model, + providerName, + inputKind, + }); + } + + static getDocumentIsEmptyText(fileName: string): string { + return this.text("getDocumentIsEmptyText", "Document {fileName} is empty or contains no readable text.", {fileName}); + } + + static getDocumentContentText(fileName: string, content: string): string { + return this.text("getDocumentContentText", "{label} for \"{fileName}\":\n\n{content}", { + label: this.documentContentLabelText, + fileName, + content, + }); + } + + static getMistralUploadedDocumentIdMissingText(fileName: string): string { + return this.text("getMistralUploadedDocumentIdMissingText", "Mistral did not return an uploaded document id for {fileName}.", {fileName}); + } + + static getMistralDocumentProcessingFailedText(fileName: string, status: string): string { + return this.text("getMistralDocumentProcessingFailedText", "Mistral could not process document {fileName}: {status}", { + fileName, + status + }); + } + + static getMistralDocumentProcessingTimedOutText(fileName: string): string { + return this.text("getMistralDocumentProcessingTimedOutText", "Mistral did not process document {fileName} within the allotted time.", {fileName}); + } + + static getAttachmentMissingFromCacheText(fileName: string): string { + return this.text("getAttachmentMissingFromCacheText", "⚠️ Attachment file is missing from the cache: {fileName}", {fileName}); + } + + static getZipInvalidLocalHeaderText(entryName: string): string { + return this.text("getZipInvalidLocalHeaderText", "ZIP archive is corrupted: invalid local header for {entryName}.", {entryName}); + } + + static getZipUnsupportedCompressionMethodText(method: number, entryName: string): string { + return this.text("getZipUnsupportedCompressionMethodText", "ZIP archive uses unsupported compression method {method} for {entryName}.", { + method, + entryName + }); + } + + static getGzipUncompressedLimitText(maxBytes: number): string { + return this.text("getGzipUncompressedLimitText", "GZIP archive exceeds the uncompressed data limit ({maxBytes} bytes).", {maxBytes}); + } + + static getNestedArchiveDepthLimitText(maxDepth: number): string { + return this.text("getNestedArchiveDepthLimitText", "nested archive depth limit reached ({maxDepth})", {maxDepth}); + } + + static getUnsupportedArchiveFormatText(fileName: string): string { + return this.text("getUnsupportedArchiveFormatText", "Archive format \"{fileName}\" is not supported by local RAG.", {fileName}); + } + + static getDocumentEmptyOrNoExtractableText(fileName: string): string { + return this.text("getDocumentEmptyOrNoExtractableText", "Document \"{fileName}\" is empty or contains no extractable text.", {fileName}); + } + + static getUnsupportedLocalRagDocumentFormatText(fileName: string): string { + return this.text("getUnsupportedLocalRagDocumentFormatText", "Document format \"{fileName}\" is not supported by local RAG. Supported formats: text files, code, CSV, JSON, Markdown, YAML, XML, DOCX, text PDFs, and ZIP/TAR/GZIP archives containing those files.", {fileName}); + } + + static getOllamaEmbeddingInvalidResponseText(model: string): string { + return this.text("getOllamaEmbeddingInvalidResponseText", "Ollama embedding model \"{model}\" returned an invalid response.", {model}); + } + + static getProviderNotAvailableForAccessText(providerName: string): string { + return this.text("getProviderNotAvailableForAccessText", "Provider {providerName} is not available for your access level.", {providerName}); + } + + static getProviderSpeechToTextUnsupportedText(providerName: string): string { + return this.text("getProviderSpeechToTextUnsupportedText", "Provider {providerName} does not support speech-to-text or is not configured for it.", {providerName}); + } + + static getProviderTextToSpeechUnsupportedText(providerName: string): string { + return this.text("getProviderTextToSpeechUnsupportedText", "Provider {providerName} does not support text-to-speech or is not configured for it.", {providerName}); + } + + static getTextToSpeechTooLongText(actualLength: number, maxLength: number): string { + return this.text("getTextToSpeechTooLongText", "Text for speech synthesis is too long: {actualLength} characters, maximum {maxLength}.", { + actualLength, + maxLength, + }); + } + + static getTextToSpeechCaption(providerName: string, model: string, voice?: string): string { + return [ + `TTS: ${providerName}`, + `model: ${model}`, + voice ? `voice: ${voice}` : null, + ].filter(Boolean).join("\n"); + } + + static getQrCodeTextTooLongText(actualLength: number, maxLength: number): string { + return this.text("getQrCodeTextTooLongText", "Text is too long for QR ({actualLength} characters). It will be trimmed to {maxLength} characters.", { + actualLength, + maxLength, + }); + } + + static getQrCodeReadyText(content: string): string { + return this.text("getQrCodeReadyText", "QR code ready ✅\nContent:\n
{content}
", {content}); + } + + static getQrCodeFailedText(error: ErrorLike | BoundaryValue | null | undefined): string { + return this.text("getQrCodeFailedText", "Could not generate QR: {reason}", { + reason: error instanceof Error ? error.message : String(error), + }); + } + + static get shutdownFallbackText() { + return this.text("shutdownFallbackText", "..."); + } + + static get shutdownSequenceTexts() { + return this.textArray("shutdownSequenceTexts", [ + "well then, everyone", + "it was nice talking to you", + "but it is time for me to rest", + "all the best", + ]); + } + + static get shutdownDoneText() { + return this.text("shutdownDoneText", "*R.I.P*"); + } + + static getWhenPrefixText(): string { + return this.text("getWhenPrefixText", "in "); + } + + static get whenNowText() { + return this.text("whenNowText", "right now"); + } + + static get whenNeverText() { + return this.text("whenNeverText", "never"); + } + + static get whenYearUnitText() { + return this.text("whenYearUnitText", "year"); + } + + static get whenDayUnitText() { + return this.text("whenDayUnitText", "day"); + } + + static get whenWeekUnitText() { + return this.text("whenWeekUnitText", "week"); + } + + static get whenMonthUnitText() { + return this.text("whenMonthUnitText", "month"); + } + + static get whenHourUnitText() { + return this.text("whenHourUnitText", "hour"); + } + + static get whenMinuteUnitText() { + return this.text("whenMinuteUnitText", "minute"); + } + + static get whenSecondUnitText() { + return this.text("whenSecondUnitText", "second"); + } + + static getWhenDurationText(value: number, unit: string): string { + const pluralUnit = value === 1 ? unit : this.text("getWhenPluralUnitText", "{unit}s", {unit}); + return this.text("getWhenDurationText", "{prefix}{value} {unit}", { + prefix: this.getWhenPrefixText(), + value, + unit: pluralUnit, + }); + } + + static getPingReportText( + telegramPingMs: string, + apiPingMs: string, + messageDate: string, + messageTime: string, + localDate: string, + localTime: string, + ): string { + return this.text("getPingReportText", "```ping\nTG: {telegramPingMs}ms\nAPI {apiPingMs}ms\n\n🗓️ Message date: {messageDate}\n🕒 Message time: {messageTime}\n\n🗓️ Local date : {localDate}\n🕒 Local time: {localTime}```", { + telegramPingMs, + apiPingMs, + messageDate, + messageTime, + localDate, + localTime, + }); + } + + static getAiProviderMaxConcurrentRequests(provider: AiProvider): number { + switch (provider) { + case AiProvider.OLLAMA: + return Environment.OLLAMA_MAX_CONCURRENT_REQUESTS; + case AiProvider.MISTRAL: + return Environment.MISTRAL_MAX_CONCURRENT_REQUESTS; + case AiProvider.OPENAI: + return Environment.OPENAI_MAX_CONCURRENT_REQUESTS; + } + } + + private static processEnvAsRecord(): EnvRecord { + return Object.fromEntries( + Object.entries(process.env) + .filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ); + } + + private static parseNumberSet(value: string | undefined): Set { + if (!value) { + return new Set(); + } + + const numbers = value + .split(",") + .map(e => Number.parseInt(e.trim(), 10)) + .filter(Number.isSafeInteger); + + return new Set(numbers); + } + + private static getFileMtimeMs(filePath: string): number | undefined { + try { + return fs.statSync(filePath).mtimeMs; + } catch (e) { + if (e instanceof Error && "code" in e && (e as {code?: string}).code === "ENOENT") { + return undefined; + } + + throw e; + } + } + + private static readEnvFile(): EnvRecord { + if (!fs.existsSync(Environment.ENV_FILE_PATH)) { + return {}; + } + + const envFile = fs.readFileSync(Environment.ENV_FILE_PATH, "utf8"); + return parseDotEnv(envFile); + } + + private static readConfigSource(): EnvRecord { + return { + ...Environment.readEnvFile(), + ...Environment.processEnvAsRecord(), + }; + } + + static getOptionalConfigValue(name: string): string | undefined { + return normalizeString(Environment.readConfigSource()[name]); + } + + private static getSystemPromptPath(): string { + return path.join(Environment.DATA_PATH, "SYSTEM_PROMPT.md"); + } + + private static getRankerToolPromptPath(): string { + return path.join(Environment.DATA_PATH, "TOOL_RANKER_PROMPT.md"); + } + + private static readSystemPrompt(): string | undefined { + const promptPath = Environment.getSystemPromptPath(); + + if (!fs.existsSync(promptPath)) { + return undefined; + } + + const prompt = fs.readFileSync(promptPath, "utf8").trim(); + return prompt.length > 0 ? prompt : undefined; + } + + private static readRankerToolPromptPath(): string | undefined { + const promptPath = Environment.getRankerToolPromptPath(); + + if (!fs.existsSync(promptPath)) { + return undefined; + } + + const prompt = fs.readFileSync(promptPath, "utf8").trim(); + return prompt.length > 0 ? prompt : undefined; + } + + private static refreshSystemPrompt(): void { + Environment.SYSTEM_PROMPT = Environment.readSystemPrompt() ?? Environment.envSystemPrompt; + } + + private static refreshRankerToolPrompt(): void { + Environment.RANKER_TOOL_PROMPT = Environment.readRankerToolPromptPath() ?? Environment.envRankerToolPrompt; + } + + private static applyStartupEnv(env: StartupEnv): void { + Environment.BOT_TOKEN = env.BOT_TOKEN; + Environment.TEST_ENVIRONMENT = env.TEST_ENVIRONMENT; + Environment.IS_DOCKER = env.IS_DOCKER ?? false; + + const defaultDataPath = env.DATA_PATH + ?? (Environment.IS_DOCKER + ? "/" + path.join("config", "data") + : path.join(os.homedir(), ".local", "share", "tg-chat-bot")); + const defaultDatabaseUrl = "file:" + path.join(defaultDataPath, Environment.DB_FILE_NAME); + const databaseUrl = env.DATABASE_URL ?? env.DB_PATH ?? defaultDatabaseUrl; + + Environment.DATA_PATH = defaultDataPath; + Environment.DB_PATH = databaseUrl; + Environment.DB_KIND = /^postgres(?:ql)?:\/\//i.test(databaseUrl) ? "postgres" : "sqlite"; + Environment.DB_FILE_PATH = databaseUrl.startsWith("file:") + ? databaseUrl.slice("file:".length) + : undefined; + } + + private static applyRuntimeEnv(env: RuntimeEnv): void { + Environment.CHAT_IDS_WHITELIST = Environment.parseNumberSet(env.CHAT_IDS_WHITELIST); + Environment.BOT_PREFIX = env.BOT_PREFIX; + Environment.CREATOR_ID = env.CREATOR_ID; + Environment.ONLY_FOR_CREATOR_MODE = env.ONLY_FOR_CREATOR_MODE; + Environment.ENABLE_UNSAFE_EVAL = env.ENABLE_UNSAFE_EVAL; + Environment.MAX_PHOTO_SIZE = env.MAX_PHOTO_SIZE; + Environment.PROCESS_LINKS = env.PROCESS_LINKS; + Environment.LOCALES_DIR = env.LOCALES_DIR; + Localization.configure(env.LOCALES_DIR); + + Environment.RATE_LIMIT_FALLBACK_POLICY = env.RATE_LIMIT_FALLBACK_POLICY; + Environment.IMAGE_HANDLE_POLICY = env.IMAGE_HANDLE_POLICY; + Environment.IMAGE_HANDLE_FALLBACK_POLICY = env.IMAGE_HANDLE_FALLBACK_POLICY; + + Environment.BRAVE_SEARCH_API_KEY = env.BRAVE_SEARCH_API_KEY; + Environment.OPEN_WEATHER_MAP_API_KEY = env.OPEN_WEATHER_MAP_API_KEY; + + Environment.FILE_TOOLS_ROOT_DIR = env.FILE_TOOLS_ROOT_DIR + ? path.resolve(env.FILE_TOOLS_ROOT_DIR) + : undefined; + + Environment.ENABLE_FS_TOOLS = env.ENABLE_FS_TOOLS ?? false; + + Environment.DEFAULT_AI_PROVIDER = env.DEFAULT_AI_PROVIDER; + + Environment.envSystemPrompt = env.SYSTEM_PROMPT; + Environment.envRankerToolPrompt = env.RANKER_TOOL_PROMPT; + Environment.SYSTEM_PROMPT = env.SYSTEM_PROMPT; + Environment.RANKER_TOOL_PROMPT = env.RANKER_TOOL_PROMPT; + Environment.USE_NAMES_IN_PROMPT = env.USE_NAMES_IN_PROMPT; + Environment.USE_SYSTEM_PROMPT = env.USE_SYSTEM_PROMPT; + Environment.SEND_TIME_TOOK = env.SEND_TIME_TOOK ?? false; + + Environment.ENABLE_PYTHON_INTERPRETER = env.ENABLE_PYTHON_INTERPRETER ?? false; + + Environment.OLLAMA_API_KEY = env.OLLAMA_API_KEY; + Environment.OLLAMA_ADDRESS = env.OLLAMA_ADDRESS; + Environment.OLLAMA_CHAT_MODEL = env.OLLAMA_CHAT_MODEL; + Environment.OLLAMA_IMAGE_MODEL = env.OLLAMA_IMAGE_MODEL ?? env.OLLAMA_CHAT_MODEL; + Environment.OLLAMA_THINK_MODEL = env.OLLAMA_THINK_MODEL ?? env.OLLAMA_CHAT_MODEL; + Environment.OLLAMA_AUDIO_MODEL = env.OLLAMA_AUDIO_MODEL ?? env.OLLAMA_CHAT_MODEL; + Environment.OLLAMA_EMBEDDING_MODEL = env.OLLAMA_EMBEDDING_MODEL; + Environment.OLLAMA_RAG_CHUNK_SIZE = env.OLLAMA_RAG_CHUNK_SIZE; + Environment.OLLAMA_RAG_CHUNK_OVERLAP = Math.min(env.OLLAMA_RAG_CHUNK_OVERLAP, Math.max(1, env.OLLAMA_RAG_CHUNK_SIZE - 1)); + Environment.OLLAMA_RAG_TOP_K = env.OLLAMA_RAG_TOP_K; + Environment.OLLAMA_RAG_MAX_CONTEXT_CHARS = env.OLLAMA_RAG_MAX_CONTEXT_CHARS; + Environment.OLLAMA_RAG_MIN_SCORE = env.OLLAMA_RAG_MIN_SCORE; + Environment.OLLAMA_RAG_MAX_ARCHIVE_FILES = env.OLLAMA_RAG_MAX_ARCHIVE_FILES; + Environment.OLLAMA_RAG_MAX_ARCHIVE_BYTES = env.OLLAMA_RAG_MAX_ARCHIVE_BYTES; + Environment.OLLAMA_RAG_MAX_ARCHIVE_DEPTH = env.OLLAMA_RAG_MAX_ARCHIVE_DEPTH; + Environment.OLLAMA_MAX_CONCURRENT_REQUESTS = env.OLLAMA_MAX_CONCURRENT_REQUESTS; + + Environment.MISTRAL_API_KEY = env.MISTRAL_API_KEY; + Environment.MISTRAL_MODEL = env.MISTRAL_MODEL; + Environment.MISTRAL_TRANSCRIPTION_MODEL = env.MISTRAL_TRANSCRIPTION_MODEL; + Environment.MISTRAL_TTS_MODEL = env.MISTRAL_TTS_MODEL; + Environment.MISTRAL_TTS_VOICE_ID = env.MISTRAL_TTS_VOICE_ID; + Environment.MISTRAL_MAX_CONCURRENT_REQUESTS = env.MISTRAL_MAX_CONCURRENT_REQUESTS; + + Environment.OPENAI_BASE_URL = env.OPENAI_BASE_URL; + Environment.OPENAI_API_KEY = env.OPENAI_API_KEY; + Environment.OPENAI_MODEL = env.OPENAI_MODEL; + Environment.OPENAI_IMAGE_MODEL = env.OPENAI_IMAGE_MODEL; + Environment.OPENAI_TRANSCRIPTION_MODEL = env.OPENAI_TRANSCRIPTION_MODEL; + Environment.OPENAI_TTS_MODEL = env.OPENAI_TTS_MODEL; + Environment.OPENAI_TTS_VOICE = env.OPENAI_TTS_VOICE; + Environment.OPENAI_TTS_INSTRUCTIONS = env.OPENAI_TTS_INSTRUCTIONS; + Environment.OPENAI_MAX_CONCURRENT_REQUESTS = env.OPENAI_MAX_CONCURRENT_REQUESTS; + } + + static load(): void { + const rawEnv = Environment.readConfigSource(); + + const startupEnv = StartupEnvSchema.parse(rawEnv); + const runtimeEnv = RuntimeEnvSchema.parse(rawEnv); + + Environment.applyStartupEnv(startupEnv); + Environment.applyRuntimeEnv(runtimeEnv); + + Environment.refreshSystemPrompt(); + Environment.refreshRankerToolPrompt(); + + Environment.lastEnvMtimeMs = Environment.getFileMtimeMs(Environment.ENV_FILE_PATH); + Environment.lastSystemPromptMtimeMs = Environment.getFileMtimeMs(Environment.getSystemPromptPath()); + Environment.lastRankerToolPromptMtimeMs = Environment.getFileMtimeMs(Environment.getRankerToolPromptPath()); + } + + static reloadRuntimeConfigIfChanged(): void { + try { + const envMtimeMs = Environment.getFileMtimeMs(Environment.ENV_FILE_PATH); + const systemPromptMtimeMs = Environment.getFileMtimeMs(Environment.getSystemPromptPath()); + const rankerToolPromptMtimeMs = Environment.getFileMtimeMs(Environment.getRankerToolPromptPath()); + + const envChanged = envMtimeMs !== Environment.lastEnvMtimeMs; + const systemPromptChanged = systemPromptMtimeMs !== Environment.lastSystemPromptMtimeMs; + const rankerToolPromptChanged = rankerToolPromptMtimeMs !== Environment.lastRankerToolPromptMtimeMs; + + Localization.reloadIfChanged(); + + if (!envChanged && !systemPromptChanged) { + return; + } + + if (envChanged) { + const rawEnv = Environment.readConfigSource(); + const runtimeEnv = RuntimeEnvSchema.parse(rawEnv); + + Environment.applyRuntimeEnv(runtimeEnv); + Environment.refreshSystemPrompt(); + Environment.refreshRankerToolPrompt(); + Environment.lastEnvMtimeMs = envMtimeMs; + } + + if (systemPromptChanged) { + Environment.refreshSystemPrompt(); + Environment.lastSystemPromptMtimeMs = systemPromptMtimeMs; + } + + if (rankerToolPromptChanged) { + Environment.refreshRankerToolPrompt(); + Environment.lastRankerToolPromptMtimeMs = rankerToolPromptMtimeMs; + } + } catch (e) { + appLogger.child("environment").error("runtime_reload.failed", {error: e instanceof Error ? e : String(e)}); + } + } + + static setOnlyForCreatorMode(enable: boolean): void { + this.ONLY_FOR_CREATOR_MODE = enable; + } + + static setBraveSearchApiKey(apiKey: string | undefined): void { + this.BRAVE_SEARCH_API_KEY = apiKey; + } + + static setOpenWeatherMapApiKey(openWeatherMapApiKey: string | undefined): void { + this.OPEN_WEATHER_MAP_API_KEY = openWeatherMapApiKey; + } + + static setFileToolsRootDir(rootDir: string | undefined): void { + this.FILE_TOOLS_ROOT_DIR = rootDir ? path.resolve(rootDir) : undefined; + } + + static setSystemPrompt(prompt: string | undefined): void { this.SYSTEM_PROMPT = prompt; } - static setAdmins(admins: Set) { + static setUseNamesInPrompt(use: boolean): void { + this.USE_NAMES_IN_PROMPT = use; + } + + static setUseSystemPrompt(use: boolean): void { + this.USE_SYSTEM_PROMPT = use; + } + + static setSendTimeTook(send: boolean): void { + this.SEND_TIME_TOOK = send; + } + + static setAdmins(admins: Set): void { this.ADMIN_IDS = admins; } static async addAdmin(id: number): Promise { const has = this.ADMIN_IDS.has(id); + if (!has) { this.ADMIN_IDS.add(id); await saveData(); @@ -154,6 +1947,7 @@ export class Environment { static async removeAdmin(id: number): Promise { const has = this.ADMIN_IDS.has(id); + if (has) { this.ADMIN_IDS.delete(id); await saveData(); @@ -162,42 +1956,91 @@ export class Environment { return has; } - static setMuted(muted: Set) { + static setMuted(muted: Set): void { this.MUTED_IDS = muted; } static async addMute(id: number): Promise { - if (this.MUTED_IDS.has(id)) return Promise.resolve(false); + if (this.MUTED_IDS.has(id)) { + return false; + } this.MUTED_IDS.add(id); await saveData(); - return Promise.resolve(true); + return true; } static async removeMute(id: number): Promise { - if (!this.MUTED_IDS.has(id)) return Promise.resolve(false); + if (!this.MUTED_IDS.has(id)) { + return false; + } + this.MUTED_IDS.delete(id); await saveData(); - return Promise.resolve(true); + return true; } - static setAnswers(answers: Answers) { + static setAnswers(answers: Answers): void { this.ANSWERS = answers; } - static setOllamaModel(newModel: string) { - Environment.OLLAMA_MODEL = newModel; + static setOllamaApiKey(key: string | undefined): void { + this.OLLAMA_API_KEY = key; } - static setGeminiModel(newModel: string) { - Environment.GEMINI_MODEL = newModel; + static setOllamaAddress(address: string | undefined): void { + this.OLLAMA_ADDRESS = address; } - static setMistralModel(newModel: string) { - Environment.MISTRAL_MODEL = newModel; + static setOllamaModel(ollamaModel: string): void { + this.OLLAMA_CHAT_MODEL = ollamaModel; } - static setOpenAIModel(newModel: string) { - Environment.OPENAI_MODEL = newModel; + static setOllamaThinkModel(ollamaThinkModel: string): void { + this.OLLAMA_THINK_MODEL = ollamaThinkModel; } -} \ No newline at end of file + + static setOllamaImageModel(ollamaImageModel: string): void { + this.OLLAMA_IMAGE_MODEL = ollamaImageModel; + } + + static setMistralApiKey(newMistralApiKey: string | undefined): void { + this.MISTRAL_API_KEY = newMistralApiKey; + } + + static setMistralModel(newModel: string): void { + this.MISTRAL_MODEL = newModel; + } + + static setMistralTranscriptionModel(newModel: string): void { + this.MISTRAL_TRANSCRIPTION_MODEL = newModel; + } + + static setMistralTtsModel(newModel: string): void { + this.MISTRAL_TTS_MODEL = newModel; + } + + static setOpenAIBaseUrl(newAIBaseUrl: string | undefined): void { + this.OPENAI_BASE_URL = newAIBaseUrl; + } + + static setOpenAIApiKey(newAIApiKey: string | undefined): void { + this.OPENAI_API_KEY = newAIApiKey; + } + + static setOpenAIModel(newModel: string): void { + this.OPENAI_MODEL = newModel; + } + + static setOpenAIImageModel(newImageModel: string): void { + this.OPENAI_IMAGE_MODEL = newImageModel; + } + + static setOpenAITranscriptionModel(newModel: string): void { + this.OPENAI_TRANSCRIPTION_MODEL = newModel; + } + + static setOpenAITtsModel(newModel: string): void { + this.OPENAI_TTS_MODEL = newModel; + } +} diff --git a/src/common/localization.ts b/src/common/localization.ts new file mode 100644 index 0000000..cab74d0 --- /dev/null +++ b/src/common/localization.ts @@ -0,0 +1,258 @@ +import {AsyncLocalStorage} from "node:async_hooks"; +import fs from "node:fs"; +import path from "node:path"; +import {appLogger} from "../logging/logger"; + +const logger = appLogger.child("localization"); + +export const DEFAULT_LOCALE = "en"; +export const DEFAULT_LANGUAGE_CHOICE = "default"; + +export type LanguageChoice = string; +export type LocalizationParam = string | number | boolean | null | undefined; +export type LocalizationParams = Record; +interface LocalizationBundle { + readonly [key: string]: LocalizationValue; +} +type LocalizationValue = string | number | boolean | null | undefined | readonly LocalizationValue[] | LocalizationBundle; + +const KNOWN_LANGUAGE_ORDER = ["en", "ru", "ua"]; + +function normalizeLanguageCode(value: string | undefined | null): string | undefined { + const normalized = value?.trim().toLowerCase().replace("_", "-"); + if (!normalized) return undefined; + + const code = normalized.split("-")[0]; + return code === "uk" ? "ua" : code; +} + +function readMtimeMs(filePath: string): number | undefined { + try { + return fs.statSync(filePath).mtimeMs; + } catch (error) { + if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +function valueByPath(bundle: LocalizationBundle, key: string): LocalizationValue | undefined { + if (Object.prototype.hasOwnProperty.call(bundle, key)) { + return bundle[key]; + } + + return key.split(".").reduce((value, part) => { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return (value as LocalizationBundle)[part]; + }, bundle); +} + +function interpolate(value: string, params: LocalizationParams): string { + return value.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => { + const param = params[key]; + return param === undefined || param === null ? match : String(param); + }); +} + +export class Localization { + private static localesDir = path.resolve("locales"); + private static bundles = new Map(); + private static fileMtimeMs = new Map(); + private static fileSignature = ""; + private static readonly storage = new AsyncLocalStorage(); + + static configure(localesDir: string): void { + Localization.localesDir = path.resolve(localesDir); + Localization.reload(true); + } + + static reloadIfChanged(): void { + Localization.reload(false); + } + + static runWithLocale(locale: string, callback: () => T): T { + const resolved = Localization.normalizeLocale(locale) ?? DEFAULT_LOCALE; + return Localization.storage.run(resolved, callback); + } + + static currentLocale(): string { + return Localization.storage.getStore() ?? DEFAULT_LOCALE; + } + + static resolveLocale(choice: LanguageChoice | undefined | null, telegramLanguageCode?: string): string { + Localization.reloadIfChanged(); + + const normalizedChoice = Localization.normalizeLocale(choice); + if (normalizedChoice && normalizedChoice !== DEFAULT_LANGUAGE_CHOICE && Localization.bundles.has(normalizedChoice)) { + return normalizedChoice; + } + + const telegramLocale = Localization.normalizeLocale(telegramLanguageCode); + if (telegramLocale && Localization.bundles.has(telegramLocale)) { + return telegramLocale; + } + + return Localization.bundles.has(DEFAULT_LOCALE) + ? DEFAULT_LOCALE + : Localization.availableLocaleCodes()[0] ?? DEFAULT_LOCALE; + } + + static normalizeLocale(value: LanguageChoice | undefined | null): string | undefined { + return normalizeLanguageCode(value); + } + + static isKnownLanguageChoice(value: string | undefined | null): boolean { + if (!value) return false; + if (value === DEFAULT_LANGUAGE_CHOICE) return true; + + const normalized = Localization.normalizeLocale(value); + if (!normalized) return false; + + Localization.reloadIfChanged(); + return Localization.bundles.has(normalized); + } + + static availableLocaleCodes(): string[] { + Localization.reloadIfChanged(); + + return [...Localization.bundles.keys()].sort((a, b) => { + const aIndex = KNOWN_LANGUAGE_ORDER.indexOf(a); + const bIndex = KNOWN_LANGUAGE_ORDER.indexOf(b); + + if (aIndex !== -1 || bIndex !== -1) { + return (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) + - (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex); + } + + return a.localeCompare(b); + }); + } + + static languageChoices(): string[] { + return [DEFAULT_LANGUAGE_CHOICE, ...Localization.availableLocaleCodes()]; + } + + static languageLabel(choice: LanguageChoice): string { + if (choice === DEFAULT_LANGUAGE_CHOICE) { + return Localization.text("language.default", {}, "Default"); + } + + const locale = Localization.normalizeLocale(choice) ?? choice; + return Localization.text(`language.${locale}`, {}, locale.toUpperCase()); + } + + static languageInstructionName(choice: LanguageChoice): string { + if (choice === DEFAULT_LANGUAGE_CHOICE) return ""; + + const locale = Localization.normalizeLocale(choice) ?? choice; + const bundle = Localization.bundles.get(locale); + const value = bundle ? valueByPath(bundle, "language.instructionName") : undefined; + return typeof value === "string" && value.trim().length > 0 ? value : locale; + } + + static text( + key: string, + params: LocalizationParams = {}, + fallback = key, + locale = Localization.currentLocale(), + ): string { + Localization.reloadIfChanged(); + + const value = Localization.lookup(locale, key); + return interpolate(typeof value === "string" ? value : fallback, params); + } + + static textArray( + key: string, + params: LocalizationParams = {}, + fallback: string[] = [], + locale = Localization.currentLocale(), + ): string[] { + Localization.reloadIfChanged(); + + const value = Localization.lookup(locale, key); + const values = Array.isArray(value) && value.every(item => typeof item === "string") + ? value + : fallback; + + return values.map(item => interpolate(item, params)); + } + + private static lookup(locale: string, key: string): LocalizationValue | undefined { + const normalized = Localization.normalizeLocale(locale) ?? DEFAULT_LOCALE; + const bundleValue = Localization.lookupInBundle(normalized, key); + if (bundleValue !== undefined) return bundleValue; + + if (normalized !== DEFAULT_LOCALE) { + const fallbackValue = Localization.lookupInBundle(DEFAULT_LOCALE, key); + if (fallbackValue !== undefined) return fallbackValue; + } + + return undefined; + } + + private static lookupInBundle(locale: string, key: string): LocalizationValue | undefined { + const bundle = Localization.bundles.get(locale); + return bundle ? valueByPath(bundle, key) : undefined; + } + + private static listLocaleFiles(): Map { + const files = new Map(); + + if (!fs.existsSync(Localization.localesDir)) { + return files; + } + + for (const entry of fs.readdirSync(Localization.localesDir, {withFileTypes: true})) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + + const locale = Localization.normalizeLocale(path.basename(entry.name, ".json")); + if (locale) { + files.set(locale, path.join(Localization.localesDir, entry.name)); + } + } + + return files; + } + + private static reload(force: boolean): void { + try { + const files = Localization.listLocaleFiles(); + const signature = [...files.entries()] + .map(([locale, filePath]) => `${locale}:${filePath}`) + .sort() + .join("|"); + + const mtimes = new Map(); + let changed = force || signature !== Localization.fileSignature; + + for (const [locale, filePath] of files) { + const mtimeMs = readMtimeMs(filePath); + mtimes.set(locale, mtimeMs); + + if (mtimeMs !== Localization.fileMtimeMs.get(locale)) { + changed = true; + } + } + + if (!changed) return; + + const bundles = new Map(); + for (const [locale, filePath] of files) { + try { + bundles.set(locale, JSON.parse(fs.readFileSync(filePath, "utf8")) as LocalizationBundle); + } catch (e) { + logger.error("file_load.failed", {filePath, locale, error: e instanceof Error ? e : String(e)}); + const previous = Localization.bundles.get(locale); + if (previous) bundles.set(locale, previous); + } + } + + Localization.bundles = bundles; + Localization.fileMtimeMs = mtimes; + Localization.fileSignature = signature; + logger.debug("reload.done", {force, locales: [...bundles.keys()]}); + } catch (e) { + logger.error("reload.failed", {error: e instanceof Error ? e : String(e)}); + } + } +} diff --git a/src/common/message-part.ts b/src/common/message-part.ts index e6e4958..c10d268 100644 --- a/src/common/message-part.ts +++ b/src/common/message-part.ts @@ -1,6 +1,25 @@ +export type MessageImagePart = { + data: string; + mimeType: string; +} + +export type MessageAudioPart = { + data: string; + mimeType: string; +} + export type MessagePart = { bot: boolean; name?: string; + langCode?: string; + userName?: string; content: string; - images: string[]; -} \ No newline at end of file + deletedByBotAt?: number | null; + images?: string[]; + imageParts?: MessageImagePart[]; + audios?: string[]; + audioParts?: MessageAudioPart[]; + documents?: string[]; + videos?: string[]; + videoNotes?: string[]; +} diff --git a/src/common/message-store.ts b/src/common/message-store.ts index f067906..4ccc472 100644 --- a/src/common/message-store.ts +++ b/src/common/message-store.ts @@ -2,9 +2,15 @@ import {StoredMessage} from "../model/stored-message"; import {Message} from "typescript-telegram-bot-api"; import {extractTextMessage, getPhotoMaxSize, isStoredMessage} from "../util/utils"; import {messageDao} from "../index"; +import {KeyedAsyncLock} from "../util/async-lock"; +import {setLruMapValue} from "../util/lru-map"; +import {createStoredImageAttachment} from "./stored-attachment-utils"; + +const MESSAGE_CACHE_MAX_ENTRIES = 10_000; export class MessageStore { private static map = new Map(); + private static locks = new KeyedAsyncLock(); private static key(chatId: number, messageId: number) { return `${chatId}:${messageId}`; @@ -15,30 +21,58 @@ export class MessageStore { } static async put(m: Message | StoredMessage): Promise { + const maxSize = isStoredMessage(m) ? null : getPhotoMaxSize(m.photo); + const msg: StoredMessage = isStoredMessage(m) ? m : { chatId: m.chat.id, id: m.message_id, - replyToMessageId: m.reply_to_message?.message_id ?? null, - fromId: m.from.id, + replyToMessageId: m.reply_to_message?.message_id, + fromId: m.from?.id, text: extractTextMessage(m), - date: m.date ?? 0, - photoMaxSizeFilePath: m.photo ? [getPhotoMaxSize(m.photo).file_unique_id] : null - }; + quoteText: m.quote?.text, + date: m.date ?? 0, + deletedByBotAt: undefined, + attachments: maxSize ? [createStoredImageAttachment({ + fileId: maxSize.file_id, + fileUniqueId: maxSize.file_unique_id, + fileName: `${maxSize.file_unique_id || maxSize.file_id}.jpg`, + })] : undefined, + pipelineAudit: undefined, + }; - this.map.set(this.key(msg.chatId, msg.id), msg); - await messageDao.insert(messageDao.mapStoredTo([msg])); - return msg; + const key = this.key(msg.chatId, msg.id); + return this.locks.runExclusive(key, async () => { + const existing = this.map.get(key) ?? await messageDao.getById({chatId: msg.chatId, id: msg.id}); + const merged: StoredMessage = { + chatId: msg.chatId, + id: msg.id, + replyToMessageId: msg.replyToMessageId ?? existing?.replyToMessageId, + fromId: msg.fromId, + text: msg.text !== undefined ? msg.text : existing?.text, + quoteText: msg.quoteText ? msg.quoteText : existing?.quoteText, + date: msg.date, + deletedByBotAt: msg.deletedByBotAt !== undefined ? msg.deletedByBotAt : existing?.deletedByBotAt, + attachments: msg.attachments !== undefined ? msg.attachments : existing?.attachments, + pipelineAudit: msg.pipelineAudit !== undefined ? msg.pipelineAudit : existing?.pipelineAudit, + }; + + setLruMapValue(this.map, key, merged, MESSAGE_CACHE_MAX_ENTRIES); + await messageDao.insert(messageDao.mapStoredTo([merged])); + return merged; + }); } - static async get(chatId: number, messageId: number): Promise { + static async get(chatId: number, messageId: number | undefined): Promise { + if (!messageId) return null; + const message = await messageDao.getById({chatId: chatId, id: messageId}); if (!message) return null; - this.map.set(this.key(message.chatId, messageId), message); + setLruMapValue(this.map, this.key(message.chatId, messageId), message, MESSAGE_CACHE_MAX_ENTRIES); return message; } static clear() { this.map.clear(); } -} \ No newline at end of file +} diff --git a/src/common/policies.ts b/src/common/policies.ts index 33f4602..98c67d9 100644 --- a/src/common/policies.ts +++ b/src/common/policies.ts @@ -14,4 +14,10 @@ export enum ImageHandleFallbackPolicy { NOTIFY_USER = "NOTIFY_USER", IGNORE_USER = "IGNORE_USER", USE_OLLAMA = "USE_OLLAMA", -} \ No newline at end of file +} + +export enum ToolRankerFallbackPolicy { + MAIN_MODEL = "MAIN_MODEL", + ALL_TOOLS = "ALL_TOOLS", + NO_TOOLS = "NO_TOOLS", +} diff --git a/src/common/request-audit-store.ts b/src/common/request-audit-store.ts new file mode 100644 index 0000000..194a4b1 --- /dev/null +++ b/src/common/request-audit-store.ts @@ -0,0 +1,56 @@ +import {createHash} from "node:crypto"; +import {DatabaseManager} from "../db/database-manager"; +import type {RequestAuditDbRow} from "../db/db-types"; +import type {PipelineAuditEvent} from "../ai/user-request-pipeline"; + +function hashId(parts: Array): string { + return createHash("sha256").update(parts.map(part => part === null || part === undefined ? "" : String(part)).join("\u0000")).digest("hex"); +} + +function toAuditRow(params: { + requestId: string; + messageChatId: number; + messageId: number; + event: PipelineAuditEvent; + ordinal: number; +}): RequestAuditDbRow { + const startedAt = params.event.startedAt ?? null; + const finishedAt = params.event.finishedAt ?? null; + const durationMs = params.event.durationMs ?? null; + const details = params.event.details ? JSON.stringify(params.event.details) : null; + + return { + id: hashId([params.requestId, params.messageChatId, params.messageId, params.event.stage, params.event.status, startedAt, finishedAt, params.ordinal]), + requestId: params.requestId, + messageChatId: params.messageChatId, + messageId: params.messageId, + stage: params.event.stage, + status: params.event.status, + startedAt, + finishedAt, + durationMs, + provider: params.event.provider ?? null, + model: params.event.model ?? null, + details, + error: params.event.error ?? null, + }; +} + +export class RequestAuditStore { + static async putMessageAudit(params: { + requestId: string; + messageChatId: number; + messageId: number; + events: PipelineAuditEvent[]; + }): Promise { + const rows = params.events.map((event, ordinal) => toAuditRow({ + requestId: params.requestId, + messageChatId: params.messageChatId, + messageId: params.messageId, + event, + ordinal, + })); + + await DatabaseManager.upsertRequestAudits(rows); + } +} diff --git a/src/common/stored-attachment-utils.ts b/src/common/stored-attachment-utils.ts new file mode 100644 index 0000000..865da7b --- /dev/null +++ b/src/common/stored-attachment-utils.ts @@ -0,0 +1,50 @@ +import path from "node:path"; +import {Environment} from "./environment"; +import {StoredAttachment} from "../model/stored-attachment"; + +export function photoCachePathForUniqueId(uniqueId: string): string { + return path.join(Environment.DATA_PATH, "cache", "photo", `${uniqueId}.jpg`); +} + +export function createStoredImageAttachment(params: { + fileId: string; + fileUniqueId?: string; + fileName?: string; + cachePath?: string; +}): StoredAttachment { + const fileUniqueId = params.fileUniqueId ?? params.fileId; + return { + kind: "image", + fileId: params.fileId, + fileUniqueId: params.fileUniqueId, + fileName: params.fileName ?? `${fileUniqueId}.jpg`, + mimeType: "image/jpeg", + cachePath: params.cachePath ?? photoCachePathForUniqueId(fileUniqueId), + }; +} + +export function storedAttachmentIdentity(attachment: StoredAttachment): string { + return [ + attachment.kind, + attachment.fileUniqueId || attachment.fileId, + attachment.cachePath, + ].join(":"); +} + +export function uniqueStoredAttachments(attachments: StoredAttachment[]): StoredAttachment[] { + const seen = new Set(); + const result: StoredAttachment[] = []; + + for (const attachment of attachments) { + const key = storedAttachmentIdentity(attachment); + if (seen.has(key)) continue; + seen.add(key); + result.push(attachment); + } + + return result; +} + +export function filterUserVisibleStoredAttachments(attachments: StoredAttachment[]): StoredAttachment[] { + return attachments.filter(attachment => attachment.scope !== "internal_artifact"); +} diff --git a/src/common/user-ai-settings.ts b/src/common/user-ai-settings.ts new file mode 100644 index 0000000..dd1254d --- /dev/null +++ b/src/common/user-ai-settings.ts @@ -0,0 +1,530 @@ +import {Environment} from "./environment"; +import {UserStore} from "./user-store"; +import {AiProvider} from "../model/ai-provider"; +import {StoredUser} from "../model/stored-user"; +import {resolveAiRuntimeTarget} from "../ai/ai-runtime-target"; +import {DEFAULT_LANGUAGE_CHOICE, LanguageChoice, Localization,} from "./localization"; + +export const DEFAULT_AI_PROVIDER_CHOICE = "DEFAULT"; +export const DEFAULT_AI_CONTEXT_SIZE_CHOICE = "DEFAULT"; +export const AI_CONTEXT_SIZE_MAX_CHOICE = "MAX"; +export const USER_AI_CONTEXT_SIZE_PRESETS = [1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144] as const; +export const MIN_USER_AI_CONTEXT_SIZE = 1024; +export const MAX_USER_AI_CONTEXT_SIZE = 1_000_000; +export const AI_VOICE_MODE_EXECUTE = "execute"; +export const AI_VOICE_MODE_TRANSCRIPT = "transcript"; +export const AI_IMAGE_OUTPUT_MODE_PHOTO = "photo"; +export const AI_IMAGE_OUTPUT_MODE_DOCUMENT = "document"; +export type UserAiProviderChoice = AiProvider | typeof DEFAULT_AI_PROVIDER_CHOICE; +export type UserAiContextSizeChoice = number | typeof DEFAULT_AI_CONTEXT_SIZE_CHOICE | typeof AI_CONTEXT_SIZE_MAX_CHOICE; +export type UserAiVoiceMode = typeof AI_VOICE_MODE_EXECUTE | typeof AI_VOICE_MODE_TRANSCRIPT; +export type UserAiImageOutputMode = typeof AI_IMAGE_OUTPUT_MODE_PHOTO | typeof AI_IMAGE_OUTPUT_MODE_DOCUMENT; +export type UserInterfaceLanguage = LanguageChoice; +export type UserAiResponseLanguage = LanguageChoice; +export type UserTier = "creator" | "admin" | "user"; + +export type EffectiveUserAiSettings = { + userId: number; + tier: UserTier; + providerChoice: UserAiProviderChoice; + effectiveProvider: AiProvider; + interfaceLanguage: UserInterfaceLanguage; + responseLanguage: UserAiResponseLanguage; + contextSizeChoice: UserAiContextSizeChoice; + contextSize?: number; + voiceMode: UserAiVoiceMode; + imageOutputMode: UserAiImageOutputMode; + availableProviderChoices: UserAiProviderChoice[]; + availableContextSizeChoices: UserAiContextSizeChoice[]; + availableVoiceModes: UserAiVoiceMode[]; + availableImageOutputModes: UserAiImageOutputMode[]; +}; + +const CREATOR_PROVIDERS: readonly AiProvider[] = [ + AiProvider.OLLAMA, + AiProvider.MISTRAL, + AiProvider.OPENAI, +]; + +const ADMIN_PROVIDERS: readonly AiProvider[] = [ + AiProvider.MISTRAL, + AiProvider.OPENAI, +]; + +const USER_PROVIDERS: readonly AiProvider[] = [ + AiProvider.MISTRAL, + AiProvider.OLLAMA, +]; + +export const DEFAULT_INTERFACE_LANGUAGE: UserInterfaceLanguage = DEFAULT_LANGUAGE_CHOICE; +export const DEFAULT_AI_RESPONSE_LANGUAGE: UserAiResponseLanguage = DEFAULT_LANGUAGE_CHOICE; +export const DEFAULT_AI_VOICE_MODE: UserAiVoiceMode = AI_VOICE_MODE_EXECUTE; +export const DEFAULT_AI_IMAGE_OUTPUT_MODE: UserAiImageOutputMode = AI_IMAGE_OUTPUT_MODE_PHOTO; + +export function getUserLanguageChoices(): string[] { + return Localization.languageChoices(); +} + +export function getUserAiContextSizeChoices(): UserAiContextSizeChoice[] { + return [DEFAULT_AI_CONTEXT_SIZE_CHOICE, ...USER_AI_CONTEXT_SIZE_PRESETS, AI_CONTEXT_SIZE_MAX_CHOICE]; +} + +export function getUserAiVoiceModes(): UserAiVoiceMode[] { + return [AI_VOICE_MODE_EXECUTE, AI_VOICE_MODE_TRANSCRIPT]; +} + +export function getUserAiImageOutputModes(): UserAiImageOutputMode[] { + return [AI_IMAGE_OUTPUT_MODE_PHOTO, AI_IMAGE_OUTPUT_MODE_DOCUMENT]; +} + +export function getUserTier(userId: number): UserTier { + if (userId === Environment.CREATOR_ID) return "creator"; + if (Environment.ADMIN_IDS.has(userId)) return "admin"; + return "user"; +} + +function allowedProvidersForTier(tier: UserTier): readonly AiProvider[] { + switch (tier) { + case "creator": + return CREATOR_PROVIDERS; + case "admin": + return ADMIN_PROVIDERS; + case "user": + return USER_PROVIDERS; + } +} + +export function isAiProviderConfigured(provider: AiProvider): boolean { + const target = resolveAiRuntimeTarget(provider, "chat"); + + switch (provider) { + case AiProvider.OLLAMA: + return !!target.baseUrl && !!target.model; + case AiProvider.MISTRAL: + return !!target.apiKey && !!target.model; + case AiProvider.OPENAI: + return !!target.apiKey && !!target.model; + } +} + +export function getAvailableAiProviderChoices(userId: number): UserAiProviderChoice[] { + const tier = getUserTier(userId); + const providers = allowedProvidersForTier(tier).filter(isAiProviderConfigured); + return [DEFAULT_AI_PROVIDER_CHOICE, ...providers]; +} + +export function normalizeAiProviderChoice(value: string | undefined | null): UserAiProviderChoice | undefined { + if (!value) return undefined; + if (value === DEFAULT_AI_PROVIDER_CHOICE) return DEFAULT_AI_PROVIDER_CHOICE; + + const providers = Object.values(AiProvider) as string[]; + return providers.includes(value) ? value as AiProvider : undefined; +} + +export function normalizeAiContextSizeChoice(value: string | number | undefined | null): UserAiContextSizeChoice | undefined { + if (value === undefined || value === null || value === "") return undefined; + let numericValue: number; + + if (typeof value === "string") { + const normalized = value.trim(); + const lower = normalized.toLowerCase(); + if (normalized === DEFAULT_AI_CONTEXT_SIZE_CHOICE || lower === "default" || lower === "auto") { + return DEFAULT_AI_CONTEXT_SIZE_CHOICE; + } + + if (normalized === AI_CONTEXT_SIZE_MAX_CHOICE || lower === "max") { + return AI_CONTEXT_SIZE_MAX_CHOICE; + } + + numericValue = Number(normalized); + } else { + numericValue = value; + } + + if (numericValue === -1) { + return AI_CONTEXT_SIZE_MAX_CHOICE; + } + + if (!Number.isSafeInteger(numericValue) || numericValue < MIN_USER_AI_CONTEXT_SIZE || numericValue > MAX_USER_AI_CONTEXT_SIZE) { + return undefined; + } + + return numericValue; +} + +export function normalizeAiVoiceMode(value: string | undefined | null): UserAiVoiceMode | undefined { + if (!value) return undefined; + + switch (value.trim().toLowerCase()) { + case AI_VOICE_MODE_EXECUTE: + case "command": + case "commands": + case "ai": + return AI_VOICE_MODE_EXECUTE; + case AI_VOICE_MODE_TRANSCRIPT: + case "transcribe": + case "transcription": + case "text": + return AI_VOICE_MODE_TRANSCRIPT; + default: + return undefined; + } +} + +export function normalizeAiImageOutputMode(value: string | undefined | null): UserAiImageOutputMode | undefined { + if (!value) return undefined; + + switch (value.trim().toLowerCase()) { + case AI_IMAGE_OUTPUT_MODE_PHOTO: + case "photo": + case "photos": + case "image": + case "images": + return AI_IMAGE_OUTPUT_MODE_PHOTO; + case AI_IMAGE_OUTPUT_MODE_DOCUMENT: + case "doc": + case "docs": + case "file": + case "files": + return AI_IMAGE_OUTPUT_MODE_DOCUMENT; + default: + return undefined; + } +} + +export function normalizeUserLanguageChoice(value: string | undefined | null): UserInterfaceLanguage | undefined { + if (!value) return undefined; + if (value === DEFAULT_LANGUAGE_CHOICE) return DEFAULT_LANGUAGE_CHOICE; + + const normalized = Localization.normalizeLocale(value); + return normalized && Localization.isKnownLanguageChoice(normalized) + ? normalized + : undefined; +} + +export function normalizeInterfaceLanguage(value: string | undefined | null): UserInterfaceLanguage | undefined { + return normalizeUserLanguageChoice(value); +} + +export function normalizeAiResponseLanguage(value: string | undefined | null): UserAiResponseLanguage | undefined { + return normalizeUserLanguageChoice(value); +} + +export function getProviderChoiceLabel(choice: UserAiProviderChoice): string { + if (choice === DEFAULT_AI_PROVIDER_CHOICE) { + return Localization.text("providerChoice.default", {}, "Default"); + } + + return choice.charAt(0) + choice.slice(1).toLowerCase(); +} + +export function getResponseLanguageLabel(language: UserAiResponseLanguage): string { + return Localization.languageLabel(language); +} + +export function getContextSizeLabel(choice: UserAiContextSizeChoice): string { + if (choice === DEFAULT_AI_CONTEXT_SIZE_CHOICE) { + return Environment.userSettingsContextSizeDefaultText; + } + + if (choice === AI_CONTEXT_SIZE_MAX_CHOICE) { + return Environment.userSettingsContextSizeMaxText; + } + + return Environment.getUserSettingsContextSizeText(choice); +} + +export function getVoiceModeLabel(mode: UserAiVoiceMode): string { + switch (mode) { + case AI_VOICE_MODE_EXECUTE: + return Environment.userSettingsVoiceModeExecuteText; + case AI_VOICE_MODE_TRANSCRIPT: + return Environment.userSettingsVoiceModeTranscriptText; + } +} + +export function getImageOutputModeLabel(mode: UserAiImageOutputMode): string { + switch (mode) { + case AI_IMAGE_OUTPUT_MODE_PHOTO: + return Environment.userSettingsImageOutputPhotoText; + case AI_IMAGE_OUTPUT_MODE_DOCUMENT: + return Environment.userSettingsImageOutputDocumentText; + } +} + +export function getInterfaceLanguageLabel(language: UserInterfaceLanguage): string { + return Localization.languageLabel(language); +} + +export function getResponseLanguageInstruction(language: UserAiResponseLanguage): string { + const instructions = [ + "Language:" + ]; + + if (language === DEFAULT_LANGUAGE_CHOICE) { + instructions.push("Always answer in the language of the user’s latest message unless explicitly asked otherwise."); + } else { + instructions.push(`Always answer to the user in ${Localization.languageInstructionName(language)}. If the user specifically requests another language, comply with that request.`); + } + + return instructions.join("\n"); +} + +function shouldUpdateInterfaceLanguage(user: StoredUser | null, language: UserInterfaceLanguage): boolean { + return !!user?.interfaceLanguage && user.interfaceLanguage !== language; +} + +function shouldUpdateProvider(user: StoredUser | null, choice: UserAiProviderChoice): boolean { + return !!user?.aiProvider && user.aiProvider !== choice; +} + +function shouldUpdateLanguage(user: StoredUser | null, language: UserAiResponseLanguage): boolean { + return !!user?.aiResponseLanguage && user.aiResponseLanguage !== language; +} + +function shouldUpdateContextSize(user: StoredUser | null, choice: UserAiContextSizeChoice): boolean { + if (!user) return false; + if (choice === DEFAULT_AI_CONTEXT_SIZE_CHOICE && user.aiContextSize === undefined) return false; + return normalizeAiContextSizeChoice(user.aiContextSize) !== choice; +} + +function shouldUpdateVoiceMode(user: StoredUser | null, mode: UserAiVoiceMode): boolean { + return !!user?.aiVoiceMode && user.aiVoiceMode !== mode; +} + +function shouldUpdateImageOutputMode(user: StoredUser | null, mode: UserAiImageOutputMode): boolean { + return !!user?.aiImageOutputMode && user.aiImageOutputMode !== mode; +} + +function contextSizeChoiceToStored(choice: UserAiContextSizeChoice): number | undefined { + return choice === DEFAULT_AI_CONTEXT_SIZE_CHOICE ? undefined : choice === AI_CONTEXT_SIZE_MAX_CHOICE ? -1 : choice; +} + +export async function ensureValidUserAiSettings(userId: number): Promise { + const user = await UserStore.get(userId); + const availableProviderChoices = getAvailableAiProviderChoices(userId); + let availableContextSizeChoices = getUserAiContextSizeChoices(); + const availableVoiceModes = getUserAiVoiceModes(); + let providerChoice = normalizeAiProviderChoice(user?.aiProvider) ?? DEFAULT_AI_PROVIDER_CHOICE; + let interfaceLanguage = normalizeInterfaceLanguage(user?.interfaceLanguage) ?? DEFAULT_INTERFACE_LANGUAGE; + let responseLanguage = normalizeAiResponseLanguage(user?.aiResponseLanguage) ?? DEFAULT_AI_RESPONSE_LANGUAGE; + let contextSizeChoice = normalizeAiContextSizeChoice(user?.aiContextSize) ?? DEFAULT_AI_CONTEXT_SIZE_CHOICE; + let voiceMode = normalizeAiVoiceMode(user?.aiVoiceMode) ?? DEFAULT_AI_VOICE_MODE; + let imageOutputMode = normalizeAiImageOutputMode(user?.aiImageOutputMode) ?? DEFAULT_AI_IMAGE_OUTPUT_MODE; + + if (!availableProviderChoices.includes(providerChoice)) { + providerChoice = availableProviderChoices[0] ?? DEFAULT_AI_PROVIDER_CHOICE; + } + + if (!Localization.isKnownLanguageChoice(interfaceLanguage)) { + interfaceLanguage = DEFAULT_INTERFACE_LANGUAGE; + } + + if (!Localization.isKnownLanguageChoice(responseLanguage)) { + responseLanguage = DEFAULT_AI_RESPONSE_LANGUAGE; + } + + if (contextSizeChoice !== DEFAULT_AI_CONTEXT_SIZE_CHOICE && !normalizeAiContextSizeChoice(contextSizeChoice)) { + contextSizeChoice = DEFAULT_AI_CONTEXT_SIZE_CHOICE; + } + + if (!availableContextSizeChoices.includes(contextSizeChoice)) { + availableContextSizeChoices = [ + DEFAULT_AI_CONTEXT_SIZE_CHOICE, + ...[ + ...availableContextSizeChoices.filter(choice => choice !== DEFAULT_AI_CONTEXT_SIZE_CHOICE), + contextSizeChoice, + ].sort((a, b) => Number(a) - Number(b)), + ]; + } + + if (!availableVoiceModes.includes(voiceMode)) { + voiceMode = DEFAULT_AI_VOICE_MODE; + } + + if (!getUserAiImageOutputModes().includes(imageOutputMode)) { + imageOutputMode = DEFAULT_AI_IMAGE_OUTPUT_MODE; + } + + if ( + shouldUpdateProvider(user, providerChoice) + || shouldUpdateInterfaceLanguage(user, interfaceLanguage) + || shouldUpdateLanguage(user, responseLanguage) + || shouldUpdateContextSize(user, contextSizeChoice) + || shouldUpdateVoiceMode(user, voiceMode) + || shouldUpdateImageOutputMode(user, imageOutputMode) + ) { + await UserStore.updateSettings(userId, { + interfaceLanguage, + aiProvider: providerChoice, + aiResponseLanguage: responseLanguage, + aiContextSize: contextSizeChoiceToStored(contextSizeChoice), + aiVoiceMode: voiceMode, + aiImageOutputMode: imageOutputMode, + }); + } + + return { + userId, + tier: getUserTier(userId), + providerChoice, + effectiveProvider: providerChoice === DEFAULT_AI_PROVIDER_CHOICE ? Environment.DEFAULT_AI_PROVIDER : providerChoice, + interfaceLanguage, + responseLanguage, + contextSizeChoice, + contextSize: contextSizeChoiceToStored(contextSizeChoice), + voiceMode, + imageOutputMode, + availableProviderChoices, + availableContextSizeChoices, + availableVoiceModes, + availableImageOutputModes: getUserAiImageOutputModes(), + }; +} + +export async function resolveEffectiveAiProviderForUser(userId: number | undefined): Promise { + if (!userId) return Environment.DEFAULT_AI_PROVIDER; + return (await ensureValidUserAiSettings(userId)).effectiveProvider; +} + +export async function resolveAiResponseLanguageForUser(userId: number | undefined): Promise { + if (!userId) return DEFAULT_AI_RESPONSE_LANGUAGE; + return (await ensureValidUserAiSettings(userId)).responseLanguage; +} + +export async function resolveAiContextSizeForUser(userId: number | undefined): Promise { + if (!userId) return undefined; + return (await ensureValidUserAiSettings(userId)).contextSize; +} + +export async function resolveAiVoiceModeForUser(userId: number | undefined): Promise { + if (!userId) return DEFAULT_AI_VOICE_MODE; + return (await ensureValidUserAiSettings(userId)).voiceMode; +} + +export async function resolveAiImageOutputModeForUser(userId: number | undefined): Promise { + if (!userId) return DEFAULT_AI_IMAGE_OUTPUT_MODE; + return (await ensureValidUserAiSettings(userId)).imageOutputMode; +} + +export async function resolveInterfaceLocaleForUser( + userId: number | undefined, + telegramLanguageCode?: string, +): Promise { + if (!userId) { + return Localization.resolveLocale(DEFAULT_INTERFACE_LANGUAGE, telegramLanguageCode); + } + + const settings = await ensureValidUserAiSettings(userId); + const user = await UserStore.get(userId); + return Localization.resolveLocale(settings.interfaceLanguage, telegramLanguageCode ?? user?.langCode); +} + +export async function setUserAiProviderChoice( + userId: number, + choice: UserAiProviderChoice, +): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> { + const settings = await ensureValidUserAiSettings(userId); + + if (!settings.availableProviderChoices.includes(choice)) { + return {ok: false, settings}; + } + + await UserStore.updateSettings(userId, { + interfaceLanguage: settings.interfaceLanguage, + aiProvider: choice, + aiResponseLanguage: settings.responseLanguage, + }); + + return {ok: true, settings: await ensureValidUserAiSettings(userId)}; +} + +export async function setUserAiContextSizeChoice( + userId: number, + choice: UserAiContextSizeChoice, +): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> { + const settings = await ensureValidUserAiSettings(userId); + const normalized = normalizeAiContextSizeChoice(choice); + + if (!normalized && normalized !== -1) { + return {ok: false, settings}; + } + + await UserStore.updateSettings(userId, { + aiContextSize: contextSizeChoiceToStored(normalized), + }); + + return {ok: true, settings: await ensureValidUserAiSettings(userId)}; +} + +export async function setUserAiVoiceMode( + userId: number, + mode: UserAiVoiceMode, +): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> { + const settings = await ensureValidUserAiSettings(userId); + + if (!getUserAiVoiceModes().includes(mode)) { + return {ok: false, settings}; + } + + await UserStore.updateSettings(userId, { + aiVoiceMode: mode, + }); + + return {ok: true, settings: await ensureValidUserAiSettings(userId)}; +} + +export async function setUserAiImageOutputMode( + userId: number, + mode: UserAiImageOutputMode, +): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> { + const settings = await ensureValidUserAiSettings(userId); + + if (!getUserAiImageOutputModes().includes(mode)) { + return {ok: false, settings}; + } + + await UserStore.updateSettings(userId, { + aiImageOutputMode: mode, + }); + + return {ok: true, settings: await ensureValidUserAiSettings(userId)}; +} + +export async function setUserAiResponseLanguage( + userId: number, + language: UserAiResponseLanguage, +): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> { + const settings = await ensureValidUserAiSettings(userId); + + if (!Localization.isKnownLanguageChoice(language)) { + return {ok: false, settings}; + } + + await UserStore.updateSettings(userId, { + interfaceLanguage: settings.interfaceLanguage, + aiProvider: settings.providerChoice, + aiResponseLanguage: language, + }); + + return {ok: true, settings: await ensureValidUserAiSettings(userId)}; +} + +export async function setUserInterfaceLanguage( + userId: number, + language: UserInterfaceLanguage, +): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> { + const settings = await ensureValidUserAiSettings(userId); + + if (!Localization.isKnownLanguageChoice(language)) { + return {ok: false, settings}; + } + + await UserStore.updateSettings(userId, { + interfaceLanguage: language, + aiProvider: settings.providerChoice, + aiResponseLanguage: settings.responseLanguage, + }); + + return {ok: true, settings: await ensureValidUserAiSettings(userId)}; +} diff --git a/src/common/user-settings-view.ts b/src/common/user-settings-view.ts new file mode 100644 index 0000000..055c812 --- /dev/null +++ b/src/common/user-settings-view.ts @@ -0,0 +1,218 @@ +import {InlineKeyboardMarkup} from "typescript-telegram-bot-api"; +import {Environment} from "./environment"; +import { + DEFAULT_AI_PROVIDER_CHOICE, + EffectiveUserAiSettings, + getContextSizeLabel, + getInterfaceLanguageLabel, + getProviderChoiceLabel, + getResponseLanguageLabel, + getImageOutputModeLabel, + getVoiceModeLabel, + getUserLanguageChoices, + UserAiContextSizeChoice, + UserAiImageOutputMode, + UserAiProviderChoice, + UserAiResponseLanguage, + UserAiVoiceMode, + UserInterfaceLanguage, +} from "./user-ai-settings"; + +export const USER_SETTINGS_CALLBACK_PREFIX = "/settings"; + +export type UserSettingsScreen = "main" | "provider" | "interfaceLanguage" | "responseLanguage" | "contextSize" | "voiceMode" | "imageOutput"; + +function tierLabel(tier: EffectiveUserAiSettings["tier"]): string { + const labels: Record = { + creator: Environment.userSettingsCreatorTierText, + admin: Environment.userSettingsAdminTierText, + user: Environment.userSettingsUserTierText, + }; + + return labels[tier]; +} + +function callbackData(settings: EffectiveUserAiSettings, screen: UserSettingsScreen, value?: string): string { + return [USER_SETTINGS_CALLBACK_PREFIX, String(settings.userId), screen, value].filter(Boolean).join(" "); +} + +function selectedText(selected: boolean, text: string): string { + return selected ? Environment.getUserSettingsSelectedText(text) : text; +} + +function currentProviderText(settings: EffectiveUserAiSettings): string { + if (settings.providerChoice !== DEFAULT_AI_PROVIDER_CHOICE) { + return getProviderChoiceLabel(settings.providerChoice); + } + + return `${getProviderChoiceLabel(DEFAULT_AI_PROVIDER_CHOICE)} (${getProviderChoiceLabel(Environment.DEFAULT_AI_PROVIDER)})`; +} + +export function formatUserSettingsText(settings: EffectiveUserAiSettings, screen: UserSettingsScreen = "main"): string { + const title = Environment.getUserSettingsTitle(screen); + + return [ + title, + "", + Environment.getUserSettingsFieldText(Environment.userSettingsTierLabel, tierLabel(settings.tier)), + Environment.getUserSettingsFieldText(Environment.userSettingsAiProviderLabel, currentProviderText(settings)), + Environment.getUserSettingsFieldText(Environment.userSettingsInterfaceLanguageLabel, getInterfaceLanguageLabel(settings.interfaceLanguage)), + Environment.getUserSettingsFieldText(Environment.userSettingsResponseLanguageLabel, getResponseLanguageLabel(settings.responseLanguage)), + Environment.getUserSettingsFieldText(Environment.userSettingsContextSizeLabel, getContextSizeLabel(settings.contextSizeChoice)), + Environment.getUserSettingsFieldText(Environment.userSettingsVoiceModeLabel, getVoiceModeLabel(settings.voiceMode)), + Environment.getUserSettingsFieldText(Environment.userSettingsImageOutputLabel, getImageOutputModeLabel(settings.imageOutputMode)), + ].join("\n"); +} + +export function buildUserSettingsKeyboard(settings: EffectiveUserAiSettings, screen: UserSettingsScreen = "main"): InlineKeyboardMarkup { + if (screen === "provider") { + return { + inline_keyboard: [ + ...settings.availableProviderChoices.map(choice => { + const text = choice === DEFAULT_AI_PROVIDER_CHOICE + ? currentProviderText({...settings, providerChoice: DEFAULT_AI_PROVIDER_CHOICE}) + : getProviderChoiceLabel(choice); + + return [{ + text: selectedText(settings.providerChoice === choice, text), + callback_data: callbackData(settings, "provider", choice), + }]; + }), + [{text: Environment.userSettingsBackButtonText, callback_data: callbackData(settings, "main")}], + ], + }; + } + + if (screen === "interfaceLanguage") { + return { + inline_keyboard: [ + ...getUserLanguageChoices().map(language => [{ + text: selectedText(settings.interfaceLanguage === language, getInterfaceLanguageLabel(language)), + callback_data: callbackData(settings, "interfaceLanguage", language), + }]), + [{text: Environment.userSettingsBackButtonText, callback_data: callbackData(settings, "main")}], + ], + }; + } + + if (screen === "responseLanguage") { + return { + inline_keyboard: [ + ...getUserLanguageChoices().map(language => [{ + text: selectedText(settings.responseLanguage === language, getResponseLanguageLabel(language)), + callback_data: callbackData(settings, "responseLanguage", language), + }]), + [{text: Environment.userSettingsBackButtonText, callback_data: callbackData(settings, "main")}], + ], + }; + } + + if (screen === "contextSize") { + return { + inline_keyboard: [ + ...settings.availableContextSizeChoices.map(choice => [{ + text: selectedText(settings.contextSizeChoice === choice, getContextSizeLabel(choice)), + callback_data: callbackData(settings, "contextSize", String(choice)), + }]), + [{text: Environment.userSettingsBackButtonText, callback_data: callbackData(settings, "main")}], + ], + }; + } + + if (screen === "voiceMode") { + return { + inline_keyboard: [ + ...settings.availableVoiceModes.map(mode => [{ + text: selectedText(settings.voiceMode === mode, getVoiceModeLabel(mode)), + callback_data: callbackData(settings, "voiceMode", mode), + }]), + [{text: Environment.userSettingsBackButtonText, callback_data: callbackData(settings, "main")}], + ], + }; + } + + if (screen === "imageOutput") { + return { + inline_keyboard: [ + ...settings.availableImageOutputModes.map(mode => [{ + text: selectedText(settings.imageOutputMode === mode, getImageOutputModeLabel(mode)), + callback_data: callbackData(settings, "imageOutput", mode), + }]), + [{text: Environment.userSettingsBackButtonText, callback_data: callbackData(settings, "main")}], + ], + }; + } + + return { + inline_keyboard: [ + [{ + text: Environment.getUserSettingsFieldText(Environment.userSettingsAiProviderButtonPrefix, currentProviderText(settings)), + callback_data: callbackData(settings, "provider") + }], + [{ + text: Environment.getUserSettingsFieldText(Environment.userSettingsInterfaceLanguageButtonPrefix, getInterfaceLanguageLabel(settings.interfaceLanguage)), + callback_data: callbackData(settings, "interfaceLanguage") + }], + [{ + text: Environment.getUserSettingsFieldText(Environment.userSettingsResponseLanguageButtonPrefix, getResponseLanguageLabel(settings.responseLanguage)), + callback_data: callbackData(settings, "responseLanguage") + }], + [{ + text: Environment.getUserSettingsFieldText(Environment.userSettingsContextSizeButtonPrefix, getContextSizeLabel(settings.contextSizeChoice)), + callback_data: callbackData(settings, "contextSize") + }], + [{ + text: Environment.getUserSettingsFieldText(Environment.userSettingsVoiceModeButtonPrefix, getVoiceModeLabel(settings.voiceMode)), + callback_data: callbackData(settings, "voiceMode") + }], + [{ + text: Environment.getUserSettingsFieldText(Environment.userSettingsImageOutputButtonPrefix, getImageOutputModeLabel(settings.imageOutputMode)), + callback_data: callbackData(settings, "imageOutput") + }], + ], + }; +} + +export function parseUserSettingsCallbackData(data: string | undefined): { + userId: number; + screen: UserSettingsScreen; + providerChoice?: UserAiProviderChoice; + interfaceLanguage?: UserInterfaceLanguage; + responseLanguage?: UserAiResponseLanguage; + contextSizeChoice?: UserAiContextSizeChoice | string; + voiceMode?: UserAiVoiceMode; + imageOutputMode?: UserAiImageOutputMode; +} | null { + if (!data?.startsWith(USER_SETTINGS_CALLBACK_PREFIX)) return null; + + const [, userIdValue, screenValue, value] = data.split(" "); + const userId = Number(userIdValue); + const screen = (screenValue === "language" ? "responseLanguage" : screenValue || "main") as UserSettingsScreen; + + if (!Number.isSafeInteger(userId)) { + return null; + } + + if ( + screen !== "main" + && screen !== "provider" + && screen !== "interfaceLanguage" + && screen !== "responseLanguage" + && screen !== "contextSize" + && screen !== "voiceMode" + && screen !== "imageOutput" + ) { + return null; + } + + return { + userId, + screen, + providerChoice: screen === "provider" ? value as UserAiProviderChoice | undefined : undefined, + interfaceLanguage: screen === "interfaceLanguage" ? value as UserInterfaceLanguage | undefined : undefined, + responseLanguage: screen === "responseLanguage" ? value as UserAiResponseLanguage | undefined : undefined, + contextSizeChoice: screen === "contextSize" ? value as UserAiContextSizeChoice | string | undefined : undefined, + voiceMode: screen === "voiceMode" ? value as UserAiVoiceMode | undefined : undefined, + imageOutputMode: screen === "imageOutput" ? value as UserAiImageOutputMode | undefined : undefined, + }; +} diff --git a/src/common/user-store.ts b/src/common/user-store.ts index 1689252..15d74ce 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -1,6 +1,9 @@ import {User} from "typescript-telegram-bot-api"; import {userDao} from "../index"; import {StoredUser} from "../model/stored-user"; +import {getLruMapValue, setLruMapValue} from "../util/lru-map"; + +const USER_CACHE_MAX_ENTRIES = 5_000; export class UserStore { private static map = new Map(); @@ -10,6 +13,7 @@ export class UserStore { } static async put(u: User): Promise { + const current = getLruMapValue(this.map, u.id); const user: StoredUser = { id: u.id, isBot: u.is_bot, @@ -17,23 +21,40 @@ export class UserStore { lastName: u.last_name, userName: u.username, isPremium: u.is_premium, + langCode: u.language_code, + interfaceLanguage: current?.interfaceLanguage, + aiProvider: current?.aiProvider, + aiResponseLanguage: current?.aiResponseLanguage, + aiContextSize: current?.aiContextSize, + aiVoiceMode: current?.aiVoiceMode, + aiImageOutputMode: current?.aiImageOutputMode, }; - this.map.set(u.id, user); + setLruMapValue(this.map, u.id, user, USER_CACHE_MAX_ENTRIES); await userDao.insert(userDao.mapTo([u])); return user; } + static async updateSettings( + id: number, + settings: Partial> + ): Promise { + await userDao.updateSettings(id, settings); + const user = await userDao.getById({id}); + if (user) setLruMapValue(this.map, id, user, USER_CACHE_MAX_ENTRIES); + return user; + } + static async get(id: number): Promise { const user = await userDao.getById({id: id}); if (!user) return null; - this.map.set(id, user); + setLruMapValue(this.map, id, user, USER_CACHE_MAX_ENTRIES); return user; } static clear() { this.map.clear(); } -} \ No newline at end of file +} diff --git a/src/db/database-manager.ts b/src/db/database-manager.ts index ed6209a..e756159 100644 --- a/src/db/database-manager.ts +++ b/src/db/database-manager.ts @@ -1,17 +1,2213 @@ import "dotenv/config"; -import {drizzle, LibSQLDatabase} from "drizzle-orm/libsql"; +import {createClient, type Client as LibSqlClient} from "@libsql/client"; +import {createHash} from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import {Pool, type QueryResultRow} from "pg"; +import {deflateRawSync} from "node:zlib"; import {Environment} from "../common/environment"; +import {appLogger} from "../logging/logger"; import {logError} from "../util/utils"; +import type {BoundaryValue} from "../common/boundary-types"; +import type {AiRequestDbRow, ArtifactDbRow, AttachmentDbRow, MessageDbRow, RequestAuditDbRow, UserDbRow} from "./db-types"; +import type {StoredAttachment} from "../model/stored-attachment"; +import type {StoredMessage} from "../model/stored-message"; +import type {StoredUser} from "../model/stored-user"; +import {createStoredImageAttachment, uniqueStoredAttachments} from "../common/stored-attachment-utils"; +import type {StoredAiRequest} from "../model/stored-ai-request"; + +export type DatabaseKind = "sqlite" | "postgres"; + +type DatabaseBackend = + | { + kind: "sqlite"; + client: LibSqlClient; +} + | { + kind: "postgres"; + pool: Pool; +}; + +type DbColumnDefinition = { + name: string; + sql: string; +}; + +type ZipEntryInput = { + fileName: string; + content: Buffer; +}; + +type DbValue = string | number | boolean | bigint | null | undefined; + +export type DatabaseBackupArtifact = { + filePath: string; + fileName: string; + contentType: string; + cleanup: () => Promise; +}; + +export type DatabaseBackupPayload = { + schemaVersion: number; + createdAt: string; + database: { + kind: DatabaseKind; + summary: string; + }; + users: StoredUser[]; + messages: StoredMessage[]; + attachments?: AttachmentDbRow[]; + artifacts?: ArtifactDbRow[]; + requestAudits?: RequestAuditDbRow[]; + aiRequests?: StoredAiRequest[]; +}; + +export type UserSettingsUpdate = Partial>; + +const USER_COLUMNS: readonly string[] = [ + "id", + "isBot", + "firstName", + "lastName", + "userName", + "isPremium", + "langCode", + "interfaceLanguage", + "aiProvider", + "aiResponseLanguage", + "aiContextSize", + "aiVoiceMode", + "aiImageOutputMode", +]; + +const MESSAGE_COLUMNS: readonly string[] = [ + "id", + "chatId", + "replyToMessageId", + "fromId", + "text", + "quoteText", + "date", + "deletedByBotAt", + "attachments", + "pipelineAudit", +]; + +const ATTACHMENT_COLUMNS: readonly string[] = [ + "id", + "messageChatId", + "messageId", + "direction", + "scope", + "kind", + "artifactKind", + "fileId", + "fileUniqueId", + "fileName", + "mimeType", + "cachePath", + "sizeBytes", + "sha256", + "metadata", + "createdAt", +]; + +const ARTIFACT_COLUMNS: readonly string[] = [ + "id", + "requestId", + "messageChatId", + "messageId", + "kind", + "stage", + "attachmentId", + "payload", + "createdAt", +]; + +const REQUEST_AUDIT_COLUMNS: readonly string[] = [ + "id", + "requestId", + "messageChatId", + "messageId", + "stage", + "status", + "startedAt", + "finishedAt", + "durationMs", + "provider", + "model", + "details", + "error", +]; + +const AI_REQUEST_COLUMNS: readonly string[] = [ + "requestId", + "chatId", + "messageId", + "responseMessageId", + "fromId", + "provider", + "model", + "status", + "startedAt", + "finishedAt", + "error", +]; + +const SCHEMA_VERSION = 7; +const SCHEMA_META_KEY = "database_schema_version"; + +type LegacyMessageDbRow = MessageDbRow & { photoMaxSizeFilePath: string | null }; export class DatabaseManager { + private static readonly logger = appLogger.child("database"); + private static readonly CRC32_TABLE = (() => { + const table = new Uint32Array(256); - static db: LibSQLDatabase; + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) { + c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + } + table[i] = c >>> 0; + } - static init() { + return table; + })(); + + static backend: DatabaseBackend; + static kind: DatabaseKind = "sqlite"; + static ready: Promise = Promise.resolve(); + + static init(): void { + const startedAt = Date.now(); + const databaseUrl = Environment.DB_PATH; + + DatabaseManager.logger.info("startup.init.start", { + database: Environment.databaseSummaryText, + }); + + if (/^postgres(?:ql)?:\/\//i.test(databaseUrl)) { + const pool = new Pool({ + connectionString: databaseUrl, + max: 10, + }); + + DatabaseManager.backend = { + kind: "postgres", + pool, + }; + DatabaseManager.kind = "postgres"; + } else { + const sqliteFilePath = Environment.DB_FILE_PATH ?? databaseUrl.replace(/^file:/i, ""); + const sqliteDir = path.dirname(sqliteFilePath); + if (sqliteDir && !fs.existsSync(sqliteDir)) { + fs.mkdirSync(sqliteDir, {recursive: true}); + } + + const client = createClient({url: databaseUrl}); + + DatabaseManager.backend = { + kind: "sqlite", + client, + }; + DatabaseManager.kind = "sqlite"; + } + + DatabaseManager.logger.success("startup.init.done", { + kind: DatabaseManager.kind, + duration: `${Date.now() - startedAt}ms`, + }); + + DatabaseManager.ready = DatabaseManager.ensureSchema().catch(error => { + logError(error); + throw error; + }); + } + + static async close(): Promise { + await DatabaseManager.ready; + + if (DatabaseManager.backend.kind === "postgres") { + await DatabaseManager.backend.pool.end(); + return; + } + + DatabaseManager.backend.client.close(); + } + + static async exportBackupArtifact(): Promise { + await DatabaseManager.ready; + + const backup = await DatabaseManager.buildBackupPayload(); + const stamp = DatabaseManager.makeBackupStamp(); + const entries: ZipEntryInput[] = [ + { + fileName: "database.json", + content: Buffer.from(JSON.stringify(backup, null, 2), "utf8"), + }, + { + fileName: "database.sql", + content: Buffer.from(DatabaseManager.buildSqlDump(backup), "utf8"), + }, + ]; + + if (DatabaseManager.kind === "sqlite" && Environment.DB_FILE_PATH && fs.existsSync(Environment.DB_FILE_PATH)) { + entries.push({ + fileName: path.basename(Environment.DB_FILE_PATH), + content: await fsp.readFile(Environment.DB_FILE_PATH), + }); + } + + const zipBuffer = DatabaseManager.buildZip(entries); + return await DatabaseManager.writeTempArtifact( + `database-backup-${stamp}.zip`, + zipBuffer, + "application/zip", + ); + } + + static async exportBackupArtifacts(): Promise { + return [await DatabaseManager.exportBackupArtifact()]; + } + + static async importBackupFromJsonPayload(payload: DatabaseBackupPayload): Promise<{ users: number; messages: number }> { + await DatabaseManager.ready; + + if ( + payload.schemaVersion !== 1 + && payload.schemaVersion !== 2 + && payload.schemaVersion !== 3 + && payload.schemaVersion !== 4 + && payload.schemaVersion !== 5 + && payload.schemaVersion !== 6 + && payload.schemaVersion !== 7 + ) { + throw new Error(`Unsupported backup schema version: ${payload.schemaVersion}`); + } + + if (!Array.isArray(payload.users) || !Array.isArray(payload.messages)) { + throw new Error("Invalid backup payload structure"); + } + + const users = payload.users.map(DatabaseManager.normalizeImportedUser); + const messages = payload.messages.map(DatabaseManager.normalizeImportedMessage); + const attachments = Array.isArray(payload.attachments) ? payload.attachments.map(DatabaseManager.normalizeImportedAttachment) : []; + const artifacts = Array.isArray(payload.artifacts) ? payload.artifacts.map(DatabaseManager.normalizeImportedArtifact) : []; + const requestAudits = Array.isArray(payload.requestAudits) ? payload.requestAudits.map(DatabaseManager.normalizeImportedRequestAudit) : []; + const aiRequests = (payload.aiRequests ?? []).map(DatabaseManager.normalizeImportedAiRequest); + const persistDerivedTables = !attachments.length && !artifacts.length && !requestAudits.length; + + await DatabaseManager.transaction(async tx => { + await tx.execute("DELETE FROM \"request_audit\""); + await tx.execute("DELETE FROM \"artifacts\""); + await tx.execute("DELETE FROM \"attachments\""); + await tx.execute("DELETE FROM \"ai_requests\""); + await tx.execute("DELETE FROM \"messages\""); + await tx.execute("DELETE FROM \"users\""); + + if (users.length) { + const {query, params} = DatabaseManager.buildBulkUpsertQuery( + "users", + USER_COLUMNS, + ["id"], + users, + ["isBot", "firstName", "lastName", "userName", "isPremium", "langCode"], + ); + await tx.execute(query, params); + } + + if (messages.length) { + const {query, params} = DatabaseManager.buildBulkUpsertQuery("messages", MESSAGE_COLUMNS, ["chatId", "id"], messages); + await tx.execute(query, params); + } + + if (attachments.length) { + const {query, params} = DatabaseManager.buildBulkUpsertQuery("attachments", ATTACHMENT_COLUMNS, ["id"], attachments); + await tx.execute(query, params); + } + + if (artifacts.length) { + const {query, params} = DatabaseManager.buildBulkUpsertQuery("artifacts", ARTIFACT_COLUMNS, ["id"], artifacts); + await tx.execute(query, params); + } + + if (requestAudits.length) { + const {query, params} = DatabaseManager.buildBulkUpsertQuery("request_audit", REQUEST_AUDIT_COLUMNS, ["id"], requestAudits); + await tx.execute(query, params); + } + + if (aiRequests.length) { + const {query, params} = DatabaseManager.buildBulkUpsertQuery("ai_requests", AI_REQUEST_COLUMNS, ["requestId"], aiRequests); + await tx.execute(query, params); + } + + if (persistDerivedTables) { + const derivedAttachments = messages.flatMap(message => DatabaseManager.attachmentRowsFromMessageRow(message)); + const derivedArtifacts = messages.flatMap(message => DatabaseManager.artifactRowsFromMessageRow(message)); + const derivedRequestAudits = messages.flatMap(message => DatabaseManager.requestAuditRowsFromMessageRow(message)); + + if (derivedAttachments.length) { + const {query, params} = DatabaseManager.buildBulkUpsertQuery("attachments", ATTACHMENT_COLUMNS, ["id"], derivedAttachments); + await tx.execute(query, params); + } + + if (derivedArtifacts.length) { + const {query, params} = DatabaseManager.buildBulkUpsertQuery("artifacts", ARTIFACT_COLUMNS, ["id"], derivedArtifacts); + await tx.execute(query, params); + } + + if (derivedRequestAudits.length) { + const {query, params} = DatabaseManager.buildBulkUpsertQuery("request_audit", REQUEST_AUDIT_COLUMNS, ["id"], derivedRequestAudits); + await tx.execute(query, params); + } + } + }); + + return { + users: users.length, + messages: messages.length, + }; + } + + static async getAllUsers(): Promise { + await DatabaseManager.ready; + return DatabaseManager.query(` + SELECT + "id", + "isBot", + "firstName", + "lastName", + "userName", + "isPremium", + "langCode", + "interfaceLanguage", + "aiProvider", + "aiResponseLanguage", + "aiContextSize", + "aiVoiceMode", + "aiImageOutputMode" + FROM "users" + ORDER BY "id" + `); + } + + static async getUserById(id: number): Promise { + await DatabaseManager.ready; + const rows = await DatabaseManager.query(` + SELECT + "id", + "isBot", + "firstName", + "lastName", + "userName", + "isPremium", + "langCode", + "interfaceLanguage", + "aiProvider", + "aiResponseLanguage", + "aiContextSize", + "aiVoiceMode", + "aiImageOutputMode" + FROM "users" + WHERE "id" = ${DatabaseManager.placeholder(1)} + LIMIT 1 + `, [id]); + + return rows[0] ?? null; + } + + static async getUsersByIds(ids: number[]): Promise { + await DatabaseManager.ready; + if (!ids.length) return []; + + const {query, params} = DatabaseManager.buildInQuery(` + SELECT + "id", + "isBot", + "firstName", + "lastName", + "userName", + "isPremium", + "langCode", + "interfaceLanguage", + "aiProvider", + "aiResponseLanguage", + "aiContextSize", + "aiVoiceMode", + "aiImageOutputMode" + FROM "users" + WHERE "id" IN (__IN__) + ORDER BY "id" + `, ids); + + return DatabaseManager.query(query, params); + } + + static async upsertUsers(rows: UserDbRow[]): Promise { + await DatabaseManager.ready; + if (!rows.length) return; + + const {query, params} = DatabaseManager.buildBulkUpsertQuery( + "users", + USER_COLUMNS, + ["id"], + rows, + ["isBot", "firstName", "lastName", "userName", "isPremium", "langCode"], + ); + await DatabaseManager.execute(query, params); + } + + static async updateUserSettings(id: number, settings: UserSettingsUpdate): Promise { + await DatabaseManager.ready; + + const entries = Object.entries(settings).filter(([, value]) => value !== undefined); + if (!entries.length) return; + + const assignments: string[] = []; + const params: DbValue[] = []; + let index = 1; + + for (const [column, value] of entries) { + assignments.push(`"${column}" = ${DatabaseManager.placeholder(index++)}`); + params.push(DatabaseManager.normalizeValue(value)); + } + + params.push(id); + const query = ` + UPDATE "users" + SET ${assignments.join(", ")} + WHERE "id" = ${DatabaseManager.placeholder(index)} + `; + + await DatabaseManager.execute(query, params); + } + + static async getAllMessages(): Promise { + await DatabaseManager.ready; + return DatabaseManager.query(` + SELECT + "id", + "chatId", + "replyToMessageId", + "fromId", + "text", + "quoteText", + "date", + "deletedByBotAt", + "attachments", + "pipelineAudit" + FROM "messages" + ORDER BY "chatId", "id" + `); + } + + static async getMessageById(chatId: number, id: number): Promise { + await DatabaseManager.ready; + const rows = await DatabaseManager.query(` + SELECT + "id", + "chatId", + "replyToMessageId", + "fromId", + "text", + "quoteText", + "date", + "deletedByBotAt", + "attachments", + "pipelineAudit" + FROM "messages" + WHERE "chatId" = ${DatabaseManager.placeholder(1)} + AND "id" = ${DatabaseManager.placeholder(2)} + LIMIT 1 + `, [chatId, id]); + + return rows[0] ?? null; + } + + static async getMessagesByIds(chatId: number, ids: number[]): Promise { + await DatabaseManager.ready; + if (!ids.length) return []; + + const {query, params} = DatabaseManager.buildInQuery(` + SELECT + "id", + "chatId", + "replyToMessageId", + "fromId", + "text", + "quoteText", + "date", + "deletedByBotAt", + "attachments", + "pipelineAudit" + FROM "messages" + WHERE "chatId" = ${DatabaseManager.placeholder(1)} + AND "id" IN (__IN__) + ORDER BY "id" + `, [chatId, ...ids], 2); + + return DatabaseManager.query(query, params); + } + + static async upsertMessages(rows: MessageDbRow[], options?: {persistDerivedTables?: boolean}): Promise { + await DatabaseManager.ready; + if (!rows.length) return; + + const persistDerivedTables = options?.persistDerivedTables !== false; + + await DatabaseManager.transaction(async tx => { + const {query, params} = DatabaseManager.buildBulkUpsertQuery("messages", MESSAGE_COLUMNS, ["chatId", "id"], rows); + await tx.execute(query, params); + + if (!persistDerivedTables) return; + + const uniqueMessageKeys = new Set(rows.map(row => `${row.chatId}:${row.id}`)); + for (const key of uniqueMessageKeys) { + const [chatId, messageId] = key.split(":").map(value => Number(value)); + await tx.execute(`DELETE FROM "attachments" WHERE "messageChatId" = ${DatabaseManager.placeholder(1)} AND "messageId" = ${DatabaseManager.placeholder(2)}`, [chatId, messageId]); + await tx.execute(`DELETE FROM "artifacts" WHERE "messageChatId" = ${DatabaseManager.placeholder(1)} AND "messageId" = ${DatabaseManager.placeholder(2)}`, [chatId, messageId]); + await tx.execute(`DELETE FROM "request_audit" WHERE "messageChatId" = ${DatabaseManager.placeholder(1)} AND "messageId" = ${DatabaseManager.placeholder(2)}`, [chatId, messageId]); + } + + const attachments = rows.flatMap(message => DatabaseManager.attachmentRowsFromMessageRow(message)); + const artifacts = rows.flatMap(message => DatabaseManager.artifactRowsFromMessageRow(message)); + const requestAudits = rows.flatMap(message => DatabaseManager.requestAuditRowsFromMessageRow(message)); + + if (attachments.length) { + const result = DatabaseManager.buildBulkUpsertQuery("attachments", ATTACHMENT_COLUMNS, ["id"], attachments); + await tx.execute(result.query, result.params); + } + + if (artifacts.length) { + const result = DatabaseManager.buildBulkUpsertQuery("artifacts", ARTIFACT_COLUMNS, ["id"], artifacts); + await tx.execute(result.query, result.params); + } + + if (requestAudits.length) { + const result = DatabaseManager.buildBulkUpsertQuery("request_audit", REQUEST_AUDIT_COLUMNS, ["id"], requestAudits); + await tx.execute(result.query, result.params); + } + }); + } + + static async getAllAiRequests(): Promise { + await DatabaseManager.ready; + return DatabaseManager.query(` + SELECT + "requestId", + "chatId", + "messageId", + "responseMessageId", + "fromId", + "provider", + "model", + "status", + "startedAt", + "finishedAt", + "error" + FROM "ai_requests" + ORDER BY "startedAt" + `); + } + + static async getAiRequestByMessage(chatId: number, messageId: number): Promise { + await DatabaseManager.ready; + const rows = await DatabaseManager.query(` + SELECT + "requestId", + "chatId", + "messageId", + "responseMessageId", + "fromId", + "provider", + "model", + "status", + "startedAt", + "finishedAt", + "error" + FROM "ai_requests" + WHERE "chatId" = ${DatabaseManager.placeholder(1)} + AND "messageId" = ${DatabaseManager.placeholder(2)} + ORDER BY "startedAt" DESC + LIMIT 1 + `, [chatId, messageId]); + + return rows[0] ?? null; + } + + static async upsertAiRequests(rows: AiRequestDbRow[]): Promise { + await DatabaseManager.ready; + if (!rows.length) return; + + const {query, params} = DatabaseManager.buildBulkUpsertQuery("ai_requests", AI_REQUEST_COLUMNS, ["requestId"], rows); + await DatabaseManager.execute(query, params); + } + + static async getAllAttachments(): Promise { + await DatabaseManager.ready; + return DatabaseManager.query(` + SELECT + "id", + "messageChatId", + "messageId", + "direction", + "scope", + "kind", + "artifactKind", + "fileId", + "fileUniqueId", + "fileName", + "mimeType", + "cachePath", + "sizeBytes", + "sha256", + "metadata", + "createdAt" + FROM "attachments" + ORDER BY "messageChatId", "messageId", "createdAt", "id" + `); + } + + static async getAttachmentsByMessage(chatId: number, messageId: number): Promise { + await DatabaseManager.ready; + return DatabaseManager.query(` + SELECT + "id", + "messageChatId", + "messageId", + "direction", + "scope", + "kind", + "artifactKind", + "fileId", + "fileUniqueId", + "fileName", + "mimeType", + "cachePath", + "sizeBytes", + "sha256", + "metadata", + "createdAt" + FROM "attachments" + WHERE "messageChatId" = ${DatabaseManager.placeholder(1)} + AND "messageId" = ${DatabaseManager.placeholder(2)} + ORDER BY "createdAt", "id" + `, [chatId, messageId]); + } + + static async getAllArtifacts(): Promise { + await DatabaseManager.ready; + return DatabaseManager.query(` + SELECT + "id", + "requestId", + "messageChatId", + "messageId", + "kind", + "stage", + "attachmentId", + "payload", + "createdAt" + FROM "artifacts" + ORDER BY "messageChatId", "messageId", "createdAt", "id" + `); + } + + static async getArtifactsByRequestId(requestId: string): Promise { + await DatabaseManager.ready; + return DatabaseManager.query(` + SELECT + "id", + "requestId", + "messageChatId", + "messageId", + "kind", + "stage", + "attachmentId", + "payload", + "createdAt" + FROM "artifacts" + WHERE "requestId" = ${DatabaseManager.placeholder(1)} + ORDER BY "createdAt", "id" + `, [requestId]); + } + + static async getArtifactsByMessage(chatId: number, messageId: number): Promise { + await DatabaseManager.ready; + return DatabaseManager.query(` + SELECT + "id", + "requestId", + "messageChatId", + "messageId", + "kind", + "stage", + "attachmentId", + "payload", + "createdAt" + FROM "artifacts" + WHERE "messageChatId" = ${DatabaseManager.placeholder(1)} + AND "messageId" = ${DatabaseManager.placeholder(2)} + ORDER BY "createdAt", "id" + `, [chatId, messageId]); + } + + static async getAllRequestAudits(): Promise { + await DatabaseManager.ready; + return DatabaseManager.query(` + SELECT + "id", + "requestId", + "messageChatId", + "messageId", + "stage", + "status", + "startedAt", + "finishedAt", + "durationMs", + "provider", + "model", + "details", + "error" + FROM "request_audit" + ORDER BY "messageChatId", "messageId", "startedAt", "id" + `); + } + + static async getRequestAuditsByMessage(chatId: number, messageId: number): Promise { + await DatabaseManager.ready; + return DatabaseManager.query(` + SELECT + "id", + "requestId", + "messageChatId", + "messageId", + "stage", + "status", + "startedAt", + "finishedAt", + "durationMs", + "provider", + "model", + "details", + "error" + FROM "request_audit" + WHERE "messageChatId" = ${DatabaseManager.placeholder(1)} + AND "messageId" = ${DatabaseManager.placeholder(2)} + ORDER BY "startedAt", "id" + `, [chatId, messageId]); + } + + static async upsertAttachments(rows: AttachmentDbRow[]): Promise { + await DatabaseManager.ready; + if (!rows.length) return; + + const {query, params} = DatabaseManager.buildBulkUpsertQuery("attachments", ATTACHMENT_COLUMNS, ["id"], rows); + await DatabaseManager.execute(query, params); + } + + static async upsertArtifacts(rows: ArtifactDbRow[]): Promise { + await DatabaseManager.ready; + if (!rows.length) return; + + const {query, params} = DatabaseManager.buildBulkUpsertQuery("artifacts", ARTIFACT_COLUMNS, ["id"], rows); + await DatabaseManager.execute(query, params); + } + + static async upsertRequestAudits(rows: RequestAuditDbRow[]): Promise { + await DatabaseManager.ready; + if (!rows.length) return; + + const {query, params} = DatabaseManager.buildBulkUpsertQuery("request_audit", REQUEST_AUDIT_COLUMNS, ["id"], rows); + await DatabaseManager.execute(query, params); + } + + private static async ensureSchema(): Promise { + const startedAt = Date.now(); + DatabaseManager.logger.info("startup.schema.start", { + kind: DatabaseManager.kind, + }); + + const measure = async (step: string, task: () => Promise): Promise => { + const stepStartedAt = Date.now(); + DatabaseManager.logger.debug("startup.schema.step.start", { + kind: DatabaseManager.kind, + step, + }); + try { + const result = await task(); + DatabaseManager.logger.debug("startup.schema.step.done", { + kind: DatabaseManager.kind, + step, + duration: `${Date.now() - stepStartedAt}ms`, + }); + return result; + } catch (error) { + DatabaseManager.logger.error("startup.schema.step.failed", { + kind: DatabaseManager.kind, + step, + duration: `${Date.now() - stepStartedAt}ms`, + error: error instanceof Error ? error : String(error), + }); + throw error; + } + }; + + const currentVersion = await measure("getSchemaVersion", () => DatabaseManager.getSchemaVersion()); + if (currentVersion === SCHEMA_VERSION) { + DatabaseManager.logger.success("startup.schema.done", { + kind: DatabaseManager.kind, + schemaVersion: currentVersion, + duration: `${Date.now() - startedAt}ms`, + migrated: false, + }); + return; + } + + DatabaseManager.logger.warn("startup.schema.migrate", { + kind: DatabaseManager.kind, + currentVersion: currentVersion ?? 0, + targetVersion: SCHEMA_VERSION, + }); + + await measure("ensureUsersTable", () => DatabaseManager.ensureUsersTable()); + await measure("ensureMessagesTable", () => DatabaseManager.ensureMessagesTable()); + await measure("ensureAttachmentsTable", () => DatabaseManager.ensureAttachmentsTable()); + await measure("ensureArtifactsTable", () => DatabaseManager.ensureArtifactsTable()); + await measure("ensureRequestAuditTable", () => DatabaseManager.ensureRequestAuditTable()); + await measure("ensureAiRequestsTable", () => DatabaseManager.ensureAiRequestsTable()); + await measure("migrateLegacyMessagePhotoColumn", () => DatabaseManager.migrateLegacyMessagePhotoColumn()); + await measure("migrateLegacyNormalizedTables", () => DatabaseManager.migrateLegacyNormalizedTables()); + await measure("setSchemaVersion", () => DatabaseManager.setSchemaVersion(SCHEMA_VERSION)); + + DatabaseManager.logger.success("startup.schema.done", { + kind: DatabaseManager.kind, + schemaVersion: SCHEMA_VERSION, + duration: `${Date.now() - startedAt}ms`, + migrated: true, + }); + } + + private static async buildBackupPayload(): Promise { + const [users, messages, attachments, artifacts, requestAudits, aiRequests] = await Promise.all([ + DatabaseManager.getAllUsers(), + DatabaseManager.getAllMessages(), + DatabaseManager.getAllAttachments().catch(() => []), + DatabaseManager.getAllArtifacts().catch(() => []), + DatabaseManager.getAllRequestAudits().catch(() => []), + DatabaseManager.getAllAiRequests().catch(() => []), + ]); + + return { + schemaVersion: SCHEMA_VERSION, + createdAt: new Date().toISOString(), + database: { + kind: DatabaseManager.kind, + summary: Environment.databaseSummaryText, + }, + users: users.map(DatabaseManager.toStoredUser), + messages: messages.map(DatabaseManager.toStoredMessage), + attachments, + artifacts, + requestAudits, + aiRequests: aiRequests.map(DatabaseManager.toStoredAiRequest), + }; + } + + private static async writeTempArtifact(fileName: string, content: string | Buffer, contentType: string): Promise { + const filePath = path.join(os.tmpdir(), `tg-chat-bot-${process.pid}-${Date.now()}-${fileName}`); + await fsp.writeFile(filePath, content); + + return { + filePath, + fileName, + contentType, + cleanup: async () => { + await fsp.unlink(filePath).catch(() => undefined); + }, + }; + } + + private static buildSqlDump(payload: DatabaseBackupPayload): string { + const lines: string[] = [ + "-- tg-chat-bot database dump", + `-- schemaVersion: ${payload.schemaVersion}`, + `-- createdAt: ${payload.createdAt}`, + `-- database: ${payload.database.summary}`, + "", + "BEGIN TRANSACTION;", + "", + "DROP INDEX IF EXISTS \"messages_chatId_id_idx\";", + "DROP INDEX IF EXISTS \"attachments_messageChatId_messageId_idx\";", + "DROP INDEX IF EXISTS \"artifacts_requestId_idx\";", + "DROP INDEX IF EXISTS \"request_audit_requestId_idx\";", + "DROP INDEX IF EXISTS \"ai_requests_chatId_messageId_idx\";", + "DROP INDEX IF EXISTS \"ai_requests_status_idx\";", + "DROP TABLE IF EXISTS \"request_audit\";", + "DROP TABLE IF EXISTS \"artifacts\";", + "DROP TABLE IF EXISTS \"attachments\";", + "DROP TABLE IF EXISTS \"ai_requests\";", + "DROP TABLE IF EXISTS \"messages\";", + "DROP TABLE IF EXISTS \"users\";", + "", + "CREATE TABLE \"users\"", + "(", + " \"id\" INTEGER PRIMARY KEY NOT NULL,", + " \"isBot\" INTEGER NOT NULL,", + " \"firstName\" TEXT NOT NULL,", + " \"lastName\" TEXT,", + " \"userName\" TEXT,", + " \"isPremium\" INTEGER,", + " \"langCode\" TEXT,", + " \"interfaceLanguage\" TEXT DEFAULT 'default',", + " \"aiProvider\" TEXT,", + " \"aiResponseLanguage\" TEXT DEFAULT 'ru',", + " \"aiContextSize\" INTEGER,", + " \"aiVoiceMode\" TEXT DEFAULT 'execute',", + " \"aiImageOutputMode\" TEXT DEFAULT 'photo'", + ");", + "", + "CREATE TABLE \"messages\"", + "(", + " \"id\" INTEGER NOT NULL,", + " \"chatId\" INTEGER NOT NULL,", + " \"replyToMessageId\" INTEGER,", + " \"fromId\" INTEGER NOT NULL,", + " \"text\" TEXT,", + " \"quoteText\" TEXT,", + " \"date\" INTEGER NOT NULL,", + " \"deletedByBotAt\" INTEGER,", + " \"attachments\" TEXT,", + " \"pipelineAudit\" TEXT,", + " PRIMARY KEY (\"chatId\", \"id\")", + ");", + "", + "CREATE UNIQUE INDEX \"messages_chatId_id_idx\" ON \"messages\" (\"chatId\", \"id\");", + "", + "CREATE TABLE \"attachments\"", + "(", + " \"id\" TEXT PRIMARY KEY NOT NULL,", + " \"messageChatId\" INTEGER NOT NULL,", + " \"messageId\" INTEGER NOT NULL,", + " \"direction\" TEXT NOT NULL,", + " \"scope\" TEXT NOT NULL,", + " \"kind\" TEXT NOT NULL,", + " \"artifactKind\" TEXT,", + " \"fileId\" TEXT NOT NULL,", + " \"fileUniqueId\" TEXT,", + " \"fileName\" TEXT NOT NULL,", + " \"mimeType\" TEXT,", + " \"cachePath\" TEXT NOT NULL,", + " \"sizeBytes\" INTEGER,", + " \"sha256\" TEXT,", + " \"metadata\" TEXT,", + " \"createdAt\" TEXT NOT NULL", + ");", + "", + "CREATE INDEX \"attachments_messageChatId_messageId_idx\" ON \"attachments\" (\"messageChatId\", \"messageId\");", + "", + "CREATE TABLE \"artifacts\"", + "(", + " \"id\" TEXT PRIMARY KEY NOT NULL,", + " \"requestId\" TEXT NOT NULL,", + " \"messageChatId\" INTEGER NOT NULL,", + " \"messageId\" INTEGER NOT NULL,", + " \"kind\" TEXT NOT NULL,", + " \"stage\" TEXT NOT NULL,", + " \"attachmentId\" TEXT,", + " \"payload\" TEXT NOT NULL,", + " \"createdAt\" TEXT NOT NULL", + ");", + "", + "CREATE INDEX \"artifacts_requestId_idx\" ON \"artifacts\" (\"requestId\");", + "", + "CREATE TABLE \"request_audit\"", + "(", + " \"id\" TEXT PRIMARY KEY NOT NULL,", + " \"requestId\" TEXT NOT NULL,", + " \"messageChatId\" INTEGER NOT NULL,", + " \"messageId\" INTEGER NOT NULL,", + " \"stage\" TEXT NOT NULL,", + " \"status\" TEXT NOT NULL,", + " \"startedAt\" TEXT,", + " \"finishedAt\" TEXT,", + " \"durationMs\" INTEGER,", + " \"provider\" TEXT,", + " \"model\" TEXT,", + " \"details\" TEXT,", + " \"error\" TEXT", + ");", + "", + "CREATE INDEX \"request_audit_requestId_idx\" ON \"request_audit\" (\"requestId\");", + "", + "CREATE TABLE \"ai_requests\"", + "(", + " \"requestId\" TEXT PRIMARY KEY NOT NULL,", + " \"chatId\" INTEGER NOT NULL,", + " \"messageId\" INTEGER NOT NULL,", + " \"responseMessageId\" INTEGER,", + " \"fromId\" INTEGER NOT NULL,", + " \"provider\" TEXT NOT NULL,", + " \"model\" TEXT NOT NULL,", + " \"status\" TEXT NOT NULL,", + " \"startedAt\" TEXT NOT NULL,", + " \"finishedAt\" TEXT,", + " \"error\" TEXT", + ");", + "", + "CREATE INDEX \"ai_requests_chatId_messageId_idx\" ON \"ai_requests\" (\"chatId\", \"messageId\");", + "CREATE INDEX \"ai_requests_status_idx\" ON \"ai_requests\" (\"status\");", + "", + ]; + + const userRows = payload.users.map(user => { + return `(${[ + DatabaseManager.sqlLiteral(user.id), + DatabaseManager.sqlLiteral(user.isBot ? 1 : 0), + DatabaseManager.sqlLiteral(user.firstName), + DatabaseManager.sqlLiteral(user.lastName ?? null), + DatabaseManager.sqlLiteral(user.userName ?? null), + DatabaseManager.sqlLiteral(user.isPremium === undefined ? null : (user.isPremium ? 1 : 0)), + DatabaseManager.sqlLiteral(user.langCode ?? null), + DatabaseManager.sqlLiteral(user.interfaceLanguage ?? "default"), + DatabaseManager.sqlLiteral(user.aiProvider ?? null), + DatabaseManager.sqlLiteral(user.aiResponseLanguage ?? "ru"), + DatabaseManager.sqlLiteral(user.aiContextSize ?? null), + DatabaseManager.sqlLiteral(user.aiVoiceMode ?? "execute"), + DatabaseManager.sqlLiteral(user.aiImageOutputMode ?? "photo"), + ].join(", ")})`; + }); + + if (userRows.length) { + lines.push( + "INSERT INTO \"users\" (\"id\", \"isBot\", \"firstName\", \"lastName\", \"userName\", \"isPremium\", \"langCode\", \"interfaceLanguage\", \"aiProvider\", \"aiResponseLanguage\", \"aiContextSize\", \"aiVoiceMode\", \"aiImageOutputMode\") VALUES", + `${userRows.join(",\n")};`, + "", + ); + } + + const messageRows = payload.messages.map(message => { + return `(${[ + DatabaseManager.sqlLiteral(message.id), + DatabaseManager.sqlLiteral(message.chatId), + DatabaseManager.sqlLiteral(message.replyToMessageId ?? null), + DatabaseManager.sqlLiteral(message.fromId), + DatabaseManager.sqlLiteral(message.text ?? null), + DatabaseManager.sqlLiteral(message.quoteText ?? null), + DatabaseManager.sqlLiteral(message.date), + DatabaseManager.sqlLiteral(message.deletedByBotAt ?? null), + DatabaseManager.sqlLiteral(message.attachments?.length ? JSON.stringify(message.attachments) : null), + DatabaseManager.sqlLiteral(message.pipelineAudit?.length ? JSON.stringify(message.pipelineAudit) : null), + ].join(", ")})`; + }); + + if (messageRows.length) { + lines.push( + "INSERT INTO \"messages\" (\"id\", \"chatId\", \"replyToMessageId\", \"fromId\", \"text\", \"quoteText\", \"date\", \"deletedByBotAt\", \"attachments\", \"pipelineAudit\") VALUES", + `${messageRows.join(",\n")};`, + "", + ); + } + + const attachmentRows = (payload.attachments ?? []).map(attachment => { + return `(${[ + DatabaseManager.sqlLiteral(attachment.id), + DatabaseManager.sqlLiteral(attachment.messageChatId), + DatabaseManager.sqlLiteral(attachment.messageId), + DatabaseManager.sqlLiteral(attachment.direction), + DatabaseManager.sqlLiteral(attachment.scope), + DatabaseManager.sqlLiteral(attachment.kind), + DatabaseManager.sqlLiteral(attachment.artifactKind ?? null), + DatabaseManager.sqlLiteral(attachment.fileId), + DatabaseManager.sqlLiteral(attachment.fileUniqueId ?? null), + DatabaseManager.sqlLiteral(attachment.fileName), + DatabaseManager.sqlLiteral(attachment.mimeType ?? null), + DatabaseManager.sqlLiteral(attachment.cachePath), + DatabaseManager.sqlLiteral(attachment.sizeBytes ?? null), + DatabaseManager.sqlLiteral(attachment.sha256 ?? null), + DatabaseManager.sqlLiteral(attachment.metadata ?? null), + DatabaseManager.sqlLiteral(attachment.createdAt), + ].join(", ")})`; + }); + + if (attachmentRows.length) { + lines.push( + "INSERT INTO \"attachments\" (\"id\", \"messageChatId\", \"messageId\", \"direction\", \"scope\", \"kind\", \"artifactKind\", \"fileId\", \"fileUniqueId\", \"fileName\", \"mimeType\", \"cachePath\", \"sizeBytes\", \"sha256\", \"metadata\", \"createdAt\") VALUES", + `${attachmentRows.join(",\n")};`, + "", + ); + } + + const artifactRows = (payload.artifacts ?? []).map(artifact => { + return `(${[ + DatabaseManager.sqlLiteral(artifact.id), + DatabaseManager.sqlLiteral(artifact.requestId), + DatabaseManager.sqlLiteral(artifact.messageChatId), + DatabaseManager.sqlLiteral(artifact.messageId), + DatabaseManager.sqlLiteral(artifact.kind), + DatabaseManager.sqlLiteral(artifact.stage), + DatabaseManager.sqlLiteral(artifact.attachmentId ?? null), + DatabaseManager.sqlLiteral(artifact.payload), + DatabaseManager.sqlLiteral(artifact.createdAt), + ].join(", ")})`; + }); + + if (artifactRows.length) { + lines.push( + "INSERT INTO \"artifacts\" (\"id\", \"requestId\", \"messageChatId\", \"messageId\", \"kind\", \"stage\", \"attachmentId\", \"payload\", \"createdAt\") VALUES", + `${artifactRows.join(",\n")};`, + "", + ); + } + + const auditRows = (payload.requestAudits ?? []).map(audit => { + return `(${[ + DatabaseManager.sqlLiteral(audit.id), + DatabaseManager.sqlLiteral(audit.requestId), + DatabaseManager.sqlLiteral(audit.messageChatId), + DatabaseManager.sqlLiteral(audit.messageId), + DatabaseManager.sqlLiteral(audit.stage), + DatabaseManager.sqlLiteral(audit.status), + DatabaseManager.sqlLiteral(audit.startedAt ?? null), + DatabaseManager.sqlLiteral(audit.finishedAt ?? null), + DatabaseManager.sqlLiteral(audit.durationMs ?? null), + DatabaseManager.sqlLiteral(audit.provider ?? null), + DatabaseManager.sqlLiteral(audit.model ?? null), + DatabaseManager.sqlLiteral(audit.details ?? null), + DatabaseManager.sqlLiteral(audit.error ?? null), + ].join(", ")})`; + }); + + if (auditRows.length) { + lines.push( + "INSERT INTO \"request_audit\" (\"id\", \"requestId\", \"messageChatId\", \"messageId\", \"stage\", \"status\", \"startedAt\", \"finishedAt\", \"durationMs\", \"provider\", \"model\", \"details\", \"error\") VALUES", + `${auditRows.join(",\n")};`, + "", + ); + } + + const aiRequestRows = (payload.aiRequests ?? []).map(request => { + return `(${[ + DatabaseManager.sqlLiteral(request.requestId), + DatabaseManager.sqlLiteral(request.chatId), + DatabaseManager.sqlLiteral(request.messageId), + DatabaseManager.sqlLiteral(request.responseMessageId ?? null), + DatabaseManager.sqlLiteral(request.fromId), + DatabaseManager.sqlLiteral(request.provider), + DatabaseManager.sqlLiteral(request.model), + DatabaseManager.sqlLiteral(request.status), + DatabaseManager.sqlLiteral(request.startedAt), + DatabaseManager.sqlLiteral(request.finishedAt ?? null), + DatabaseManager.sqlLiteral(request.error ?? null), + ].join(", ")})`; + }); + + if (aiRequestRows.length) { + lines.push( + "INSERT INTO \"ai_requests\" (\"requestId\", \"chatId\", \"messageId\", \"responseMessageId\", \"fromId\", \"provider\", \"model\", \"status\", \"startedAt\", \"finishedAt\", \"error\") VALUES", + `${aiRequestRows.join(",\n")};`, + "", + ); + } + + lines.push("COMMIT;"); + return lines.join("\n"); + } + + private static async transaction(work: (tx: { execute(query: string, params?: DbValue[]): Promise }) => Promise): Promise { + if (DatabaseManager.backend.kind === "postgres") { + const client = await DatabaseManager.backend.pool.connect(); + try { + await client.query("BEGIN"); + const result = await work({ + execute: async (query: string, params: DbValue[] = []) => { + await client.query(query, params); + }, + }); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + } + + const backend = DatabaseManager.backend; + await backend.client.execute("BEGIN"); try { - DatabaseManager.db = drizzle(Environment.DB_PATH); - } catch (e) { - logError(e); + const result = await work({ + execute: async (query: string, params: DbValue[] = []) => { + await backend.client.execute(query, params as never); + }, + }); + await backend.client.execute("COMMIT"); + return result; + } catch (error) { + await backend.client.execute("ROLLBACK").catch(() => undefined); + throw error; } } -} \ No newline at end of file + + private static toStoredUser(user: UserDbRow): StoredUser { + return { + id: user.id, + isBot: user.isBot === 1, + firstName: user.firstName, + lastName: user.lastName ?? undefined, + userName: user.userName ?? undefined, + isPremium: user.isPremium === null ? undefined : user.isPremium === 1, + langCode: user.langCode ?? undefined, + interfaceLanguage: user.interfaceLanguage ?? undefined, + aiProvider: user.aiProvider ?? undefined, + aiResponseLanguage: user.aiResponseLanguage ?? undefined, + aiContextSize: user.aiContextSize ?? undefined, + aiVoiceMode: user.aiVoiceMode ?? undefined, + aiImageOutputMode: user.aiImageOutputMode ?? undefined, + }; + } + + private static toStoredMessage(message: MessageDbRow): StoredMessage { + return { + chatId: message.chatId, + id: message.id, + replyToMessageId: message.replyToMessageId ?? undefined, + fromId: message.fromId, + text: message.text ?? undefined, + quoteText: message.quoteText ?? undefined, + date: message.date, + deletedByBotAt: message.deletedByBotAt ?? undefined, + attachments: DatabaseManager.parseStoredAttachments(message.attachments), + pipelineAudit: DatabaseManager.parsePipelineAudit(message.pipelineAudit), + }; + } + + private static toStoredAiRequest(row: AiRequestDbRow): StoredAiRequest { + return { + requestId: row.requestId, + chatId: row.chatId, + messageId: row.messageId, + responseMessageId: row.responseMessageId ?? undefined, + fromId: row.fromId, + provider: row.provider as StoredAiRequest["provider"], + model: row.model, + status: row.status as StoredAiRequest["status"], + startedAt: row.startedAt, + finishedAt: row.finishedAt ?? undefined, + error: row.error ?? undefined, + }; + } + + private static normalizeImportedUser(user: StoredUser): UserDbRow { + if (typeof user !== "object" || user === null) { + throw new Error("Invalid user backup entry"); + } + + return { + id: DatabaseManager.normalizeInt(user.id, "users.id"), + isBot: DatabaseManager.normalizeBoolToInt(user.isBot, "users.isBot"), + firstName: DatabaseManager.normalizeString(user.firstName, "users.firstName"), + lastName: DatabaseManager.normalizeNullableString(user.lastName, "users.lastName"), + userName: DatabaseManager.normalizeNullableString(user.userName, "users.userName"), + isPremium: DatabaseManager.normalizeNullableBoolToInt(user.isPremium, "users.isPremium"), + langCode: DatabaseManager.normalizeNullableString(user.langCode, "users.langCode"), + interfaceLanguage: DatabaseManager.normalizeNullableString(user.interfaceLanguage, "users.interfaceLanguage"), + aiProvider: DatabaseManager.normalizeNullableString(user.aiProvider, "users.aiProvider"), + aiResponseLanguage: DatabaseManager.normalizeNullableString(user.aiResponseLanguage, "users.aiResponseLanguage"), + aiContextSize: DatabaseManager.normalizeNullableInt(user.aiContextSize, "users.aiContextSize"), + aiVoiceMode: DatabaseManager.normalizeNullableString(user.aiVoiceMode, "users.aiVoiceMode"), + aiImageOutputMode: DatabaseManager.normalizeNullableString(user.aiImageOutputMode, "users.aiImageOutputMode"), + }; + } + + private static normalizeImportedMessage(message: StoredMessage & { photoMaxSizeFilePath?: string[] | null }): MessageDbRow { + if (typeof message !== "object" || message === null) { + throw new Error("Invalid message backup entry"); + } + + return { + id: DatabaseManager.normalizeInt(message.id, "messages.id"), + chatId: DatabaseManager.normalizeInt(message.chatId, "messages.chatId"), + replyToMessageId: DatabaseManager.normalizeNullableInt(message.replyToMessageId, "messages.replyToMessageId"), + fromId: DatabaseManager.normalizeInt(message.fromId, "messages.fromId"), + text: DatabaseManager.normalizeNullableString(message.text, "messages.text"), + quoteText: DatabaseManager.normalizeNullableString(message.quoteText, "messages.quoteText"), + date: DatabaseManager.normalizeInt(message.date, "messages.date"), + deletedByBotAt: DatabaseManager.normalizeNullableInt(message.deletedByBotAt, "messages.deletedByBotAt"), + attachments: DatabaseManager.normalizeAttachments(DatabaseManager.mergeStoredAttachments( + Array.isArray(message.attachments) ? message.attachments : undefined, + message.photoMaxSizeFilePath, + )), + pipelineAudit: DatabaseManager.normalizePipelineAudit(Array.isArray(message.pipelineAudit) ? message.pipelineAudit : undefined), + }; + } + + private static normalizeImportedAiRequest(request: StoredAiRequest): AiRequestDbRow { + if (typeof request !== "object" || request === null) { + throw new Error("Invalid AI request backup entry"); + } + + return { + requestId: DatabaseManager.normalizeString(request.requestId, "ai_requests.requestId"), + chatId: DatabaseManager.normalizeInt(request.chatId, "ai_requests.chatId"), + messageId: DatabaseManager.normalizeInt(request.messageId, "ai_requests.messageId"), + responseMessageId: DatabaseManager.normalizeNullableInt(request.responseMessageId, "ai_requests.responseMessageId"), + fromId: DatabaseManager.normalizeInt(request.fromId, "ai_requests.fromId"), + provider: DatabaseManager.normalizeString(request.provider, "ai_requests.provider"), + model: DatabaseManager.normalizeString(request.model, "ai_requests.model"), + status: DatabaseManager.normalizeString(request.status, "ai_requests.status"), + startedAt: DatabaseManager.normalizeString(request.startedAt, "ai_requests.startedAt"), + finishedAt: DatabaseManager.normalizeNullableString(request.finishedAt, "ai_requests.finishedAt"), + error: DatabaseManager.normalizeNullableString(request.error, "ai_requests.error"), + }; + } + + private static normalizeImportedAttachment(attachment: AttachmentDbRow): AttachmentDbRow { + if (typeof attachment !== "object" || attachment === null) { + throw new Error("Invalid attachment backup entry"); + } + + return { + id: DatabaseManager.normalizeString(attachment.id, "attachments.id"), + messageChatId: DatabaseManager.normalizeInt(attachment.messageChatId, "attachments.messageChatId"), + messageId: DatabaseManager.normalizeInt(attachment.messageId, "attachments.messageId"), + direction: DatabaseManager.normalizeString(attachment.direction, "attachments.direction"), + scope: DatabaseManager.normalizeString(attachment.scope, "attachments.scope"), + kind: DatabaseManager.normalizeString(attachment.kind, "attachments.kind"), + artifactKind: DatabaseManager.normalizeNullableString(attachment.artifactKind, "attachments.artifactKind"), + fileId: DatabaseManager.normalizeString(attachment.fileId, "attachments.fileId"), + fileUniqueId: DatabaseManager.normalizeNullableString(attachment.fileUniqueId, "attachments.fileUniqueId"), + fileName: DatabaseManager.normalizeString(attachment.fileName, "attachments.fileName"), + mimeType: DatabaseManager.normalizeNullableString(attachment.mimeType, "attachments.mimeType"), + cachePath: DatabaseManager.normalizeString(attachment.cachePath, "attachments.cachePath"), + sizeBytes: DatabaseManager.normalizeNullableInt(attachment.sizeBytes, "attachments.sizeBytes"), + sha256: DatabaseManager.normalizeNullableString(attachment.sha256, "attachments.sha256"), + metadata: DatabaseManager.normalizeNullableString(attachment.metadata, "attachments.metadata"), + createdAt: DatabaseManager.normalizeString(attachment.createdAt, "attachments.createdAt"), + }; + } + + private static normalizeImportedArtifact(artifact: ArtifactDbRow): ArtifactDbRow { + if (typeof artifact !== "object" || artifact === null) { + throw new Error("Invalid artifact backup entry"); + } + + return { + id: DatabaseManager.normalizeString(artifact.id, "artifacts.id"), + requestId: DatabaseManager.normalizeString(artifact.requestId, "artifacts.requestId"), + messageChatId: DatabaseManager.normalizeInt(artifact.messageChatId, "artifacts.messageChatId"), + messageId: DatabaseManager.normalizeInt(artifact.messageId, "artifacts.messageId"), + kind: DatabaseManager.normalizeString(artifact.kind, "artifacts.kind"), + stage: DatabaseManager.normalizeString(artifact.stage, "artifacts.stage"), + attachmentId: DatabaseManager.normalizeNullableString(artifact.attachmentId, "artifacts.attachmentId"), + payload: DatabaseManager.normalizeString(artifact.payload, "artifacts.payload"), + createdAt: DatabaseManager.normalizeString(artifact.createdAt, "artifacts.createdAt"), + }; + } + + private static normalizeImportedRequestAudit(audit: RequestAuditDbRow): RequestAuditDbRow { + if (typeof audit !== "object" || audit === null) { + throw new Error("Invalid request audit backup entry"); + } + + return { + id: DatabaseManager.normalizeString(audit.id, "request_audit.id"), + requestId: DatabaseManager.normalizeString(audit.requestId, "request_audit.requestId"), + messageChatId: DatabaseManager.normalizeInt(audit.messageChatId, "request_audit.messageChatId"), + messageId: DatabaseManager.normalizeInt(audit.messageId, "request_audit.messageId"), + stage: DatabaseManager.normalizeString(audit.stage, "request_audit.stage"), + status: DatabaseManager.normalizeString(audit.status, "request_audit.status"), + startedAt: DatabaseManager.normalizeNullableString(audit.startedAt, "request_audit.startedAt"), + finishedAt: DatabaseManager.normalizeNullableString(audit.finishedAt, "request_audit.finishedAt"), + durationMs: DatabaseManager.normalizeNullableInt(audit.durationMs, "request_audit.durationMs"), + provider: DatabaseManager.normalizeNullableString(audit.provider, "request_audit.provider"), + model: DatabaseManager.normalizeNullableString(audit.model, "request_audit.model"), + details: DatabaseManager.normalizeNullableString(audit.details, "request_audit.details"), + error: DatabaseManager.normalizeNullableString(audit.error, "request_audit.error"), + }; + } + + private static attachmentRowsFromMessageRow(message: MessageDbRow): AttachmentDbRow[] { + const attachments = DatabaseManager.parseStoredAttachments(message.attachments) ?? []; + const createdAt = new Date(message.date * 1000).toISOString(); + + return attachments.map((attachment, ordinal) => DatabaseManager.toAttachmentDbRow({ + messageChatId: message.chatId, + messageId: message.id, + attachment, + direction: attachment.scope === "bot_output" ? "output" : "input", + createdAt, + ordinal, + })); + } + + private static artifactRowsFromMessageRow(message: MessageDbRow): ArtifactDbRow[] { + const attachments = DatabaseManager.parseStoredAttachments(message.attachments) ?? []; + const createdAt = new Date(message.date * 1000).toISOString(); + const requestId = DatabaseManager.requestIdFromMessageRow(message); + + return attachments.flatMap((attachment, ordinal) => { + if (!attachment.artifactKind) return []; + + const attachmentRow = DatabaseManager.toAttachmentDbRow({ + messageChatId: message.chatId, + messageId: message.id, + attachment, + direction: attachment.scope === "bot_output" ? "output" : "input", + createdAt, + ordinal, + }); + + return [DatabaseManager.toArtifactDbRow({ + requestId, + messageChatId: message.chatId, + messageId: message.id, + attachment, + createdAt, + attachmentId: attachmentRow.id, + })]; + }); + } + + private static requestAuditRowsFromMessageRow(message: MessageDbRow): RequestAuditDbRow[] { + const events = DatabaseManager.parsePipelineAudit(message.pipelineAudit) ?? []; + const requestId = DatabaseManager.requestIdFromMessageRow(message); + + return events.map((event, ordinal) => DatabaseManager.toRequestAuditDbRow({ + requestId, + messageChatId: message.chatId, + messageId: message.id, + event, + ordinal, + })); + } + + private static toAttachmentDbRow(input: { + messageChatId: number; + messageId: number; + attachment: StoredAttachment; + direction: string; + createdAt: string; + ordinal: number; + }): AttachmentDbRow { + const attachment = input.attachment; + const id = DatabaseManager.hashRowId([ + input.messageChatId, + input.messageId, + input.direction, + attachment.scope ?? "user_input", + attachment.kind, + attachment.fileUniqueId ?? attachment.fileId, + attachment.fileName, + attachment.cachePath, + attachment.artifactKind ?? "", + input.ordinal, + ]); + + return { + id, + messageChatId: input.messageChatId, + messageId: input.messageId, + direction: input.direction, + scope: attachment.scope ?? "user_input", + kind: attachment.kind, + artifactKind: attachment.artifactKind ?? null, + fileId: attachment.fileId, + fileUniqueId: attachment.fileUniqueId ?? null, + fileName: attachment.fileName, + mimeType: attachment.mimeType ?? null, + cachePath: attachment.cachePath, + sizeBytes: attachment.sizeBytes ?? null, + sha256: attachment.sha256 ?? null, + metadata: attachment.metadata ? JSON.stringify(attachment.metadata) : null, + createdAt: input.createdAt, + }; + } + + private static toArtifactDbRow(input: { + requestId: string; + messageChatId: number; + messageId: number; + attachment: StoredAttachment; + createdAt: string; + attachmentId: string | null; + }): ArtifactDbRow { + const kind = input.attachment.artifactKind ?? "unknown"; + const payload = { + kind, + createdAt: input.createdAt, + fileName: input.attachment.fileName, + mimeType: input.attachment.mimeType ?? null, + cachePath: input.attachment.cachePath, + sizeBytes: input.attachment.sizeBytes ?? null, + sha256: input.attachment.sha256 ?? null, + metadata: input.attachment.metadata ?? null, + scope: input.attachment.scope ?? null, + }; + + return { + id: DatabaseManager.hashRowId([ + input.requestId, + input.messageChatId, + input.messageId, + kind, + input.attachmentId ?? "", + input.createdAt, + ]), + requestId: input.requestId, + messageChatId: input.messageChatId, + messageId: input.messageId, + kind, + stage: kind, + attachmentId: input.attachmentId, + payload: JSON.stringify(payload), + createdAt: input.createdAt, + }; + } + + private static toRequestAuditDbRow(input: { + requestId: string; + messageChatId: number; + messageId: number; + event: NonNullable[number]; + ordinal: number; + }): RequestAuditDbRow { + return { + id: DatabaseManager.hashRowId([ + input.requestId, + input.messageChatId, + input.messageId, + input.event.stage, + input.event.status, + input.event.startedAt ?? "", + input.event.finishedAt ?? "", + input.ordinal, + ]), + requestId: input.requestId, + messageChatId: input.messageChatId, + messageId: input.messageId, + stage: input.event.stage, + status: input.event.status, + startedAt: input.event.startedAt ?? null, + finishedAt: input.event.finishedAt ?? null, + durationMs: input.event.durationMs ?? null, + provider: input.event.provider ?? null, + model: input.event.model ?? null, + details: input.event.details ? JSON.stringify(input.event.details) : null, + error: input.event.error ?? null, + }; + } + + private static requestIdFromMessageRow(message: MessageDbRow): string { + return `message:${message.chatId}:${message.id}`; + } + + private static hashRowId(parts: Array): string { + return createHash("sha256").update(parts.map(part => part === null || part === undefined ? "" : String(part)).join("\u0000")).digest("hex"); + } + + private static mergeStoredAttachments( + attachments: StoredAttachment[] | undefined, + legacyPhotoIds: string[] | undefined | null, + ): StoredAttachment[] | null { + const merged = uniqueStoredAttachments([ + ...(attachments ?? []), + ...DatabaseManager.legacyPhotoIdsToAttachments(legacyPhotoIds), + ]); + + return merged.length ? merged : null; + } + + private static legacyPhotoIdsToAttachments(photoMaxSizeFilePath?: string[] | null): StoredAttachment[] { + if (!Array.isArray(photoMaxSizeFilePath) || !photoMaxSizeFilePath.length) return []; + + return photoMaxSizeFilePath + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map(uniqueId => createStoredImageAttachment({ + fileId: uniqueId, + fileUniqueId: uniqueId, + })); + } + + private static normalizeLegacyMessageRow(message: LegacyMessageDbRow): MessageDbRow { + return { + id: DatabaseManager.normalizeInt(message.id, "messages.id"), + chatId: DatabaseManager.normalizeInt(message.chatId, "messages.chatId"), + replyToMessageId: DatabaseManager.normalizeNullableInt(message.replyToMessageId, "messages.replyToMessageId"), + fromId: DatabaseManager.normalizeInt(message.fromId, "messages.fromId"), + text: DatabaseManager.normalizeNullableString(message.text, "messages.text"), + quoteText: DatabaseManager.normalizeNullableString(message.quoteText, "messages.quoteText"), + date: DatabaseManager.normalizeInt(message.date, "messages.date"), + deletedByBotAt: DatabaseManager.normalizeNullableInt(message.deletedByBotAt, "messages.deletedByBotAt"), + attachments: DatabaseManager.normalizeAttachments(DatabaseManager.mergeStoredAttachments( + DatabaseManager.parseStoredAttachments(message.attachments), + DatabaseManager.legacyPhotoIdsFromColumn(message.photoMaxSizeFilePath), + )), + pipelineAudit: null, + }; + } + + private static legacyPhotoIdsFromColumn(photoMaxSizeFilePath: string | null): string[] | undefined { + if (!photoMaxSizeFilePath?.trim()) return undefined; + return photoMaxSizeFilePath + .split(";") + .filter((value): value is string => typeof value === "string" && value.trim().length > 0); + } + + private static parseStoredAttachments(value: string | null): StoredAttachment[] | undefined { + if (!value?.trim()) return undefined; + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed as StoredAttachment[] : undefined; + } catch { + return undefined; + } + } + + private static normalizeAttachments(value: StoredAttachment[] | null | undefined): string | null { + if (!value?.length) return null; + return JSON.stringify(value); + } + + private static parsePipelineAudit(value: string | null): StoredMessage["pipelineAudit"] { + if (!value?.trim()) return undefined; + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed as StoredMessage["pipelineAudit"] : undefined; + } catch { + return undefined; + } + } + + private static normalizePipelineAudit(value: StoredMessage["pipelineAudit"]): string | null { + if (!value?.length) return null; + return JSON.stringify(value); + } + + private static normalizeInt(value: BoundaryValue, field: string): number { + const number = typeof value === "string" ? Number(value) : value; + if (typeof number !== "number" || !Number.isSafeInteger(number)) { + throw new Error(`Invalid numeric value for ${field}`); + } + return number; + } + + private static normalizeNullableInt(value: BoundaryValue, field: string): number | null { + if (value === null || value === undefined || value === "") return null; + return DatabaseManager.normalizeInt(value, field); + } + + private static normalizeBoolToInt(value: BoundaryValue, field: string): number { + if (value === true || value === 1 || value === "1") return 1; + if (value === false || value === 0 || value === "0") return 0; + throw new Error(`Invalid boolean value for ${field}`); + } + + private static normalizeNullableBoolToInt(value: BoundaryValue, field: string): number | null { + if (value === null || value === undefined) return null; + return DatabaseManager.normalizeBoolToInt(value, field); + } + + private static normalizeString(value: BoundaryValue, field: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`Invalid string value for ${field}`); + } + return value; + } + + private static normalizeNullableString(value: BoundaryValue, field: string): string | null { + if (value === null || value === undefined) return null; + if (typeof value !== "string") { + throw new Error(`Invalid string value for ${field}`); + } + return value; + } + + private static sqlLiteral(value: DbValue): string { + if (value === null || value === undefined) return "NULL"; + if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL"; + if (typeof value === "boolean") return value ? "1" : "0"; + if (typeof value === "bigint") return value.toString(); + return `'${String(value).replace(/'/g, "''")}'`; + } + + private static buildZip(entries: ZipEntryInput[]): Buffer { + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let offset = 0; + + for (const entry of entries) { + const fileName = Buffer.from(entry.fileName, "utf8"); + const compressed = deflateRawSync(entry.content); + const crc = DatabaseManager.crc32(entry.content); + const localHeader = Buffer.alloc(30 + fileName.length); + + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt16LE(0x0800, 6); + localHeader.writeUInt16LE(8, 8); + localHeader.writeUInt16LE(0, 10); + localHeader.writeUInt16LE(0, 12); + localHeader.writeUInt32LE(crc, 14); + localHeader.writeUInt32LE(compressed.length, 18); + localHeader.writeUInt32LE(entry.content.length, 22); + localHeader.writeUInt16LE(fileName.length, 26); + localHeader.writeUInt16LE(0, 28); + fileName.copy(localHeader, 30); + + localParts.push(localHeader, compressed); + + const centralHeader = Buffer.alloc(46 + fileName.length); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE(20, 4); + centralHeader.writeUInt16LE(20, 6); + centralHeader.writeUInt16LE(0x0800, 8); + centralHeader.writeUInt16LE(8, 10); + centralHeader.writeUInt16LE(0, 12); + centralHeader.writeUInt16LE(0, 14); + centralHeader.writeUInt32LE(crc, 16); + centralHeader.writeUInt32LE(compressed.length, 20); + centralHeader.writeUInt32LE(entry.content.length, 24); + centralHeader.writeUInt16LE(fileName.length, 28); + centralHeader.writeUInt16LE(0, 30); + centralHeader.writeUInt16LE(0, 32); + centralHeader.writeUInt16LE(0, 34); + centralHeader.writeUInt16LE(0, 36); + centralHeader.writeUInt32LE(0, 38); + centralHeader.writeUInt32LE(offset, 42); + fileName.copy(centralHeader, 46); + + centralParts.push(centralHeader); + offset += localHeader.length + compressed.length; + } + + const centralDirectory = Buffer.concat(centralParts); + const localData = Buffer.concat(localParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(localData.length, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([localData, centralDirectory, end]); + } + + private static crc32(buffer: Buffer): number { + let crc = 0xffffffff; + for (const byte of buffer) { + crc = DatabaseManager.CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; + } + + private static makeBackupStamp(): string { + return new Date().toISOString().replace(/[:.]/g, "-"); + } + + private static async ensureUsersTable(): Promise { + await DatabaseManager.execute(` + CREATE TABLE IF NOT EXISTS "users" + ( + "id" INTEGER PRIMARY KEY NOT NULL, + "isBot" INTEGER NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT, + "userName" TEXT, + "isPremium" INTEGER, + "langCode" TEXT, + "interfaceLanguage" TEXT DEFAULT 'default', + "aiProvider" TEXT, + "aiResponseLanguage" TEXT DEFAULT 'ru', + "aiContextSize" INTEGER, + "aiVoiceMode" TEXT DEFAULT 'execute', + "aiImageOutputMode" TEXT DEFAULT 'photo' + ) + `); + + const columns = await DatabaseManager.getTableColumns("users"); + const missingColumns: DbColumnDefinition[] = [ + {name: "langCode", sql: "\"langCode\" TEXT"}, + {name: "aiProvider", sql: "\"aiProvider\" TEXT"}, + {name: "interfaceLanguage", sql: "\"interfaceLanguage\" TEXT DEFAULT 'default'"}, + {name: "aiResponseLanguage", sql: "\"aiResponseLanguage\" TEXT DEFAULT 'ru'"}, + {name: "aiContextSize", sql: "\"aiContextSize\" INTEGER"}, + {name: "aiVoiceMode", sql: "\"aiVoiceMode\" TEXT DEFAULT 'execute'"}, + {name: "aiImageOutputMode", sql: "\"aiImageOutputMode\" TEXT DEFAULT 'photo'"}, + ].filter(column => !columns.has(column.name)); + + for (const column of missingColumns) { + await DatabaseManager.execute(`ALTER TABLE "users" ADD COLUMN ${column.sql}`); + } + } + + private static async ensureMessagesTable(): Promise { + await DatabaseManager.execute(` + CREATE TABLE IF NOT EXISTS "messages" + ( + "id" INTEGER NOT NULL, + "chatId" INTEGER NOT NULL, + "replyToMessageId" INTEGER, + "fromId" INTEGER NOT NULL, + "text" TEXT, + "quoteText" TEXT, + "date" INTEGER NOT NULL, + "deletedByBotAt" INTEGER, + "attachments" TEXT, + "pipelineAudit" TEXT, + PRIMARY KEY ("chatId", "id") + ) + `); + + const columns = await DatabaseManager.getTableColumns("messages"); + const missingColumns: DbColumnDefinition[] = [ + {name: "quoteText", sql: "\"quoteText\" TEXT"}, + {name: "deletedByBotAt", sql: "\"deletedByBotAt\" INTEGER"}, + {name: "attachments", sql: "\"attachments\" TEXT"}, + {name: "pipelineAudit", sql: "\"pipelineAudit\" TEXT"}, + ].filter(column => !columns.has(column.name)); + + for (const column of missingColumns) { + await DatabaseManager.execute(`ALTER TABLE "messages" ADD COLUMN ${column.sql}`); + } + + await DatabaseManager.execute(` + CREATE UNIQUE INDEX IF NOT EXISTS "messages_chatId_id_idx" + ON "messages" ("chatId", "id") + `); + } + + private static async ensureAiRequestsTable(): Promise { + await DatabaseManager.execute(` + CREATE TABLE IF NOT EXISTS "ai_requests" + ( + "requestId" TEXT PRIMARY KEY NOT NULL, + "chatId" INTEGER NOT NULL, + "messageId" INTEGER NOT NULL, + "responseMessageId" INTEGER, + "fromId" INTEGER NOT NULL, + "provider" TEXT NOT NULL, + "model" TEXT NOT NULL, + "status" TEXT NOT NULL, + "startedAt" TEXT NOT NULL, + "finishedAt" TEXT, + "error" TEXT + ) + `); + + await DatabaseManager.execute(` + CREATE INDEX IF NOT EXISTS "ai_requests_chatId_messageId_idx" + ON "ai_requests" ("chatId", "messageId") + `); + + await DatabaseManager.execute(` + CREATE INDEX IF NOT EXISTS "ai_requests_status_idx" + ON "ai_requests" ("status") + `); + } + + private static async ensureAttachmentsTable(): Promise { + await DatabaseManager.execute(` + CREATE TABLE IF NOT EXISTS "attachments" + ( + "id" TEXT PRIMARY KEY NOT NULL, + "messageChatId" INTEGER NOT NULL, + "messageId" INTEGER NOT NULL, + "direction" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "artifactKind" TEXT, + "fileId" TEXT NOT NULL, + "fileUniqueId" TEXT, + "fileName" TEXT NOT NULL, + "mimeType" TEXT, + "cachePath" TEXT NOT NULL, + "sizeBytes" INTEGER, + "sha256" TEXT, + "metadata" TEXT, + "createdAt" TEXT NOT NULL + ) + `); + + await DatabaseManager.execute(` + CREATE INDEX IF NOT EXISTS "attachments_messageChatId_messageId_idx" + ON "attachments" ("messageChatId", "messageId") + `); + } + + private static async ensureArtifactsTable(): Promise { + await DatabaseManager.execute(` + CREATE TABLE IF NOT EXISTS "artifacts" + ( + "id" TEXT PRIMARY KEY NOT NULL, + "requestId" TEXT NOT NULL, + "messageChatId" INTEGER NOT NULL, + "messageId" INTEGER NOT NULL, + "kind" TEXT NOT NULL, + "stage" TEXT NOT NULL, + "attachmentId" TEXT, + "payload" TEXT NOT NULL, + "createdAt" TEXT NOT NULL + ) + `); + + await DatabaseManager.execute(` + CREATE INDEX IF NOT EXISTS "artifacts_requestId_idx" + ON "artifacts" ("requestId") + `); + } + + private static async ensureRequestAuditTable(): Promise { + await DatabaseManager.execute(` + CREATE TABLE IF NOT EXISTS "request_audit" + ( + "id" TEXT PRIMARY KEY NOT NULL, + "requestId" TEXT NOT NULL, + "messageChatId" INTEGER NOT NULL, + "messageId" INTEGER NOT NULL, + "stage" TEXT NOT NULL, + "status" TEXT NOT NULL, + "startedAt" TEXT, + "finishedAt" TEXT, + "durationMs" INTEGER, + "provider" TEXT, + "model" TEXT, + "details" TEXT, + "error" TEXT + ) + `); + + await DatabaseManager.execute(` + CREATE INDEX IF NOT EXISTS "request_audit_requestId_idx" + ON "request_audit" ("requestId") + `); + } + + private static async migrateLegacyMessagePhotoColumn(): Promise { + const columns = await DatabaseManager.getTableColumns("messages"); + if (!columns.has("photoMaxSizeFilePath")) return; + + const rows = await DatabaseManager.query(` + SELECT + "id", + "chatId", + "replyToMessageId", + "fromId", + "text", + "quoteText", + "date", + "deletedByBotAt", + "attachments", + "pipelineAudit", + "photoMaxSizeFilePath" + FROM "messages" + ORDER BY "chatId", "id" + `); + + const migratedRows = rows.map(DatabaseManager.normalizeLegacyMessageRow); + const tempTable = "messages__migrate"; + + await DatabaseManager.transaction(async tx => { + await tx.execute(`DROP TABLE IF EXISTS "${tempTable}"`); + await tx.execute(` + CREATE TABLE "${tempTable}" + ( + "id" INTEGER NOT NULL, + "chatId" INTEGER NOT NULL, + "replyToMessageId" INTEGER, + "fromId" INTEGER NOT NULL, + "text" TEXT, + "quoteText" TEXT, + "date" INTEGER NOT NULL, + "deletedByBotAt" INTEGER, + "attachments" TEXT, + "pipelineAudit" TEXT, + PRIMARY KEY ("chatId", "id") + ) + `); + + if (migratedRows.length) { + const {query, params} = DatabaseManager.buildBulkUpsertQuery( + tempTable, + MESSAGE_COLUMNS, + ["chatId", "id"], + migratedRows, + ); + await tx.execute(query, params); + } + + await tx.execute(`DROP TABLE "messages"`); + await tx.execute(`ALTER TABLE "${tempTable}" RENAME TO "messages"`); + await tx.execute(` + CREATE UNIQUE INDEX IF NOT EXISTS "messages_chatId_id_idx" + ON "messages" ("chatId", "id") + `); + }); + } + + private static async migrateLegacyNormalizedTables(): Promise { + const messages = await DatabaseManager.getAllMessages(); + const attachments = messages.flatMap(message => DatabaseManager.attachmentRowsFromMessageRow(message)); + const artifacts = messages.flatMap(message => DatabaseManager.artifactRowsFromMessageRow(message)); + const requestAudits = messages.flatMap(message => DatabaseManager.requestAuditRowsFromMessageRow(message)); + + await DatabaseManager.transaction(async tx => { + await tx.execute("DELETE FROM \"request_audit\""); + await tx.execute("DELETE FROM \"artifacts\""); + await tx.execute("DELETE FROM \"attachments\""); + + if (attachments.length) { + const {query, params} = DatabaseManager.buildBulkUpsertQuery("attachments", ATTACHMENT_COLUMNS, ["id"], attachments); + await tx.execute(query, params); + } + + if (artifacts.length) { + const {query, params} = DatabaseManager.buildBulkUpsertQuery("artifacts", ARTIFACT_COLUMNS, ["id"], artifacts); + await tx.execute(query, params); + } + + if (requestAudits.length) { + const {query, params} = DatabaseManager.buildBulkUpsertQuery("request_audit", REQUEST_AUDIT_COLUMNS, ["id"], requestAudits); + await tx.execute(query, params); + } + }); + } + + private static async getSchemaVersion(): Promise { + if (DatabaseManager.backend.kind === "postgres") { + await DatabaseManager.ensureSchemaMetaTable(); + const rows = await DatabaseManager.query<{value: string | null}>(` + SELECT "value" + FROM "schema_meta" + WHERE "key" = ${DatabaseManager.placeholder(1)} + LIMIT 1 + `, [SCHEMA_META_KEY]); + + const raw = rows[0]?.value; + if (!raw?.trim()) return null; + + const parsed = Number(raw); + return Number.isSafeInteger(parsed) ? parsed : null; + } + + const rows = await DatabaseManager.query<{user_version: number}>("PRAGMA user_version"); + const raw = rows[0]?.user_version ?? 0; + return Number.isSafeInteger(raw) ? raw : null; + } + + private static async setSchemaVersion(version: number): Promise { + if (DatabaseManager.backend.kind === "postgres") { + await DatabaseManager.ensureSchemaMetaTable(); + await DatabaseManager.execute(` + INSERT INTO "schema_meta" ("key", "value") + VALUES (${DatabaseManager.placeholder(1)}, ${DatabaseManager.placeholder(2)}) + ON CONFLICT ("key") + DO UPDATE SET "value" = excluded."value" + `, [SCHEMA_META_KEY, String(version)]); + return; + } + + await DatabaseManager.execute(`PRAGMA user_version = ${version}`); + } + + private static async ensureSchemaMetaTable(): Promise { + await DatabaseManager.execute(` + CREATE TABLE IF NOT EXISTS "schema_meta" + ( + "key" TEXT PRIMARY KEY NOT NULL, + "value" TEXT NOT NULL + ) + `); + } + + private static async getTableColumns(tableName: string): Promise> { + if (DatabaseManager.backend.kind === "postgres") { + const rows = await DatabaseManager.query<{column_name: string}>(` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = ${DatabaseManager.placeholder(1)} + `, [tableName]); + + return new Set(rows.map(row => row.column_name)); + } + + const rows = await DatabaseManager.query<{name: string}>(`PRAGMA table_info("${tableName}")`); + return new Set(rows.map(row => row.name)); + } + + private static async query(query: string, params: DbValue[] = []): Promise { + if (DatabaseManager.backend.kind === "postgres") { + const result = await DatabaseManager.backend.pool.query(query, params); + return result.rows; + } + + const result = await DatabaseManager.backend.client.execute(query, params as never); + const rows: QueryResultRow[] = result.rows as QueryResultRow[]; + return rows.map(row => row as T); + } + + private static async execute(query: string, params: DbValue[] = []): Promise { + await DatabaseManager.query(query, params); + } + + private static normalizeValue(value: DbValue | undefined): DbValue | null { + return value === undefined ? null : value; + } + + private static placeholder(index: number): string { + return DatabaseManager.backend.kind === "postgres" ? `$${index}` : "?"; + } + + private static buildBulkUpsertQuery>( + tableName: string, + columns: readonly string[], + conflictColumns: readonly string[], + rows: readonly T[], + updateColumns: readonly string[] = columns.filter(column => !conflictColumns.includes(column)), + ): {query: string; params: DbValue[]} { + const params: DbValue[] = []; + const values: string[] = []; + let index = 1; + + for (const row of rows) { + const placeholders = columns.map(column => { + params.push(DatabaseManager.normalizeValue(row[column])); + return DatabaseManager.placeholder(index++); + }); + + values.push(`(${placeholders.join(", ")})`); + } + + const updateClause = updateColumns.map(column => `"${column}" = excluded."${column}"`).join(", "); + + return { + query: ` + INSERT INTO "${tableName}" + (${columns.map(column => `"${column}"`).join(", ")}) + VALUES ${values.join(", ")} + ON CONFLICT (${conflictColumns.map(column => `"${column}"`).join(", ")}) + DO UPDATE SET ${updateClause} + `, + params, + }; + } + + private static buildInQuery(queryTemplate: string, params: DbValue[], inStartIndex = 1): {query: string; params: DbValue[]} { + const inValues = params.slice(inStartIndex); + const placeholders = inValues.map((_, index) => DatabaseManager.placeholder(inStartIndex + index)); + return { + query: queryTemplate.replace("__IN__", placeholders.join(", ")), + params, + }; + } +} diff --git a/src/db/database.ts b/src/db/database.ts index b9d894c..ba2e375 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -3,6 +3,7 @@ import {Environment} from "../common/environment"; import {logError} from "../util/utils"; import {Answers} from "../model/answers"; import path from "node:path"; +import {KeyedAsyncLock} from "../util/async-lock"; type DataJsonFile = { admins: number[] @@ -11,9 +12,42 @@ type DataJsonFile = { export let jsonFile: DataJsonFile; +const DEFAULT_DATA: DataJsonFile = { + admins: [], + muted: [], +}; + +const DEFAULT_ANSWERS: Answers = { + test: ["a"], + prefix: ["?"], + better: ["Better"], + who: [], + kick: [], + invite: [], + day: [], +}; + +const dataFileLock = new KeyedAsyncLock(); + +function ensureDataPath(): void { + fs.mkdirSync(Environment.DATA_PATH, {recursive: true}); +} + +function readJsonFile(fileName: string, defaultValue: T): T { + ensureDataPath(); + + const filePath = `${Environment.DATA_PATH}/${fileName}`; + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, JSON.stringify(defaultValue, null, 2)); + return structuredClone(defaultValue); + } + + return JSON.parse(fs.readFileSync(filePath).toString()) as T; +} + export async function readData(): Promise { try { - jsonFile = JSON.parse(fs.readFileSync(`${Environment.DATA_PATH}/data.json`).toString()); + jsonFile = readJsonFile("data.json", DEFAULT_DATA); const admins = jsonFile.admins || []; admins.unshift(Environment.CREATOR_ID); @@ -23,48 +57,43 @@ export async function readData(): Promise { return Promise.resolve(); } catch (e) { - logError(e); + logError(e instanceof Error ? e : String(e)); return Promise.reject(e); } } -export async function readPrompts(): Promise { - try { - const prompt = fs.readFileSync(path.join(Environment.DATA_PATH, "system_prompt.txt")).toString().trim(); - if (prompt.length) { - Environment.setSystemPrompt(prompt); - } - } catch (e) { - logError(e); - } - - return Promise.resolve(); -} - export async function saveData(): Promise { - const adminIds: number[] = []; - Environment.ADMIN_IDS.forEach(id => adminIds.push(id)); - jsonFile.admins = adminIds; + return dataFileLock.runExclusive("data.json", async () => { + ensureDataPath(); + jsonFile ??= structuredClone(DEFAULT_DATA); - const mutedList: number[] = []; - Environment.MUTED_IDS.forEach(id => mutedList.push(id)); - jsonFile.muted = mutedList; + const adminIds: number[] = []; + Environment.ADMIN_IDS.forEach(id => adminIds.push(id)); + jsonFile.admins = adminIds; - try { - fs.writeFileSync(`${Environment.DATA_PATH}/data.json`, JSON.stringify(jsonFile)); - return readData(); - } catch (e) { - return Promise.reject(e); - } + const mutedList: number[] = []; + Environment.MUTED_IDS.forEach(id => mutedList.push(id)); + jsonFile.muted = mutedList; + + try { + const filePath = path.join(Environment.DATA_PATH, "data.json"); + const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(jsonFile)); + fs.renameSync(tmpPath, filePath); + return readData(); + } catch (e) { + return Promise.reject(e); + } + }); } export async function retrieveAnswers(): Promise { try { - const json: Answers = JSON.parse(fs.readFileSync(`${Environment.DATA_PATH}/answers.json`).toString()); + const json = readJsonFile("answers.json", DEFAULT_ANSWERS); Environment.setAnswers(json); return Promise.resolve(); } catch (e) { - logError(e); + logError(e instanceof Error ? e : String(e)); return Promise.reject(e); } -} \ No newline at end of file +} diff --git a/src/db/db-types.ts b/src/db/db-types.ts new file mode 100644 index 0000000..8b6cad3 --- /dev/null +++ b/src/db/db-types.ts @@ -0,0 +1,89 @@ +export type UserDbRow = { + id: number; + isBot: number; + firstName: string; + lastName: string | null; + userName: string | null; + isPremium: number | null; + langCode: string | null; + interfaceLanguage: string | null; + aiProvider: string | null; + aiResponseLanguage: string | null; + aiContextSize: number | null; + aiVoiceMode: string | null; + aiImageOutputMode: string | null; +}; + +export type MessageDbRow = { + id: number; + chatId: number; + replyToMessageId: number | null; + fromId: number; + text: string | null; + quoteText: string | null; + date: number; + deletedByBotAt: number | null; + attachments: string | null; + pipelineAudit: string | null; +}; + +export type AttachmentDbRow = { + id: string; + messageChatId: number; + messageId: number; + direction: string; + scope: string; + kind: string; + artifactKind: string | null; + fileId: string; + fileUniqueId: string | null; + fileName: string; + mimeType: string | null; + cachePath: string; + sizeBytes: number | null; + sha256: string | null; + metadata: string | null; + createdAt: string; +}; + +export type ArtifactDbRow = { + id: string; + requestId: string; + messageChatId: number; + messageId: number; + kind: string; + stage: string; + attachmentId: string | null; + payload: string; + createdAt: string; +}; + +export type RequestAuditDbRow = { + id: string; + requestId: string; + messageChatId: number; + messageId: number; + stage: string; + status: string; + startedAt: string | null; + finishedAt: string | null; + durationMs: number | null; + provider: string | null; + model: string | null; + details: string | null; + error: string | null; +}; + +export type AiRequestDbRow = { + requestId: string; + chatId: number; + messageId: number; + responseMessageId: number | null; + fromId: number; + provider: string; + model: string; + status: string; + startedAt: string; + finishedAt: string | null; + error: string | null; +}; diff --git a/src/db/message-dao.ts b/src/db/message-dao.ts index 2bf7768..ebf1917 100644 --- a/src/db/message-dao.ts +++ b/src/db/message-dao.ts @@ -1,110 +1,182 @@ -import {MessageInsert, messagesTable} from "./schema"; import {DatabaseManager} from "./database-manager"; import {StoredMessage} from "../model/stored-message"; -import {and, eq} from "drizzle-orm"; -import {inArray} from "drizzle-orm/sql/expressions/conditions"; import {Dao} from "../base/dao"; -import {buildExcludedSet} from "../util/utils"; +import {appLogger} from "../logging/logger"; +import {StoredAttachment} from "../model/stored-attachment"; +import {MessageDbRow} from "./db-types"; +import type {PipelineAuditEvent} from "../ai/user-request-pipeline"; -export class MessageDao extends Dao { +export class MessageDao extends Dao { - private tag: string = "MessageDao"; + private readonly logger = appLogger.child("dao:messages"); override async getAll(): Promise { const then = Date.now(); - const messages = await DatabaseManager.db.select().from(messagesTable); + const messages = await DatabaseManager.getAllMessages(); + const hydrated = await this.hydrateMissingMessageData(messages); const now = Date.now(); const diff = now - then; - console.log(`${this.tag}: getAll()`, `took ${diff}ms; size: ${messages.length}`); + this.logger.trace("get_all", {dao: "messages", duration: `${diff}ms`, size: hydrated.length}); - return this.mapFrom(messages); + return this.mapFrom(hydrated); } override async getById(params: { chatId: number, id: number }): Promise { const then = Date.now(); - const messages = - await DatabaseManager.db.select() - .from(messagesTable) - .where( - and( - eq(messagesTable.chatId, params.chatId), - eq(messagesTable.id, params.id) - ) - ); + const message = await DatabaseManager.getMessageById(params.chatId, params.id); + const hydrated = await this.hydrateMissingMessageData(message ? [message] : []); const now = Date.now(); const diff = now - then; - console.log(`${this.tag}: getById(${params.chatId}, ${params.id})`, `took ${diff}ms; size: ${messages.length}`); + this.logger.trace("get_by_id", {dao: "messages", chatId: params.chatId, id: params.id, duration: `${diff}ms`, size: hydrated.length}); - const m = messages[0]; - if (!m) return null; - return this.mapFrom([m])[0]; + if (!hydrated.length) return null; + return this.mapFrom(hydrated)[0]; } override async getByIds(params: { chatId: number, ids: number[] }): Promise { const then = Date.now(); - const messages = - await DatabaseManager.db.select() - .from(messagesTable) - .where( - and( - eq(messagesTable.chatId, params.chatId), - inArray(messagesTable.id, params.ids) - ) - ); + const messages = await DatabaseManager.getMessagesByIds(params.chatId, params.ids); + const hydrated = await this.hydrateMissingMessageData(messages); const now = Date.now(); const diff = now - then; - console.log(`${this.tag}: getByIds(${params.chatId}, ${params.ids})`, `took ${diff}ms; size: ${messages.length}`); + this.logger.trace("get_by_ids", {dao: "messages", chatId: params.chatId, ids: params.ids, duration: `${diff}ms`, size: hydrated.length}); - return this.mapFrom(messages); + return this.mapFrom(hydrated); } - async insert(values: MessageInsert[]): Promise { + async insert(values: MessageDbRow[]): Promise { + if (!values.length) return true; + const then = Date.now(); - const r = await DatabaseManager.db - .insert(messagesTable) - .values(values) - .onConflictDoUpdate({ - target: messagesTable.id, - set: buildExcludedSet(messagesTable, ["id"]) - }); + await DatabaseManager.upsertMessages(values); const now = Date.now(); const diff = now - then; - console.log(`${this.tag}: insert(size: ${values.length})`, `took ${diff}ms'; inserted: ${r.rowsAffected}`); + this.logger.debug("insert", {dao: "messages", duration: `${diff}ms`, size: values.length}); return true; } - mapStoredTo(messages: StoredMessage[]): MessageInsert[] { + mapStoredTo(messages: StoredMessage[]): MessageDbRow[] { return messages.map(msg => { return { chatId: msg.chatId, id: msg.id, - replyToMessageId: msg.replyToMessageId, + replyToMessageId: msg.replyToMessageId ?? null, fromId: msg.fromId, - text: msg.text, + text: msg.text ?? null, + quoteText: msg.quoteText ?? null, date: msg.date, - photoMaxSizeFilePath: msg.photoMaxSizeFilePath?.join(";"), + deletedByBotAt: msg.deletedByBotAt ?? null, + attachments: msg.attachments?.length ? JSON.stringify(msg.attachments) : null, + pipelineAudit: msg.pipelineAudit?.length ? JSON.stringify(msg.pipelineAudit) : null, }; }); } - mapFrom(messages: MessageInsert[]): StoredMessage[] { + mapFrom(messages: MessageDbRow[]): StoredMessage[] { return messages.map(m => { return { chatId: m.chatId, id: m.id, - replyToMessageId: m.replyToMessageId, + replyToMessageId: m.replyToMessageId || undefined, fromId: m.fromId, text: m.text, + quoteText: m.quoteText, date: m.date, - photoMaxSizeFilePath: m.photoMaxSizeFilePath?.split(";") + deletedByBotAt: m.deletedByBotAt, + attachments: parseAttachments(m.attachments), + pipelineAudit: parsePipelineAudit(m.pipelineAudit), }; }); } -} \ No newline at end of file + + private async hydrateMissingMessageData(messages: MessageDbRow[]): Promise { + if (!messages.length) return []; + + return await Promise.all(messages.map(async message => { + if (message.attachments?.trim() && message.pipelineAudit?.trim()) return message; + + const [attachments, audits] = await Promise.all([ + message.attachments?.trim() ? Promise.resolve(null) : DatabaseManager.getAttachmentsByMessage(message.chatId, message.id), + message.pipelineAudit?.trim() ? Promise.resolve(null) : DatabaseManager.getRequestAuditsByMessage(message.chatId, message.id), + ]); + const normalizedAttachments = attachments ?? []; + const normalizedAudits = audits ?? []; + + return { + ...message, + attachments: message.attachments ?? (normalizedAttachments.length ? JSON.stringify(normalizedAttachments.map(row => attachmentFromRow(row))) : null), + pipelineAudit: message.pipelineAudit ?? (normalizedAudits.length ? JSON.stringify(normalizedAudits.map(row => auditFromRow(row))) : null), + }; + })); + } +} + +function parsePipelineAudit(value?: string | null): PipelineAuditEvent[] | undefined { + if (!value?.trim()) return undefined; + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function parseAttachments(value?: string | null): StoredAttachment[] | undefined { + if (!value?.trim()) return undefined; + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function attachmentFromRow(row: Awaited>[number]): StoredAttachment { + return { + kind: row.kind as StoredAttachment["kind"], + fileId: row.fileId, + fileUniqueId: row.fileUniqueId ?? undefined, + fileName: row.fileName, + mimeType: row.mimeType ?? undefined, + cachePath: row.cachePath, + sizeBytes: row.sizeBytes ?? undefined, + sha256: row.sha256 ?? undefined, + scope: row.scope as StoredAttachment["scope"] | undefined, + artifactKind: row.artifactKind as StoredAttachment["artifactKind"] | undefined, + metadata: parseJsonObject(row.metadata), + }; +} + +function auditFromRow(row: Awaited>[number]): NonNullable[number] { + return { + stage: row.stage as NonNullable[number]["stage"], + status: row.status as NonNullable[number]["status"], + startedAt: row.startedAt ?? undefined, + finishedAt: row.finishedAt ?? undefined, + durationMs: row.durationMs ?? undefined, + provider: row.provider as NonNullable[number]["provider"], + model: row.model ?? undefined, + details: parseJsonObject(row.details), + error: row.error ?? undefined, + }; +} + +function parseJsonObject(value?: string | null): Record | undefined { + if (!value?.trim()) return undefined; + + try { + const parsed = JSON.parse(value); + return typeof parsed === "object" && parsed !== null ? parsed as Record : undefined; + } catch { + return undefined; + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts deleted file mode 100644 index 9a4ef17..0000000 --- a/src/db/schema.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {int, sqliteTable, text} from "drizzle-orm/sqlite-core"; - -export const messagesTable = sqliteTable("messages", { - id: int().primaryKey().unique().notNull(), - chatId: int().notNull(), - replyToMessageId: int(), - fromId: int().notNull(), - text: text(), - date: int().notNull(), - photoMaxSizeFilePath: text(), -}); - -export type MessageInsert = typeof messagesTable.$inferInsert; - -export const usersTable = sqliteTable("users", { - id: int().primaryKey().unique().notNull(), - isBot: int().notNull(), - firstName: text().notNull(), - lastName: text(), - userName: text(), - isPremium: int(), -}); - -export type UserInsert = typeof usersTable.$inferInsert; diff --git a/src/db/user-dao.ts b/src/db/user-dao.ts index 4223f7e..499704d 100644 --- a/src/db/user-dao.ts +++ b/src/db/user-dao.ts @@ -1,24 +1,23 @@ import {StoredUser} from "../model/stored-user"; import {Dao} from "../base/dao"; +import {appLogger} from "../logging/logger"; import {DatabaseManager} from "./database-manager"; -import {UserInsert, usersTable} from "./schema"; -import {eq} from "drizzle-orm"; -import {inArray} from "drizzle-orm/sql/expressions/conditions"; import {User} from "typescript-telegram-bot-api"; -import {boolToInt, buildExcludedSet} from "../util/utils"; +import {boolToInt} from "../util/utils"; +import {UserDbRow} from "./db-types"; -export class UserDao extends Dao { +export class UserDao extends Dao { - private tag: string = "UserDao"; + private readonly logger = appLogger.child("dao:users"); override async getAll(): Promise { const then = Date.now(); - const users = await DatabaseManager.db.select().from(usersTable); + const users = await DatabaseManager.getAllUsers(); const now = Date.now(); const diff = now - then; - console.log(`${this.tag}: getAll()`, `took ${diff}ms; size: ${users.length}`); + this.logger.trace("get_all", {dao: "users", duration: `${diff}ms`, size: users.length}); return this.mapFrom(users); } @@ -26,80 +25,87 @@ export class UserDao extends Dao { override async getById(params: { id: number }): Promise { const then = Date.now(); - const users = - await DatabaseManager.db.select() - .from(usersTable) - .where( - eq(usersTable.id, params.id) - ); + const user = await DatabaseManager.getUserById(params.id); const now = Date.now(); const diff = now - then; - console.log(`${this.tag}: getById(${params.id})`, `took ${diff}ms; size: ${users.length}`); + this.logger.trace("get_by_id", {dao: "users", id: params.id, duration: `${diff}ms`, size: user ? 1 : 0}); - const u = users[0]; - if (!u) return null; - return this.mapFrom([u])[0]; + if (!user) return null; + return this.mapFrom([user])[0]; } override async getByIds(params: { ids: number[] }): Promise { const then = Date.now(); - const users = - await DatabaseManager.db.select() - .from(usersTable) - .where( - inArray(usersTable.id, params.ids) - ); + const users = await DatabaseManager.getUsersByIds(params.ids); const now = Date.now(); const diff = now - then; - console.log(`${this.tag}: getByIds(${params.ids})`, `took ${diff}ms; size: ${users.length}`); + this.logger.trace("get_by_ids", {dao: "users", ids: params.ids, duration: `${diff}ms`, size: users.length}); return this.mapFrom(users); } - override async insert(values: UserInsert[] | UserInsert): Promise { + override async insert(values: UserDbRow[] | UserDbRow): Promise { const rows = Array.isArray(values) ? values : [values]; + if (!rows.length) return true; const then = Date.now(); - const r = await DatabaseManager.db - .insert(usersTable) - .values(rows) - .onConflictDoUpdate({ - target: usersTable.id, - set: buildExcludedSet(usersTable, ["id"]) - }); + await DatabaseManager.upsertUsers(rows); const now = Date.now(); const diff = now - then; - console.log(`${this.tag}: insert(size: ${rows.length})`, `took ${diff}ms; inserted: ${r.rowsAffected}`); + this.logger.debug("insert", {dao: "users", duration: `${diff}ms`, size: rows.length}); return true; } - mapTo(users: User[]): UserInsert[] { + async updateSettings( + id: number, + settings: Partial> + ): Promise { + await DatabaseManager.updateUserSettings(id, settings); + + return true; + } + + mapTo(users: User[]): UserDbRow[] { return users.map(u => { return { id: u.id, isBot: boolToInt(u.is_bot), firstName: u.first_name, - lastName: u.last_name, - userName: u.username, - isPremium: boolToInt(u.is_premium) + lastName: u.last_name ?? null, + userName: u.username ?? null, + isPremium: boolToInt(u.is_premium), + langCode: u.language_code ?? null, + interfaceLanguage: null, + aiProvider: null, + aiResponseLanguage: null, + aiContextSize: null, + aiVoiceMode: null, + aiImageOutputMode: null, }; }); } - mapFrom(users: UserInsert[]): StoredUser[] { + mapFrom(users: UserDbRow[]): StoredUser[] { return users.map(u => { return { id: u.id, isBot: u.isBot === 1, firstName: u.firstName, - lastName: u.lastName, - userName: u.userName, - isPremium: u.isPremium === 1 + lastName: u.lastName === null ? undefined : u.lastName, + userName: u.userName === null ? undefined : u.userName, + isPremium: u.isPremium === 1, + langCode: u.langCode === null ? undefined : u.langCode, + interfaceLanguage: u.interfaceLanguage === null ? undefined : u.interfaceLanguage, + aiProvider: u.aiProvider === null ? undefined : u.aiProvider, + aiResponseLanguage: u.aiResponseLanguage === null ? undefined : u.aiResponseLanguage, + aiContextSize: u.aiContextSize === null ? undefined : u.aiContextSize, + aiVoiceMode: u.aiVoiceMode === null ? undefined : u.aiVoiceMode, + aiImageOutputMode: u.aiImageOutputMode === null ? undefined : u.aiImageOutputMode, }; }); } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 13f8229..69d93f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,15 @@ import "dotenv/config"; +import {appLogger} from "./logging/logger"; import {Environment} from "./common/environment"; -import {TelegramBot, User} from "typescript-telegram-bot-api"; +import {BotCommand, TelegramBot, User} from "typescript-telegram-bot-api"; import {Command} from "./base/command"; +import type {LogDetails} from "./logging/logger"; import { - delay, initSystemSpecs, logError, processCallbackQuery, processEditedMessage, + processGuestMessage, processInlineQuery, processMyChatMember, processNewMessage @@ -20,24 +22,21 @@ import {Ping} from "./commands/ping"; import {RandomString} from "./commands/random-string"; import {SystemInfo} from "./commands/system-info"; import {Test} from "./commands/test"; -import {readData, readPrompts, retrieveAnswers} from "./db/database"; +import {readData, retrieveAnswers} from "./db/database"; import {Uptime} from "./commands/uptime"; import {WhatBetter} from "./commands/what-better"; import {When} from "./commands/when"; import {RandomInt} from "./commands/random-int"; import {Ban} from "./commands/ban"; import {Quote} from "./commands/quote"; -import {Ollama} from "ollama"; import {OllamaSearch} from "./commands/ollama-search"; import {Id} from "./commands/id"; -import {OllamaPrompt} from "./commands/ollama-prompt"; import {AdminsAdd} from "./commands/admins-add"; import {AdminsRemove} from "./commands/admins-remove"; import {Shutdown} from "./commands/shutdown"; import {Leave} from "./commands/leave"; import {OllamaChat} from "./commands/ollama-chat"; import {Start} from "./commands/start"; -import {GeminiChat} from "./commands/gemini-chat"; import {Choice} from "./commands/choice"; import {Coin} from "./commands/coin"; import {Qr} from "./commands/qr"; @@ -49,40 +48,33 @@ import {MessageDao} from "./db/message-dao"; import {DatabaseManager} from "./db/database-manager"; import {UserDao} from "./db/user-dao"; import {UserStore} from "./common/user-store"; -import {OllamaRequest} from "./model/ollama-request"; import {CallbackCommand} from "./base/callback-command"; -import {OllamaCancel} from "./callback_commands/ollama-cancel"; +import {AiCancel} from "./callback_commands/ai-cancel"; +import {AiRegenerate} from "./callback_commands/ai-regenerate"; import {MistralChat} from "./commands/mistral-chat"; import {Transliteration} from "./commands/transliteration"; import {OllamaListModels} from "./commands/ollama-list-models"; import {OllamaGetModel} from "./commands/ollama-get-model"; import {OllamaSetModel} from "./commands/ollama-set-model"; -import {Mistral} from "@mistralai/mistralai"; -import {GoogleGenAI} from "@google/genai"; import {MistralGetModel} from "./commands/mistral-get-model"; import {MistralSetModel} from "./commands/mistral-set-model"; import {MistralListModels} from "./commands/mistral-list-models"; -import {GeminiListModels} from "./commands/gemini-list-models"; -import {GeminiGetModel} from "./commands/gemini-get-model"; -import {GeminiSetModel} from "./commands/gemini-set-model"; import {Debug} from "./commands/debug"; -import {GeminiGenerateImage} from "./commands/gemini-generate-image"; -import {YouTubeDownload} from "./commands/youtube-download"; import fs from "node:fs"; import path from "node:path"; -import {setInterval} from "node:timers"; -import {OpenAI} from "openai"; import {OpenAIChat} from "./commands/openai-chat"; import {OpenAIListModels} from "./commands/openai-list-models"; import {OpenAIGetModel} from "./commands/openai-get-model"; import {OpenAISetModel} from "./commands/openai-set-model"; import {Info} from "./commands/info"; -import {OpenAIGenImage} from "./commands/openai-gen-image"; -import {clearUpFolderFromOldFiles} from "./util/files"; -import {DownloadYtVideo} from "./callback_commands/download-yt-video"; -import {YtInfo} from "./callback_commands/yt-info"; import {AdminsList} from "./commands/admins-list"; import {ExportDb} from "./commands/export-db"; +import {ImportDb} from "./commands/import-db"; +import {Settings} from "./commands/settings"; +import {UserSettingsCallback} from "./callback_commands/user-settings"; +import {TextToSpeech} from "./commands/text-to-speech"; +import {SpeechToText} from "./commands/speech-to-text"; +import {cleanupInternalArtifactCache} from "./ai/internal-artifact-store"; process.setUncaughtExceptionCaptureCallback(logError); @@ -95,42 +87,6 @@ export const userDao = new UserDao(); export const bot = new TelegramBot({botToken: Environment.BOT_TOKEN, testEnvironment: Environment.TEST_ENVIRONMENT}); export let botUser: User; -export const googleAi = new GoogleGenAI({apiKey: Environment.GEMINI_API_KEY}); -export const mistralAi = new Mistral({apiKey: Environment.MISTRAL_API_KEY}); -export const openAi = new OpenAI({apiKey: Environment.OPENAI_API_KEY, baseURL: Environment.OPENAI_BASE_URL, dangerouslyAllowBrowser: true}); - -export const ollama = new Ollama({ - host: Environment.OLLAMA_ADDRESS, - headers: {"Authorization": `Bearer ${Environment.OLLAMA_API_KEY}`} -}); - -export const ollamaRequests: OllamaRequest[] = []; - -export function getOllamaRequest(uuid: string): OllamaRequest | null { - return ollamaRequests.find(r => r.uuid === uuid); -} - -export function updateOllamaRequest(uuid: string, request: OllamaRequest) { - const index = ollamaRequests.findIndex(r => r.uuid === uuid); - if (index >= 0) { - ollamaRequests[index] = request; - } -} - -export function abortOllamaRequest(uuid: string): boolean { - const request = getOllamaRequest(uuid); - if (!request || request.done) return false; - - try { - request.stream.abort(); - updateOllamaRequest(uuid, {...request, done: true}); - return true; - } catch (e) { - logError(e); - return false; - } -} - export const commands: Command[] = [ new Start(), new Help(), @@ -160,17 +116,19 @@ export const commands: Command[] = [ new Transliteration(), new Debug(), new Info(), + new Settings(), + new TextToSpeech(), + new SpeechToText(), new AdminsAdd(), new AdminsRemove(), new AdminsList(), new ExportDb(), + new ImportDb(), new Shutdown(), new Leave(), - - new YouTubeDownload() ]; if (Environment.ENABLE_UNSAFE_EVAL) { @@ -178,15 +136,14 @@ if (Environment.ENABLE_UNSAFE_EVAL) { } export const callbackCommands: CallbackCommand[] = [ - new OllamaCancel(), - new DownloadYtVideo(), - new YtInfo() + new AiCancel(), + new AiRegenerate(), + new UserSettingsCallback(), ]; -if (Environment.OLLAMA_ADDRESS && Environment.OLLAMA_MODEL && Environment.SYSTEM_PROMPT) { +if (Environment.OLLAMA_ADDRESS && Environment.OLLAMA_CHAT_MODEL) { commands.push( new OllamaChat(), - new OllamaPrompt(), new OllamaListModels(), new OllamaGetModel(), new OllamaSetModel() @@ -197,16 +154,6 @@ if (Environment.OLLAMA_API_KEY) { commands.push(new OllamaSearch()); } -if (Environment.GEMINI_API_KEY) { - commands.push( - new GeminiChat(), - new GeminiListModels(), - new GeminiGetModel(), - new GeminiSetModel(), - new GeminiGenerateImage() - ); -} - if (Environment.MISTRAL_API_KEY) { commands.push( new MistralChat(), @@ -222,29 +169,70 @@ if (Environment.OPENAI_API_KEY) { new OpenAIListModels(), new OpenAIGetModel(), new OpenAISetModel(), - new OpenAIGenImage() ); } export const cacheDir = path.join(Environment.DATA_PATH, "cache"); export const photoDir = path.join(cacheDir, "photo"); export const photoGenDir = path.join(photoDir, "gen"); +export const documentDir = path.join(cacheDir, "document"); +export const audioDir = path.join(cacheDir, "audio"); export const videoDir = path.join(cacheDir, "video"); +export const videoNotesDir = path.join(cacheDir, "video-note"); export const videoTempDir = path.join(videoDir, "temp"); +export const filesDir = path.join(Environment.DATA_PATH, "files"); + +export const NOTES_HEADER = "## Notes\n"; +export const notesDir = path.join(Environment.DATA_PATH, "notes"); +export const notesRootFile = path.join(notesDir, "index.md"); + +const logger = appLogger.child("main"); + let isShuttingDown = false; -async function shutdown(signal: NodeJS.Signals) { +async function measureStartupStep(step: string, task: () => Promise | T, details?: () => LogDetails): Promise { + const startedAt = Date.now(); + logger.info("startup.step.start", { + step, + ...(details?.() ?? {}), + }); + + try { + const result = await task(); + logger.success("startup.step.done", { + step, + duration: `${Date.now() - startedAt}ms`, + ...(details?.() ?? {}), + }); + return result; + } catch (error) { + logger.error("startup.step.failed", { + step, + duration: `${Date.now() - startedAt}ms`, + ...(details?.() ?? {}), + error: error instanceof Error ? error : String(error), + }); + throw error; + } +} + +export async function shutdown(signal: NodeJS.Signals | "manual") { if (isShuttingDown) return; isShuttingDown = true; - console.log(`Received ${signal}. Stopping bot polling...`); + logger.warn("shutdown.signal", {signal}); try { await bot.stopPolling(); } catch (error) { - logError(error); + logError(error instanceof Error ? error : String(error)); } finally { + try { + await DatabaseManager.close(); + } catch (error) { + logError(error instanceof Error ? error : String(error)); + } process.exit(0); } } @@ -252,69 +240,71 @@ async function shutdown(signal: NodeJS.Signals) { async function main() { const start = Date.now(); - await readPrompts(); + logger.info("startup.begin", { + testEnvironment: Environment.TEST_ENVIRONMENT, + isDocker: Environment.IS_DOCKER, + dataPath: Environment.DATA_PATH, + database: Environment.databaseSummaryText, + }); - console.log(Environment.SYSTEM_PROMPT); - - console.log( - `TEST_ENVIRONMENT: ${Environment.TEST_ENVIRONMENT}\n` + - `DATA_PATH: ${Environment.DATA_PATH}\n` + - `MAX_PHOTO_SIZE: ${Environment.MAX_PHOTO_SIZE}\n` + - `ONLY_FOR_CREATOR: ${Environment.ONLY_FOR_CREATOR_MODE}\n` + - `DEFAULT_AI_PROVIDER: ${Environment.DEFAULT_AI_PROVIDER}` - ); - - const dirsToCheck = [cacheDir, photoDir, photoGenDir, videoDir, videoTempDir]; - dirsToCheck.forEach(dir => { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir); + await measureStartupStep("environment.load", () => Environment.load()); + const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir, filesDir]; + await measureStartupStep("prepare_directories", () => { + const created: string[] = []; + for (const dir of dirsToCheck) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, {recursive: true}); + created.push(dir); + } } - }); + return {created}; + }, () => ({directories: dirsToCheck.length})); - const now = new Date(); + const notesRootFilePath = path.join(notesDir, "index.md"); + await measureStartupStep("prepare_notes_index", () => { + if (!fs.existsSync(notesRootFilePath)) { + fs.writeFileSync(notesRootFilePath, "\n" + NOTES_HEADER); + } - const midnight = new Date(); - midnight.setHours(0, 0, 0, 0); - midnight.setDate(now.getDate() + 1); + if (!(fs.readFileSync(notesRootFilePath).toString().includes(NOTES_HEADER))) { + fs.appendFileSync(notesRootFilePath, "\n" + NOTES_HEADER); + } + }, () => ({notesRootFilePath})); - const diff = midnight.getTime() - now.getTime(); - console.log("Clearing up cache will be started in " + diff + "ms"); + await measureStartupStep("cleanup_internal_artifacts", () => cleanupInternalArtifactCache(), () => ({retentionDays: 14})); - clearUpFolderFromOldFiles(cacheDir); - delay(diff).then(() => { - setInterval(() => { - console.log("Started clearing up cache"); - clearUpFolderFromOldFiles(cacheDir); - }, 1000 * 60 * 60 * 24); - }); - - const cmds = commands.filter(cmd => { + const cmds = await measureStartupStep("build_commands", () => commands.filter(cmd => { return cmd.title && cmd.title.startsWith("/") && cmd.title.split(" ").length === 1 && cmd.description; }).map(cmd => { return { - command: cmd.title.toLowerCase(), + command: cmd.title?.toLowerCase() || "", description: cmd.description, }; + }) as BotCommand[], () => ({commands: commands.length})); + + await measureStartupStep("database.ready", () => DatabaseManager.ready, () => ({database: Environment.databaseSummaryText})); + + const [_, __, ___, me] = await measureStartupStep("load_runtime", () => Promise.all( + [ + measureStartupStep("init_system_specs", () => initSystemSpecs()), + measureStartupStep("read_data", () => readData()), + measureStartupStep("retrieve_answers", () => retrieveAnswers()), + measureStartupStep("bot.getMe", () => bot.getMe()), + measureStartupStep("bot.setMyCommands", () => bot.setMyCommands({commands: cmds, scope: {type: "default"}})), + ] + )); + botUser = me; + await measureStartupStep("user_store.put", () => UserStore.put(botUser), () => ({botId: botUser.id})); + await measureStartupStep("bot.startPolling", () => bot.startPolling(), () => ({botId: botUser.id})); + + const end = Date.now(); + const diff = Math.abs(end - start); + logger.success("startup.ready", { + duration: `${diff}ms`, + commands: cmds.length, + botId: botUser.id, + botUsername: botUser.username }); - - try { - const results = await Promise.all( - [ - initSystemSpecs(), readData(), retrieveAnswers(), - bot.getMe(), - bot.setMyCommands({commands: cmds, scope: {type: "default"}}) - ] - ); - botUser = results[3]; - await UserStore.put(botUser); - await bot.startPolling(); - - const end = Date.now(); - const diff = Math.abs(end - start); - console.log(`Bot started in ${diff}ms!`); - } catch (error) { - logError(error); - } } bot.on("my_chat_member", processMyChatMember); @@ -322,6 +312,7 @@ bot.on("edited_message", processEditedMessage); bot.on("message", processNewMessage); bot.on("inline_query", processInlineQuery); bot.on("callback_query", processCallbackQuery); +bot.on("guest_message", processGuestMessage); process.on("SIGTERM", () => { shutdown("SIGTERM").catch(logError); @@ -331,4 +322,7 @@ process.on("SIGINT", () => { shutdown("SIGINT").catch(logError); }); -main().catch(logError); \ No newline at end of file +main().catch(error => { + logError(error); + process.exit(1); +}); diff --git a/src/logging/ai-logger.ts b/src/logging/ai-logger.ts new file mode 100644 index 0000000..f6be159 --- /dev/null +++ b/src/logging/ai-logger.ts @@ -0,0 +1,69 @@ +import {Message} from "typescript-telegram-bot-api"; +import {createLogger, formatDuration, LogDetails, LogLevel} from "./logger"; + +export type AiRunnerLogLevel = LogLevel; +export type AiRunnerLogDetails = LogDetails; + +export type AiLogToolCallLike = { + id: string; + name: string; + argumentsText: string; +}; + +const aiRunnerLogger = createLogger("unified-ai-runner", { + envPrefix: "AI", + defaultLevel: "debug", + enabledEnvNames: ["AI_RUNNER_LOGS", "AI_LOG_ENABLED"], + colorsEnvNames: ["AI_RUNNER_LOG_COLORS", "AI_LOG_COLORS"], +}); + +function safeJsonParseObject(value?: string): LogDetails { + if (!value?.trim()) return {}; + + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as LogDetails + : {}; + } catch { + return {}; + } +} + +export function aiLog(level: AiRunnerLogLevel, event: string, details?: AiRunnerLogDetails): void { + aiRunnerLogger[level](event, details); +} + +export function aiLogDuration(startedAt: number): string { + return formatDuration(startedAt); +} + +export function aiLogToolCall(toolCall: AiLogToolCallLike): LogDetails { + return { + id: toolCall.id, + name: toolCall.name, + arguments: safeJsonParseObject(toolCall.argumentsText), + }; +} + +export function aiLogMessageIdentity(msg: Message | undefined): LogDetails | undefined { + if (!msg) return undefined; + return { + chatId: msg.chat?.id, + chatType: msg.chat?.type, + messageId: msg.message_id, + fromId: msg.from?.id, + username: msg.from?.username, + }; +} + +export function aiLogProviderTarget(target: {provider: string; purpose?: string; model?: string; baseUrl?: string; apiKey?: string} | undefined): LogDetails | undefined { + if (!target) return undefined; + return { + provider: target.provider, + purpose: target.purpose, + model: target.model, + baseUrl: target.baseUrl, + apiKey: target.apiKey, + }; +} diff --git a/src/logging/logger.ts b/src/logging/logger.ts new file mode 100644 index 0000000..10a582a --- /dev/null +++ b/src/logging/logger.ts @@ -0,0 +1,357 @@ +import type {BoundaryValue} from "../common/boundary-types"; + +export type LogLevel = "trace" | "debug" | "info" | "success" | "warn" | "error"; + +export type LogPrimitive = string | number | boolean | bigint | null | undefined; +export interface LogDetails { + [key: string]: LogValue; +} +export type LogValue = LogPrimitive | Error | Date | Buffer | readonly LogValue[] | object | BoundaryValue; + +export type LoggerOptions = { + envPrefix?: string; + defaultLevel?: LogLevel; + enabledEnvNames?: readonly string[]; + colorsEnvNames?: readonly string[]; +}; + +export type Logger = { + scope: string; + trace(event: string, details?: LogDetails): void; + debug(event: string, details?: LogDetails): void; + info(event: string, details?: LogDetails): void; + success(event: string, details?: LogDetails): void; + warn(event: string, details?: LogDetails): void; + error(event: string, details?: LogDetails): void; + child(scope: string, options?: LoggerOptions): Logger; + duration(startedAt: number): string; + enabled(level?: LogLevel): boolean; +}; + +const DEFAULT_MAX_STRING = 600; +const DEFAULT_MAX_ARRAY = 8; +const DEFAULT_MAX_DEPTH = 3; + +const LOG_LEVEL_WEIGHT: Record = { + trace: 10, + debug: 20, + info: 30, + success: 30, + warn: 40, + error: 50, +}; + +const LOG_COLORS: Record = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + trace: "\x1b[90m", + debug: "\x1b[90m", + info: "\x1b[36m", + success: "\x1b[32m", + warn: "\x1b[33m", + error: "\x1b[31m", + label: "\x1b[35m", + key: "\x1b[94m", + value: "\x1b[97m", +}; + +const FALSE_VALUES = new Set(["0", "false", "no", "off", "disable", "disabled"]); +const TRUE_VALUES = new Set(["1", "true", "yes", "on", "enable", "enabled"]); + +export function envBool(name: string, defaultValue: boolean): boolean { + const value = process.env[name]; + if (value === undefined) return defaultValue; + + const normalized = value.trim().toLowerCase(); + if (FALSE_VALUES.has(normalized)) return false; + if (TRUE_VALUES.has(normalized)) return true; + return defaultValue; +} + +function envNumber(name: string, defaultValue: number): number { + const raw = process.env[name]; + if (!raw?.trim()) return defaultValue; + + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultValue; +} + +function configuredMaxString(): number { + return envNumber("LOG_MAX_STRING", DEFAULT_MAX_STRING); +} + +function configuredMaxArray(): number { + return envNumber("LOG_MAX_ARRAY", DEFAULT_MAX_ARRAY); +} + +function configuredMaxDepth(): number { + return envNumber("LOG_MAX_DEPTH", DEFAULT_MAX_DEPTH); +} + +function isValidLogLevel(level: string): level is LogLevel { + return level in LOG_LEVEL_WEIGHT; +} + +function scopedEnvName(prefix: string | undefined, suffix: string): string | undefined { + if (!prefix?.trim()) return undefined; + return `${prefix.trim().toUpperCase()}_${suffix}`; +} + +function configuredMinLevel(options: LoggerOptions): LogLevel { + const scoped = scopedEnvName(options.envPrefix, "LOG_LEVEL"); + const raw = (scoped ? process.env[scoped] : undefined) ?? process.env.LOG_LEVEL; + const normalized = raw?.trim().toLowerCase(); + + if (normalized && isValidLogLevel(normalized)) return normalized; + return options.defaultLevel ?? "debug"; +} + +function envChainEnabled(names: readonly string[], defaultValue: boolean): boolean { + return names.every(name => envBool(name, defaultValue)); +} + +function logsEnabled(options: LoggerOptions): boolean { + const scoped = scopedEnvName(options.envPrefix, "LOG_ENABLED"); + const names = [ + "LOG_ENABLED", + "APP_LOG_ENABLED", + ...(scoped ? [scoped] : []), + ...(options.enabledEnvNames ?? []), + ]; + + return envChainEnabled(names, true); +} + +function colorsEnabled(options: LoggerOptions): boolean { + if (process.env.NO_COLOR) return false; + + const scoped = scopedEnvName(options.envPrefix, "LOG_COLORS"); + const names = [ + "LOG_COLORS", + ...(scoped ? [scoped] : []), + ...(options.colorsEnvNames ?? []), + ]; + + return envChainEnabled(names, true); +} + +function shouldWriteLevel(level: LogLevel, options: LoggerOptions): boolean { + return LOG_LEVEL_WEIGHT[level] >= LOG_LEVEL_WEIGHT[configuredMinLevel(options)]; +} + +function paint(value: string, color: keyof typeof LOG_COLORS, options: LoggerOptions): string { + if (!colorsEnabled(options)) return value; + return `${LOG_COLORS[color]}${value}${LOG_COLORS.reset}`; +} + +export function truncateLogString(value: string, max = configuredMaxString()): string { + if (value.length <= max) return value; + return `${value.slice(0, max)}… (+${value.length - max} chars)`; +} + +function isSecretKey(keyPath: string): boolean { + const normalized = keyPath.toLowerCase(); + return normalized.includes("token") + || normalized.includes("secret") + || normalized.includes("password") + || normalized.includes("passwd") + || normalized.includes("apikey") + || normalized.includes("api_key") + || normalized.includes("authorization") + || normalized.includes("cookie") + || normalized.includes("session") + || normalized.endsWith(".key") + || normalized === "key"; +} + +function isPromptKey(keyPath: string): boolean { + const normalized = keyPath.toLowerCase(); + return normalized.includes("prompt") || normalized.includes("systemprompt"); +} + +function isTextPreviewKey(keyPath: string): boolean { + const normalized = keyPath.toLowerCase(); + return normalized.includes("content") + || normalized.includes("message") + || normalized.includes("text") + || normalized.includes("preview") + || normalized.includes("input") + || normalized.includes("output") + || normalized.includes("transcript"); +} + +function isToolArgsKey(keyPath: string): boolean { + const normalized = keyPath.toLowerCase(); + return normalized.endsWith("args") + || normalized.endsWith("arguments") + || normalized.includes("toolargs") + || normalized.includes("tool_args"); +} + +function isDaoKey(keyPath: string): boolean { + const normalized = keyPath.toLowerCase(); + return normalized.includes("dao") + || normalized.includes("database") + || normalized.includes("db.") + || normalized.includes("sql") + || normalized.includes("chunk"); +} + +function shouldRedactKey(keyPath: string): boolean { + if (isSecretKey(keyPath)) return true; + if (isPromptKey(keyPath) && !envBool("AI_LOG_PROMPTS", false)) return true; + if (isToolArgsKey(keyPath) && !envBool("AI_LOG_TOOL_ARGS", false)) return true; + if (isDaoKey(keyPath) && !envBool("AI_LOG_DAO", false)) return true; + if (isTextPreviewKey(keyPath) && !envBool("AI_LOG_TEXT_PREVIEW", false)) return true; + return false; +} + +function primitiveToLogValue(value: LogValue): LogValue | undefined { + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack?.split("\n").slice(0, 8).join("\n"), + }; + } + + if (value instanceof Date) return value.toISOString(); + if (typeof value === "string") return truncateLogString(value); + if (typeof value === "number" || typeof value === "boolean" || value === null || value === undefined) return value; + if (typeof value === "bigint") return value.toString(); + if (Buffer.isBuffer(value)) return ``; + return undefined; +} + +function looksLikeLargeBinaryKey(key: string): boolean { + const normalized = key.toLowerCase(); + return normalized === "data" + || normalized === "image_url" + || normalized.endsWith("b64") + || normalized.endsWith("base64") + || normalized.includes("binary"); +} + +export function flattenLogDetails( + value: LogValue, + keyPath = "", + depth = 0, + seen = new WeakSet(), +): LogDetails { + if (keyPath && shouldRedactKey(keyPath)) { + return {[keyPath]: ""}; + } + + const primitive = primitiveToLogValue(value); + if (primitive !== undefined || value === undefined) { + return keyPath ? {[keyPath]: primitive} : {value: primitive}; + } + + if (typeof value !== "object" || value === null) { + return keyPath ? {[keyPath]: String(value)} : {value: String(value)}; + } + + if (seen.has(value)) { + return keyPath ? {[keyPath]: "[Circular]"} : {value: "[Circular]"}; + } + seen.add(value); + + if (Array.isArray(value)) { + if (depth >= configuredMaxDepth()) { + return keyPath ? {[keyPath]: `[Array ${value.length}]`} : {value: `[Array ${value.length}]`}; + } + + const entries: LogDetails = {}; + value.slice(0, configuredMaxArray()).forEach((item, index) => { + Object.assign(entries, flattenLogDetails(item, keyPath ? `${keyPath}.${index}` : String(index), depth + 1, seen)); + }); + if (value.length > configuredMaxArray()) { + entries[keyPath ? `${keyPath}.__more` : "__more"] = value.length - configuredMaxArray(); + } + return entries; + } + + if (depth >= configuredMaxDepth()) { + return keyPath ? {[keyPath]: "[Object]"} : {value: "[Object]"}; + } + + const entries: LogDetails = {}; + for (const [key, raw] of Object.entries(value as Record)) { + const childPath = keyPath ? `${keyPath}.${key}` : key; + if (looksLikeLargeBinaryKey(key) && typeof raw === "string") { + entries[childPath] = `<${raw.length} chars>`; + continue; + } + + Object.assign(entries, flattenLogDetails(raw, childPath, depth + 1, seen)); + } + + return entries; +} + +export function redactLogValue(value: LogValue): LogDetails { + return flattenLogDetails(value); +} + +function formatDetails(details: LogDetails | undefined, options: LoggerOptions): string { + if (!details || !Object.keys(details).length) return ""; + + const flattened = flattenLogDetails(details); + const chunks = Object.entries(flattened).map(([key, value]) => { + const safeValue = typeof value === "string" ? value : JSON.stringify(value); + return `${paint(key, "key", options)}=${paint(safeValue ?? "undefined", "value", options)}`; + }); + + return ` ${chunks.join(" ")}`; +} + +function writeLine(level: LogLevel, line: string): void { + if (level === "error") { + console.error(line); + return; + } + + if (level === "warn") { + console.warn(line); + return; + } + + console.log(line); +} + +export function formatDuration(startedAt: number): string { + const ms = Date.now() - startedAt; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +export function createLogger(scope: string, options: LoggerOptions = {}): Logger { + const normalizedScope = scope.trim() || "app"; + const resolvedOptions = {...options}; + + const log = (level: LogLevel, event: string, details?: LogDetails): void => { + if (!logsEnabled(resolvedOptions) || !shouldWriteLevel(level, resolvedOptions)) return; + + const timestamp = paint(new Date().toISOString(), "dim", resolvedOptions); + const prefix = paint(normalizedScope, "bold", resolvedOptions); + const levelText = paint(level.toUpperCase().padEnd(7), level, resolvedOptions); + const eventText = paint(event, "label", resolvedOptions); + writeLine(level, `${timestamp} ${prefix} ${levelText} ${eventText}${formatDetails(details, resolvedOptions)}`); + }; + + return { + scope: normalizedScope, + trace: (event, details) => log("trace", event, details), + debug: (event, details) => log("debug", event, details), + info: (event, details) => log("info", event, details), + success: (event, details) => log("success", event, details), + warn: (event, details) => log("warn", event, details), + error: (event, details) => log("error", event, details), + child: (childScope, childOptions) => createLogger(`${normalizedScope}:${childScope}`, {...resolvedOptions, ...childOptions}), + duration: formatDuration, + enabled: (level = "debug") => logsEnabled(resolvedOptions) && shouldWriteLevel(level, resolvedOptions), + }; +} + +export const appLogger = createLogger("app", {envPrefix: "APP", defaultLevel: "debug"}); diff --git a/src/model/ai-capability-info.ts b/src/model/ai-capability-info.ts index 345928a..291374a 100644 --- a/src/model/ai-capability-info.ts +++ b/src/model/ai-capability-info.ts @@ -1,5 +1,14 @@ +import {AiProvider} from "./ai-provider"; + +export type AiEndpointInfo = { + provider?: AiProvider; + baseUrl?: string; + external?: boolean; +}; + export type AiCapabilityInfo = { supported?: boolean, external?: boolean, - model?: string -}; \ No newline at end of file + model?: string, + endpoint?: AiEndpointInfo, +}; diff --git a/src/model/ai-model-capabilities.ts b/src/model/ai-model-capabilities.ts index ca540fb..175171d 100644 --- a/src/model/ai-model-capabilities.ts +++ b/src/model/ai-model-capabilities.ts @@ -1,8 +1,16 @@ import {AiCapabilityInfo} from "./ai-capability-info"; export class AiModelCapabilities { - vision?: AiCapabilityInfo; - ocr?: AiCapabilityInfo; - thinking?: AiCapabilityInfo; - tools?: AiCapabilityInfo; -} \ No newline at end of file + chat: AiCapabilityInfo | undefined; + vision: AiCapabilityInfo | undefined; + ocr: AiCapabilityInfo | undefined; + thinking: AiCapabilityInfo | undefined; + extendedThinking: AiCapabilityInfo | undefined; + tools: AiCapabilityInfo | undefined; + toolRank: AiCapabilityInfo | undefined; + audio: AiCapabilityInfo | undefined; + documents: AiCapabilityInfo | undefined; + outputImages: AiCapabilityInfo | undefined; + speechToText: AiCapabilityInfo | undefined; + textToSpeech: AiCapabilityInfo | undefined; +} diff --git a/src/model/ai-provider.ts b/src/model/ai-provider.ts index cd611ea..35968d9 100644 --- a/src/model/ai-provider.ts +++ b/src/model/ai-provider.ts @@ -1,6 +1,5 @@ export enum AiProvider { OLLAMA = "OLLAMA", - GEMINI = "GEMINI", MISTRAL = "MISTRAL", OPENAI = "OPENAI", -} \ No newline at end of file +} diff --git a/src/model/ollama-request.ts b/src/model/ollama-request.ts index 458aeee..c27ab1b 100644 --- a/src/model/ollama-request.ts +++ b/src/model/ollama-request.ts @@ -1,8 +1,7 @@ export type OllamaRequest = { uuid: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - stream: any; + stream: boolean | string | number | object | null | undefined; done: boolean; fromId: number; chatId: number; -} \ No newline at end of file +} diff --git a/src/model/stored-ai-request.ts b/src/model/stored-ai-request.ts new file mode 100644 index 0000000..c15b291 --- /dev/null +++ b/src/model/stored-ai-request.ts @@ -0,0 +1,17 @@ +import type {AiProvider} from "./ai-provider"; + +export type StoredAiRequestStatus = "running" | "succeeded" | "failed" | "aborted"; + +export type StoredAiRequest = { + requestId: string; + chatId: number; + messageId: number; + responseMessageId?: number | null; + fromId: number; + provider: AiProvider; + model: string; + status: StoredAiRequestStatus; + startedAt: string; + finishedAt?: string | null; + error?: string | null; +}; diff --git a/src/model/stored-attachment.ts b/src/model/stored-attachment.ts new file mode 100644 index 0000000..ad9b7f8 --- /dev/null +++ b/src/model/stored-attachment.ts @@ -0,0 +1,16 @@ +export type StoredAttachmentKind = "image" | "document" | "audio" | "video" | "video-note"; + +export type StoredAttachment = { + kind: StoredAttachmentKind; + fileId: string; + fileUniqueId?: string; + fileName: string; + mimeType?: string; + cachePath: string; + sizeBytes?: number; + sha256?: string; + scope?: "user_input" | "bot_output" | "internal_artifact"; + artifactKind?: "rag" | "transcript" | "tool_result" | "generated_file" | "tts_audio" | "final_text" | "error"; + metadata?: Record; +}; + diff --git a/src/model/stored-message.ts b/src/model/stored-message.ts index 6d06abf..446b3ef 100644 --- a/src/model/stored-message.ts +++ b/src/model/stored-message.ts @@ -1,9 +1,15 @@ +import {StoredAttachment} from "./stored-attachment"; +import type {PipelineAuditEvent} from "../ai/user-request-pipeline"; + export type StoredMessage = { chatId: number; id: number; replyToMessageId?: number; fromId: number; - text?: string; + text?: string | null; + quoteText?: string | null; date: number; - photoMaxSizeFilePath?: string[]; -}; \ No newline at end of file + deletedByBotAt?: number | null; + attachments?: StoredAttachment[] | null; + pipelineAudit?: PipelineAuditEvent[] | null; +}; diff --git a/src/model/stored-user.ts b/src/model/stored-user.ts index 1033c74..92268ed 100644 --- a/src/model/stored-user.ts +++ b/src/model/stored-user.ts @@ -5,4 +5,11 @@ export type StoredUser = { lastName?: string; userName?: string; isPremium?: boolean; -} \ No newline at end of file + langCode?: string; + interfaceLanguage?: string; + aiProvider?: string; + aiResponseLanguage?: string; + aiContextSize?: number; + aiVoiceMode?: string; + aiImageOutputMode?: string; +} diff --git a/src/util/async-lock.ts b/src/util/async-lock.ts new file mode 100644 index 0000000..0794e40 --- /dev/null +++ b/src/util/async-lock.ts @@ -0,0 +1,76 @@ +export class AsyncSemaphore { + private active = 0; + private readonly waiters: Array<() => void> = []; + + constructor(private readonly maxActive: number) { + if (!Number.isInteger(maxActive) || maxActive < 1) { + throw new Error("AsyncSemaphore maxActive must be a positive integer."); + } + } + + async runExclusive(task: () => Promise | T): Promise { + await this.acquire(); + try { + return await task(); + } finally { + this.release(); + } + } + + private async acquire(): Promise { + if (this.active < this.maxActive) { + this.active++; + return; + } + + await new Promise(resolve => { + this.waiters.push(resolve); + }); + this.active++; + } + + private release(): void { + this.active--; + const next = this.waiters.shift(); + if (next) { + next(); + } + } +} + +export class KeyedAsyncLock { + private readonly chains = new Map>(); + + async runExclusive(key: string, task: () => Promise | T): Promise { + const previous = this.chains.get(key) ?? Promise.resolve(); + + let release!: () => void; + const current = new Promise(resolve => { + release = resolve; + }); + + const tail = previous.then(() => current, () => current); + this.chains.set(key, tail); + + await previous.catch(() => undefined); + + try { + return await task(); + } finally { + release(); + if (this.chains.get(key) === tail) { + this.chains.delete(key); + } + } + } +} + +export function createQueuedFunction() { + let chain = Promise.resolve(); + + return async function enqueue(task: () => Promise | T): Promise { + const run = chain.then(task, task); + chain = run.then(() => undefined, () => undefined); + return run; + }; +} diff --git a/src/util/files.ts b/src/util/files.ts index 3c080e1..731b0fb 100644 --- a/src/util/files.ts +++ b/src/util/files.ts @@ -1,6 +1,9 @@ import {logError} from "./utils"; import fs from "node:fs"; import path from "node:path"; +import {appLogger} from "../logging/logger"; + +const logger = appLogger.child("files"); export function clearUpFolderFromOldFiles(folder: string, recursive = true) { fs.readdir(folder, (err, files) => { @@ -29,17 +32,22 @@ export function clearUpFolderFromOldFiles(folder: string, recursive = true) { } } } catch (e) { - logError(e); + logError(e instanceof Error ? e : String(e)); } }); - console.log("filenamesToDelete", filenamesToDelete); + logger.debug("cleanup.candidates", {folder, recursive, count: filenamesToDelete.length, filenamesToDelete}); if (filenamesToDelete.length) { filenamesToDelete.forEach((filename) => { fs.rm(filename, (e) => { - if (e) logError(e); + if (e) { + logger.error("cleanup.delete_failed", {filename, error: e instanceof Error ? e : String(e)}); + logError(e instanceof Error ? e : String(e)); + } else { + logger.debug("cleanup.deleted", {filename}); + } }); }); } }); -} \ No newline at end of file +} diff --git a/src/util/html-utils.ts b/src/util/html-utils.ts new file mode 100644 index 0000000..f0b54bd --- /dev/null +++ b/src/util/html-utils.ts @@ -0,0 +1,9 @@ +export class HtmlUtils { + static escape(input: string): string { + return input + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } +} diff --git a/src/util/lru-map.ts b/src/util/lru-map.ts new file mode 100644 index 0000000..195ea5e --- /dev/null +++ b/src/util/lru-map.ts @@ -0,0 +1,27 @@ +export function getLruMapValue(map: Map, key: K): V | undefined { + if (!map.has(key)) return undefined; + + const value = map.get(key)!; + map.delete(key); + map.set(key, value); + return value; +} + +export function setLruMapValue(map: Map, key: K, value: V, maxSize: number): void { + if (maxSize < 1) { + map.clear(); + return; + } + + if (map.has(key)) { + map.delete(key); + } + + map.set(key, value); + + while (map.size > maxSize) { + const oldestKey = map.keys().next(); + if (oldestKey.done) return; + map.delete(oldestKey.value); + } +} diff --git a/src/util/markdown-v2-renderer.ts b/src/util/markdown-v2-renderer.ts new file mode 100644 index 0000000..d10cbb6 --- /dev/null +++ b/src/util/markdown-v2-renderer.ts @@ -0,0 +1,728 @@ +export type TelegramRenderMode = "draft" | "final"; + +export interface TelegramMarkdownV2RenderOptions { + /** + * draft: + * - useful for streaming/editMessageText + * - temporarily closes unfinished code blocks / inline code / bold + * + * final: + * - use after LLM finished generation + */ + mode?: TelegramRenderMode; + + /** + * Used when the rendered message is empty. + */ + fallbackText?: string; +} + +/** + * Main function. + * + * Flow: + * LLM Markdown-lite + * -> draft safety, if needed + * -> normalize unsupported Markdown + * -> parse Markdown-lite + * -> render valid Telegram MarkdownV2 + */ +export function prepareTelegramMarkdownV2( + input: string, + options: TelegramMarkdownV2RenderOptions = {}, +): string { + const mode = options.mode ?? "final"; + const fallbackText = options.fallbackText ?? "…"; + + try { + const safeInput = mode === "draft" + ? makePartialMarkdownLiteSafe(input) + : input; + + const normalized = normalizeUnsupportedMarkdown(safeInput); + const ast = parseMarkdownLite(normalized); + const rendered = renderMarkdownV2(ast).trim(); + + return rendered || escapeMarkdownV2Text(fallbackText); + } catch { + const fallback = escapeMarkdownV2Text(input).trim(); + return fallback || escapeMarkdownV2Text(fallbackText); + } +} + +/** + * Useful for editMessageText fallback. + */ +export function prepareTelegramPlainMarkdownV2(input: string, fallbackText = "…"): string { + const escaped = escapeMarkdownV2Text(input).trim(); + return escaped || escapeMarkdownV2Text(fallbackText); +} + +/** + * Draft-safe mode for streaming. + * + * Fixes cases like: + * + * ```ts + * const x = + * + * or: + * + * *partial bold + * + * or: + * + * `partial code + */ +export function makePartialMarkdownLiteSafe(input: string): string { + let text = input.replace(/\r\n?/g, "\n"); + + if (isInsideFencedCodeBlock(text)) { + return closeUnclosedFencedCodeBlock(text); + } + + return transformOutsideFencedCode(text, (outside) => { + let result = outside; + result = closeUnclosedInlineCode(result); + result = closeUnclosedBold(result); + return result; + }); +} + +/** + * Converts unsupported / annoying Markdown into simpler Markdown-lite. + * + * Does not transform fenced code blocks. + */ +export function normalizeUnsupportedMarkdown(input: string): string { + const text = input.replace(/\r\n?/g, "\n").trim(); + + return transformOutsideFencedCode(text, (raw) => { + let result = raw; + + result = normalizeMarkdownTables(result); + + result = result + // Images: ![alt](url) -> [alt](url) + .replace(/!\[([^\]\n]*)]\(([^)\n]+)\)/g, "[$1]($2)") + + // Common Markdown bold -> Markdown-lite bold + .replace(/\*\*([^*\n]+)\*\*/g, "*$1*") + .replace(/__([^_\n]+)__/g, "*$1*") + + .replace(/^`([^`\n]+)$/gm, (_, title: string) => { + const cleanTitle = title.trim(); + return cleanTitle ? `*${cleanTitle}*` : ""; + }) + + // Headings -> bold labels + .replace(/^#{1,6}\s+(.+)$/gm, (_, title: string) => { + const cleanTitle = title + .replace(/[*_`[\]()~>#+\-=|{}.!]/g, "") + .trim(); + + return cleanTitle ? `*${cleanTitle}*` : ""; + }) + + // Horizontal rules + .replace(/^\s*(-{3,}|\*{3,}|_{3,})\s*$/gm, "") + + // Task lists -> normal bullets + .replace(/^(\s*)[-*]\s+\[[ xX]]\s+/gm, "$1- ") + + // HTML line breaks -> newline + .replace(//gi, "\n") + + // Strip simple raw HTML tags, keep content + .replace(/<\/?(?:p|div|span|strong|b|em|i|u|s|del|code|pre)[^>]*>/gi, "") + + // Too many blank lines + .replace(/\n{3,}/g, "\n\n"); + + return result.trim(); + }); +} + +/** + * AST + */ + +type InlineNode = + | { type: "text"; value: string } + | { type: "bold"; children: InlineNode[] } + | { type: "code"; value: string } + | { type: "link"; text: string; url: string }; + +type BlockNode = + | { type: "paragraph"; children: InlineNode[] } + | { type: "pre"; lang?: string; value: string } + | { type: "quote"; lines: InlineNode[][] }; + +/** + * Block parser: + * - fenced code blocks + * - quotes + * - paragraphs + */ +export function parseMarkdownLite(input: string): BlockNode[] { + const lines = input.replace(/\r\n?/g, "\n").split("\n"); + const blocks: BlockNode[] = []; + + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (!line.trim()) { + i++; + continue; + } + + const fenceStart = line.match(/^```\s*([^`]*)\s*$/); + + if (fenceStart) { + const lang = sanitizeCodeLanguage(fenceStart[1]); + const body: string[] = []; + + i++; + + while (i < lines.length && !/^```\s*$/.test(lines[i])) { + body.push(lines[i]); + i++; + } + + if (i < lines.length) { + i++; + } + + blocks.push({ + type: "pre", + lang, + value: body.join("\n"), + }); + + continue; + } + + if (/^\s*>\s?/.test(line)) { + const quoteLines: InlineNode[][] = []; + + while (i < lines.length && /^\s*>\s?/.test(lines[i])) { + const quoteLine = lines[i].replace(/^\s*>\s?/, ""); + quoteLines.push(parseInlineMarkdownLite(quoteLine)); + i++; + } + + blocks.push({ + type: "quote", + lines: quoteLines, + }); + + continue; + } + + const paragraphLines: string[] = []; + + while ( + i < lines.length && + lines[i].trim() && + !/^```\s*([^`]*)\s*$/.test(lines[i]) && + !/^\s*>\s?/.test(lines[i]) + ) { + paragraphLines.push(lines[i]); + i++; + } + + if (paragraphLines.length === 0) { + paragraphLines.push(lines[i]); + i++; + } + + blocks.push({ + type: "paragraph", + children: parseInlineMarkdownLite(paragraphLines.join("\n")), + }); + } + + return blocks; +} + +/** + * Inline parser: + * - *bold* + * - `code` + * - [text](url) + * + * This is intentionally not a full Markdown parser. + */ +export function parseInlineMarkdownLite(source: string): InlineNode[] { + const nodes: InlineNode[] = []; + let buffer = ""; + let i = 0; + + const flushText = () => { + if (buffer) { + nodes.push({ type: "text", value: buffer }); + buffer = ""; + } + }; + + while (i < source.length) { + const ch = source[i]; + + if (ch === "`") { + const end = findNextUnescaped(source, "`", i + 1); + + if (end !== -1) { + flushText(); + + nodes.push({ + type: "code", + value: source.slice(i + 1, end), + }); + + i = end + 1; + continue; + } + } + + if (ch === "[") { + const labelEnd = findNextUnescaped(source, "]", i + 1); + + if (labelEnd !== -1 && source[labelEnd + 1] === "(") { + const urlStart = labelEnd + 2; + const urlEnd = findMarkdownLinkEnd(source, urlStart); + + if (urlEnd !== -1) { + const text = source.slice(i + 1, labelEnd).trim(); + const url = source.slice(urlStart, urlEnd).trim(); + + if (text && isSafeUrl(url)) { + flushText(); + + nodes.push({ + type: "link", + text, + url, + }); + + i = urlEnd + 1; + continue; + } + } + } + } + + if (ch === "*" && canStartBold(source, i)) { + const end = findBoldEnd(source, i + 1); + + if (end !== -1 && canEndBold(source, end)) { + const content = source.slice(i + 1, end); + + if (content.trim()) { + flushText(); + + nodes.push({ + type: "bold", + children: parseInlineMarkdownLite(content), + }); + + i = end + 1; + continue; + } + } + } + + buffer += ch; + i++; + } + + flushText(); + + return nodes; +} + +/** + * MarkdownV2 renderer + */ + +export function renderMarkdownV2(blocks: BlockNode[]): string { + return blocks + .map(renderBlockMarkdownV2) + .filter(Boolean) + .join("\n\n") + .trim(); +} + +function renderBlockMarkdownV2(block: BlockNode): string { + switch (block.type) { + case "paragraph": + return renderInlineMarkdownV2(block.children); + + case "pre": { + const lang = block.lang ? block.lang : ""; + const code = escapeMarkdownV2Code(block.value); + + if (lang) { + return "```" + lang + "\n" + code + "\n```"; + } + + return "```\n" + code + "\n```"; + } + + case "quote": + return block.lines + .map((line) => ">" + renderInlineMarkdownV2(line)) + .join("\n"); + } +} + +function renderInlineMarkdownV2(nodes: InlineNode[]): string { + return nodes.map(renderInlineNodeMarkdownV2).join(""); +} + +function renderInlineNodeMarkdownV2(node: InlineNode): string { + switch (node.type) { + case "text": + return escapeMarkdownV2Text(node.value); + + case "bold": + return "*" + renderInlineMarkdownV2(node.children) + "*"; + + case "code": + return "`" + escapeMarkdownV2Code(node.value) + "`"; + + case "link": + return `[${escapeMarkdownV2Text(node.text)}](${escapeMarkdownV2LinkUrl(node.url)})`; + } +} + +/** + * Telegram MarkdownV2 escaping + */ + +export function escapeMarkdownV2Text(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/([_*\[\]()~`>#+\-=|{}.!])/g, "\\$1"); +} + +export function escapeMarkdownV2Code(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/`/g, "\\`"); +} + +export function escapeMarkdownV2LinkUrl(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\)/g, "\\)"); +} + +/** + * Draft safety helpers + */ + +function closeUnclosedFencedCodeBlock(input: string): string { + if (!isInsideFencedCodeBlock(input)) { + return input; + } + + return input.endsWith("\n") + ? input + "```" + : input + "\n```"; +} + +function isInsideFencedCodeBlock(input: string): boolean { + const fenceMatches = [...input.matchAll(/^```/gm)]; + return fenceMatches.length % 2 === 1; +} + +function closeUnclosedInlineCode(input: string): string { + let count = 0; + let escaped = false; + + for (const ch of input) { + if (escaped) { + escaped = false; + continue; + } + + if (ch === "\\") { + escaped = true; + continue; + } + + if (ch === "`") { + count++; + } + } + + return count % 2 === 1 ? input + "`" : input; +} + +function closeUnclosedBold(input: string): string { + let count = 0; + let escaped = false; + + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (ch === "\\") { + escaped = true; + continue; + } + + if (ch !== "*") { + continue; + } + + if (isLikelyListMarker(input, i)) { + continue; + } + + count++; + } + + return count % 2 === 1 ? input + "*" : input; +} + +function isLikelyListMarker(input: string, index: number): boolean { + const prev = input[index - 1]; + const next = input[index + 1]; + + const isLineStart = index === 0 || prev === "\n"; + return isLineStart && next === " "; +} + +/** + * Generic helpers + */ + +function findNextUnescaped(source: string, target: string, from: number): number { + for (let i = from; i < source.length; i++) { + if (source[i] === "\\" && i + 1 < source.length) { + i++; + continue; + } + + if (source[i] === target) { + return i; + } + } + + return -1; +} + +function findBoldEnd(source: string, from: number): number { + for (let i = from; i < source.length; i++) { + if (source[i] === "\\" && i + 1 < source.length) { + i++; + continue; + } + + if (source[i] === "*") { + return i; + } + } + + return -1; +} + +function findMarkdownLinkEnd(source: string, from: number): number { + let depth = 0; + + for (let i = from; i < source.length; i++) { + const ch = source[i]; + + if (ch === "\\" && i + 1 < source.length) { + i++; + continue; + } + + if (ch === "\n") { + return -1; + } + + if (ch === "(") { + depth++; + continue; + } + + if (ch === ")") { + if (depth === 0) { + return i; + } + + depth--; + } + } + + return -1; +} + +function canStartBold(source: string, index: number): boolean { + const prev = source[index - 1]; + const next = source[index + 1]; + + if (!next || /\s/.test(next)) { + return false; + } + + if (prev && /\w/.test(prev) && /\w/.test(next)) { + return false; + } + + return true; +} + +function canEndBold(source: string, index: number): boolean { + const prev = source[index - 1]; + const next = source[index + 1]; + + if (!prev || /\s/.test(prev)) { + return false; + } + + if (next && /\w/.test(prev) && /\w/.test(next)) { + return false; + } + + return true; +} + +function sanitizeCodeLanguage(value: string | undefined): string | undefined { + if (!value) return undefined; + + const lang = value.trim(); + + if (!lang) return undefined; + + // Telegram language hint after ``` can be used as a visual label too. + // Keep it permissive, but reject dangerous/newline/weird marker chars. + if (!/^[^\s`\\]{1,32}$/.test(lang)) { + return undefined; + } + + return lang; +} + +function isSafeUrl(url: string): boolean { + return /^(https?:\/\/|tg:\/\/|mailto:)/i.test(url); +} + +/** + * Applies transform only outside fenced code blocks. + */ +function transformOutsideFencedCode( + input: string, + transform: (text: string) => string, +): string { + const fences: string[] = []; + const fenceRegex = /```[^\n]*\n[\s\S]*?(?:\n```|$)/g; + + const protectedText = input.replace(fenceRegex, (match) => { + const index = fences.push(match) - 1; + return `\uE000FENCE_${index}\uE001`; + }); + + const transformed = transform(protectedText); + + return transformed.replace(/\uE000FENCE_(\d+)\uE001/g, (_, index: string) => { + return fences[Number(index)] ?? ""; + }); +} + +/** + * Converts Markdown tables into simple list rows. + * + * Example: + * | A | B | + * |---|---| + * | 1 | 2 | + * + * -> + * - A: 1; B: 2 + */ +function normalizeMarkdownTables(input: string): string { + const lines = input.split("\n"); + const output: string[] = []; + + let i = 0; + + while (i < lines.length) { + const current = lines[i]; + const next = lines[i + 1]; + + if (next && isMarkdownTableSeparator(next) && current.includes("|")) { + const headers = parseTableRow(current); + const rows: string[][] = []; + + i += 2; + + while (i < lines.length && lines[i].includes("|") && lines[i].trim()) { + rows.push(parseTableRow(lines[i])); + i++; + } + + if (rows.length === 0) { + output.push(headers.join(" / ")); + continue; + } + + for (const row of rows) { + const cells = row + .map((cell, index) => { + const header = headers[index]; + + if (!cell) return ""; + if (!header) return cell; + + return `${header}: ${cell}`; + }) + .filter(Boolean); + + output.push(`- ${cells.join("; ")}`); + } + + continue; + } + + output.push(current); + i++; + } + + return output.join("\n"); +} + +function isMarkdownTableSeparator(line: string): boolean { + const cells = parseTableRow(line); + + return ( + cells.length >= 2 && + cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())) + ); +} + +function parseTableRow(line: string): string[] { + return line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim()); +} + +/** + * Optional helper for streaming/editing. + * + * You can adapt this to your own bot wrapper. + */ +export function shouldEditRenderedMessage(previous: string, next: string): boolean { + return previous !== next && next.trim().length > 0; +} \ No newline at end of file diff --git a/src/util/random-utils.ts b/src/util/random-utils.ts new file mode 100644 index 0000000..90503a4 --- /dev/null +++ b/src/util/random-utils.ts @@ -0,0 +1,14 @@ +export class RandomUtils { + static int(max: number): number { + return Math.floor(Math.random() * Math.floor(max)); + } + + static rangedInt(from: number, to: number): number { + return RandomUtils.int(to - from) + from; + } + + static value(list: readonly T[]): T | undefined { + if (!list.length) return undefined; + return list[RandomUtils.int(list.length)]; + } +} diff --git a/src/util/shell-command-runner.ts b/src/util/shell-command-runner.ts new file mode 100644 index 0000000..8bc701c --- /dev/null +++ b/src/util/shell-command-runner.ts @@ -0,0 +1,132 @@ +import {exec} from "node:child_process"; +import {promisify} from "node:util"; +import {appLogger} from "../logging/logger"; +import type {ErrorLike} from "../common/boundary-types"; + +const execAsync = promisify(exec); +const logger = appLogger.child("shell-command-runner"); + +type ShellCommandFailure = { + code?: string | number; + stdout?: string; + stderr?: string; + message?: string; +}; + +export type ShellCommandResult = { + stdout: string | null | undefined; + stderr: string | null | undefined; +}; + +export class ShellCommandRunner { + private static readonly 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/, + /\bdel\b/i, + /\berase\b/i, + /\brd\b/i, + /\bformat\b/i, + /\btaskkill\b/i, + /\bRemove-Item\b/i, + /\bMove-Item\b/i, + /\bStop-Process\b/i, + /\bRestart-Computer\b/i, + /\bStop-Computer\b/i, + /\bcurl\b/, + /\bwget\b/, + /\bInvoke-WebRequest\b/i, + /\bInvoke-RestMethod\b/i, + /\bssh\b/, + /\bscp\b/, + /\brsync\b/, + /\bnc\b/, + /\bnmap\b/, + /\.\./, + /\/etc\/?/, + /\/home\/?/, + /\/root\/?/, + /~\//, + /\.ssh/, + /\.env/, + ]; + + static async run(command: string): Promise { + ShellCommandRunner.assertSafe(command); + + try { + const {stdout, stderr} = await execAsync(command, { + timeout: 15_000, + maxBuffer: 64 * 1024, + }); + if (stdout) { + logger.debug("command.stdout", {command, stdout}); + } + + if (stderr) { + logger.warn("command.stderr", {command, stderr}); + } + + return {stdout, stderr}; + } catch (error) { + const err = ShellCommandRunner.normalizeFailure(error instanceof Error ? error : String(error)); + logger.error("command.failed", {command, code: err.code, stderr: err.stderr, error: err.message}); + + return {stdout: err.stdout ?? null, stderr: err.stderr ?? err.message}; + } + } + + private static normalizeFailure(error: ErrorLike | object | number | boolean | null | undefined): ShellCommandFailure { + if (typeof error === "string") { + return {message: error}; + } + + if (error instanceof Error) { + const failure: ShellCommandFailure = { + message: error.message, + }; + + if ("code" in error && (typeof error.code === "string" || typeof error.code === "number")) { + failure.code = error.code; + } + + return failure; + } + + if (typeof error === "object" && error !== null) { + const failure = error as ShellCommandFailure; + return { + code: failure.code, + stdout: failure.stdout, + stderr: failure.stderr, + message: failure.message, + }; + } + + return { + message: String(error), + }; + } + + private static assertSafe(command: string): void { + if (command.length > 500) { + throw new Error("Command is too long"); + } + + for (const pattern of ShellCommandRunner.forbiddenPatterns) { + if (pattern.test(command)) { + throw new Error(`Forbidden shell command pattern: ${pattern}`); + } + } + } +} diff --git a/src/util/telegram-api-queue.ts b/src/util/telegram-api-queue.ts new file mode 100644 index 0000000..e82f9d2 --- /dev/null +++ b/src/util/telegram-api-queue.ts @@ -0,0 +1,726 @@ +/** + * Conservative Telegram Bot API promise queue. + * + * Defaults intentionally prefer safety over throughput: + * - global bot limit: 30 requests / second; + * - per-chat limit: 1 request / second; + * - likely group/channel chats: 20 requests / minute; + * - edit methods: 6 requests / second. + * + * Telegram can still return 429 for dynamic flood limits. In that case the + * queue always honors `parameters.retry_after` and requeues the task. + */ + +import {appLogger} from "../logging/logger"; +import type {BoundaryValue} from "../common/boundary-types"; + +const logger = appLogger.child("telegram-api-queue"); + +export type TelegramChatId = number | string; + +export type TelegramChatType = string; + +export type TelegramApiTaskContext = { + attempt: number; + signal?: AbortSignal; +}; + +export type TelegramApiTask = (context: TelegramApiTaskContext) => Promise; + +export type RateLimitConfig = { + maxRequests: number; + intervalMs: number; +}; + +export type TelegramApiQueueTaskOptions = { + chatId?: TelegramChatId; + chatType?: TelegramChatType; + method?: string; + priority?: number; + maxAttempts?: number; + signal?: AbortSignal; + skipPerChatLimit?: boolean; +}; + +export type TelegramApiRetryEvent = { + taskId: number; + method?: string; + chatId?: TelegramChatId; + attempt: number; + delayMs: number; + reason: "telegram_retry_after" | "transient_error"; + error: Error | string | BoundaryValue | null | undefined; +}; + +export type TelegramApiQueueOptions = { + globalLimit?: Partial; + perChatLimit?: Partial; + groupChatLimit?: Partial; + editLimit?: Partial; + maxConcurrent?: number; + maxAttempts?: number; + baseRetryDelayMs?: number; + maxRetryDelayMs?: number; + retryJitterMs?: number; + retryAfterSafetyMs?: number; + maxQueueSize?: number; + onRetry?: (event: TelegramApiRetryEvent) => void; +}; + +export type TelegramApiQueueStats = { + queued: number; + running: number; + closed: boolean; +}; + +type RetryDecision = { + delayMs: number; + reason: TelegramApiRetryEvent["reason"]; +}; + +type QueueEntryState = "queued" | "running" | "settled" | "cancelled"; + +type QueueEntry = { + id: number; + sequence: number; + task: TelegramApiTask; + options: TelegramApiQueueTaskOptions; + attempt: number; + notBefore: number; + state: QueueEntryState; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: Error | string | BoundaryValue | null | undefined) => void; + abortHandler?: () => void; +}; + +type ResolvedTelegramApiQueueOptions = { + globalLimit: RateLimitConfig; + perChatLimit: RateLimitConfig; + groupChatLimit: RateLimitConfig; + editLimit: RateLimitConfig; + maxConcurrent: number; + maxAttempts: number; + baseRetryDelayMs: number; + maxRetryDelayMs: number; + retryJitterMs: number; + retryAfterSafetyMs: number; + maxQueueSize: number; + onRetry?: (event: TelegramApiRetryEvent) => void; +}; + +const DEFAULT_OPTIONS: ResolvedTelegramApiQueueOptions = { + globalLimit: {maxRequests: 30, intervalMs: 1000}, + perChatLimit: {maxRequests: 1, intervalMs: 1000}, + groupChatLimit: {maxRequests: 20, intervalMs: 60_000}, + editLimit: {maxRequests: 6, intervalMs: 1000}, + maxConcurrent: 8, + maxAttempts: 5, + baseRetryDelayMs: 500, + maxRetryDelayMs: 30_000, + retryJitterMs: 250, + retryAfterSafetyMs: 250, + maxQueueSize: 10_000, +}; + +function mergeLimitConfig(base: RateLimitConfig, override?: Partial): RateLimitConfig { + return { + maxRequests: override?.maxRequests ?? base.maxRequests, + intervalMs: override?.intervalMs ?? base.intervalMs, + }; +} + +function resolveOptions(options: TelegramApiQueueOptions): ResolvedTelegramApiQueueOptions { + return { + globalLimit: mergeLimitConfig(DEFAULT_OPTIONS.globalLimit, options.globalLimit), + perChatLimit: mergeLimitConfig(DEFAULT_OPTIONS.perChatLimit, options.perChatLimit), + groupChatLimit: mergeLimitConfig(DEFAULT_OPTIONS.groupChatLimit, options.groupChatLimit), + editLimit: mergeLimitConfig(DEFAULT_OPTIONS.editLimit, options.editLimit), + maxConcurrent: options.maxConcurrent ?? DEFAULT_OPTIONS.maxConcurrent, + maxAttempts: options.maxAttempts ?? DEFAULT_OPTIONS.maxAttempts, + baseRetryDelayMs: options.baseRetryDelayMs ?? DEFAULT_OPTIONS.baseRetryDelayMs, + maxRetryDelayMs: options.maxRetryDelayMs ?? DEFAULT_OPTIONS.maxRetryDelayMs, + retryJitterMs: options.retryJitterMs ?? DEFAULT_OPTIONS.retryJitterMs, + retryAfterSafetyMs: options.retryAfterSafetyMs ?? DEFAULT_OPTIONS.retryAfterSafetyMs, + maxQueueSize: options.maxQueueSize ?? DEFAULT_OPTIONS.maxQueueSize, + onRetry: options.onRetry, + }; +} + +function createAbortError(): Error { + const error = new Error("Telegram API queue task aborted"); + error.name = "AbortError"; + return error; +} + +function createClosedError(): Error { + return new Error("Telegram API queue is closed"); +} + +function createQueueOverflowError(maxQueueSize: number): Error { + return new Error(`Telegram API queue overflow: maxQueueSize=${maxQueueSize}`); +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function isRecord(value: BoundaryValue): value is Record { + return typeof value === "object" && value !== null; +} + +function readPath(source: BoundaryValue, pathParts: readonly string[]): BoundaryValue { + let current = source; + for (const part of pathParts) { + if (!isRecord(current)) return undefined; + current = current[part]; + } + return current; +} + +function readNumber(source: BoundaryValue, paths: readonly (readonly string[])[]): number | undefined { + for (const pathParts of paths) { + const value = readPath(source, pathParts); + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + } + return undefined; +} + +function readString(source: BoundaryValue, paths: readonly (readonly string[])[]): string | undefined { + for (const pathParts of paths) { + const value = readPath(source, pathParts); + if (typeof value === "string") return value; + } + return undefined; +} + +function extractRetryAfterMs(error: BoundaryValue, safetyMs: number): number | undefined { + const retryAfterSeconds = readNumber(error, [ + ["parameters", "retry_after"], + ["response", "parameters", "retry_after"], + ["response", "body", "parameters", "retry_after"], + ["body", "parameters", "retry_after"], + ]); + + if (retryAfterSeconds === undefined) return undefined; + return Math.max(0, Math.ceil(retryAfterSeconds * 1000) + safetyMs); +} + +function extractStatusCode(error: BoundaryValue): number | undefined { + return readNumber(error, [ + ["error_code"], + ["errorCode"], + ["status"], + ["statusCode"], + ["response", "error_code"], + ["response", "status"], + ["response", "statusCode"], + ["response", "body", "error_code"], + ["body", "error_code"], + ]); +} + +function extractErrorCode(error: BoundaryValue): string | undefined { + return readString(error, [ + ["code"], + ["errno"], + ["cause", "code"], + ]); +} + +function extractErrorMessage(error: BoundaryValue): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + const message = readString(error, [ + ["message"], + ["description"], + ["response", "description"], + ["response", "body", "description"], + ["body", "description"], + ]); + return message ?? ""; +} + +function isTelegramTooManyRequests(error: BoundaryValue): boolean { + return extractStatusCode(error) === 429 || /too many requests|retry after/i.test(extractErrorMessage(error)); +} + +function isTransientError(error: BoundaryValue): boolean { + const statusCode = extractStatusCode(error); + if (statusCode !== undefined) { + if (statusCode === 408) return true; + if (statusCode >= 500 && statusCode <= 599) return true; + if (statusCode >= 400 && statusCode <= 499) return false; + } + + const code = extractErrorCode(error); + if (code && ["ETIMEDOUT", "ECONNRESET", "ECONNABORTED", "EAI_AGAIN", "ENOTFOUND", "EPIPE"].includes(code)) { + return true; + } + + return /timeout|socket hang up|network error|econnreset|econnaborted|eai_again/i.test(extractErrorMessage(error)); +} + +function isLikelyGroupChatId(chatId: TelegramChatId | undefined): boolean { + if (typeof chatId === "number") return chatId < 0; + if (typeof chatId === "string") return chatId.startsWith("-"); + return false; +} + +function isGroupLikeChat(chatType: TelegramChatType | undefined, chatId: TelegramChatId | undefined): boolean { + if (chatType === "group" || chatType === "supergroup" || chatType === "channel") return true; + if (chatType === "private") return false; + return isLikelyGroupChatId(chatId); +} + +function isEditMethod(method: string | undefined): boolean { + return !!method && method.toLowerCase().startsWith("edit"); +} + +function normalizeBucketKey(value: TelegramChatId): string { + return String(value); +} + +class SlidingWindowRateLimit { + private timestamps: number[] = []; + private pausedUntil = 0; + private lastTouched = Date.now(); + + constructor(private readonly config: RateLimitConfig) { + } + + nextDelay(now: number): number { + this.lastTouched = now; + this.prune(now); + + const pauseDelay = Math.max(0, this.pausedUntil - now); + if (pauseDelay > 0) return pauseDelay; + if (this.timestamps.length < this.config.maxRequests) return 0; + + const oldest = this.timestamps[0] ?? now; + return Math.max(0, oldest + this.config.intervalMs - now); + } + + record(now: number): void { + this.lastTouched = now; + this.prune(now); + this.timestamps.push(now); + } + + pause(delayMs: number, now: number): void { + this.lastTouched = now; + this.pausedUntil = Math.max(this.pausedUntil, now + delayMs); + } + + isIdle(now: number, idleMs: number): boolean { + this.prune(now); + return this.timestamps.length === 0 + && this.pausedUntil <= now + && now - this.lastTouched >= idleMs; + } + + private prune(now: number): void { + const minTime = now - this.config.intervalMs; + while (this.timestamps.length && (this.timestamps[0] ?? now) <= minTime) { + this.timestamps.shift(); + } + } +} + +export class TelegramApiQueue { + private readonly options: ResolvedTelegramApiQueueOptions; + private readonly globalBucket: SlidingWindowRateLimit; + private readonly editBucket: SlidingWindowRateLimit; + private readonly chatBuckets = new Map(); + private readonly groupChatBuckets = new Map(); + private readonly idleResolvers: Array<() => void> = []; + private readonly bucketIdleMs: number; + private queue: Array> = []; + private timer: NodeJS.Timeout | null = null; + private running = 0; + private nextId = 1; + private nextSequence = 1; + private closed = false; + + constructor(options: TelegramApiQueueOptions = {}) { + this.options = resolveOptions(options); + this.globalBucket = new SlidingWindowRateLimit(this.options.globalLimit); + this.editBucket = new SlidingWindowRateLimit(this.options.editLimit); + this.bucketIdleMs = Math.max(this.options.perChatLimit.intervalMs, this.options.groupChatLimit.intervalMs) * 2; + logger.debug("created", {maxConcurrent: this.options.maxConcurrent, maxAttempts: this.options.maxAttempts, maxQueueSize: this.options.maxQueueSize}); + } + + get stats(): TelegramApiQueueStats { + return { + queued: this.queue.length, + running: this.running, + closed: this.closed, + }; + } + + enqueue(task: TelegramApiTask, options: TelegramApiQueueTaskOptions = {}): Promise { + if (this.closed) { + logger.warn("enqueue.rejected.closed", {method: options.method, chatId: options.chatId}); + return Promise.reject(createClosedError()); + } + if (this.queue.length >= this.options.maxQueueSize) { + logger.error("enqueue.rejected.overflow", {method: options.method, chatId: options.chatId, queued: this.queue.length, maxQueueSize: this.options.maxQueueSize}); + return Promise.reject(createQueueOverflowError(this.options.maxQueueSize)); + } + if (options.signal?.aborted) { + logger.debug("enqueue.rejected.aborted", {method: options.method, chatId: options.chatId}); + return Promise.reject(createAbortError()); + } + + return new Promise((resolve, reject) => { + const entry: QueueEntry = { + id: this.nextId++, + sequence: this.nextSequence++, + task, + options, + attempt: 1, + notBefore: Date.now(), + state: "queued", + resolve: (value: BoundaryValue) => resolve(value as T), + reject, + }; + + this.attachAbortHandler(entry); + + this.insertEntry(entry); + logger.trace("enqueue.accepted", {taskId: entry.id, method: options.method, chatId: options.chatId, priority: options.priority, queued: this.queue.length, running: this.running}); + this.pump(); + }); + } + + waitUntilIdle(): Promise { + if (this.queue.length === 0 && this.running === 0) return Promise.resolve(); + + return new Promise(resolve => { + this.idleResolvers.push(resolve); + }); + } + + close(reason: Error | string | BoundaryValue | null | undefined = createClosedError()): void { + this.closed = true; + logger.warn("closed", {queued: this.queue.length, running: this.running, reason}); + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + const queued = this.queue; + logger.debug("close.cancel_queued", {queued: queued.length}); + this.queue = []; + for (const entry of queued) { + this.cleanupAbortHandler(entry); + entry.state = "cancelled"; + entry.reject(reason); + } + this.chatBuckets.clear(); + this.groupChatBuckets.clear(); + this.resolveIdleIfNeeded(); + } + + clear(reason: Error | string | BoundaryValue | null | undefined = new Error("Telegram API queue was cleared")): void { + const queued = this.queue; + logger.warn("cleared", {queued: queued.length, running: this.running, reason}); + this.queue = []; + for (const entry of queued) { + this.cleanupAbortHandler(entry); + entry.state = "cancelled"; + entry.reject(reason); + } + this.resolveIdleIfNeeded(); + } + + private insertEntry(entry: QueueEntry): void { + this.queue.push(entry); + this.queue.sort((left, right) => { + const priorityDiff = (right.options.priority ?? 0) - (left.options.priority ?? 0); + return priorityDiff || left.sequence - right.sequence; + }); + } + + private abortQueuedEntry(taskId: number): void { + const index = this.queue.findIndex(entry => entry.id === taskId); + if (index < 0) return; + + const entry = this.queue.splice(index, 1)[0]; + if (!entry) return; + + this.cleanupAbortHandler(entry); + entry.state = "cancelled"; + logger.debug("entry.cancelled", {taskId: entry.id, method: entry.options.method, chatId: entry.options.chatId}); + entry.reject(createAbortError()); + this.resolveIdleIfNeeded(); + } + + private pump(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + if (this.closed) return; + this.cleanupIdleBuckets(); + + while (this.running < this.options.maxConcurrent) { + const selection = this.selectNextEntry(Date.now()); + if (!selection) { + this.resolveIdleIfNeeded(); + return; + } + + if (selection.delayMs > 0) { + logger.trace("pump.delayed", {delayMs: selection.delayMs, queued: this.queue.length, running: this.running}); + this.schedule(selection.delayMs); + return; + } + + const entry = this.queue.splice(selection.index, 1)[0]; + if (!entry) continue; + this.startEntry(entry); + } + } + + private selectNextEntry(now: number): { index: number; delayMs: number } | null { + let bestBlockedIndex = -1; + let bestBlockedDelay = Number.POSITIVE_INFINITY; + + for (let index = 0; index < this.queue.length; index++) { + const entry = this.queue[index]; + if (!entry) continue; + + if (entry.options.signal?.aborted) { + this.abortQueuedEntry(entry.id); + index--; + continue; + } + + const delayMs = this.nextDelayFor(entry, now); + if (delayMs === 0) return {index, delayMs}; + if (delayMs < bestBlockedDelay) { + bestBlockedDelay = delayMs; + bestBlockedIndex = index; + } + } + + if (bestBlockedIndex < 0) return null; + return {index: bestBlockedIndex, delayMs: bestBlockedDelay}; + } + + private startEntry(entry: QueueEntry): void { + entry.state = "running"; + this.cleanupAbortHandler(entry); + this.recordStart(entry, Date.now()); + this.running++; + logger.trace("entry.started", {taskId: entry.id, method: entry.options.method, chatId: entry.options.chatId, attempt: entry.attempt, queued: this.queue.length, running: this.running}); + void this.runEntry(entry); + } + + private async runEntry(entry: QueueEntry): Promise { + try { + if (entry.options.signal?.aborted) throw createAbortError(); + + const result = await entry.task({ + attempt: entry.attempt, + signal: entry.options.signal, + }); + entry.state = "settled"; + logger.trace("entry.settled", {taskId: entry.id, method: entry.options.method, chatId: entry.options.chatId, attempt: entry.attempt}); + entry.resolve(result); + } catch (error) { + const errorValue = error instanceof Error ? error : String(error); + const retry = this.getRetryDecision(errorValue, entry); + if (retry && !this.closed) { + this.applyRetryPause(entry, retry); + entry.attempt++; + entry.notBefore = Date.now() + retry.delayMs; + entry.state = "queued"; + if (entry.options.signal?.aborted) { + entry.state = "cancelled"; + entry.reject(createAbortError()); + } else { + this.attachAbortHandler(entry); + this.insertEntry(entry); + logger.warn("entry.retry", {taskId: entry.id, method: entry.options.method, chatId: entry.options.chatId, attempt: entry.attempt - 1, delayMs: retry.delayMs, reason: retry.reason, error: errorValue}); + this.options.onRetry?.({ + taskId: entry.id, + method: entry.options.method, + chatId: entry.options.chatId, + attempt: entry.attempt - 1, + delayMs: retry.delayMs, + reason: retry.reason, + error: errorValue, + }); + } + } else { + entry.state = "settled"; + logger.error("entry.failed", {taskId: entry.id, method: entry.options.method, chatId: entry.options.chatId, attempt: entry.attempt, error: errorValue}); + entry.reject(this.closed ? createClosedError() : errorValue); + } + } finally { + this.running--; + this.pump(); + } + } + + private nextDelayFor(entry: QueueEntry, now: number): number { + const explicitDelay = Math.max(0, entry.notBefore - now); + const bucketDelay = this.bucketsFor(entry).reduce((maxDelay, bucket) => { + return Math.max(maxDelay, bucket.nextDelay(now)); + }, 0); + + return Math.max(explicitDelay, bucketDelay); + } + + private recordStart(entry: QueueEntry, now: number): void { + for (const bucket of this.bucketsFor(entry)) { + bucket.record(now); + } + } + + private bucketsFor(entry: QueueEntry): SlidingWindowRateLimit[] { + const buckets = [this.globalBucket]; + const chatId = entry.options.chatId; + + if (chatId !== undefined && !entry.options.skipPerChatLimit) { + buckets.push(this.getChatBucket(chatId)); + if (isGroupLikeChat(entry.options.chatType, chatId)) { + buckets.push(this.getGroupChatBucket(chatId)); + } + } + + if (isEditMethod(entry.options.method)) { + buckets.push(this.editBucket); + } + + return buckets; + } + + private getChatBucket(chatId: TelegramChatId): SlidingWindowRateLimit { + const key = normalizeBucketKey(chatId); + let bucket = this.chatBuckets.get(key); + if (!bucket) { + bucket = new SlidingWindowRateLimit(this.options.perChatLimit); + this.chatBuckets.set(key, bucket); + } + return bucket; + } + + private getGroupChatBucket(chatId: TelegramChatId): SlidingWindowRateLimit { + const key = normalizeBucketKey(chatId); + let bucket = this.groupChatBuckets.get(key); + if (!bucket) { + bucket = new SlidingWindowRateLimit(this.options.groupChatLimit); + this.groupChatBuckets.set(key, bucket); + } + return bucket; + } + + private getRetryDecision(error: Error | string | BoundaryValue | null | undefined, entry: QueueEntry): RetryDecision | null { + if (entry.options.signal?.aborted) return null; + + const maxAttempts = entry.options.maxAttempts ?? this.options.maxAttempts; + if (entry.attempt >= maxAttempts) return null; + + const retryAfterMs = extractRetryAfterMs(error, this.options.retryAfterSafetyMs); + if (retryAfterMs !== undefined || isTelegramTooManyRequests(error)) { + return { + delayMs: retryAfterMs ?? this.backoffDelay(entry.attempt), + reason: "telegram_retry_after", + }; + } + + if (!isTransientError(error)) return null; + + return { + delayMs: this.backoffDelay(entry.attempt), + reason: "transient_error", + }; + } + + private backoffDelay(attempt: number): number { + const exponential = this.options.baseRetryDelayMs * (2 ** Math.max(0, attempt - 1)); + const capped = Math.min(this.options.maxRetryDelayMs, exponential); + const jitter = this.options.retryJitterMs > 0 ? Math.floor(Math.random() * this.options.retryJitterMs) : 0; + return capped + jitter; + } + + private applyRetryPause(entry: QueueEntry, retry: RetryDecision): void { + if (retry.reason !== "telegram_retry_after") return; + + const now = Date.now(); + for (const bucket of this.bucketsFor(entry)) { + bucket.pause(retry.delayMs, now); + } + } + + private schedule(delayMs: number): void { + const safeDelay = Math.max(0, Math.min(delayMs, 2_147_483_647)); + this.timer = setTimeout(() => { + this.timer = null; + this.pump(); + }, safeDelay); + } + + private attachAbortHandler(entry: QueueEntry): void { + if (!entry.options.signal || entry.abortHandler) return; + entry.abortHandler = () => this.abortQueuedEntry(entry.id); + entry.options.signal.addEventListener("abort", entry.abortHandler, {once: true}); + } + + private cleanupAbortHandler(entry: QueueEntry): void { + if (!entry.abortHandler) return; + entry.options.signal?.removeEventListener("abort", entry.abortHandler); + entry.abortHandler = undefined; + } + + private resolveIdleIfNeeded(): void { + if (this.queue.length !== 0 || this.running !== 0) return; + + this.cleanupIdleBuckets(); + const resolvers = this.idleResolvers.splice(0); + for (const resolve of resolvers) { + resolve(); + } + } + + private cleanupIdleBuckets(now = Date.now()): void { + for (const [key, bucket] of this.chatBuckets) { + if (bucket.isIdle(now, this.bucketIdleMs)) { + this.chatBuckets.delete(key); + } + } + + for (const [key, bucket] of this.groupChatBuckets) { + if (bucket.isIdle(now, this.bucketIdleMs)) { + this.groupChatBuckets.delete(key); + } + } + } +} + +export const telegramApiQueue = new TelegramApiQueue(); + +export async function enqueueTelegramApi( + task: TelegramApiTask, + options?: TelegramApiQueueTaskOptions +): Promise { + return telegramApiQueue.enqueue(task, options); +} + +export async function enqueueTelegramApiCall( + task: () => Promise, + options?: TelegramApiQueueTaskOptions +): Promise { + return telegramApiQueue.enqueue(() => task(), options); +} + +export async function sleepForTelegramRetry(ms: number): Promise { + await delay(ms); +} diff --git a/src/util/utils.ts b/src/util/utils.ts index b12167e..2a1e8c9 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -1,4 +1,5 @@ import * as si from "systeminformation"; +import {appLogger} from "../logging/logger"; import {Command} from "../base/command"; import {CallbackCommand} from "../base/callback-command"; import { @@ -16,40 +17,43 @@ import { } from "typescript-telegram-bot-api"; import {Environment} from "../common/environment"; import {TelegramError} from "typescript-telegram-bot-api/dist/errors"; -import {bot, botUser, callbackCommands, commands, messageDao, ollama, photoDir} from "../index"; +import {bot, botUser, callbackCommands, commands, messageDao, photoDir} from "../index"; import os from "os"; import axios from "axios"; -import {MessagePart} from "../common/message-part"; +import {MessageAudioPart, MessageImagePart, MessagePart} from "../common/message-part"; import {StoredMessage} from "../model/stored-message"; import sharp from "sharp"; import {UserStore} from "../common/user-store"; -import * as orm from "drizzle-orm"; -import {sql, type SQL} from "drizzle-orm"; import fs from "node:fs"; import path from "node:path"; import {MessageStore} from "../common/message-store"; import {SystemInfo} from "../commands/system-info"; import {PrefixResponse} from "../commands/prefix-response"; -import {OllamaChat} from "../commands/ollama-chat"; -import {getYouTubeVideoId, getYouTubeVideoInfo, isVideoExists} from "./ytdl"; -import {YouTubeDownload} from "../commands/youtube-download"; import {ChatCommand} from "../base/chat-command"; -import {WebSearchResponse} from "../model/web-search-response"; -import {GeminiChat} from "../commands/gemini-chat"; -import {MistralChat} from "../commands/mistral-chat"; -import {OpenAIChat} from "../commands/openai-chat"; import {AiProvider} from "../model/ai-provider"; -import {AiModelCapabilities} from "../model/ai-model-capabilities"; -import {OllamaGetModel} from "../commands/ollama-get-model"; -import {GeminiGetModel} from "../commands/gemini-get-model"; -import {MistralGetModel} from "../commands/mistral-get-model"; -import {OpenAIGetModel} from "../commands/openai-get-model"; import {SendOptions} from "../model/send-options"; import {EditOptions} from "../model/edit-options"; -import VideoInfo from "youtubei.js/dist/src/parser/youtube/VideoInfo"; -import {DownloadYtVideo} from "../callback_commands/download-yt-video"; -import {TryAgain} from "../callback_commands/try-again"; import {StoredUser} from "../model/stored-user"; +import {StoredAttachment} from "../model/stored-attachment"; +import {AiDownloadedFile} from "../ai/telegram-attachments"; +import {runUnifiedAi} from "../ai/unified-ai-runner"; +import {enqueueTelegramApiCall} from "./telegram-api-queue"; +import {AsyncSemaphore, KeyedAsyncLock} from "./async-lock"; +import {resolveEffectiveAiProviderForUser, resolveInterfaceLocaleForUser} from "../common/user-ai-settings"; +import {Localization} from "../common/localization"; +import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target"; +import {RandomUtils} from "./random-utils"; +import {HtmlUtils} from "./html-utils"; +import {ShellCommandResult, ShellCommandRunner} from "./shell-command-runner"; +import type {BoundaryValue, ErrorLike} from "../common/boundary-types"; +import {createStoredImageAttachment, photoCachePathForUniqueId, uniqueStoredAttachments} from "../common/stored-attachment-utils"; +import {runTelegramMessageAttachmentPipeline} from "../ai/user-request-pipeline"; + +const imageProcessingSemaphore = new AsyncSemaphore(2); +const fileWriteLocks = new KeyedAsyncLock(); +const logger = appLogger.child("utils"); +const requirementLogger = appLogger.child("requirements"); +const messageLogger = appLogger.child("messages"); export const ignore = () => { }; @@ -66,8 +70,8 @@ export const ignoreIfMarkupFailed = (e: Error | TelegramError) => { } }; -export const logError = (e: Error | TelegramError | string) => { - console.error(e); +export const logError = (error: Error | TelegramError | string | BoundaryValue | ErrorLike | null | undefined) => { + appLogger.error("error", {error: error instanceof Error ? error : String(error)}); }; export const errorPlaceholder = async (msg: Message) => { @@ -89,10 +93,11 @@ export const isMessageTooLong = (e: Error | TelegramError) => { export function searchChatCommand( commands: Command[], text: string, - botUsername: string = botUser.username + botUsername: string | undefined = botUser.username ): Command | null { for (const command of commands) { - const match = command.finalRegexp.exec(text); + const finalRegexp = command.finalRegexp; + const match = finalRegexp.exec(text); if (!match) continue; const mentioned = match[2]?.toLowerCase(); @@ -127,7 +132,7 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m let title: string; if (isChatCommand) { - title = cmd.title; + title = cmd.title || ""; } else if (isCallbackCommand) { title = cmd.data; } else { @@ -136,7 +141,7 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m const cbId = cb?.id; const chatId = msg?.chat?.id || cb?.message?.chat?.id || -1; - const messageId = msg?.message_id || (cb && cb.message && "reply_to_message" in cb.message ? cb.message.reply_to_message.message_id : null) || -1; + const messageId = msg?.message_id || cb?.message?.message_id || -1; const fromId = msg?.from?.id || cb?.from?.id || -1; const chatType = msg?.chat?.type || cb?.message?.chat?.type || null; @@ -148,7 +153,7 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m !Environment.CHAT_IDS_WHITELIST.has(chatId) && !Environment.ADMIN_IDS.has(chatId) && !Environment.ADMIN_IDS.has(fromId)) { - console.log(`${title}: chatId whitelist ignored.`); + requirementLogger.debug("rejected.chat_whitelist", {title, chatId, fromId}); return false; } @@ -159,30 +164,33 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m if (msg) { await replyToMessage({chat_id: chatId, message_id: messageId, text: text}); } else if (cb) { - await bot.answerCallbackQuery({ - callback_query_id: cbId, - text: text, - cache_time: 0, - show_alert: true - }).catch(logError); + await enqueueTelegramApiCall( + () => bot.answerCallbackQuery({ + callback_query_id: cbId || "", + text: text, + cache_time: 0, + show_alert: true + }), + {method: "answerCallbackQuery", skipPerChatLimit: true} + ).catch(logError); } }; if (reqs.isRequiresBotCreator() && fromId !== Environment.CREATOR_ID) { - console.log(`${title}: creatorId is bad`); - await notifyUser("Вы не являетесь создателем бота."); + requirementLogger.debug("rejected.creator", {title, fromId}); + await notifyUser(Environment.notBotCreatorText); return false; } if (reqs.isRequiresBotAdmin() && !Environment.ADMIN_IDS.has(fromId)) { - console.log(`${title}: adminId is bad`); - await notifyUser("Вы не являетесь администратором бота."); + requirementLogger.debug("rejected.bot_admin", {title, fromId}); + await notifyUser(Environment.notBotAdministratorText); return false; } - if (reqs.isRequiresChat() && msg.chat.type === "private") { - console.log(`${title}: chatId is bad`); - await notifyUser("Тут Вам не чат."); + if (reqs.isRequiresChat() && msg?.chat?.type === "private") { + requirementLogger.debug("rejected.chat_required", {title, chatId, chatType}); + await notifyUser(Environment.notAChatText); return false; } @@ -190,8 +198,8 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m const member = await bot.getChatMember({chat_id: chatId, user_id: fromId}); if (!isMemberAdmin(member)) { - console.log(`${title}: chatAdminId is bad`); - await notifyUser("Вы не являетесь администратором чата."); + requirementLogger.debug("rejected.chat_admin", {title, chatId, fromId}); + await notifyUser(Environment.notChatAdministratorText); return false; } } @@ -200,31 +208,47 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m const member = await bot.getChatMember({chat_id: chatId, user_id: botUser.id}); if (!isMemberAdmin(member)) { - console.log(`${title}: botChatAdminId is bad`); - await notifyUser("Бот не является администратором чата."); + requirementLogger.debug("rejected.bot_chat_admin", {title, chatId}); + await notifyUser(Environment.botNotChatAdministratorText); return false; } } if (reqs.isRequiresReply() && !msg?.reply_to_message) { - console.log(`${title}: replyMessage is bad`); - await notifyUser("Отсутствует ответ на сообщение."); + requirementLogger.debug("rejected.reply_required", {title, chatId, messageId}); + await notifyUser(Environment.replyRequiredText); return false; } if (reqs.isRequiresSameUser()) { - let originalFromId: number | null; + let originalFromId: number | undefined; try { - const originalMessage = await MessageStore.get(chatId, messageId); - originalFromId = originalMessage?.fromId; + if (cb?.message) { + const replyMessage = "reply_to_message" in cb.message ? cb.message.reply_to_message : undefined; + originalFromId = replyMessage?.from?.id; + + if (!originalFromId && replyMessage?.message_id) { + const originalMessage = await MessageStore.get(chatId, replyMessage.message_id); + originalFromId = originalMessage?.fromId; + } + + if (!originalFromId) { + const callbackMessage = await MessageStore.get(chatId, cb.message.message_id); + const originalMessage = await MessageStore.get(chatId, callbackMessage?.replyToMessageId); + originalFromId = originalMessage?.fromId; + } + } else { + const originalMessage = await MessageStore.get(chatId, messageId); + originalFromId = originalMessage?.fromId; + } } catch (e) { - logError(e); - originalFromId = null; + logError(e instanceof Error ? e : String(e)); + originalFromId = undefined; } - if (originalFromId && fromId !== originalFromId && fromId !== Environment.CREATOR_ID) { - console.log(`${title}: sameUser is bad`); - await notifyUser("Только автор оригинального сообщения может выполнить это действие."); + if (!originalFromId || (fromId !== originalFromId && fromId !== Environment.CREATOR_ID)) { + requirementLogger.debug("rejected.same_user", {title, chatId, fromId, originalFromId}); + await notifyUser(Environment.onlyOriginalAuthorText); return false; } } @@ -237,7 +261,7 @@ export async function executeChatCommand(cmd: Command | null, msg: Message, text if (!await checkRequirements(cmd, msg)) return false; - await cmd.execute(msg, cmd.regexp.exec(text)); + await cmd.execute(msg, cmd.regexp?.exec(text)); return true; } @@ -247,7 +271,7 @@ export async function findAndExecuteCallbackCommand(commands: CallbackCommand[], const cmd = searchCallbackCommand(commands, data); if (!cmd) return false; - if (!await checkRequirements(cmd, null, query)) return false; + if (!await checkRequirements(cmd, undefined, query)) return false; await cmd.execute(query); await cmd.answerCallbackQuery(query); @@ -266,31 +290,40 @@ export async function oldEditMessageText(chatId: number, messageId: number, mess }); } -export async function editMessageText(options: EditOptions) { +export async function editMessageText(options: EditOptions, retries = 1) { if (options.text.trim().length === 0) return Promise.resolve(false); try { - const message = await bot.editMessageText({ - chat_id: "message" in options ? options.message.chat.id : options.chat_id, - message_id: "message" in options ? options.message.message_id : options.message_id, - text: options.text, - parse_mode: options.parse_mode, - reply_markup: options.reply_markup, - link_preview_options: options.link_preview_options, - }); + const chatId = "message" in options ? options.message.chat.id : options.chat_id; + const chatType = "message" in options ? options.message.chat.type : undefined; + const messageId = "message" in options ? options.message.message_id : options.message_id; + const message = await enqueueTelegramApiCall( + () => bot.editMessageText({ + chat_id: chatId, + message_id: messageId, + text: options.text, + parse_mode: options.parse_mode, + reply_markup: options.reply_markup, + link_preview_options: options.link_preview_options, + }), + { + method: "editMessageText", + chatId, + chatType, + } + ); return Promise.resolve(message); - } catch (e) { - logError(e); + } catch (error) { + logError(error instanceof Error ? error : String(error)); - if (isMarkupFailed(e)) { + if (isMarkupFailed(error as Error | TelegramError)) { return Promise.resolve(true); - } else if (isTooManyRequests(e)) { - const delay = Number(e.message.split("retry after ")[1]) || 30; - setTimeout(() => { - return Promise.resolve(); - }, delay * 1000); + } else if (isTooManyRequests(error as Error | TelegramError) && retries > 0) { + const retryAfter = Number((error instanceof Error ? error.message : String(error)).split("retry after ")[1]) || 30; + await delay(retryAfter * 1000); + return editMessageText(options, retries - 1); } else { - return Promise.reject(e); + return Promise.reject(error); } } } @@ -304,13 +337,22 @@ export async function oldSendMessage(message: Message, text: string, parseMode?: } export async function sendMessage(options: SendOptions): Promise { - const response = await bot.sendMessage({ - chat_id: "message" in options ? options.message.chat.id : options.chat_id, - text: options.text, - parse_mode: options.parse_mode, - link_preview_options: options.link_preview_options, - reply_markup: options.reply_markup, - }); + const chatId = "message" in options ? options.message.chat.id : options.chat_id; + const chatType = "message" in options ? options.message.chat.type : undefined; + const response = await enqueueTelegramApiCall( + () => bot.sendMessage({ + chat_id: chatId, + text: options.text, + parse_mode: options.parse_mode, + link_preview_options: options.link_preview_options, + reply_markup: options.reply_markup, + }), + { + method: "sendMessage", + chatId, + chatType, + } + ); await MessageStore.put(response); @@ -330,15 +372,25 @@ export async function replyToMessage(options: SendOptions): Promise { return Promise.reject("for reply there must be message or message_id"); } - const response = await bot.sendMessage({ - chat_id: "message" in options ? options.message.chat.id : options.chat_id, - text: options.text, - parse_mode: options.parse_mode, - reply_parameters: { - message_id: "message" in options ? options.message.message_id : options.message_id - }, - link_preview_options: options.link_preview_options - }); + const chatId = "message" in options ? options.message.chat.id : options.chat_id; + const chatType = "message" in options ? options.message.chat.type : undefined; + const response = await enqueueTelegramApiCall( + () => bot.sendMessage({ + chat_id: chatId, + text: options.text, + parse_mode: options.parse_mode, + reply_parameters: { + message_id: ("message" in options ? options.message.message_id : options.message_id) + }, + link_preview_options: options.link_preview_options, + reply_markup: options.reply_markup, + }), + { + method: "sendMessage", + chatId, + chatType, + } + ); await MessageStore.put(response); @@ -346,7 +398,7 @@ export async function replyToMessage(options: SendOptions): Promise { } export async function sendErrorPlaceholder(message: Message): Promise { - return await sendMessage({message: message, text: "Произошла ошибка ⚠️"}).catch(logError) as Message; + return await sendMessage({message: message, text: Environment.getErrorText()}).catch(logError) as Message; } export async function initSystemSpecs(): Promise { @@ -356,14 +408,13 @@ export async function initSystemSpecs(): Promise { const ramSize = (mem.total / 1024 / 1024 / 1024).toFixed(2); - const text = - `OS: ${os.distro}\n` + - `RUNTIME: ${run.runtime} ${run.version}\n` + - `DOCKER: ${Environment.IS_DOCKER}\n` + - `CPU: ${cpu.manufacturer} ${cpu.brand} ${cpu.physicalCores} cores ${cpu.cores} threads\n` + - `RAM: ${ramSize} GB`; - - SystemInfo.setSystemInfo(text); + SystemInfo.setSystemInfo({ + os: os.distro, + runtime: `${run.runtime} ${run.version}`, + docker: Environment.IS_DOCKER, + cpu: `${cpu.manufacturer} ${cpu.brand} ${cpu.physicalCores} ${Environment.systemInfoCpuCoresText} ${cpu.cores} ${Environment.systemInfoCpuThreadsText}`, + ramGb: ramSize, + }); return Promise.resolve(); } catch (e) { return Promise.reject(e); @@ -371,27 +422,41 @@ export async function initSystemSpecs(): Promise { } export function getRandomInt(max: number) { - return Math.floor(Math.random() * Math.floor(max)); + return RandomUtils.int(max); } export function getRangedRandomInt(from: number, to: number): number { - return getRandomInt(to - from) + from; + return RandomUtils.rangedInt(from, to); } -export function randomValue(list: T[]): T { - return list[Math.floor(Math.random() * list.length)]; +export function randomValue(list: readonly T[]): T | undefined { + return RandomUtils.value(list); } export function chatCommandToString(cmd: Command): string { - if (!cmd.title && !cmd.description) { + const description = getLocalizedCommandDescription(cmd); + + if (!cmd.title && !description) { return ""; } - if (cmd.title && cmd.description) { - return `${cmd.title}: ${cmd.description}`; + if (cmd.title && description) { + return `${cmd.title}: ${description}`; } - return `${cmd.title ? `${cmd.title}: ` : ""}${cmd.description ? `${cmd.description}` : ""}`; + return `${cmd.title ? `${cmd.title}: ` : ""}${description ? `${description}` : ""}`; +} + +function getLocalizedCommandDescription(cmd: Command): string | undefined { + if (!cmd.title) return cmd.description; + + const entry = Object.entries(Environment.commandTitles) + .find(([, title]) => title === cmd.title); + + if (!entry) return cmd.description; + + const [key] = entry as [keyof typeof Environment.commandDescriptions, string]; + return Environment.commandDescriptions[key] ?? cmd.description; } export function fullName(from: User | StoredUser): string { @@ -418,10 +483,10 @@ export function getUptime(): string { const processMinutes = Math.floor((processUptime % 3600) / 60); const processSeconds = Math.floor(processUptime % 60); - const processUptimeText = `${processDays > 0 ? `${processDays} д. ` : ""}` + - `${processHours > 0 ? `${processHours} ч. ` : ""}` + - `${processMinutes > 0 ? `${processMinutes} м. ` : ""}` + - `${processSeconds > 0 ? `${processSeconds} с.` : ""}`; + const processUptimeText = `${processDays > 0 ? `${processDays} d ` : ""}` + + `${processHours > 0 ? `${processHours} h ` : ""}` + + `${processMinutes > 0 ? `${processMinutes} m ` : ""}` + + `${processSeconds > 0 ? `${processSeconds} s` : ""}`; const osUptime = Math.ceil(os.uptime()); @@ -430,12 +495,12 @@ export function getUptime(): string { const osMinutes = Math.floor((osUptime % 3600) / 60); const osSeconds = Math.floor(osUptime % 60); - const osUptimeText = `${osDays > 0 ? `${osDays} д. ` : ""}` + - `${osHours > 0 ? `${osHours} ч. ` : ""}` + - `${osMinutes > 0 ? `${osMinutes} м. ` : ""}` + - `${osSeconds > 0 ? `${osSeconds} с.` : ""}`; + const osUptimeText = `${osDays > 0 ? `${osDays} d ` : ""}` + + `${osHours > 0 ? `${osHours} h ` : ""}` + + `${osMinutes > 0 ? `${osMinutes} m ` : ""}` + + `${osSeconds > 0 ? `${osSeconds} s` : ""}`; - return `${Environment.IS_DOCKER ? "Docker контейнер" : "Процесс"}:\n${processUptimeText}\n\nСистема:\n${osUptimeText}`; + return Environment.getUptimeText(processUptimeText, osUptimeText); } export const delay = (ms: number, signal?: AbortSignal): Promise => @@ -445,11 +510,25 @@ export const delay = (ms: number, signal?: AbortSignal): Promise => return; } - const id = setTimeout(resolve, ms); + let onAbort: (() => void) | undefined; + let id: NodeJS.Timeout; + + const cleanup = () => { + clearTimeout(id); + if (onAbort) { + signal?.removeEventListener("abort", onAbort); + onAbort = undefined; + } + }; + + id = setTimeout(() => { + cleanup(); + resolve(); + }, ms); if (signal) { - const onAbort = () => { - clearTimeout(id); + onAbort = () => { + cleanup(); reject(new DOMException("Aborted", "AbortError")); }; signal.addEventListener("abort", onAbort, {once: true}); @@ -458,227 +537,687 @@ export const delay = (ms: number, signal?: AbortSignal): Promise => const MARKDOWN_V2_RESERVED_RE = /([\\_*\[\]()~`>#+\-=|{}.!])/g; -function escapePlainMarkdownV2(s: string): string { +// const TOKEN_PREFIX = "\uE000TG_MD_V2_"; +// const TOKEN_SUFFIX = "\uE001"; +// const TOKEN_RE = /\uE000TG_MD_V2_(\d+)\uE001/g; + +// type TokenHit = { +// key: string; +// end: number; +// }; + +// type InlineStyleKind = +// | "bold" +// | "italic" +// | "underline" +// | "strikethrough" +// | "spoiler"; + +// type InlineStyle = { +// inputDelimiter: string; +// outputDelimiter: string; +// kind: InlineStyleKind; +// }; + +// class TelegramMarkdownV2TokenStore { +// private readonly tokens: string[] = []; +// +// add(value: string): string { +// const key = `${TOKEN_PREFIX}${this.tokens.length}${TOKEN_SUFFIX}`; +// this.tokens.push(value); +// return key; +// } +// +// readAt(s: string, index: number): TokenHit | null { +// if (!s.startsWith(TOKEN_PREFIX, index)) { +// return null; +// } +// +// const idStart = index + TOKEN_PREFIX.length; +// const idEnd = s.indexOf(TOKEN_SUFFIX, idStart); +// +// if (idEnd === -1) { +// return null; +// } +// +// const rawId = s.slice(idStart, idEnd); +// +// if (!/^\d+$/.test(rawId)) { +// return null; +// } +// +// return { +// key: s.slice(index, idEnd + TOKEN_SUFFIX.length), +// end: idEnd + TOKEN_SUFFIX.length, +// }; +// } +// +// restore(s: string): string { +// return s.replace(TOKEN_RE, (match, rawId) => { +// return this.tokens[Number(rawId)] ?? match; +// }); +// } +// } + +export function escapePlainMarkdownV2(s: string): string { return s.replace(MARKDOWN_V2_RESERVED_RE, "\\$1"); } -function escapeCodeMarkdownV2(s: string): string { +export function escapeCodeMarkdownV2(s: string): string { return s.replace(/[\\`]/g, "\\$&"); } -function escapeLinkUrlMarkdownV2(s: string): string { +export function buildCancelledGenerationText(baseText: string, provider: string, limit: number = 4096): string { + const cancellationBlock = `\`\`\`${Environment.getCancelledText(provider)}\n\`\`\``; + const separator = "\n\n"; + const trimmedBase = baseText.trim(); + + // Return regular Markdown, not already escaped MarkdownV2. + // Final escaping must happen exactly once right before sending to Telegram. + if (!trimmedBase.length) { + return cancellationBlock; + } + + const fullText = `${trimmedBase}${separator}${cancellationBlock}`; + if (fullText.length <= limit) { + return fullText; + } + + const maxBaseLength = Math.max(0, limit - cancellationBlock.length - separator.length - 3); + const truncatedBase = trimmedBase.slice(0, maxBaseLength).trimEnd(); + + return `${truncatedBase}...${separator}${cancellationBlock}`; +} + + +export function escapeLinkUrlMarkdownV2(s: string): string { return s.replace(/[\\)]/g, "\\$&"); } -function escapeMarkdownV2PreservingAllowedFormatting(s: string): string { - let result = ""; - let i = 0; +// function normalizeLineEndings(s: string): string { +// return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +// } - while (i < s.length) { - // links: [text](url) - if (s[i] === "[") { - const linkMatch = s.slice(i).match(/^\[([^\]\n]+)]\(([^)\n]+)\)/); +// function stripOneOuterNewline(s: string): string { +// return s.replace(/^\n/, "").replace(/\n$/, ""); +// } - if (linkMatch) { - const [, text, url] = linkMatch; - result += `[${escapePlainMarkdownV2(text)}](${escapeLinkUrlMarkdownV2(url)})`; - i += linkMatch[0].length; - continue; - } - } +// function normalizeCodeLanguage(lang: string | undefined): string { +// const trimmed = lang?.trim() ?? ""; +// return /^[a-zA-Z0-9_-]+$/.test(trimmed) ? trimmed : ""; +// } - // monospace: `text` - if (s[i] === "`") { - const end = s.indexOf("`", i + 1); +// function renderCodeBlockMarkdownV2(code: string, lang?: string): string { +// const safeLang = normalizeCodeLanguage(lang); +// const safeCode = escapeCodeMarkdownV2(stripOneOuterNewline(code)); +// return "```" + safeLang + "\n" + safeCode + "\n```"; +// } - if (end !== -1) { - const content = s.slice(i + 1, end); - result += "`" + escapeCodeMarkdownV2(content) + "`"; - i = end + 1; - continue; - } - } +// function renderInlineCodeMarkdownV2(code: string): string { +// return "`" + escapeCodeMarkdownV2(code) + "`"; +// } - // spoiler: ||text|| - if (s.startsWith("||", i)) { - const end = s.indexOf("||", i + 2); +// function protectFencedCodeBlocks( +// s: string, +// store: TelegramMarkdownV2TokenStore, +// ): string { +// return s.replace(/```([a-zA-Z0-9_-]*)[^\S\n]*\n?([\s\S]*?)```/g, (_full, lang: string, code: string) => { +// return store.add(renderCodeBlockMarkdownV2(code, lang)); +// }); +// } - if (end !== -1) { - const content = s.slice(i + 2, end); - result += "||" + escapeMarkdownV2PreservingAllowedFormatting(content) + "||"; - i = end + 2; - continue; - } - } +// function findClosingSquareBracket(s: string, from: number): number { +// for (let i = from; i < s.length; i++) { +// if (s[i] === "\\") { +// i++; +// continue; +// } +// +// if (s[i] === "\n") { +// return -1; +// } +// +// if (s[i] === "]") { +// return i; +// } +// } +// +// return -1; +// } - // underline: __text__ - if (s.startsWith("__", i)) { - const end = s.indexOf("__", i + 2); +// function findClosingParen(s: string, from: number): number { +// let depth = 1; +// +// for (let i = from; i < s.length; i++) { +// const ch = s[i]; +// +// if (ch === "\\") { +// i++; +// continue; +// } +// +// if (ch === "\n") { +// return -1; +// } +// +// if (ch === "(") { +// depth++; +// continue; +// } +// +// if (ch === ")") { +// depth--; +// +// if (depth === 0) { +// return i; +// } +// } +// } +// +// return -1; +// } - if (end !== -1) { - const content = s.slice(i + 2, end); - result += "__" + escapeMarkdownV2PreservingAllowedFormatting(content) + "__"; - i = end + 2; - continue; - } - } +// function parseBracketParen( +// s: string, +// openBracketIndex: number, +// ): { label: string; url: string; end: number } | null { +// if (s[openBracketIndex] !== "[") { +// return null; +// } +// +// const closeBracket = findClosingSquareBracket(s, openBracketIndex + 1); +// +// if (closeBracket === -1 || s[closeBracket + 1] !== "(") { +// return null; +// } +// +// const closeParen = findClosingParen(s, closeBracket + 2); +// +// if (closeParen === -1) { +// return null; +// } +// +// return { +// label: s.slice(openBracketIndex + 1, closeBracket), +// url: s.slice(closeBracket + 2, closeParen), +// end: closeParen + 1, +// }; +// } - // bold: *text* - if (s[i] === "*") { - const end = s.indexOf("*", i + 1); +// function unescapeMarkdownLabel(s: string): string { +// return s.replace(/\\([\\\[\]])/g, "$1"); +// } - if (end !== -1) { - const content = s.slice(i + 1, end); - result += "*" + escapeMarkdownV2PreservingAllowedFormatting(content) + "*"; - i = end + 1; - continue; - } - } +// function unescapeMarkdownUrl(s: string): string { +// return s.replace(/\\([\\)])/g, "$1"); +// } - // italic: _text_ - if (s[i] === "_") { - const end = s.indexOf("_", i + 1); +// function parseQueryParam(query: string, key: string): string | undefined { +// for (const part of query.split("&")) { +// const eq = part.indexOf("="); +// +// if (eq === -1) { +// if (part === key) { +// return ""; +// } +// +// continue; +// } +// +// const paramKey = part.slice(0, eq); +// const paramValue = part.slice(eq + 1); +// +// if (paramKey === key) { +// return paramValue; +// } +// } +// +// return undefined; +// } - if (end !== -1) { - const content = s.slice(i + 1, end); - result += "_" + escapeMarkdownV2PreservingAllowedFormatting(content) + "_"; - i = end + 1; - continue; - } - } +// export function isValidTelegramDateTimeFormat(format: string): boolean { +// return /^(?:r|w?[dD]?[tT]?)$/.test(format); +// } - // strikethrough: ~text~ - if (s[i] === "~") { - const end = s.indexOf("~", i + 1); +// function isValidTelegramTimeUrl(url: string): boolean { +// const match = /^tg:\/\/time\?(.+)$/i.exec(url.trim()); +// +// if (!match) { +// return false; +// } +// +// const query = match[1]; +// const unix = parseQueryParam(query, "unix"); +// const format = parseQueryParam(query, "format"); +// +// if (!unix || !/^-?\d+$/.test(unix)) { +// return false; +// } +// +// return format === undefined || isValidTelegramDateTimeFormat(format); +// } - if (end !== -1) { - const content = s.slice(i + 1, end); - result += "~" + escapeMarkdownV2PreservingAllowedFormatting(content) + "~"; - i = end + 1; - continue; - } - } +// function isValidTelegramEmojiUrl(url: string): boolean { +// return /^tg:\/\/emoji\?id=\d+$/i.test(url.trim()); +// } - result += escapePlainMarkdownV2(s[i]); - i++; - } +// function isTelegramSpecialEntityUrl(url: string): boolean { +// return isValidTelegramEmojiUrl(url) || isValidTelegramTimeUrl(url); +// } - return result; -} +// function renderTelegramSpecialEntityMarkdownV2(label: string, url: string): string { +// return `![${escapePlainMarkdownV2(label)}](${escapeLinkUrlMarkdownV2(url)})`; +// } -function unescapeAccidentalMarkdownV2(s: string): string { - let prev: string; +// function renderInlineLinkMarkdownV2(label: string, url: string): string { +// const safeLabel = label.trim().length > 0 ? label : url; +// return `[${escapePlainMarkdownV2(safeLabel)}](${escapeLinkUrlMarkdownV2(url)})`; +// } - do { - prev = s; - s = s.replace(/\\([_*\[\]()~`>#+\-=|{}.!\\])/g, "$1"); - } while (s !== prev); +// function findInlineCodeEnd(s: string, from: number): number { +// for (let i = from; i < s.length; i++) { +// if (s[i] === "\n") { +// return -1; +// } +// +// if (s[i] === "`") { +// return i; +// } +// } +// +// return -1; +// } - return s; -} +// function protectInlineEntities( +// s: string, +// store: TelegramMarkdownV2TokenStore, +// ): string { +// let result = ""; +// let i = 0; +// +// while (i < s.length) { +// const token = store.readAt(s, i); +// +// if (token) { +// result += token.key; +// i = token.end; +// continue; +// } +// +// if (s.startsWith("![", i)) { +// const parsed = parseBracketParen(s, i + 1); +// +// if (parsed) { +// const label = unescapeMarkdownLabel(parsed.label); +// const url = unescapeMarkdownUrl(parsed.url.trim()); +// +// if (isTelegramSpecialEntityUrl(url)) { +// result += store.add(renderTelegramSpecialEntityMarkdownV2(label, url)); +// } else { +// result += label.trim().length > 0 ? `${label}: ${url}` : url; +// } +// +// i = parsed.end; +// continue; +// } +// } +// +// if (s[i] === "[") { +// const parsed = parseBracketParen(s, i); +// +// if (parsed) { +// const label = unescapeMarkdownLabel(parsed.label); +// const url = unescapeMarkdownUrl(parsed.url.trim()); +// +// if (url.length > 0) { +// result += store.add(renderInlineLinkMarkdownV2(label, url)); +// i = parsed.end; +// continue; +// } +// } +// } +// +// if (s[i] === "`") { +// const end = findInlineCodeEnd(s, i + 1); +// +// if (end !== -1) { +// result += store.add(renderInlineCodeMarkdownV2(s.slice(i + 1, end))); +// i = end + 1; +// continue; +// } +// } +// +// result += s[i]; +// i++; +// } +// +// return result; +// } -function escapeTelegramQuoteLine(line: string): string { - const content = line.replace(/^>\s*/, ""); +// function isMarkdownTableSeparator(line: string): boolean { +// return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line); +// } - if (!content.trim()) { - return ">"; - } +// function looksLikeMarkdownTableRow(line: string): boolean { +// const trimmed = line.trim(); +// +// if (!trimmed.includes("|")) { +// return false; +// } +// +// return !(trimmed.startsWith("||") && trimmed.endsWith("||")); +// } - return ">" + escapeMarkdownV2PreservingAllowedFormatting(content); -} +// function splitMarkdownTableRow(line: string): string[] { +// const normalized = line.trim().replace(/^\|/, "").replace(/\|$/, ""); +// const cells: string[] = []; +// let current = ""; +// +// for (let i = 0; i < normalized.length; i++) { +// const ch = normalized[i]; +// +// if (ch === "\\") { +// current += ch; +// +// if (i + 1 < normalized.length) { +// current += normalized[i + 1]; +// i++; +// } +// +// continue; +// } +// +// if (ch === "|") { +// cells.push(current.trim()); +// current = ""; +// continue; +// } +// +// current += ch; +// } +// +// cells.push(current.trim()); +// return cells.filter(Boolean); +// } -function normalizeTelegramQuoteLines(s: string): string { - return s - .split("\n") - .map(line => { - if (!line.startsWith(">")) return line; +// function normalizeMarkdownTables(s: string): string { +// const lines = s.split("\n"); +// const result: string[] = []; +// let i = 0; +// +// while (i < lines.length) { +// const current = lines[i]; +// const next = lines[i + 1]; +// +// if ( +// next !== undefined && +// looksLikeMarkdownTableRow(current) && +// isMarkdownTableSeparator(next) +// ) { +// const tableRows = [current]; +// i += 2; +// +// while ( +// i < lines.length && +// looksLikeMarkdownTableRow(lines[i]) && +// !isMarkdownTableSeparator(lines[i]) +// ) { +// tableRows.push(lines[i]); +// i++; +// } +// +// for (const row of tableRows) { +// const cells = splitMarkdownTableRow(row); +// +// if (cells.length > 0) { +// result.push(cells.join(" — ")); +// } +// } +// +// continue; +// } +// +// result.push(current); +// i++; +// } +// +// return result.join("\n"); +// } - return line.replace(/^>\s+/, ">"); - }) - .join("\n"); -} +// function normalizeUnsupportedMarkdownLine(line: string): string { +// const headingMatch = /^\s*#{1,6}\s+(.+?)\s*#*\s*$/.exec(line); +// +// if (headingMatch) { +// return `*${headingMatch[1].trim()}*`; +// } +// +// if (/^\s*([-*_])(?:\s*\1){2,}\s*$/.test(line)) { +// return "— — —"; +// } +// +// line = line.replace(/^(\s*)[-*+]\s+\[\s]\s+(?=\S)/i, "$1☐ "); +// line = line.replace(/^(\s*)[-*+]\s+\[[xX]]\s+(?=\S)/, "$1☑ "); +// line = line.replace(/^(\s*)[-*+]\s+(?=\S)/, "$1• "); +// line = line.replace(/^(\s*)(\d+)[.)]\s+(?=\S)/, "$1$2) "); +// +// return line; +// } -function looksLikeMarkdownTableRow(line: string): boolean { - const trimmed = line.trim(); +// function normalizeUnsupportedMarkdown(s: string): string { +// return normalizeMarkdownTables(s) +// .split("\n") +// .map(normalizeUnsupportedMarkdownLine) +// .join("\n"); +// } - if (trimmed.startsWith("||") && trimmed.endsWith("||")) { - return false; - } +// function isWhitespace(ch: string | undefined): boolean { +// return ch !== undefined && /\s/.test(ch); +// } - const pipeCount = (trimmed.match(/\|/g) ?? []).length; +// function isWordChar(ch: string | undefined): boolean { +// return ch !== undefined && /[\p{L}\p{N}]/u.test(ch); +// } - if (pipeCount < 2) { - return false; - } +// function canOpenDelimiter( +// s: string, +// index: number, +// delimiter: string, +// kind: InlineStyleKind, +// ): boolean { +// const before = s[index - 1]; +// const after = s[index + delimiter.length]; +// +// if (after === undefined || isWhitespace(after)) { +// return false; +// } +// +// return !((kind === "bold" || kind === "italic" || kind === "strikethrough") && +// isWordChar(before) && +// isWordChar(after)); +// } - return trimmed.startsWith("|") || trimmed.endsWith("|") || pipeCount >= 2; -} +// function canCloseDelimiter( +// s: string, +// index: number, +// delimiter: string, +// kind: InlineStyleKind, +// ): boolean { +// const before = s[index - 1]; +// const after = s[index + delimiter.length]; +// +// if (before === undefined || isWhitespace(before)) { +// return false; +// } +// +// return !((kind === "bold" || kind === "italic" || kind === "strikethrough") && +// isWordChar(before) && +// isWordChar(after)); +// } -function isMarkdownTableSeparator(line: string): boolean { - return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line); -} +// function findClosingDelimiter( +// s: string, +// delimiter: string, +// from: number, +// kind: InlineStyleKind, +// store: TelegramMarkdownV2TokenStore, +// ): number { +// for (let i = from; i < s.length; i++) { +// const token = store.readAt(s, i); +// +// if (token) { +// i = token.end - 1; +// continue; +// } +// +// if (s[i] === "\\") { +// i++; +// continue; +// } +// +// if (s.startsWith(delimiter, i) && canCloseDelimiter(s, i, delimiter, kind)) { +// return i; +// } +// } +// +// return -1; +// } -function normalizeMarkdownTables(s: string): string { - return s - .split("\n") - .filter(line => !isMarkdownTableSeparator(line)) - .map(line => { - if (!looksLikeMarkdownTableRow(line)) { - return line; - } +// function formatInlineMarkdownV2( +// s: string, +// store: TelegramMarkdownV2TokenStore, +// ): string { +// const styles: InlineStyle[] = [ +// {inputDelimiter: "||", outputDelimiter: "||", kind: "spoiler"}, +// {inputDelimiter: "__", outputDelimiter: "__", kind: "underline"}, +// {inputDelimiter: "**", outputDelimiter: "*", kind: "bold"}, +// {inputDelimiter: "~~", outputDelimiter: "~", kind: "strikethrough"}, +// {inputDelimiter: "*", outputDelimiter: "*", kind: "bold"}, +// {inputDelimiter: "_", outputDelimiter: "_", kind: "italic"}, +// {inputDelimiter: "~", outputDelimiter: "~", kind: "strikethrough"}, +// ]; +// +// let result = ""; +// let i = 0; +// +// while (i < s.length) { +// const token = store.readAt(s, i); +// +// if (token) { +// result += token.key; +// i = token.end; +// continue; +// } +// +// if (s[i] === "\\" && i + 1 < s.length) { +// result += escapePlainMarkdownV2(s[i + 1]); +// i += 2; +// continue; +// } +// +// let handled = false; +// +// for (const style of styles) { +// const delimiter = style.inputDelimiter; +// +// if (!s.startsWith(delimiter, i)) { +// continue; +// } +// +// if (!canOpenDelimiter(s, i, delimiter, style.kind)) { +// continue; +// } +// +// const end = findClosingDelimiter( +// s, +// delimiter, +// i + delimiter.length, +// style.kind, +// store, +// ); +// +// if (end === -1) { +// continue; +// } +// +// const content = s.slice(i + delimiter.length, end); +// +// if (content.length === 0) { +// continue; +// } +// +// result += +// style.outputDelimiter + +// formatInlineMarkdownV2(content, store) + +// style.outputDelimiter; +// +// i = end + delimiter.length; +// handled = true; +// break; +// } +// +// if (handled) { +// continue; +// } +// +// result += escapePlainMarkdownV2(s[i]); +// i++; +// } +// +// return result; +// } - return line - .replace(/^\s*\|/, "") - .replace(/\|\s*$/, "") - .split("|") - .map(cell => cell.trim()) - .filter(Boolean) - .join(" — "); - }) - .join("\n"); -} +// function renderMarkdownV2Line( +// line: string, +// store: TelegramMarkdownV2TokenStore, +// ): string { +// if (line.startsWith("**>")) { +// let content = line.slice(3).replace(/^\s?/, ""); +// const isExpandableEnd = content.endsWith("||"); +// +// if (isExpandableEnd) { +// content = content.slice(0, -2); +// } +// +// return `**>${formatInlineMarkdownV2(content, store)}${isExpandableEnd ? "||" : ""}`; +// } +// +// if (line.startsWith(">")) { +// const content = line.slice(1).replace(/^\s?/, ""); +// +// if (!content.trim()) { +// return ">"; +// } +// +// return ">" + formatInlineMarkdownV2(content, store); +// } +// +// return formatInlineMarkdownV2(line, store); +// } -export function escapeMarkdownV2Text(s: string): string { - s = unescapeAccidentalMarkdownV2(s); - s = normalizeTelegramQuoteLines(s); +// function renderMarkdownV2( +// s: string, +// store: TelegramMarkdownV2TokenStore, +// ): string { +// return s +// .split("\n") +// .map(line => renderMarkdownV2Line(line, store)) +// .join("\n"); +// } - s = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - - s = s.replace(/^\s*[-*_]{3,}\s*$/gm, "— — —"); - s = s.replace(/^\s*[-*+]\s+(?=\S)/gm, "• "); - s = s.replace(/\*\*(.+?)\*\*/gs, "*$1*"); - s = s.replace(/~~(.+?)~~/gs, "~$1~"); - s = s.replace(/^#{1,6}\s+/gm, ""); - - s = s.replace(/```[a-zA-Z0-9_-]*\n?([\s\S]*?)```/g, (_, code) => { - return code.trim(); - }); - - s = s.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (_, alt, url) => { - return alt ? `${alt}: ${url}` : url; - }); - - s = normalizeMarkdownTables(s); - - s = s - .split("\n") - .map(line => { - if (line.startsWith(">")) { - return escapeTelegramQuoteLine(line); - } - - if (line === ">") { - return ">"; - } - - return escapeMarkdownV2PreservingAllowedFormatting(line); - }) - .join("\n"); - - s = s.replace(/\n{3,}/g, "\n\n"); - - return s.trim(); -} +// export function escapeMarkdownV2Text(input: string): string { +// const store = new TelegramMarkdownV2TokenStore(); +// +// let s = normalizeLineEndings(input); +// +// s = protectFencedCodeBlocks(s, store); +// s = protectInlineEntities(s, store); +// s = normalizeUnsupportedMarkdown(s); +// s = renderMarkdownV2(s, store); +// s = s.replace(/\n{3,}/g, "\n\n").trim(); +// s = store.restore(s); +// +// return s.trim(); +// } export async function getFileUrl(fileId: string): Promise { const file = await bot.getFile({file_id: fileId}); @@ -709,19 +1248,30 @@ export async function getUserAvatar(userId: number): Promise { return Buffer.from(res.data); } +export function extractMessageQuote(msg: Message | StoredMessage | null | undefined): string | undefined | null { + if (!msg) return null; + + return isStoredMessage(msg) ? msg.quoteText : msg.quote?.text; +} + export function extractTextMessage(msg: Message | StoredMessage | string): string | null { if (!msg) return null; if (typeof msg === "string") return msg; - const text = (isStoredMessage(msg) ? msg.text : msg.text || msg.caption || "").trim(); - if (text.length === 0) return null; + const text = (isStoredMessage(msg) ? msg.text : msg.text || msg.caption || "")?.trim(); + if (!text || !text?.length) return null; return text; } -export function cutPrefixes(msg: Message | StoredMessage | string): string { +export function escapeHtml(input: string): string { + return HtmlUtils.escape(input); +} + +export function cutPrefixes(msg: Message | StoredMessage | string | null): string | null { + if (!msg) return null; const chatCommands = commands.filter(c => c instanceof ChatCommand); - const prefixes = [Environment.BOT_PREFIX]; + const prefixes = Environment.BOT_PREFIX ? [Environment.BOT_PREFIX] : []; const pushPrefix = (c: string) => { prefixes.push(`/${c}@${botUser.username}`); prefixes.push(`/${c}`); @@ -729,10 +1279,12 @@ export function cutPrefixes(msg: Message | StoredMessage | string): string { chatCommands.forEach((cmd) => { const command = cmd.command; - if (Array.isArray(command)) { - command.forEach(pushPrefix); - } else { - pushPrefix(command); + if (command) { + if (Array.isArray(command)) { + command.forEach(pushPrefix); + } else { + pushPrefix(command); + } } }); @@ -751,39 +1303,98 @@ export function cutPrefixes(msg: Message | StoredMessage | string): string { return newText; } -export function isStoredMessage(msg: Message | StoredMessage): msg is StoredMessage { - return "id" in msg; +export function isStoredMessage(msg: Message | StoredMessage | null): msg is StoredMessage { + return !!msg && "id" in msg; } -export async function loadImagesIfExists(msg: Message | StoredMessage): Promise { +function mimeTypeFromImagePath(filePath: string, fallback = "image/jpeg"): string { + switch (path.extname(filePath).toLowerCase()) { + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".png": + return "image/png"; + case ".webp": + return "image/webp"; + case ".gif": + return "image/gif"; + default: + return fallback; + } +} + +function mimeTypeFromImageAttachment(attachment: StoredAttachment): string { + const mimeType = attachment.mimeType?.toLowerCase(); + if (mimeType?.startsWith("image/")) return mimeType; + return mimeTypeFromImagePath(attachment.cachePath); +} + +function mimeTypeFromAudioPath(filePath: string, fallback = "audio/wav"): string { + switch (path.extname(filePath).toLowerCase()) { + case ".mp3": + return "audio/mpeg"; + case ".m4a": + return "audio/m4a"; + case ".ogg": + case ".oga": + return "audio/ogg"; + case ".opus": + return "audio/opus"; + case ".flac": + return "audio/flac"; + case ".aac": + return "audio/aac"; + case ".wav": + return "audio/wav"; + default: + return fallback; + } +} + +function mimeTypeFromAudioDownload(download: AiDownloadedFile): string { + const mimeType = download.mimeType?.toLowerCase(); + if (mimeType?.startsWith("audio/")) return mimeType; + return mimeTypeFromAudioPath(download.path); +} + +export async function loadImagesIfExists(msg: Message | StoredMessage): Promise { if (isStoredMessage(msg)) { - return msg.photoMaxSizeFilePath; + return msg.attachments + ?.filter(attachment => attachment.kind === "image") + .map(attachment => attachment.fileUniqueId || path.basename(attachment.cachePath, path.extname(attachment.cachePath))); } if (!msg.photo?.length) return; const imageFilePaths: string[] = []; - for (const size of msg.photo) { - const exists = fs.existsSync(photoPathByUniqueId(size.file_unique_id)); - if (exists) { - return [size.file_unique_id]; - } + const maxSize = getPhotoMaxSize(msg.photo); + if (!maxSize) return []; + + const exists = fs.existsSync(photoPathByUniqueId(maxSize.file_unique_id)); + if (exists) { + return [maxSize.file_unique_id]; } - const maxSize = await mapPhotoSizeToMax(getPhotoMaxSize(msg.photo)); - if (maxSize) { - let imageFilePath = path.join(photoDir, maxSize.unique_file_id + ".jpg"); + const photoMaxSize = await mapPhotoSizeToMax(maxSize); + if (photoMaxSize) { + let imageFilePath: string | null = photoPathByUniqueId(maxSize.file_unique_id); if (!fs.existsSync(imageFilePath)) { - const res = await axios.get(maxSize.url, {responseType: "arraybuffer"}); - const src = Buffer.from(res.data); + await fileWriteLocks.runExclusive(imageFilePath, async () => { + if (fs.existsSync(imageFilePath!)) return; - try { - fs.writeFileSync(imageFilePath, src); - } catch (e) { - logError(e); - imageFilePath = null; - } + const res = await axios.get(photoMaxSize.url, {responseType: "arraybuffer"}); + const src = Buffer.from(res.data); + + try { + const tempPath = `${imageFilePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, src); + fs.renameSync(tempPath, imageFilePath!); + } catch (e) { + logError(e instanceof Error ? e : String(e)); + imageFilePath = null; + } + }); } if (imageFilePath) { @@ -804,62 +1415,177 @@ export async function loadImagesFromFileIds(sizes: PhotoSize[]): Promise !fs.existsSync(photoPathByUniqueId(s.file_unique_id))) .map(s => mapPhotoSizeToMax(s)); - const maxSizes = await Promise.all(promises); + const maxSizes = (await Promise.all(promises)).filter(e => !!e); const imagePromises = maxSizes.map((size) => { return axios.get(size.url, {responseType: "arraybuffer"}); }); const responses = await Promise.all(imagePromises); - const paths = responses.map((res, index) => { + const paths = await Promise.all(responses.map((res, index) => { try { const uniqueFileId = maxSizes[index].unique_file_id; const imageFilePath = path.join(photoDir, uniqueFileId + ".jpg"); const src = Buffer.from(res.data); - fs.writeFileSync(imageFilePath, src); - return uniqueFileId; + return fileWriteLocks.runExclusive(imageFilePath, async () => { + if (!fs.existsSync(imageFilePath)) { + const tempPath = `${imageFilePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, src); + fs.renameSync(tempPath, imageFilePath); + } + return uniqueFileId; + }); } catch (e) { - logError(e); + logError(e instanceof Error ? e : String(e)); return null; } - }); - const finalPaths = paths.filter(p => p); - finalPaths.unshift(...existing); + })); + const finalPaths = existing.concat(...paths.filter(p => !!p).map(p => p)); return finalPaths; } -export async function collectReplyChainText(triggerMsg: Message | StoredMessage, limit: number = 40, includeTrigger = true, cutPrefix: boolean = true): Promise { +export type ReplyChainOptions = { + triggerMsg: Message | StoredMessage | null | undefined, + limit?: number, + includeTrigger?: boolean; + cutPrefix?: boolean, + downloads?: AiDownloadedFile[] +} + +export async function collectReplyChainText(options: ReplyChainOptions): Promise { + const triggerMsg = options.triggerMsg; + const limit = options.limit ?? 40; + const includeTrigger = options.includeTrigger ?? true; + const cutPrefix = options.cutPrefix ?? true; + const downloads = options.downloads ?? []; + + if (!triggerMsg) return []; + const parts: MessagePart[] = []; - const pushPart = async (msg: Message | StoredMessage, textRequired: boolean = false) => { - const rawText = extractTextMessage(msg); - const cleanText = cutPrefix ? cutPrefixes(rawText) : rawText; - const imageNames = await loadImagesIfExists(msg); + function resolveStoredImagePath(imageName: string, attachments: StoredAttachment[]): string | undefined { + const directPath = photoPathByUniqueId(imageName); + if (fs.existsSync(directPath)) return directPath; - if (!cleanText && textRequired) return; - if (!cleanText && !imageNames?.length) return; - - const fromId = isStoredMessage(msg) ? msg.fromId : msg.from.id; - const firstName = isStoredMessage(msg) ? - (await UserStore.get(msg.fromId))?.firstName : msg.from.first_name; - - const images = imageNames ? imageNames.map(n => { - const filePath = photoPathByUniqueId(n); - return Buffer.from(fs.readFileSync(filePath)).toString("base64"); - }) : null; - - parts.push({ - bot: fromId === botUser.id, - content: cleanText ? cleanText : "", - name: firstName, - images: images ? images : [] + const attachment = attachments.find(item => { + if (item.kind !== "image") return false; + if (item.fileUniqueId && item.fileUniqueId === imageName) return true; + return path.basename(item.cachePath, path.extname(item.cachePath)) === imageName; }); + + if (attachment && fs.existsSync(attachment.cachePath)) { + return attachment.cachePath; + } + + return undefined; + } + + const pushPart = async (msg: Message | StoredMessage | undefined | null, textRequired: boolean = false, includeDownloads: boolean = false) => { + if (msg) { + const quoteText = extractMessageQuote(msg); + const rawText = extractTextMessage(msg); + const cleanText = cutPrefix ? cutPrefixes(rawText) : rawText; + const imageNames = await loadImagesIfExists(msg); + const messageDownloads = includeDownloads ? downloads : []; + const storedImageAttachments = isStoredMessage(msg) + ? (msg.attachments ?? []).filter(attachment => attachment.kind === "image" && fs.existsSync(attachment.cachePath)) + : []; + + if (!cleanText && !quoteText && textRequired) return; + if (!cleanText && !quoteText && !imageNames?.length && !storedImageAttachments.length && !messageDownloads.length) return; + + const fromId = isStoredMessage(msg) ? msg.fromId : msg.from?.id; + const user = await UserStore.get(isStoredMessage(msg) ? msg.fromId : msg.from?.id ?? -1); + + const firstName = isStoredMessage(msg) ? user?.firstName : msg.from?.first_name; + + const photoImageParts: MessageImagePart[] = imageNames ? imageNames.flatMap(n => { + const filePath = isStoredMessage(msg) + ? resolveStoredImagePath(n, storedImageAttachments) + : (fs.existsSync(photoPathByUniqueId(n)) ? photoPathByUniqueId(n) : undefined); + + if (!filePath) { + messageLogger.warn("reply_chain.image_missing", {imageName: n, chatId: isStoredMessage(msg) ? msg.chatId : msg.chat?.id, messageId: isStoredMessage(msg) ? msg.id : msg.message_id}); + return []; + } + + return [{ + data: Buffer.from(fs.readFileSync(filePath)).toString("base64"), + mimeType: mimeTypeFromImagePath(filePath), + }]; + }) : []; + const imageNameSet = new Set(imageNames ?? []); + const cachedImageAttachments = storedImageAttachments.filter(attachment => { + if (attachment.fileUniqueId && imageNameSet.has(attachment.fileUniqueId)) return false; + return !imageNameSet.has(path.basename(attachment.cachePath, path.extname(attachment.cachePath))); + }); + const cachedImageParts: MessageImagePart[] = cachedImageAttachments.map(attachment => { + return { + data: Buffer.from(fs.readFileSync(attachment.cachePath)).toString("base64"), + mimeType: mimeTypeFromImageAttachment(attachment), + }; + }); + const imageParts = [...photoImageParts, ...cachedImageParts]; + + const audios: string[] = []; + const audioParts: MessageAudioPart[] = []; + const documents: string[] = []; + const videos: string[] = []; + const videoNotes: string[] = []; + + if (messageDownloads.length) { + messageDownloads + .filter(d => d.kind === "audio") + .forEach(a => { + const data = a.buffer.toString("base64"); + audios.push(data); + audioParts.push({data, mimeType: mimeTypeFromAudioDownload(a)}); + }); + + messageDownloads + .filter(d => d.kind === "document") + .forEach(d => documents.push(d.buffer.toString("base64"))); + + messageDownloads + .filter(d => d.kind === "video") + .forEach(v => videos.push(v.buffer.toString("base64"))); + + messageDownloads + .filter(d => d.kind === "video-note") + .forEach(v => { + const data = v.buffer.toString("base64"); + videoNotes.push(data); + audioParts.push({data, mimeType: mimeTypeFromAudioDownload(v)}); + }); + } + + const content = [ + quoteText ? `[citation]:\n${quoteText}\n\n[message]:\n` : "", + cleanText ?? "" + ].join("\n").trim(); + + parts.push({ + bot: fromId === botUser.id, + content: content, + name: firstName, + langCode: user?.langCode, + userName: user?.userName, + deletedByBotAt: isStoredMessage(msg) ? msg.deletedByBotAt : undefined, + images: imageParts.map(image => image.data), + imageParts: imageParts.length ? imageParts : undefined, + audios: audios.length ? audios : undefined, + audioParts: audioParts.length ? audioParts : undefined, + documents: documents.length ? documents : undefined, + videos: videos.length ? videos : undefined, + videoNotes: videoNotes.length ? videoNotes : undefined, + }); + } }; const chatId = isStoredMessage(triggerMsg) ? triggerMsg.chatId as number : triggerMsg.chat.id; if (includeTrigger) { - await pushPart(triggerMsg); + await pushPart(triggerMsg, false, true); } const first = isStoredMessage(triggerMsg) ? @@ -914,57 +1640,60 @@ export async function waveDistortSharp( wavelength = 72, maxSide = 1024 ): Promise { - amp = clamp(amp, 2, 60); - wavelength = clamp(wavelength, 16, 300); + return imageProcessingSemaphore.runExclusive(async () => { + amp = clamp(amp, 2, 60); + wavelength = clamp(wavelength, 16, 300); - const phase1 = Math.random() * Math.PI * 2; - const phase2 = Math.random() * Math.PI * 2; - const amp2 = Math.max(6, Math.floor(amp * 0.6)); - const wavelength2 = Math.max(32, Math.floor(wavelength * 1.4)); + const phase1 = Math.random() * Math.PI * 2; + const phase2 = Math.random() * Math.PI * 2; + const amp2 = Math.max(6, Math.floor(amp * 0.6)); + const wavelength2 = Math.max(32, Math.floor(wavelength * 1.4)); - const {data, info} = await sharp(input) - .resize({width: maxSide, height: maxSide, fit: "inside", withoutEnlargement: true}) - .ensureAlpha() - .raw() - .toBuffer({resolveWithObject: true}); + const {data, info} = await sharp(input) + .resize({width: maxSide, height: maxSide, fit: "inside", withoutEnlargement: true}) + .ensureAlpha() + .raw() + .toBuffer({resolveWithObject: true}); - const width = info.width!; - const height = info.height!; - const channels = info.channels!; // обычно 4 (RGBA) + const width = info.width!; + const height = info.height!; + const channels = info.channels!; // usually 4 (RGBA) - const out = Buffer.alloc(data.length); + const out = Buffer.alloc(data.length); - for (let y = 0; y < height; y++) { - const dx = amp * Math.sin((2 * Math.PI * y) / wavelength + phase1); + for (let y = 0; y < height; y++) { + const dx = amp * Math.sin((2 * Math.PI * y) / wavelength + phase1); - for (let x = 0; x < width; x++) { - const dy = amp2 * Math.sin((2 * Math.PI * x) / wavelength2 + phase2); + for (let x = 0; x < width; x++) { + const dy = amp2 * Math.sin((2 * Math.PI * x) / wavelength2 + phase2); - const sx = Math.round(x + dx); - const sy = Math.round(y + dy); + const sx = Math.round(x + dx); + const sy = Math.round(y + dy); - const di = (y * width + x) * channels; + const di = (y * width + x) * channels; - if (sx < 0 || sx >= width || sy < 0 || sy >= height) { - // прозрачный пиксель - out[di] = 0; - out[di + 1] = 0; - out[di + 2] = 0; - out[di + 3] = 0; - continue; + if (sx < 0 || sx >= width || sy < 0 || sy >= height) { + // transparent pixel + out[di] = 0; + out[di + 1] = 0; + out[di + 2] = 0; + out[di + 3] = 0; + continue; + } + + const si = (sy * width + sx) * channels; + data.copy(out, di, si, si + channels); } - - const si = (sy * width + sx) * channels; - data.copy(out, di, si, si + channels); } - } - return await sharp(out, {raw: {width, height, channels}}) - .png() - .toBuffer(); + return await sharp(out, {raw: {width, height, channels}}) + .png() + .toBuffer(); + }); } -export async function downloadTelegramFile(filePath: string): Promise { +export async function downloadTelegramFile(filePath?: string | null): Promise { + if (!filePath) return null; const url = `https://api.telegram.org/file/bot${Environment.BOT_TOKEN}/${filePath}`; const res = await fetch(url); if (!res.ok) throw new Error(`Failed to download file: ${res.status} ${res.statusText}`); @@ -973,11 +1702,11 @@ export async function downloadTelegramFile(filePath: string): Promise { } export function extractImageFileId(reply: Message): string | null { - // photo (сжатое) + // photo (compressed) if (reply.photo?.length) { - return reply.photo[reply.photo.length - 1]!.file_id; // самое большое + return reply.photo[reply.photo.length - 1]!.file_id; // largest } - // document (обычно оригинал) + // document (usually original) if (reply.document?.mime_type?.startsWith("image/")) { return reply.document.file_id; } @@ -1003,11 +1732,11 @@ export async function makeDarkGradientBgFancy( const c2 = hslToHex(hue2, 35 + rndInt(rnd, 0, 14), 9 + rndInt(rnd, 0, 5)); const c3 = hslToHex(hue3, 30 + rndInt(rnd, 0, 14), 8 + rndInt(rnd, 0, 5)); - // случайный угол градиента + // random gradient angle const x1 = rnd(), y1 = rnd(); const x2 = 1 - x1, y2 = 1 - y1; - // мягкое свечение + // soft glow const glowHue = (hue1 + rndInt(rnd, -25, 25) + 360) % 360; const glowColor = hslToHex(glowHue, 60, 60); const glowCx = 0.35 + rnd() * 0.30; @@ -1015,10 +1744,10 @@ export async function makeDarkGradientBgFancy( const glowR = 0.55 + rnd() * 0.25; const glowOpacity = 0.14 + rnd() * 0.10; - // виньетка + // vignette const vignetteStrength = 0.55 + rnd() * 0.15; - // зерно + // grain const grainSeed = Math.floor(rnd() * 10_000); const grainAlpha = 0.10 + rnd() * 0.06; // 0.10..0.16 const grainFreq = 0.75 + rnd() * 0.35; // 0.75..1.10 @@ -1044,7 +1773,7 @@ export async function makeDarkGradientBgFancy( - + - + @@ -1073,7 +1802,7 @@ export async function makeDarkGradientBgFancy( return sharp(svg) .resize(width, height) - .blur(0.6) // чуть сгладить градиент/свечение (зерно тоже мягче) + .blur(0.6) // slightly smooth the gradient/glow (grain gets softer too) .png() .toBuffer(); } @@ -1131,71 +1860,54 @@ export function startIntervalEditor(params: { }) { let lastSent = ""; let stopped = false; + let inFlight: Promise = Promise.resolve(); - const tick = async () => { + const runTick = async () => { if (stopped /*|| (params.uuid && getOllamaRequest(params.uuid)?.done)*/) return; const next = params.getText(); if (!next || next === lastSent) return; - console.log("tick"); - try { await params.editFn(next); lastSent = next; - } catch (e) { - if ((e?.description ?? e?.message ?? "").includes("message is not modified")) return; - logError("edit failed: " + e); + } catch (error) { + const description = error instanceof Error ? error.message : String(error); + if (description.includes("message is not modified")) return; + logError("edit failed: " + description); } }; - const timer = setInterval(async () => await tick(), params.intervalMs); + const tick = async () => { + inFlight = inFlight.then(runTick, runTick); + return inFlight; + }; + + const timer = setInterval(() => { + tick().catch(logError); + }, params.intervalMs); return { tick, stop: async () => { stopped = true; clearInterval(timer); + await inFlight; await params.onStop?.(); }, }; } -export function boolToInt(bool: boolean): number { +export function boolToInt(bool: boolean | undefined): number { return bool ? 1 : 0; } -type AnyDrizzleTable = { - _: { - columns: Record; - }; -}; - -export function buildExcludedSet< - T extends AnyDrizzleTable, - K extends keyof T["_"]["columns"] & string, - E extends readonly K[] = readonly [] ->(table: T, exclude: E = [] as unknown as E): Record, SQL> { - const cols = orm.getColumns(table as never) as T["_"]["columns"]; - const excludeSet = new Set(exclude as readonly string[]); - - const entries = Object.keys(cols) - .filter((key) => !excludeSet.has(key)) - .map((key) => { - const realName = (cols as unknown)[key].name; // actual DB column name - return [key, sql.raw(`excluded.${realName}`)] as const; - }); - - return Object.fromEntries(entries) as Record, SQL>; -} - type RuntimeInfo = | { runtime: "bun"; version: string } | { runtime: "node"; version: string } - | { runtime: "unknown"; version: string }; + | { runtime: "other"; version: string }; export function getRuntimeInfo(): RuntimeInfo { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const v = (process as any).versions ?? {}; + const v = process.versions ?? {}; if (typeof v.bun === "string") { return {runtime: "bun", version: v.bun}; @@ -1204,13 +1916,12 @@ export function getRuntimeInfo(): RuntimeInfo { return {runtime: "node", version: v.node}; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {runtime: "unknown", version: String((process as any).version ?? "")}; + return {runtime: "other", version: String(process.version ?? "")}; } export type PhotoMaxSize = { width: number, height: number, url: string; file_id: string; unique_file_id: string; }; -export function getPhotoMaxSize(photos: PhotoSize[], target: number = Environment.MAX_PHOTO_SIZE): PhotoSize | null { +export function getPhotoMaxSize(photos: PhotoSize[] | undefined, target: number = Environment.MAX_PHOTO_SIZE): PhotoSize | null { if (!photos) return null; photos = photos.filter(p => Math.max(p.width, p.height) <= target); @@ -1223,12 +1934,11 @@ export function getPhotoMaxSize(photos: PhotoSize[], target: number = Environmen return photos.reduce((prev, cur) => { if (!prev) return cur; - return cur.width * cur.height > prev.width * prev.height ? cur : prev; - }, null); + }); } -export async function mapPhotoSizeToMax(size: PhotoSize): Promise { +export async function mapPhotoSizeToMax(size: PhotoSize | null): Promise { if (!size) return null; return { width: size.width, @@ -1251,338 +1961,316 @@ export async function imageToBase64(filePath: string, withMimeType: boolean = fa return base64; } catch (e) { - logError(e); + logError(e instanceof Error ? e : String(e)); return null; } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function ifTrue(exp?: any): boolean { +export function ifTrue(exp?: string | number | boolean): boolean { if (!exp) return false; - return ["true", "t", "y", 1, "1"].includes(exp); + if (typeof exp === "boolean") return exp; + + const normalized = exp.toString().toLowerCase().trim(); + return ["true", "t", "y", "1"].includes(normalized); } -export function boolToEmoji(bool: boolean): string { - return bool ? "✅" : "❌"; + +export function boolToEmoji(bool: boolean | undefined): string { + return !!bool ? "✅" : "❌"; } -export const albumCache = new Map(); +type AlbumCacheEntry = { + messages: Message[]; + timer: NodeJS.Timeout; + resolve: (value: boolean) => void; + storedMsg: StoredMessage | null; +}; -async function processAlbum(groupId: string): Promise { - const entry = albumCache.get(groupId); +export const albumCache = new Map(); + +type AlbumProcessingResult = { + photoUniqueIds?: string[] | null; + attachments: StoredAttachment[]; + text?: string | null; +}; + +async function collectAlbumStoredAttachments(entry: AlbumCacheEntry): Promise { + const storedMessages = await Promise.all( + entry.messages.map(message => MessageStore.get(message.chat.id, message.message_id)) + ); + + return uniqueStoredAttachments(storedMessages.flatMap(message => message?.attachments ?? [])); +} + +function collectAlbumText(messages: Message[]): string | null { + const parts = messages + .map(message => extractTextMessage(message)) + .filter((text): text is string => !!text?.trim()); + + return parts.length ? parts.join("\n").trim() : null; +} + +async function processAlbum(albumKey: string): Promise { + const entry = albumCache.get(albumKey); if (!entry) return; const allPhotos = entry.messages .filter(m => m.photo) .map(m => m.photo); - const allPhotoMaxSizes = await Promise.all(allPhotos.map(photo => getPhotoMaxSize(photo))); + const allPhotoMaxSizes = await Promise.all(allPhotos.map(photo => getPhotoMaxSize(photo)).filter(s => !!s)); const ids = await loadImagesFromFileIds(allPhotoMaxSizes); + const attachments = await collectAlbumStoredAttachments(entry); + const text = collectAlbumText(entry.messages); - console.log(`Received album ${groupId} with ${ids.length} photos.`); - console.log("File IDs:", ids); + albumCache.delete(albumKey); + return {photoUniqueIds: ids, attachments, text}; +} - albumCache.delete(groupId); - return ids; +function scheduleAlbumProcessing(albumKey: string, delayMs = 1000): NodeJS.Timeout { + return setTimeout(async () => { + const entry = albumCache.get(albumKey); + try { + const album = await processAlbum(albumKey); + if (entry?.storedMsg) { + entry.storedMsg.attachments = uniqueStoredAttachments([ + ...(entry.storedMsg.attachments ?? []), + ...(album?.photoUniqueIds ?? []).map(uniqueId => createStoredImageAttachment({ + fileId: uniqueId, + fileUniqueId: uniqueId, + cachePath: photoCachePathForUniqueId(uniqueId), + })), + ...(album?.attachments ?? []), + ]); + if (album?.text) { + entry.storedMsg.text = album.text; + } + await MessageStore.put(entry.storedMsg).catch(logError); + } + + if (entry && album?.attachments.length) { + await Promise.all(entry.messages.map(async message => { + const stored = await MessageStore.get(message.chat.id, message.message_id); + if (!stored) return; + + stored.attachments = uniqueStoredAttachments([ + ...(stored.attachments ?? []), + ...(album.photoUniqueIds ?? []).map(uniqueId => createStoredImageAttachment({ + fileId: uniqueId, + fileUniqueId: uniqueId, + cachePath: photoCachePathForUniqueId(uniqueId), + })), + ...album.attachments, + ]); + if (album.text) { + stored.text = album.text; + } + await MessageStore.put(stored).catch(logError); + })); + } + } catch (e) { + logError(e instanceof Error ? e : String(e)); + } finally { + albumCache.delete(albumKey); + entry?.resolve(true); + } + }, delayMs); } export function photoPathByUniqueId(uniqueId: string): string { - return path.join(photoDir, uniqueId + ".jpg"); -} - -export function getCurrentModel(): string { - switch (Environment.DEFAULT_AI_PROVIDER) { - case AiProvider.OLLAMA: - return Environment.OLLAMA_MODEL; - case AiProvider.GEMINI: - return Environment.GEMINI_MODEL; - case AiProvider.MISTRAL: - return Environment.MISTRAL_MODEL; - case AiProvider.OPENAI: - return Environment.OPENAI_MODEL; - } -} - -export async function getCurrentModelCapabilities(): Promise { - let promise: Promise = null; - switch (Environment.DEFAULT_AI_PROVIDER) { - case AiProvider.OLLAMA: { - const ollamaGetModel = commands.find(c => c instanceof OllamaGetModel); - - // eslint-disable-next-line no-async-promise-executor - promise = new Promise(async (resolve, reject) => { - try { - const result = { - vision: (await ollamaGetModel.loadImageModelInfo()).vision, - ocr: null, - thinking: (await ollamaGetModel.loadThinkModelInfo()).thinking, - tools: (await ollamaGetModel.getModelCapabilities()).tools - }; - resolve(result); - } catch (e) { - reject(e); - } - }); - break; - } - case AiProvider.GEMINI: { - promise = commands.find(c => c instanceof GeminiGetModel).getModelCapabilities(); - break; - } - case AiProvider.MISTRAL: { - promise = commands.find(c => c instanceof MistralGetModel).getModelCapabilities(); - break; - } - case AiProvider.OPENAI: { - promise = commands.find(c => c instanceof OpenAIGetModel).getModelCapabilities(); - break; - } - } - - if (!promise) return null; - - try { - return await promise; - } catch (e) { - logError(e); - return null; - } + return photoCachePathForUniqueId(uniqueId); } export async function processMyChatMember(u: ChatMemberUpdated): Promise { - console.log("my_chat_member", u); + messageLogger.debug("my_chat_member", {update: u}); } -export async function processNewMessage(msg: Message): Promise { - console.log("New Message", msg); +export async function processGuestMessage(msg: Message): Promise { + // return processNewMessage(msg, true); + messageLogger.debug("guest_message.received", {message: msg}); +} + +export async function processNewMessage(msg: Message, isGuest?: boolean): Promise { + messageLogger.debug(isGuest ? "guest_message.received" : "message.received", {message: msg}); + + if (!msg.from) { + messageLogger.debug("message.skipped.no_sender", {chatId: msg.chat?.id, messageId: msg.message_id}); + return; + } + + const startedAt = Date.now(); + const from = msg.from; + Environment.reloadRuntimeConfigIfChanged(); let storedMsg: StoredMessage | null = null; + let locale = Localization.resolveLocale(undefined, from.language_code); try { const results = await Promise.all([ MessageStore.put(msg), - UserStore.put(msg.from) + UserStore.put(from) ] ); + messageLogger.debug("message.persisted", { + chatId: msg.chat.id, + messageId: msg.message_id, + fromId: from.id, + duration: logger.duration(startedAt) + }); storedMsg = results[0]; - if (!msg.media_group_id && storedMsg.photoMaxSizeFilePath) { + locale = await resolveInterfaceLocaleForUser(from.id, from.language_code); + const attachmentPipeline = await runTelegramMessageAttachmentPipeline(msg, storedMsg); + storedMsg = attachmentPipeline.storedMessage; + const rejected = attachmentPipeline.rejected; + if (rejected.length) { + await Localization.runWithLocale(locale, async () => { + await replyToMessage({ + message: msg, + text: rejected + .map(attachment => Environment.getTelegramFileTooLargeText( + attachment.fileName, + attachment.limitBytes / 1024 / 1024, + )) + .join("\n"), + }).catch(logError); + }); + } + + if (!msg.media_group_id && msg.photo?.length) { await loadImagesIfExists(msg); } } catch (e) { - logError(e); + logError(e instanceof Error ? e : String(e)); } - if ((msg.new_chat_members?.length)) { - await bot.sendMessage({chat_id: msg.chat.id, text: randomValue(Environment.ANSWERS.invite)}).catch(logError); - return; - } - - if (msg.left_chat_member && msg.left_chat_member.id !== botUser.id) { - await bot.sendMessage({chat_id: msg.chat.id, text: randomValue(Environment.ANSWERS.kick)}).catch(logError); - return; - } - - if (Environment.MUTED_IDS.has(msg.from.id)) return; - - if (msg.forward_origin) return; - - const groupId = msg.media_group_id; - if (groupId) { - await new Promise(resolve => { - if (!albumCache.has(groupId)) { - albumCache.set(groupId, { - messages: [msg], - timer: setTimeout(async () => { - const photos = await processAlbum(groupId); - console.log("processedAlbum", photos); - - storedMsg.photoMaxSizeFilePath = photos; - await MessageStore.put(storedMsg).catch(logError); - resolve(true); - }, 1000) - }); - } else { - const entry = albumCache.get(groupId); - entry.messages.push(msg); + await Localization.runWithLocale(locale, async () => { + if ((msg.new_chat_members?.length)) { + const text = randomValue(Environment.ANSWERS.invite); + if (text) { + await enqueueTelegramApiCall( + () => bot.sendMessage({chat_id: msg.chat.id, text}), + {method: "sendMessage", chatId: msg.chat.id, chatType: msg.chat.type} + ).catch(logError); } - }); - } - - const cmdText = msg.text || msg.caption || ""; - - const then = Date.now(); - - const cmd = searchChatCommand(commands, cmdText); - const executed = await executeChatCommand(cmd, msg, cmdText); - - const now = Date.now(); - const diff = now - then; - console.log("diff", diff); - - if (executed || !cmdText) return; - - const startsWithPrefix = cmdText.toLowerCase().startsWith(Environment.BOT_PREFIX.toLowerCase()); - const messageWithoutPrefix = cmdText.substring(Environment.BOT_PREFIX.length).trim(); - - if (startsWithPrefix && messageWithoutPrefix.length === 0) { - const prefixResponse = new PrefixResponse(); - if (await checkRequirements(prefixResponse, msg)) { - await prefixResponse.execute(msg); - } - return; - } - - const textToCheck = startsWithPrefix ? messageWithoutPrefix : cmdText; - - if (Environment.PROCESS_LINKS && await processYouTubeLink(msg, getFirstLink(msg))) return; - - if (msg.chat.type !== "private" && (!msg.reply_to_message || msg.reply_to_message.from.id !== botUser.id) && !startsWithPrefix) return; - - if (msg.chat.type === "private" && !Environment.ADMIN_IDS.has(msg.chat.id)) return; - - switch (Environment.DEFAULT_AI_PROVIDER) { - case AiProvider.OLLAMA: { - await commands.find(e => e instanceof OllamaChat).executeOllama(msg, textToCheck); - break; - } - case AiProvider.GEMINI: { - await commands.find(e => e instanceof GeminiChat).executeGemini(msg, textToCheck); - break; - } - case AiProvider.MISTRAL: { - await commands.find(e => e instanceof MistralChat).executeMistral(msg, textToCheck); - break; - } - case AiProvider.OPENAI: { - await commands.find(e => e instanceof OpenAIChat).executeOpenAI(msg, textToCheck); - break; - } - } -} - -function getFirstLink(msg: Message): string | null { - if (msg.entities) { - const urlEntities = msg.entities.filter(e => e.type === "url"); - if (urlEntities.length) { - const e = urlEntities[0]; - return msg.text.substring(e.offset, e.offset + e.length); - } - } - - return null; -} - -export async function processYouTubeLink(msg: Message, url?: string, id?: string): Promise { - if (!url && !id) return false; - - let waitMessage: Message | null = msg.from.id === botUser.id ? msg : null; - let videoId: string | null = null; - - try { - try { - videoId = id || getYouTubeVideoId(url); - } catch (e) { - logError(e); - return false; + return; } - const yt = commands.find(e => e instanceof YouTubeDownload); - - if (await checkRequirements(yt, msg)) { - if (!waitMessage) { - waitMessage = await replyToMessage({ - message: msg, - text: "⏳ Ищу информацию о видео..." - }); - } else { - await editMessageText({message: msg, text: "⏳ Ищу информацию о видео..."}); - + if (msg.left_chat_member && msg.left_chat_member.id !== botUser.id) { + const text = randomValue(Environment.ANSWERS.kick); + if (text) { + await enqueueTelegramApiCall( + () => bot.sendMessage({chat_id: msg.chat.id, text}), + {method: "sendMessage", chatId: msg.chat.id, chatType: msg.chat.type} + ).catch(logError); } + return; + } - let videoInfo: VideoInfo | null = null; - let ytError: string = null; + if (Environment.MUTED_IDS.has(from.id)) return; - try { - videoInfo = await getYouTubeVideoInfo(videoId); - } catch (e) { - logError(e); + if (msg.forward_origin) return; - if ("version" in e) { - ytError = e.message; - } - } - - console.log("VIDEO_INFO", videoInfo); - - let text: string = null; - - const inCache = isVideoExists({videoId: videoId}); - - const duration = videoInfo?.basic_info?.duration || null; - const canDownload = inCache || duration && duration <= 300; - - if (videoInfo) { - text = "Видео с YouTube\n\n" + - `Название: ${videoInfo.basic_info?.title}\n` + - `Автор: ${videoInfo.secondary_info?.owner?.author?.name}\n` + - `Длительность: ${duration} сек.`; - - if (!canDownload) { - text += `\n\nВидео слишком длинное (${duration} сек. > 300 сек.)`; - } - } else if (!ytError) { - text = "Информация о видео не найдена"; - } - - const errorButInCache = !videoInfo && ytError && inCache; - if (errorButInCache) { - text = "Я не смог получить информацию о видео, но нашёл его в кэше."; - } - - if (!text && ytError) { - await editMessageText({ - message: waitMessage, - text: Environment.errorText, - reply_markup: { - inline_keyboard: [[ - TryAgain.withData("/ytinfo " + videoId).asButton() - ]] + const groupId = msg.media_group_id; + if (groupId) { + const albumKey = `${msg.chat.id}:${groupId}`; + const shouldContinue = await new Promise(resolve => { + if (!albumCache.has(albumKey)) { + albumCache.set(albumKey, { + messages: [msg], + timer: scheduleAlbumProcessing(albumKey), + resolve, + storedMsg, + }); + } else { + const entry = albumCache.get(albumKey); + if (entry) { + entry.messages.push(msg); + clearTimeout(entry.timer); + entry.timer = scheduleAlbumProcessing(albumKey); } - }); - } else { - await editMessageText({ - message: waitMessage, - text: text, - reply_markup: canDownload ? { - inline_keyboard: [[ - DownloadYtVideo.withData(inCache, "/ytdl " + videoId).asButton() - ]] - } : {inline_keyboard: []} - }); + resolve(false); + } + }); + + if (!shouldContinue) return; + + storedMsg = await MessageStore.get(msg.chat.id, msg.message_id) ?? storedMsg; + } + + const cmdText = storedMsg?.text || msg.text || msg.caption || ""; + + const cmd = searchChatCommand(commands, cmdText); + const executed = await executeChatCommand(cmd, msg, cmdText); + + const hasAudioAttachment = !!msg.voice || !!msg.audio || !!msg.document?.mime_type?.startsWith("audio/") + || !!msg.video_note; + const hasImageAttachment = !!msg.photo?.length || !!msg.document?.mime_type?.startsWith("image/"); + if (executed) { + messageLogger.debug("message.command_executed", { + chatId: msg.chat.id, + messageId: msg.message_id, + command: cmd?.title + }); + return; + } + + if (!cmdText && !hasAudioAttachment && !hasImageAttachment) { + messageLogger.debug("message.skipped.empty", {chatId: msg.chat.id, messageId: msg.message_id}); + return; + } + + const hasConfiguredPrefix = Environment.BOT_PREFIX.length > 0; + const startsWithPrefix = hasConfiguredPrefix && cmdText.toLowerCase().startsWith(Environment.BOT_PREFIX.toLowerCase()); + const messageWithoutPrefix = startsWithPrefix ? cmdText.substring(Environment.BOT_PREFIX.length).trim() : cmdText.trim(); + + if (startsWithPrefix && messageWithoutPrefix.length === 0) { + const prefixResponse = new PrefixResponse(); + if (await checkRequirements(prefixResponse, msg)) { + await prefixResponse.execute(msg); + } + return; + } + + const textToCheck = startsWithPrefix ? messageWithoutPrefix : cmdText; + + if (msg.chat.type !== "private") { + if (Environment.ONLY_FOR_CREATOR_MODE && from.id !== Environment.CREATOR_ID) { + return; + } + + const isReplyToBot = !!msg.reply_to_message && msg.reply_to_message.from?.id === botUser.id; + const hasPrefix = startsWithPrefix; + const hasBotMention = !!msg.entities?.some(entity => { + if (entity.type !== "mention") return false; + const mention = msg.text?.slice(entity.offset, entity.offset + entity.length) ?? msg.caption?.slice(entity.offset, entity.offset + entity.length) ?? ""; + return mention.toLowerCase() === `@${botUser.username?.toLowerCase()}`; + }); + + if (!isReplyToBot && !hasPrefix && !hasBotMention && !hasAudioAttachment) { + messageLogger.debug("message.skipped.not_addressed", {chatId: msg.chat.id, messageId: msg.message_id}); + return; } } - return true; - } catch (e) { - logError(e); - await editMessageText({ - message: waitMessage, - text: Environment.errorText, - reply_markup: { - inline_keyboard: [[ - TryAgain.withData("/ytinfo " + videoId).asButton() - ]] - } - }); - } + const provider = await resolveEffectiveAiProviderForUser(from.id); - return false; + messageLogger.info("ai.dispatch", {chatId: msg.chat.id, messageId: msg.message_id, fromId: from.id, provider}); + void runUnifiedAi({ + provider: provider, + msg: msg, + isGuestMsg: !!isGuest, + text: textToCheck, + stream: true, + }).catch(logError); + }); } export async function processEditedMessage(msg: Message): Promise { - console.log("Edited Message", msg); + if (!msg.from) return; + + Environment.reloadRuntimeConfigIfChanged(); await UserStore.put(msg.from); @@ -1592,55 +2280,84 @@ export async function processEditedMessage(msg: Message): Promise { } export async function processInlineQuery(query: InlineQuery): Promise { - console.log("InlineQuery", query); + Environment.reloadRuntimeConfigIfChanged(); + const locale = await resolveInterfaceLocaleForUser(query.from.id, query.from.language_code); - if (Environment.CREATOR_ID !== query.from.id) { - await bot.answerInlineQuery({ - inline_query_id: query.id, - results: [], - button: { - text: "No access", - start_parameter: "nope" - } - }).catch(logError); - return; - } - - if (query.query.trim().length !== 0) { - try { - const queryResults: InlineQueryResult[] = []; - const results = await ollama.webSearch({query: query.query}); - - console.log("results", results); - - results.results.forEach((result, i) => { - const r = result as WebSearchResponse; - queryResults.push({ - type: "article", - id: `${i}`, - title: `${r.title}`, - input_message_content: { - message_text: `${r.title}\n\n${r.url}` + await Localization.runWithLocale(locale, async () => { + if (Environment.CREATOR_ID !== query.from.id) { + await enqueueTelegramApiCall( + () => bot.answerInlineQuery({ + inline_query_id: query.id, + results: [], + button: { + text: Environment.noAccessText, + start_parameter: "nope" } - }); - }); - - await bot.answerInlineQuery({ - inline_query_id: query.id, - results: queryResults, - }); - } catch (e) { - logError(e); + }), + {method: "answerInlineQuery", skipPerChatLimit: true} + ).catch(logError); + return; } - } else { - await bot.answerInlineQuery({ - inline_query_id: query.id, - results: [], - }).catch(logError); - } + + if (query.query.trim().length !== 0) { + try { + const target = resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"); + const results = await createOllamaClient(target).webSearch({query: query.query, maxResults: 10}); + const queryResults: InlineQueryResult[] = (results.results ?? []).map((result, index) => { + const content = result.content.trim(); + const [firstLine] = content.split("\n"); + const title = firstLine?.trim().slice(0, 128) || query.query; + + return { + type: "article" as const, + id: `ollama-search-${index}`, + title, + description: content.slice(0, 256), + input_message_content: { + message_text: content, + } + }; + }); + + await enqueueTelegramApiCall( + () => bot.answerInlineQuery({ + inline_query_id: query.id, + results: queryResults, + cache_time: 60, + is_personal: true, + }), + {method: "answerInlineQuery", skipPerChatLimit: true} + ); + } catch (e) { + logError(e instanceof Error ? e : String(e)); + await enqueueTelegramApiCall( + () => bot.answerInlineQuery({ + inline_query_id: query.id, + results: [], + cache_time: 0, + is_personal: true, + }), + {method: "answerInlineQuery", skipPerChatLimit: true} + ).catch(logError); + } + } else { + await enqueueTelegramApiCall( + () => bot.answerInlineQuery({ + inline_query_id: query.id, + results: [], + }), + {method: "answerInlineQuery", skipPerChatLimit: true} + ).catch(logError); + } + }); } export async function processCallbackQuery(query: CallbackQuery): Promise { - console.log("CallbackQuery", query); - await findAndExecuteCallbackCommand(callbackCommands, query); -} \ No newline at end of file + Environment.reloadRuntimeConfigIfChanged(); + const locale = await resolveInterfaceLocaleForUser(query.from.id, query.from.language_code); + await Localization.runWithLocale(locale, () => findAndExecuteCallbackCommand(callbackCommands, query)); +} + +export async function runCommand(cmd: string): Promise { + return ShellCommandRunner.run(cmd); +} diff --git a/src/util/ytdl.ts b/src/util/ytdl.ts deleted file mode 100644 index 7965915..0000000 --- a/src/util/ytdl.ts +++ /dev/null @@ -1,169 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import {videoDir, videoTempDir} from "../index"; -import ffmpeg from "fluent-ffmpeg"; -import Innertube, {Platform, Types} from "youtubei.js"; -import {Readable} from "node:stream"; -import {logError} from "./utils"; -import {performFFmpeg} from "./ffmpeg"; -import VideoInfo from "youtubei.js/dist/src/parser/youtube/VideoInfo"; - -let innertube: Innertube | null = null; - -export async function getYT(): Promise { - if (innertube) { - return innertube; - } else { - innertube = await Innertube.create({ - generate_session_locally: true, - retrieve_player: true - }); - return innertube; - } -} - -export function getYouTubeVideoId(url: string): string { - const regex = /(?:(?:youtube\.com|music\.youtube\.com)\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?|shorts|clip)\/|.*[?&]v=)|youtu\.be\/)([^"&?/\s]{11})/i; - const match = url.match(regex); - if (!match || !match[1]) throw new Error("Invalid YouTube or Shorts URL"); - return match[1]; -} - -export async function getYouTubeVideoInfo(videoId: string): Promise { - try { - return (await getYT()).getInfo(videoId, {client: "ANDROID"}); - } catch (e) { - logError(e); - } -} - -export function isVideoExists(options: DownloadOptions): boolean { - const videoId = "videoId" in options ? options.videoId : getYouTubeVideoId(options.url); - const filePath = path.join(videoDir, `${videoId}.mp4`); - return fs.existsSync(filePath); -} - -export function getVideoFromCache(videoId: string): Buffer | null { - if (!isVideoExists({videoId: videoId})) return null; - - const filePath = path.join(videoDir, `${videoId}.mp4`); - return Buffer.from(fs.readFileSync(filePath)); -} - -export type DownloadOptions = { - url: string -} | { - videoId: string; -} - -export async function downloadVideoFromYouTube(options: DownloadOptions): Promise<{ - time: number, - exists?: boolean, - buffer: Buffer | null -}> { - const start = Date.now(); - let buffer: Buffer | null = null; - - try { - const videoId = "videoId" in options ? options.videoId : getYouTubeVideoId(options.url); - const filePath = path.join(videoDir, `${videoId}.mp4`); - if (fs.existsSync(filePath)) { - const buffer = Buffer.from(fs.readFileSync(filePath)); - return { - time: Date.now() - start, - exists: true, - buffer: buffer - }; - } - - Platform.shim.eval = async (data: Types.BuildScriptResult, env: Record) => { - const properties = []; - if (env.n) properties.push(`n: exportedVars.nFunction("${env.n}")`); - if (env.sig) properties.push(`sig: exportedVars.sigFunction("${env.sig}")`); - - const code = `${data.output}\nreturn { ${properties.join(", ")} }`; - return new Function(code)(); - }; - - const yt = await getYT(); - - const videoInfo = await yt.getInfo(videoId, {client: "ANDROID"}); - console.log("Video info", videoInfo); - - console.log(`Fetching metadata for: ${videoId}...`); - - const targetQuality = "360p"; - - const videoFormat = videoInfo.streaming_data?.formats.find(f => f.quality_label.startsWith(targetQuality)) - || videoInfo.streaming_data?.adaptive_formats.find(f => f.quality_label.startsWith(targetQuality)); - - const audioFormat = videoInfo.chooseFormat({type: "audio", quality: "best", language: "original"}); - - console.log("Video format: ", videoFormat); - console.log("Audio Format: ", audioFormat); - - if (!videoFormat) { - console.log(`Quality ${targetQuality} not found. Falling back to best available.`); - } - - const videoWebStream = await videoInfo.download({ - itag: videoFormat.itag, - client: "ANDROID" - }); - - const audioWebStream = await videoInfo.download({ - itag: audioFormat.itag, - client: "ANDROID" - }); - - const videoStream = Readable.fromWeb(videoWebStream as any); - const audioStream = Readable.fromWeb(audioWebStream as any); - - const videoPath = path.join(videoTempDir, `temp_video_${videoId}.mp4`); - const audioPath = path.join(videoTempDir, `temp_audio_${videoId}.mp4`); - - const writeStream = (stream: any, path: string) => - new Promise((resolve, reject) => { - const file = fs.createWriteStream(path); - stream.pipe(file); - file.on("finish", resolve); - file.on("error", reject); - }); - - await Promise.all([ - writeStream(videoStream, videoPath), - writeStream(audioStream, audioPath) - ]); - - await performFFmpeg(() => - ffmpeg() - .input(videoPath) - .input(audioPath) - .videoCodec("copy") - .audioCodec("copy") - .save(filePath) - .on("progress", (progress) => { - console.log("progress", progress); - }) - ).catch(logError); - - fs.unlinkSync(videoPath); - fs.unlinkSync(audioPath); - - buffer = fs.readFileSync(filePath); - - console.log(`✅ Saved to ${videoId}.mp4`); - } catch (error) { - console.error("❌ Download failed:", error instanceof Error ? error.message : error); - throw error; - } - - const end = Date.now(); - const diff = end - start; - console.log(`Video downloaded.\ntook ${diff}ms`); - - return { - time: diff, - buffer: buffer, - }; -} \ No newline at end of file diff --git a/test/fallback-executor.test.mjs b/test/fallback-executor.test.mjs new file mode 100644 index 0000000..6cc8470 --- /dev/null +++ b/test/fallback-executor.test.mjs @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + DEFAULT_PIPELINE_FALLBACK_POLICIES, +} = await import("../dist/ai/user-request-pipeline/blueprint.js"); +const { + decidePipelineFallback, + fallbackReasonFromStageStatus, + resolvePipelineFallbackAction, +} = await import("../dist/ai/user-request-pipeline/fallback-executor.js"); + +test("fallback executor resolves configured failed action", () => { + assert.equal( + resolvePipelineFallbackAction({ + stage: "input_size_gate", + reason: "failed", + policies: DEFAULT_PIPELINE_FALLBACK_POLICIES, + }), + "notify_user", + ); +}); + +test("fallback executor uses default action for missing policy", () => { + assert.equal( + resolvePipelineFallbackAction({ + stage: "send_response", + reason: "failed", + policies: [], + }), + "fail_request", + ); + assert.equal( + resolvePipelineFallbackAction({ + stage: "send_response", + reason: "unavailable", + policies: [], + }), + "continue_without_stage", + ); +}); + +test("fallback decision exposes notify and continuation flags", () => { + const decision = decidePipelineFallback({ + stage: "document_rag", + reason: "failed", + policies: DEFAULT_PIPELINE_FALLBACK_POLICIES, + }); + + assert.equal(decision.action, "notify_user"); + assert.equal(decision.shouldNotifyUser, true); + assert.equal(decision.shouldContinue, true); + assert.equal(decision.shouldFailRequest, false); +}); + +test("fallback reason maps only failed and skipped statuses", () => { + assert.equal(fallbackReasonFromStageStatus("failed"), "failed"); + assert.equal(fallbackReasonFromStageStatus("skipped"), "unavailable"); + assert.equal(fallbackReasonFromStageStatus("succeeded"), undefined); +}); diff --git a/test/pipeline.test.mjs b/test/pipeline.test.mjs new file mode 100644 index 0000000..1495e39 --- /dev/null +++ b/test/pipeline.test.mjs @@ -0,0 +1,178 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const {UserRequestPipeline} = await import("../dist/ai/user-request-pipeline/pipeline.js"); +const {splitAttachmentsBySize} = await import("../dist/ai/user-request-pipeline/size-gate.js"); +const {PIPELINE_ATTACHMENT_LIMIT_BYTES} = await import("../dist/ai/user-request-pipeline/types.js"); + +function baseState() { + return { + requestId: "test-request", + chatId: 1, + messageId: 2, + fromId: 3, + receivedAt: new Date(0).toISOString(), + text: "hello", + settings: { + provider: "OLLAMA", + responseLanguage: "default", + voiceMode: "execute", + imageOutputMode: "photo", + }, + inputAttachments: [], + outputAttachments: [], + artifacts: [], + toolRankDecisions: [], + audit: [], + }; +} + +test("pipeline runs only requested stage slice", async () => { + const state = baseState(); + const pipeline = new UserRequestPipeline({ + stageNames: ["input_size_gate", "download_attachments"], + stages: [{ + name: "input_size_gate", + async run() { + return { + stage: "input_size_gate", + status: "succeeded", + details: {checked: true}, + }; + }, + }], + }); + + await pipeline.run(state, new AbortController().signal); + + assert.equal(state.audit.length, 3); + assert.equal(state.audit[0].stage, "input_size_gate"); + assert.equal(state.audit[0].status, "running"); + assert.equal(state.audit[1].stage, "input_size_gate"); + assert.equal(state.audit[1].status, "succeeded"); + assert.deepEqual(state.audit[1].details, {checked: true}); + assert.equal(state.audit[2].stage, "download_attachments"); + assert.equal(state.audit[2].status, "skipped"); + assert.deepEqual(state.audit[2].details, { + reason: "stage_not_registered", + fallbackAction: "continue_without_stage", + }); +}); + +test("pipeline stops when fallback decision is fail_request", async () => { + const state = baseState(); + const pipeline = new UserRequestPipeline({ + stageNames: ["send_response"], + stages: [{ + name: "send_response", + async run() { + throw new Error("send failed"); + }, + }], + fallbackPolicies: [{ + stage: "send_response", + onUnavailable: "fail_request", + onFailed: "fail_request", + }], + }); + + await assert.rejects(() => pipeline.run(state, new AbortController().signal), /send failed/); + assert.equal(state.audit.at(-1).stage, "send_response"); + assert.equal(state.audit.at(-1).status, "failed"); + assert.equal(state.audit.at(-1).details.fallbackAction, "fail_request"); +}); + +test("pipeline continues when fallback decision allows continuation", async () => { + const state = baseState(); + const pipeline = new UserRequestPipeline({ + stageNames: ["document_rag", "send_response"], + stages: [ + { + name: "document_rag", + async run() { + throw new Error("rag failed"); + }, + }, + { + name: "send_response", + async run() { + return { + stage: "send_response", + status: "succeeded", + }; + }, + }, + ], + }); + + await pipeline.run(state, new AbortController().signal); + assert.equal(state.audit.some(event => event.stage === "document_rag" && event.status === "failed"), true); + assert.equal(state.audit.at(-1).stage, "send_response"); + assert.equal(state.audit.at(-1).status, "succeeded"); +}); + +test("pipeline persists stage artifacts and direction-aware attachments", async () => { + const state = baseState(); + const pipeline = new UserRequestPipeline({ + stageNames: ["persist_output_artifacts"], + stages: [{ + name: "persist_output_artifacts", + async run() { + return { + stage: "persist_output_artifacts", + status: "succeeded", + artifacts: [{ + kind: "final_text", + stage: "persist_output_artifacts", + createdAt: new Date(0).toISOString(), + text: "answer", + }], + attachments: [ + { + direction: "input", + kind: "document", + fileName: "input.txt", + sizeBytes: 10, + }, + { + direction: "output", + kind: "document", + fileName: "output.txt", + sizeBytes: 20, + }, + ], + }; + }, + }], + }); + + await pipeline.run(state, new AbortController().signal); + + assert.equal(state.artifacts.length, 1); + assert.equal(state.artifacts[0].kind, "final_text"); + assert.equal(state.inputAttachments.length, 1); + assert.equal(state.inputAttachments[0].fileName, "input.txt"); + assert.equal(state.outputAttachments.length, 1); + assert.equal(state.outputAttachments[0].fileName, "output.txt"); +}); + +test("size gate splits accepted and rejected attachments", () => { + const result = splitAttachmentsBySize([ + { + direction: "input", + kind: "document", + fileName: "small.txt", + sizeBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES, + }, + { + direction: "input", + kind: "document", + fileName: "large.txt", + sizeBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES + 1, + }, + ]); + + assert.deepEqual(result.accepted.map(attachment => attachment.fileName), ["small.txt"]); + assert.equal(result.rejected.length, 1); + assert.equal(result.rejected[0].attachment.fileName, "large.txt"); +}); diff --git a/test/rag-artifact.test.mjs b/test/rag-artifact.test.mjs new file mode 100644 index 0000000..0f4f8b0 --- /dev/null +++ b/test/rag-artifact.test.mjs @@ -0,0 +1,154 @@ +import test, {after} from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tg-chat-bot-rag-")); +process.env.BOT_TOKEN = process.env.BOT_TOKEN ?? "test-token"; +process.env.CREATOR_ID = process.env.CREATOR_ID ?? "1"; +process.env.DATA_PATH = tempRoot; +process.env.DB_PATH = `file:${path.join(tempRoot, "test.sqlite")}`; +process.env.TEST_ENVIRONMENT = "true"; + +const {Environment} = await import("../dist/common/environment.js"); +Environment.load(); + +const {DatabaseManager} = await import("../dist/db/database-manager.js"); +DatabaseManager.init(); +await DatabaseManager.ready; + +const {ArtifactStore} = await import("../dist/common/artifact-store.js"); +const {filterUserVisibleStoredAttachments} = await import("../dist/common/stored-attachment-utils.js"); +const {AiProvider} = await import("../dist/model/ai-provider.js"); +const {persistRagArtifactAttachment} = await import("../dist/ai/rag-artifact-store.js"); + +after(async () => { + await DatabaseManager.close().catch(() => undefined); + fs.rmSync(tempRoot, {recursive: true, force: true}); +}); + +test("internal artifacts are not treated as user-visible attachments", () => { + const visible = filterUserVisibleStoredAttachments([ + { + kind: "document", + fileId: "visible", + fileName: "visible.txt", + cachePath: "/tmp/visible.txt", + }, + { + kind: "document", + fileId: "internal", + fileName: "rag.json", + cachePath: "/tmp/rag.json", + scope: "internal_artifact", + artifactKind: "rag", + }, + ]); + + assert.equal(visible.length, 1); + assert.equal(visible[0].fileId, "visible"); +}); + +test("RAG artifacts persist structured ollama metadata", async () => { + const chatId = 42; + const messageId = 7; + + const attachment = await persistRagArtifactAttachment({ + provider: AiProvider.OLLAMA, + prepared: { + provider: AiProvider.OLLAMA, + prepared: true, + cleanup: async () => undefined, + artifact: { + query: "What is in the file?", + extractedDocuments: [ + {documentIndex: 0, fileName: "report.txt", textChars: 120}, + ], + selectedChunks: [ + { + sourceId: "doc1-1", + documentIndex: 0, + documentName: "report.txt", + chunkIndex: 0, + chunkCount: 1, + textChars: 120, + score: 0.91, + }, + ], + skippedDocuments: [ + {documentIndex: 1, fileName: "ignored.bin", reason: "unsupported format"}, + ], + providerState: { + embeddingModel: "nomic-embed-text:latest", + topK: 8, + chunkSize: 1400, + chunkOverlap: 220, + maxContextChars: 14000, + minScore: 0.12, + maxArchiveFiles: 200, + maxArchiveBytes: 50 * 1024 * 1024, + maxArchiveDepth: 2, + }, + }, + }, + downloads: [{ + kind: "document", + fileId: "file-1", + fileName: "report.txt", + buffer: Buffer.from("hello world"), + path: path.join(tempRoot, "report.txt"), + }], + chatId, + messageId, + details: { + embeddingModel: "nomic-embed-text:latest", + topK: 8, + chunkSize: 1400, + chunkOverlap: 220, + maxContextChars: 14000, + artifact: { + query: "What is in the file?", + extractedDocuments: [ + {documentIndex: 0, fileName: "report.txt", textChars: 120}, + ], + selectedChunks: [ + { + sourceId: "doc1-1", + documentIndex: 0, + documentName: "report.txt", + chunkIndex: 0, + chunkCount: 1, + textChars: 120, + score: 0.91, + }, + ], + skippedDocuments: [ + {documentIndex: 1, fileName: "ignored.bin", reason: "unsupported format"}, + ], + providerState: { + embeddingModel: "nomic-embed-text:latest", + topK: 8, + chunkSize: 1400, + chunkOverlap: 220, + maxContextChars: 14000, + minScore: 0.12, + maxArchiveFiles: 200, + maxArchiveBytes: 50 * 1024 * 1024, + maxArchiveDepth: 2, + }, + }, + }, + }); + + assert.equal(attachment?.artifactKind, "rag"); + assert.equal(fs.existsSync(attachment.cachePath), true); + + const stored = await ArtifactStore.getByMessage(chatId, messageId); + assert.equal(stored.length, 1); + assert.equal(stored[0].kind, "rag"); + assert.equal(stored[0].payload.providerState.query, "What is in the file?"); + assert.equal(stored[0].payload.providerState.selectedChunks[0].score, 0.91); + assert.equal(stored[0].payload.providerState.skippedDocuments[0].reason, "unsupported format"); + assert.equal(stored[0].payload.providerState.ollama.embeddingModel, "nomic-embed-text:latest"); +}); diff --git a/test/tool-ranker.test.mjs b/test/tool-ranker.test.mjs new file mode 100644 index 0000000..37fb28e --- /dev/null +++ b/test/tool-ranker.test.mjs @@ -0,0 +1,154 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + buildToolRankerSystemPrompt, + getToolRankerAvailableToolInfos, + sanitizeToolRankerResult, +} = await import("../dist/ai/tool-ranker-metadata.js"); + +function toolInfos(...toolTypes) { + return getToolRankerAvailableToolInfos(toolTypes.map(type => ({type}))); +} + +function promptFor(...toolTypes) { + return buildToolRankerSystemPrompt({ + availableTools: toolInfos(...toolTypes), + includeExamples: true, + maxExamplesPerTool: 1, + compact: true, + }); +} + +test("prompt contains only available tools", () => { + const prompt = promptFor("no_tool", "get_datetime", "image_generation", "code_interpreter", "file_search"); + + assert.ok(prompt.includes("no_tool")); + assert.ok(prompt.includes("get_datetime")); + assert.ok(prompt.includes("image_generation")); + assert.ok(prompt.includes("code_interpreter")); + assert.ok(prompt.includes("file_search")); + assert.ok(!prompt.includes("get_weather")); + assert.ok(!prompt.includes("python_interpreter")); +}); + +test("prompt does not contain disabled tools", () => { + const prompt = promptFor("no_tool", "read_file", "search_files"); + + assert.ok(prompt.includes("read_file")); + assert.ok(prompt.includes("search_files")); + assert.ok(!prompt.includes("get_weather")); + assert.ok(!prompt.includes("shell_execute")); + assert.ok(!prompt.includes("python_interpreter")); +}); + +test("examples are filtered when tools are unavailable", () => { + const prompt = promptFor("no_tool", "read_file", "search_files"); + + assert.ok(prompt.includes("прочитай src/index.ts")); + assert.ok(prompt.includes("найди где используется sendMessage")); + assert.ok(!prompt.includes("погода завтра")); + assert.ok(!prompt.includes("выполни этот python код")); +}); + +test("prompt includes image generation routing example", () => { + const prompt = promptFor("no_tool", "image_generation"); + + assert.ok(prompt.includes("сделай его лысым")); + assert.ok(prompt.includes(JSON.stringify({toolNames: ["image_generation"]}))); +}); + +test("prompt includes weather routing example", () => { + const prompt = promptFor("no_tool", "get_weather"); + + assert.ok(prompt.includes("погода завтра")); + assert.ok(prompt.includes(JSON.stringify({toolNames: ["get_weather"]}))); +}); + +test("prompt includes web search routing example for current information", () => { + const prompt = promptFor("no_tool", "web_search"); + + assert.ok(prompt.includes("найди актуальную документацию OpenAI API")); + assert.ok(prompt.includes(JSON.stringify({toolNames: ["web_search"]}))); +}); + +test("prompt includes read file routing example for known file paths", () => { + const prompt = promptFor("no_tool", "read_file"); + + assert.ok(prompt.includes("прочитай src/index.ts")); + assert.ok(prompt.includes(JSON.stringify({toolNames: ["read_file"]}))); +}); + +test("prompt includes search files routing example for usage search", () => { + const prompt = promptFor("no_tool", "search_files"); + + assert.ok(prompt.includes("найди где используется sendMessage")); + assert.ok(prompt.includes(JSON.stringify({toolNames: ["search_files"]}))); +}); + +test("prompt includes edit file patch routing example for targeted edits", () => { + const prompt = promptFor("no_tool", "edit_file_patch"); + + assert.ok(prompt.includes("исправь этот баг патчем")); + assert.ok(prompt.includes(JSON.stringify({toolNames: ["edit_file_patch"]}))); +}); + +test("prompt includes update file routing example for full overwrite", () => { + const prompt = promptFor("no_tool", "update_file"); + + assert.ok(prompt.includes("полностью перезапиши config.json")); + assert.ok(prompt.includes(JSON.stringify({toolNames: ["update_file"]}))); +}); + +test("prompt includes delete path caution for explicit deletion only", () => { + const prompt = promptFor("no_tool", "delete_path"); + + assert.ok(prompt.includes("delete_path only when the user clearly asks to delete or remove something.")); + assert.ok(prompt.includes("удали папку dist")); + assert.ok(prompt.includes(JSON.stringify({toolNames: ["delete_path"]}))); +}); + +test("sanitizer returns no_tool for normal explanation", () => { + const result = sanitizeToolRankerResult({ + raw: "объясни docker volumes", + availableToolNames: ["read_file", "search_files"], + }); + + assert.deepEqual(result, ["no_tool"]); +}); + +test("sanitizer removes unavailable tools", () => { + const result = sanitizeToolRankerResult({ + raw: JSON.stringify({toolNames: ["read_file", "missing_tool"]}), + availableToolNames: ["read_file"], + }); + + assert.deepEqual(result, ["read_file"]); +}); + +test("sanitizer deduplicates tools", () => { + const result = sanitizeToolRankerResult({ + raw: JSON.stringify({toolNames: ["read_file", "read_file", "search_files"]}), + availableToolNames: ["read_file", "search_files"], + }); + + assert.deepEqual(result, ["read_file", "search_files"]); +}); + +test("sanitizer handles malformed output", () => { + const result = sanitizeToolRankerResult({ + raw: "```json\nnot json\n```", + availableToolNames: ["read_file"], + }); + + assert.deepEqual(result, ["no_tool"]); +}); + +test("sanitizer removes no_tool when mixed with real tools", () => { + const result = sanitizeToolRankerResult({ + raw: JSON.stringify({toolNames: ["no_tool", "read_file"]}), + availableToolNames: ["read_file"], + }); + + assert.deepEqual(result, ["read_file"]); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index 451301e..3d67840 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -4,7 +4,8 @@ "rootDir": "src", "outDir": "dist", "types": ["node"], - "skipLibCheck": true + "skipLibCheck": true, + "ignoreDeprecations": "6.0" }, "include": ["src/**/*.ts"], "exclude": ["node_modules"] diff --git a/tsconfig.json b/tsconfig.json index 4347ee4..95d7581 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,19 +1,32 @@ { "compilerOptions": { "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "Node", + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "src", "outDir": "dist", - "mapRoot": "maps", - "sourceMap": true, - "resolveJsonModule": true, + "incremental": false, + "isolatedModules": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, "esModuleInterop": true, + "resolveJsonModule": true, + "sourceMap": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "allowJs": true + "types": [ + "node", + "bun" + ] }, "include": [ - "src" + "src/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" ] }