From 6b8bd8d7a62b3ddbbefa120f4da4f55308893cd7 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Mon, 16 Mar 2026 03:47:15 +0800 Subject: [PATCH] Initial commit: Telegram CLI Bot with AI memory, tools and multi-provider support --- .env.example | 81 + .gitignore | 42 + AI_AGENT_TOOLS.md | 261 +++ AI_PROVIDERS.md | 102 ++ AI_PROVIDER_ARCHITECTURE.md | 233 +++ CRON_SYSTEM.md | 219 +++ FILE_SYSTEM_TOOL.md | 140 ++ GRADIENT_MEMORY.md | 225 +++ HTML_ERROR_FIX.md | 94 + INSTRUMENTS_FIX.md | 129 ++ MEMORY_CONTEXT.md | 264 +++ MEMORY_SYSTEM.md | 124 ++ README.md | 409 +++++ SYSTEM_PROMPT.md | 183 ++ TODO.md | 97 + TOOLS.md | 342 ++++ VECTOR_RAG_MEMORY.md | 244 +++ add_channels.py | 55 + authorize_qwen.sh | 20 + bot.py | 2654 ++++++++++++++++++++++++++++ bot/__init__.py | 22 + bot/ai_agent.py | 786 ++++++++ bot/ai_provider_manager.py | 294 +++ bot/base_ai_provider.py | 347 ++++ bot/compaction.py | 520 ++++++ bot/config.py | 54 + bot/handlers/__init__.py | 18 + bot/handlers/ai_presets.py | 298 ++++ bot/handlers/callbacks.py | 878 +++++++++ bot/handlers/commands.py | 528 ++++++ bot/handlers/files.py | 507 ++++++ bot/keyboards/__init__.py | 11 + bot/keyboards/menus.py | 242 +++ bot/models/__init__.py | 24 + bot/models/server.py | 264 +++ bot/models/session.py | 189 ++ bot/models/user_state.py | 89 + bot/providers/__init__.py | 14 + bot/providers/gigachat_provider.py | 519 ++++++ bot/providers/opencode_provider.py | 346 ++++ bot/providers/qwen_provider.py | 268 +++ bot/services/__init__.py | 16 + bot/services/command_executor.py | 332 ++++ bot/services/cron_scheduler.py | 184 ++ bot/tools/__init__.py | 127 ++ bot/tools/cron_tool.py | 704 ++++++++ bot/tools/ddgs_tool.py | 106 ++ bot/tools/file_system_tool.py | 803 +++++++++ bot/tools/gigachat_tool.py | 692 ++++++++ bot/tools/rss_tool.py | 367 ++++ bot/tools/ssh_tool.py | 364 ++++ bot/tools/telegram_web_tool.py | 232 +++ bot/utils/__init__.py | 22 + bot/utils/cleaners.py | 82 + bot/utils/decorators.py | 36 + bot/utils/formatters.py | 346 ++++ bot/utils/qwen_oauth.py | 593 +++++++ bot/utils/ssh_readers.py | 293 +++ install-systemd-service.sh | 148 ++ install.sh | 257 +++ memory_system.py | 708 ++++++++ qwen_integration.py | 835 +++++++++ requirements.txt | 12 + run.sh | 63 + system_prompt.md | 374 ++++ telegram-bot.service | 20 + telegram_channels.json | 5 + test_memory.py | 258 +++ vector_memory.py | 742 ++++++++ 69 files changed, 20857 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AI_AGENT_TOOLS.md create mode 100644 AI_PROVIDERS.md create mode 100644 AI_PROVIDER_ARCHITECTURE.md create mode 100644 CRON_SYSTEM.md create mode 100644 FILE_SYSTEM_TOOL.md create mode 100644 GRADIENT_MEMORY.md create mode 100644 HTML_ERROR_FIX.md create mode 100644 INSTRUMENTS_FIX.md create mode 100644 MEMORY_CONTEXT.md create mode 100644 MEMORY_SYSTEM.md create mode 100644 README.md create mode 100644 SYSTEM_PROMPT.md create mode 100644 TODO.md create mode 100644 TOOLS.md create mode 100644 VECTOR_RAG_MEMORY.md create mode 100644 add_channels.py create mode 100644 authorize_qwen.sh create mode 100644 bot.py create mode 100644 bot/__init__.py create mode 100644 bot/ai_agent.py create mode 100644 bot/ai_provider_manager.py create mode 100644 bot/base_ai_provider.py create mode 100644 bot/compaction.py create mode 100644 bot/config.py create mode 100644 bot/handlers/__init__.py create mode 100644 bot/handlers/ai_presets.py create mode 100644 bot/handlers/callbacks.py create mode 100644 bot/handlers/commands.py create mode 100644 bot/handlers/files.py create mode 100644 bot/keyboards/__init__.py create mode 100644 bot/keyboards/menus.py create mode 100644 bot/models/__init__.py create mode 100644 bot/models/server.py create mode 100644 bot/models/session.py create mode 100644 bot/models/user_state.py create mode 100644 bot/providers/__init__.py create mode 100644 bot/providers/gigachat_provider.py create mode 100644 bot/providers/opencode_provider.py create mode 100644 bot/providers/qwen_provider.py create mode 100644 bot/services/__init__.py create mode 100644 bot/services/command_executor.py create mode 100644 bot/services/cron_scheduler.py create mode 100644 bot/tools/__init__.py create mode 100644 bot/tools/cron_tool.py create mode 100644 bot/tools/ddgs_tool.py create mode 100644 bot/tools/file_system_tool.py create mode 100644 bot/tools/gigachat_tool.py create mode 100644 bot/tools/rss_tool.py create mode 100644 bot/tools/ssh_tool.py create mode 100644 bot/tools/telegram_web_tool.py create mode 100644 bot/utils/__init__.py create mode 100644 bot/utils/cleaners.py create mode 100644 bot/utils/decorators.py create mode 100644 bot/utils/formatters.py create mode 100644 bot/utils/qwen_oauth.py create mode 100644 bot/utils/ssh_readers.py create mode 100755 install-systemd-service.sh create mode 100755 install.sh create mode 100644 memory_system.py create mode 100644 qwen_integration.py create mode 100644 requirements.txt create mode 100755 run.sh create mode 100644 system_prompt.md create mode 100644 telegram-bot.service create mode 100644 telegram_channels.json create mode 100644 test_memory.py create mode 100644 vector_memory.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c1733dd --- /dev/null +++ b/.env.example @@ -0,0 +1,81 @@ +# Telegram Bot Token +# Получите токен у @BotFather в Telegram +TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz + +# Настройки бота +BOT_NAME=CLI Assistant +BOT_DESCRIPTION=Бот для выполнения CLI команд +BOT_ICON_EMOJI=🤖 + +# Разрешённые пользователи (список ID через запятую) +# Пустой список = доступ открыт для всех +# Ваш ID можно узнать через @userinfobot +ALLOWED_USERS= + +# Рабочая директория для команд +WORKING_DIRECTORY=/home/mirivlad + +# =========================================== +# SSH Серверы для AI-агента +# =========================================== +# Формат: name|host|port|user|tag|password +# name - имя сервера (используется в ответах бота) +# host - IP адрес или домен +# port - SSH порт (обычно 22) +# user - пользователь SSH +# tag - тег для категоризации (web, db, prod, и т.д.) +# password - пароль SSH (или используйте SSH-ключи) +# +# Пример: +# SERVERS=tomas|192.168.1.54|22|mirivlad|web|moloko22 +# +# Для нескольких серверов используйте запятую: +# SERVERS=home|192.168.1.54|22|user|web|pass123,work|10.0.0.5|22|admin|db|pass456 +SERVERS= + +# SSH ключ для подключения (альтернатива паролю) +# SSH_KEY_PATH=/home/user/.ssh/id_ed25519 + +# =========================================== +# GigaChat API (Сбер) +# =========================================== +# Получите credentials в SberDevices Developer Portal: +# https://developers.sber.ru/docs/ru/gigachat +# +# GIGACHAT_CLIENT_ID - ID клиента (UUID) +# GIGACHAT_CLIENT_SECRET - Секрет клиента +# GIGACHAT_SCOPE - Область доступа (обычно GIGACHAT_API_PERS) +# GIGACHAT_AUTH_URL - URL авторизации (https://ngw.devices.sberbank.ru:9443/api/v2/oauth) +# GIGACHAT_MODEL - Модель по умолчанию (GigaChat-Pro или GigaChat-Max) +# +# Пример: +GIGACHAT_CLIENT_ID=your-client-id-here +GIGACHAT_CLIENT_SECRET=your-client-secret-here +GIGACHAT_SCOPE=GIGACHAT_API_PERS +GIGACHAT_AUTH_URL=https://ngw.devices.sberbank.ru:9443/api/v2/oauth +GIGACHAT_MODEL=GigaChat-Pro + +# =========================================== +# YandexGPT API (Яндекс) +# =========================================== +# Получите credentials в Yandex Cloud Console: +# https://cloud.yandex.ru/docs/fundamentals/concepts/infrastructure +# +# YANDEX_FOLDER_ID - ID каталога в Yandex Cloud +# YANDEX_API_KEY - API ключ (или используйте IAM-токен) +# YANDEX_MODEL - Модель по умолчанию (yandexgpt/latest или yandexgpt-lite/latest) +# +# Пример: +# YANDEX_FOLDER_ID=b1gxxxxxxxxxxxxxxxx +# YANDEX_API_KEY=your-api-key-here +# YANDEX_MODEL=yandexgpt/latest + +# =========================================== +# SOCKS5 Proxy (опционально) +# =========================================== +# Использовать прокси для подключения к Telegram API +USE_PROXY=false +PROXY_HOST=127.0.0.1 +PROXY_PORT=1080 +PROXY_USERNAME= +PROXY_PASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84e20ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ + +# Config +config.yaml +bot_config.json +*.env +.env + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Logs +*.log +cron_logs/ + +# Cache +.cache/ +huggingface_cache/ + +# OS +.DS_Store +Thumbs.db + +# Database (personal data - do not commit) +*.db +*.sqlite3 +vector_db/ + +# Personal files +*.bak +uploads/ diff --git a/AI_AGENT_TOOLS.md b/AI_AGENT_TOOLS.md new file mode 100644 index 0000000..ea49e92 --- /dev/null +++ b/AI_AGENT_TOOLS.md @@ -0,0 +1,261 @@ +# 🤖 AI-Агент с Автономными Инструментами + +## ✅ Реализовано + +**Интеграция завершена успешно!** Теперь твой бот умеет **самостоятельно решать**, когда использовать инструменты — без прямых команд! + +--- + +## 📋 Что реализовано + +### 1. Улучшенный AI-агент (`bot/ai_agent.py`) + +**Добавлено:** +- ✅ Расширенные триггеры для всех инструментов +- ✅ Приоритет инструментов: SSH > Cron > Поиск > RSS +- ✅ Контекстная чувствительность (оценка уверенности) +- ✅ Логирование использования инструментов +- ✅ Поддержка пользовательских предпочтений + +### 2. SSH-инструмент (`bot/tools/ssh_tool.py`) + +**Возможности:** +- ✅ Подключение к серверам по SSH (asyncssh) +- ✅ Выполнение команд с таймаутом +- ✅ Красивое форматирование вывода +- ✅ Конфигурация серверов (по умолчанию: 192.168.1.54) + +**Пример:** +``` +Ты: Проверь нагрузку на сервере +Бот: 🖥️ SSH: home + Команда: uptime + Вывод: 14:30:00 up 10 days, load average: 0.15 + ✅ Успешно +``` + +### 3. Cron-инструмент (`bot/tools/cron_tool.py`) + +**Возможности:** +- ✅ Создание задач по расписанию +- ✅ Поддержка форматов: `*/5 * * * *`, `@hourly`, `@daily`, `@weekly` +- ✅ Просмотр, удаление, включение/выключение задач +- ✅ Немедленный запуск задач + +**Пример:** +``` +Ты: Напомни каждый день делать бэкап +Бот: ⏰ Ваши задачи: + ✅ Daily Backup + Команда: /home/user/backup.sh + Расписание: @daily + Следующий запуск: 2024-03-11 00:00:00 +``` + +### 4. Обновлённый `format_tool_result` в `bot.py` + +**Добавлено:** +- ✅ Форматирование для SSH-команд +- ✅ Форматирование для Cron-задач +- ✅ Обработка ошибок с красивым выводом + +--- + +## 🎯 Как это работает + +### Автономное использование инструментов + +Бот **сам понимает**, когда нужен инструмент: + +| Твоя фраза | Бот использует | Почему | +|------------|----------------|--------| +| "Найди информацию про Python" | 🔍 DDGS Search | Триггер "найди" | +| "Почитай новости IT" | 📰 RSS Reader | Триггер "новости" | +| "Проверь сервер" | 🖥️ SSH Executor | Триггер "проверь сервер" | +| "Напомни каждый день" | ⏰ Cron Manager | Триггер "каждый день" | + +### Приоритеты + +Если сообщение подходит под несколько инструментов: + +1. **SSH** (системные задачи) — высший приоритет +2. **Cron** (планирование) +3. **Поиск** (информация) +4. **RSS** (новости) + +--- + +## 📦 Доступные инструменты + +| Инструмент | Назначение | Триггеры | +|------------|------------|----------| +| `ddgs_tool` | Поиск в интернете | "найди", "поиск", "узнай", "как сделать" | +| `rss_tool` | Чтение RSS лент | "новости", "почитай", "лента", "IT новости" | +| `ssh_tool` | SSH-команды | "проверь сервер", "выполни команду", "uptime" | +| `cron_tool` | Задачи по расписанию | "напомни", "запланируй", "каждый день" | + +--- + +## 🔧 Настройка + +### Добавление сервера в SSH + +Открой `bot/tools/ssh_tool.py` и добавь: + +```python +self.servers['myserver'] = ServerConfig( + host='192.168.1.100', + port=22, + username='user', + password='pass' # или client_keys=['/path/to/key'] +) +``` + +### Добавление триггеров + +Открой `bot/ai_agent.py` и добавь в соответствующий список: + +```python +SEARCH_TRIGGERS = [ + # ... существующие ... + # ВАЖНО: только явные запросы поиска, избегай одиночных слов! + 'мой_триггер' # новый триггер +] +``` + +--- + +## 🧪 Тестирование + +Проверка работы AI-агента: + +```bash +cd ~/git/telegram-cli-bot +python3 -c " +import asyncio +from bot.ai_agent import ai_agent + +async def test(): + tests = [ + 'Найди информацию про Python', + 'Почитай новости', + 'Проверь нагрузку на сервере', + 'Напомни каждый день', + 'Привет!' + ] + + for msg in tests: + decision = await ai_agent.decide(msg) + print(f'{msg}: {decision.tool_name or \"нет инструмента\"} (conf={decision.confidence})') + +asyncio.run(test()) +" +``` + +**Ожидаемый результат:** +``` +Найди информацию про Python: ddgs_search (conf=0.9) +Почитай новости: rss_reader (conf=0.9) +Проверь нагрузку на сервере: ssh_executor (conf=0.9) +Напомни каждый день: cron_manager (conf=0.85) +Привет!: нет инструмента (conf=0.0) +``` + +--- + +## 📝 Примеры использования + +### 🔍 Поиск в интернете + +``` +Ты: Найди руководство по async/await в Python +Бот: 🔍 Результаты поиска: + 1. **Python Async/Await Documentation** + https://docs.python.org/3/library/asyncio.html + asyncio is a library to write concurrent code ... +``` + +### 📰 Чтение новостей + +``` +Ты: Почитай новости Linux +Бот: 📰 Последние новости: + 1. Linux Kernel 6.8 Released + 📅 2024-03-10 14:30:00 + 🔗 https://... +``` + +### 🖥️ SSH-команды + +``` +Ты: Сколько места на диске? +Бот: 🖥️ SSH: home + Команда: df -h + Вывод: + Filesystem Size Used Avail + /dev/sda1 100G 50G 50G + ✅ Успешно +``` + +### ⏰ Cron-задачи + +``` +Ты: Запланируй бэкап каждый день в 3 ночи +Бот: ✅ Задача добавлена: + • ID: 1 + • Название: Daily Backup + • Расписание: 0 3 * * * + • Следующий запуск: 2024-03-11 03:00:00 +``` + +--- + +## 🚀 Запуск бота + +```bash +cd ~/git/telegram-cli-bot +./run.sh +``` + +Или вручную: + +```bash +python3 bot.py +``` + +--- + +## 📊 Статистика + +**Файлы изменены/созданы:** +- ✅ `bot/ai_agent.py` — улучшенный AI-агент +- ✅ `bot/tools/ssh_tool.py` — SSH-инструмент (новый) +- ✅ `bot/tools/cron_tool.py` — Cron-инструмент (новый) +- ✅ `bot/tools/__init__.py` — реестр (обновлён) +- ✅ `bot.py` — форматирование результатов (обновлён) +- ✅ `TOOLS.md` — документация (обновлена) + +**Строк кода добавлено:** ~600+ + +**Инструментов доступно:** 4 + +--- + +## 🎯 Следующие шаги + +**Можно добавить:** +1. **Веб-скрапинг** — парсинг конкретных сайтов +2. **Мониторинг** — авто-проверка метрик сервера +3. **Уведомления** — отправка уведомлений по расписанию +4. **Интеграции** — GitHub API, Docker API, etc. + +--- + +## ⚠️ Важно + +- Бот персональный — нет ролевой модели и ограничений +- Инструменты доступны **всегда** в AI-режиме +- Бот **сам решает** когда использовать инструмент +- Логи пишутся в `bot.log` + +**Приятного использования! 🚀** diff --git a/AI_PROVIDERS.md b/AI_PROVIDERS.md new file mode 100644 index 0000000..56f109c --- /dev/null +++ b/AI_PROVIDERS.md @@ -0,0 +1,102 @@ +# AI Provider Switching + +## Обзор + +Бот поддерживает переключение между AI-провайдерами: +- **Qwen Code** — основной провайдер (Alibaba) +- **GigaChat** — альтернативный провайдер (Sber) + +## Использование + +### Через команду `/ai` + +**Просмотр текущего статуса:** +``` +/ai +``` + +Покажет текущего провайдера и доступные опции. + +**Переключение на Qwen:** +``` +/ai qwen +``` + +**Переключение на GigaChat:** +``` +/ai gigachat +``` + +### Через меню + +1. Нажмите `/settings` или кнопку "⚙️ Настройки бота" +2. Выберите "🤖 AI-провайдер" +3. Доступные опции: + - "🔄 Переключить AI-провайдер" — переключает на альтернативный провайдер + - "ℹ️ Информация о провайдерах" — подробная информация о каждом провайдере + +## Архитектура + +### Новые файлы + +- `bot/ai_provider_manager.py` — менеджер управления провайдерами +- `bot/models/user_state.py` — добавлено поле `current_ai_provider` + +### Изменённые файлы + +- `bot.py` — модифицирован `handle_ai_task()` для использования текущего провайдера +- `bot/handlers/commands.py` — добавлена команда `/ai` +- `bot/handlers/callbacks.py` — добавлены обработчики меню AI-провайдера +- `bot/keyboards/menus.py` — добавлено меню "🤖 AI-провайдер" + +## Как это работает + +1. **Хранение состояния**: Каждый пользователь имеет своё предпочтение провайдера в `UserState.current_ai_provider` + +2. **Обработка запросов**: При получении AI-запроса `handle_ai_task()` проверяет текущего провайдера и использует соответствующий API: + - **Qwen**: Потоковый вывод с `on_chunk` callback + - **GigaChat**: Ответ целиком + +3. **Переключение**: При переключении провайдера обновляется состояние пользователя, новый запрос сразу идёт через выбранного провайдера + +## Настройка GigaChat + +Для использования GigaChat добавьте в `.env`: + +```env +# GigaChat API (Сбер) +GIGACHAT_CLIENT_ID=ваш-client-id-uuid +GIGACHAT_CLIENT_SECRET=ваш-client-secret +GIGACHAT_SCOPE=GIGACHAT_API_PERS +GIGACHAT_AUTH_URL=https://ngw.devices.sberbank.ru:9443/api/v2/oauth +GIGACHAT_MODEL=GigaChat-Pro +``` + +## Отображение в ответе + +В конце каждого AI-ответа указывается используемый провайдер: + +``` +📊 Контекст: 0.5% +🤖 AI: Qwen Code +``` + +или + +``` +📊 Контекст: 0.5% +🤖 AI: GigaChat +``` + +## Приоритеты провайдеров + +- **По умолчанию**: Qwen Code +- **Если GigaChat не настроен**: Переключение недоступно (показывается ошибка) +- **Инструменты**: Доступны только с Qwen (GigaChat используется только для чата) + +## Будущие улучшения + +- [ ] Умное переключение (автоматический выбор провайдера по типу задачи) +- [ ] Поддержка инструментов для GigaChat +- [ ] Статистика использования провайдеров +- [ ] Настройка провайдера по умолчанию diff --git a/AI_PROVIDER_ARCHITECTURE.md b/AI_PROVIDER_ARCHITECTURE.md new file mode 100644 index 0000000..2c51bd7 --- /dev/null +++ b/AI_PROVIDER_ARCHITECTURE.md @@ -0,0 +1,233 @@ +# AI Provider Architecture v0.7.1 + +## Обзор + +Начиная с версии 0.7.1 бот использует **универсальный интерфейс** для всех AI-провайдеров. Это позволяет любому AI-провайдеру работать с инструментами (SSH, DDGS, RSS, Cron) одинаковым образом. + +## Архитектура + +``` +┌─────────────────────────────────────────────────────────┐ +│ Bot (bot.py) │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ AIProviderManager │ │ +│ │ ┌─────────────┐ ┌──────────────────┐ │ │ +│ │ │ QwenCode │ │ GigaChat │ ... │ │ +│ │ │ Provider │ │ Provider │ │ │ +│ │ └──────┬──────┘ └────────┬─────────┘ │ │ +│ │ │ │ │ │ +│ │ └──────────┬───────┘ │ │ +│ │ │ │ │ +│ │ ┌──────────▼──────────┐ │ │ +│ │ │ BaseAIProvider │ │ │ +│ │ │ (Protocol) │ │ │ +│ │ └──────────┬──────────┘ │ │ +│ └────────────────────┼────────────────────────────┘ │ +│ │ │ +│ ┌─────────────┴──────────────┐ │ +│ │ Tools Registry │ │ +│ │ ┌──────┐ ┌─────┐ ┌────┐ │ │ +│ │ │ SSH │ │DDGS │ │RSS │ │ │ +│ │ └──────┘ └─────┘ └────┘ │ │ +│ └────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +## Ключевые компоненты + +### 1. BaseAIProvider (`bot/base_ai_provider.py`) + +**Базовый протокол** для всех AI-провайдеров: + +```python +class BaseAIProvider(ABC): + @property + def provider_name(self) -> str: ... + + @property + def supports_tools(self) -> bool: ... + + @property + def supports_streaming(self) -> bool: ... + + async def chat(...) -> ProviderResponse: ... + + async def process_with_tools(...) -> ProviderResponse: ... +``` + +**Ключевая особенность:** Метод `process_with_tools` реализует универсальный цикл: +1. Отправить запрос провайдеру +2. Распарсить вызовы инструментов +3. Выполнить инструменты +4. Отправить результаты обратно +5. Повторить пока не будет финального ответа + +### 2. QwenCodeProvider (`bot/providers/qwen_provider.py`) + +Адаптер для Qwen Code CLI: +- **Нативная поддержка инструментов** через stream-json +- **Потоковый вывод** (on_chunk callback) +- Парсинг tool calls из JSON ответа + +### 3. GigaChatProvider (`bot/providers/gigachat_provider.py`) + +Адаптер для GigaChat API: +- **Эмуляция инструментов** через текстовые блоки +- Парсинг `` ```tool {...} ``` `` из текста +- Автоматическое формирование tool prompt + +### 4. AIProviderManager (`bot/ai_provider_manager.py`) + +Управление провайдерами: +- Переключение между провайдерами +- Единый реестр инструментов для всех +- Маршрутизация запросов + +## Как это работает + +### Сценарий 1: Qwen Code + +```python +# Пользователь: "проверь нагрузку на сервере" + +1. AIProviderManager получает запрос +2. Вызывает QwenCodeProvider.process_with_tools() +3. Qwen возвращает: + { + "content": "Проверяю нагрузку...", + "tool_calls": [{"name": "ssh_tool", "args": {"command": "uptime"}}] + } +4. AIProviderManager выполняет ssh_tool.execute() +5. Результат возвращается Qwen для финального ответа +``` + +### Сценарий 2: GigaChat + +```python +# Пользователь: "найди новости про Python" + +1. AIProviderManager получает запрос +2. Вызывает GigaChatProvider.process_with_tools() +3. GigaChat возвращает текст: + "Ищу новости... + ```tool + {"name": "ddgs_tool", "arguments": {"query": "Python news 2026"}} + ```" +4. GigaChatProvider._parse_tool_calls() извлекает вызов +5. AIProviderManager выполняет ddgs_tool.execute() +6. Результат возвращается GigaChat для продолжения + +``` + +## Добавление нового провайдера + +Чтобы добавить новый AI-провайдер (например, OpenAI): + +```python +# bot/providers/openai_provider.py + +from bot.base_ai_provider import BaseAIProvider, ProviderResponse, AIMessage, ToolCall + +class OpenAIProvider(BaseAIProvider): + @property + def provider_name(self) -> str: + return "OpenAI" + + @property + def supports_tools(self) -> bool: + return True # Или False если не поддерживает + + @property + def supports_streaming(self) -> bool: + return True + + async def chat(...) -> ProviderResponse: + # Реализация через OpenAI API + ... +``` + +Затем зарегистрировать в `AIProviderManager._init_providers()`: + +```python +from bot.providers.openai_provider import OpenAIProvider +self._providers[AIProvider.OPENAI.value] = OpenAIAI(api_key=...) +``` + +## Преимущества архитектуры + +| Преимущество | Описание | +|-------------|----------| +| **Единый интерфейс** | Все провайдеры работают одинаково | +| **Инструменты для всех** | Любой провайдер может использовать SSH, DDGS, RSS, Cron | +| **Легкое расширение** | Новый провайдер = один класс | +| **Совместимость** | Старый код продолжает работать | +| **Гибкость** | Можно переключаться на лету | + +## Настройка + +### Для Qwen Code + +```bash +# Требуется установленный qwen-code CLI +npm install -g @anthropic-ai/qwen-code +``` + +### Для GigaChat + +Добавьте в `.env`: + +```env +GIGACHAT_CLIENT_ID=ваш-client-id-uuid +GIGACHAT_CLIENT_SECRET=ваш-client-secret +GIGACHAT_SCOPE=GIGACHAT_API_PERS +``` + +## Переключение провайдера + +```telegram +/ai qwen # Переключиться на Qwen Code +/ai gigachat # Переключиться на GigaChat +/ai # Показать текущего провайдера +``` + +## Статус провайдеров + +| Провайдер | Инструменты | Стриминг | Статус | +|-----------|-------------|----------|--------| +| Qwen Code | ✅ Нативно | ✅ | ✅ Готов | +| GigaChat | ✅ Эмуляция | ❌ | ✅ Готов | +| OpenAI | ✅ Нативно | ✅ | 🔜 Скоро | +| YandexGPT | ✅ Эмуляция | ❌ | 🔜 Скоро | + +## Миграция с v0.5.x + +Старый код: +```python +# Прямой вызов qwen_manager +result = await qwen_manager.run_task(...) +``` + +Новый код: +```python +# Через AIProviderManager +result = await ai_provider_manager.execute_request( + provider_id="qwen", + user_id=user_id, + prompt="запрос" +) +``` + +## Будущие улучшения + +- [ ] Поддержка OpenAI Provider +- [ ] YandexGPT Provider +- [ ] Автоматический выбор провайдера по типу задачи +- [ ] Балансировка нагрузки между провайдерами +- [ ] Кэширование ответов +- [ ] Fallback при ошибке провайдера + +--- + +*Версия: 0.7.1* +*Дата: 2026-02-26* diff --git a/CRON_SYSTEM.md b/CRON_SYSTEM.md new file mode 100644 index 0000000..f43cd73 --- /dev/null +++ b/CRON_SYSTEM.md @@ -0,0 +1,219 @@ +# 🕐 Интеллектуальная Cron-система + +Интеллектуальная система планирования задач для Telegram CLI Bot. + +## 📋 Особенности + +В отличие от классического cron, задачи выполняются не как команды, а как **промпты для ИИ-агента**. + +### Структура задачи + +```python +CronJob: + - id: int # Уникальный ID + - name: str # Название задачи + - prompt: str # Промпт для ИИ-агента + - schedule: str # Расписание (@hourly, @daily, */5 * * * *) + - user_id: int # ID пользователя Telegram + - enabled: bool # Включена ли задача + - notify: bool # Уведомлять пользователя в Telegram + - log_results: bool # Сохранять результат в лог-файл + - last_run: datetime # Последнее выполнение + - next_run: datetime # Следующее выполнение + - created_at: datetime # Дата создания +``` + +## 🔄 Процесс выполнения + +``` +┌─────────────────────────────────────────────────────────┐ +│ Cron Scheduler (проверяет каждую минуту) │ +│ ↓ (если время пришло) │ +│ Отправляет промпт ИИ-агенту │ +│ ↓ │ +│ ИИ-агент (Рик) анализирует промпт: │ +│ - Решает какой инструмент использовать │ +│ - Выполняет инструмент (поиск, SSH, RSS) │ +│ ↓ │ +│ Если notify=True → отправка уведомления в Telegram │ +│ Если log_results=True → сохранение в лог-файл │ +└─────────────────────────────────────────────────────────┘ +``` + +## 📝 Команды управления + +### `/cron list` - Показать все задачи + +``` +/cron list +``` + +**Пример вывода:** +``` +⏰ Ваши задачи: + +✅ Проверка диска (ID: 1) + 🔔📝 Промпт: Проверить свободное место на сервере... + Расписание: @daily + Следующий запуск: 2026-02-26 00:00:00 + Последний запуск: 2026-02-25 00:00:00 +``` + +### `/cron add` - Добавить задачу + +``` +/cron add +``` + +**Параметры:** +- `name` - название задачи +- `schedule` - расписание: + - `@hourly` - каждый час + - `@daily` - каждый день + - `@weekly` - каждую неделю + - `*/5 * * * *` - каждые 5 минут (cron format) +- `prompt` - промпт для ИИ-агента + +**Примеры:** + +```bash +# Ежедневная проверка диска +/cron add check_disk @daily Проверить свободное место на сервере home + +# Ежечасные новости +/cron add tech_news @hourly Что нового в Linux сегодня + +# Каждые 5 минут мониторинг +/cron add monitor */5 * * * * Проверить нагрузку на сервер +``` + +### `/cron run` - Выполнить задачу немедленно + +``` +/cron run +``` + +**Пример:** +``` +/cron run 1 +``` + +### `/cron remove` - Удалить задачу + +``` +/cron remove +``` + +### `/cron toggle` - Включить/выключить задачу + +``` +/cron toggle +``` + +## 🛠️ Инструменты ИИ-агента + +При выполнении задачи ИИ-агент может использовать: + +| Инструмент | Назначение | Триггеры | +|------------|------------|----------| +| `ddgs_tool` | Поиск в интернете | "найди", "поиск", "узнай" | +| `rss_tool` | Чтение RSS лент | "новости", "почитай", "лента" | +| `ssh_tool` | SSH-команды | "проверь сервер", "выполни команду" | +| `cron_tool` | Управление задачами | "напомни", "запланируй" | + +## 📂 Логирование + +Результаты выполнения задач сохраняются в: +``` +cron_logs/ + cron_job_1_check_disk.log + cron_job_2_tech_news.log + ... +``` + +**Формат лога:** +``` +============================================================ +[2026-02-25 10:30:00] Задача: Проверка диска (ID: 1) +============================================================ +Промпт: +Проверить свободное место на сервере home + +Результат: +Задача 'Проверка диска' выполнена. + +Использован инструмент: ssh_tool +Результат: Filesystem Size Used Avail Use% Mounted on +... + +``` + +## 🔔 Уведомления + +Если `notify=True`, бот отправляет уведомление в Telegram: + +``` +✅ Задача 'Проверка диска' выполнена. + +Использован инструмент: ssh_tool +Результат: Свободно 45GB на /dev/sda1 +``` + +## 💡 Примеры использования + +### 1. Ежедневный мониторинг диска + +```bash +/cron add disk_daily @daily Проверить свободное место на сервере home. Если меньше 10GB - предупредить +``` + +### 2. Ежечасные новости IT + +```bash +/cron add it_news @hourly Найти свежие новости про Python и Linux за последний час +``` + +### 3. Мониторинг нагрузки каждые 5 минут + +```bash +/cron add load_monitor */5 * * * * Проверить нагрузку CPU и RAM на сервере +``` + +### 4. Еженедельный поиск уязвимостей + +```bash +/cron add security_scan @weekly Найти информацию о новых уязвимостях в Linux за неделю +``` + +## 🚀 Архитектура + +``` +bot/ + tools/ + cron_tool.py # Инструмент управления задачами + services/ + cron_scheduler.py # Планировщик (проверка каждую минуту) + handlers/ + commands.py # Обработчик команды /cron +``` + +## ⚙️ Технические детали + +- **Проверка задач:** каждую минуту (60 секунд) +- **Хранение:** SQLite (`cron.db`) +- **Логи:** текстовые файлы (`cron_logs/`) +- **Формат расписания:** cron format или специальные (@hourly, @daily, @weekly) + +## 🎯 Отличия от классического cron + +| Классический cron | Интеллектуальный cron | +|-------------------|----------------------| +| Выполняет команды | Выполняет промпты для ИИ | +| Жёсткая логика | Гибкое решение через ИИ | +| Вывод в stdout/email | Уведомления в Telegram + логи | +| Нет контекста | ИИ использует контекст и память | + +--- + +*Версия: 0.8.0* +*Интеллектуальная cron-система с AI-агентом* diff --git a/FILE_SYSTEM_TOOL.md b/FILE_SYSTEM_TOOL.md new file mode 100644 index 0000000..20f15d6 --- /dev/null +++ b/FILE_SYSTEM_TOOL.md @@ -0,0 +1,140 @@ +# File System Tool - Документация + +## 📋 Описание + +Инструмент для работы с файловой системой Linux. Позволяет AI-агенту (Qwen Code или GigaChat) выполнять операции с файлами и директориями. + +## 🎯 Доступные операции + +| Операция | Описание | Параметры | +|----------|----------|-----------| +| `read` | Чтение файла | `path`, `limit` (макс. строк) | +| `write` | Запись в файл | `path`, `content`, `append` | +| `copy` | Копирование файла/директории | `source`, `destination` | +| `move` | Перемещение/переименование | `source`, `destination` | +| `delete` | Удаление файла/директории | `path`, `recursive` | +| `mkdir` | Создание директории | `path`, `parents` | +| `list` | Список файлов в директории | `path`, `show_hidden` | +| `info` | Информация о файле | `path` | +| `search` | Поиск файлов по паттерну | `path`, `pattern`, `max_results` | +| `shell` | Выполнение shell-команды | `command`, `timeout` | + +## 🔒 Безопасность + +Инструмент имеет систему проверки путей: + +### Разрешённые пути (можно читать/записывать): +- Домашняя директория пользователя (`/home/mirivlad`) +- `/tmp` +- `/var/tmp` + +### Запрещённые пути (только чтение с ограничениями): +- `/etc`, `/usr`, `/bin`, `/sbin` +- `/boot`, `/dev`, `/proc`, `/sys` +- Корень `/` (кроме разрешённых поддиректорий) + +## 📝 Примеры использования + +### Через AI-агента (автоматически) + +``` +Пользователь: "прочитай файл /home/mirivlad/test.txt" +AI-агент → file_system_tool(operation='read', path='/home/mirivlad/test.txt') +``` + +### Прямой вызов + +```python +from bot.tools.file_system_tool import FileSystemTool + +tool = FileSystemTool() + +# Чтение файла +result = await tool.execute(operation='read', path='/path/to/file.txt') + +# Запись файла +result = await tool.execute( + operation='write', + path='/path/to/file.txt', + content='Содержимое файла' +) + +# Копирование +result = await tool.execute( + operation='copy', + source='/source/file.txt', + destination='/dest/file.txt' +) + +# Список директории +result = await tool.execute( + operation='list', + path='/home/mirivlad' +) +``` + +## 🤖 Интеграция с AI-провайдерами + +### GigaChat + +GigaChat использует текстовый формат для вызова инструментов: + +```` +```tool +{"name": "file_system_tool", "arguments": {"operation": "read", "path": "/tmp/test.txt"}} +``` +```` + +### Qwen Code + +Qwen Code поддерживает нативные tool calls через stream-json. + +## 📊 Триггеры для активации + +AI-агент автоматически активирует `file_system_tool` при обнаружении триггеров: + +### Прямые триггеры: +- "прочитай файл", "покажи файл", "открой файл" +- "создай файл", "запиши в файл", "сохрани" +- "скопируй файл", "перемести файл", "удали файл" +- "создай директорию", "создай папку" +- "список файлов", "что в папке" +- "найди файл", "поиск файла" + +### Команды Unix: +- `cat `, `ls `, `mkdir `, `cp `, `mv `, `rm `, `touch ` + +## ⚠️ Ограничения + +1. **Безопасность**: Нельзя записывать/удалять в системных директориях +2. **Размер файлов**: При чтении ограничено 100 строками (настраивается через `limit`) +3. **Shell команды**: Разрешены только безопасные команды (`ls`, `cat`, `cp`, `mv`, `rm`, `mkdir`, `find`, `grep`, etc.) +4. **Таймаут**: Для shell команд таймаут 30 секунд по умолчанию + +## 🔄 История операций + +Инструмент сохраняет историю последних 100 операций для отладки: + +```python +tool._operation_history # Список последних операций +``` + +## 📁 Расположение + +``` +bot/tools/file_system_tool.py +``` + +## 🔧 Добавление в реестр + +Инструмент автоматически регистрируется при импорте: + +```python +from bot.tools import file_system_tool # Авто-регистрация +``` + +--- + +**Версия:** 0.8.0 +**Совместимость:** Telegram CLI Bot 0.8.0+ +**AI-провайдеры:** Qwen Code, GigaChat diff --git a/GRADIENT_MEMORY.md b/GRADIENT_MEMORY.md new file mode 100644 index 0000000..d077234 --- /dev/null +++ b/GRADIENT_MEMORY.md @@ -0,0 +1,225 @@ +# 🧠 Градиентная память (Gradient Memory) + +**Версия:** 1.0 +**Дата:** 2026-03-08 + +--- + +## 📋 Что это? + +Градиентная память — это система памяти для ИИ-агента, которая имитирует человеческую память: + +> **То что близко — чётко, то что далеко — размыто.** + +Как у людей: недавние события помним детально, старые — в общих чертах, а для глубокого поиска используем "внешние носители" (записи, заметки). + +--- + +## 🏗️ Архитектура + +### Три уровня памяти: + +``` +┌──────────────────────────────────────────────────────┐ +│ КОНТЕКСТ ДЛЯ ИИ │ +├──────────────────────────────────────────────────────┤ +│ 📋 ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ (факты из SQLite) │ +│ • Имя, предпочтения, проекты │ +│ • Технологии, инструменты │ +├──────────────────────────────────────────────────────┤ +│ 💬 STM: Short-Term Memory (последние 5 сообщений) │ +│ • Полный текст сообщений │ +│ • Чёткая память о недавнем │ +│ • ~500-1000 токенов │ +├──────────────────────────────────────────────────────┤ +│ 🕰️ LTM: Long-Term Memory (сообщения 6-20) │ +│ • Сжатое содержимое (50 символов) │ +│ • Размытая память о прошлом │ +│ • ~500-700 токенов │ +├──────────────────────────────────────────────────────┤ +│ 🔍 RAG: Retrieval-Augmented Generation │ +│ • Глубокий поиск через ChromaDB │ +│ • Семантический поиск по всем сообщениям │ +│ • ~500-1000 токенов │ +└──────────────────────────────────────────────────────┘ +``` + +--- + +## 🔧 Как это работает + +### 1. **STM (Short-Term Memory)** + +**Размер:** последние 5 сообщений +**Содержимое:** полностью +**Назначение:** контекст текущего диалога + +```python +# Пример STM: +Пользователь: Отлично, а в какой папке ты менял файлы?... +Ассистент: Владимир, я пока не менял файлы... +Пользователь: Ну вот. Ты только что меня код и снова забыл... +Ассистент: Понимаю твою проблему, Владимир... +Пользователь: Мне кажется последней реплики маловато... +``` + +### 2. **LTM (Long-Term Memory)** + +**Размер:** сообщения 6-20 +**Содержимое:** сжато (первые 50 символов) +**Назначение:** общий контекст диалога + +```python +# Пример LTM (сжато): +Пользователь: Надо чтобы ты помнил ну скажем штук 5 моих... +Ассистент: Понял задачу, Владимир. Это разумный подход... +Пользователь: Что нового в Linux сегодня? +Ассистент: Сейчас проверю RSS-ленты... +``` + +### 3. **RAG (Retrieval-Augmented Generation)** + +**Размер:** до 5 релевантных сообщений +**Содержимое:** полностью (150 символов для превью) +**Назначение:** глубокий поиск по запросу + +```python +# Пример RAG (поиск по запросу "память"): +[0.85] Ассистент: У тебя уже есть продвинутая гибридная система... +[0.78] Пользователь: Как в тумане - то что близко - четко видеть... +[0.72] Ассистент: Градиентная память с разной детализацией... +``` + +--- + +## 📁 Файлы + +- `vector_memory.py` — основная реализация +- `memory_system.py` — SQLite хранилище +- `memory.db` — SQLite база данных +- `vector_db/` — ChromaDB хранилище +- `bot/compaction.py` — суммаризация старых сообщений + +--- + +## 🚀 Использование + +### Через бота: + +``` +/memory_test — показать градиентную память +/memory — статистика памяти +/facts — сохранённые факты +/compact — суммаризация истории +``` + +### В коде: + +```python +from vector_memory import get_context + +# Получить контекст с градиентной памятью +context = get_context( + user_id=123456, + query="последние сообщения", + stm_size=5, # STM: 5 сообщений + ltm_size=15 # LTM: 15 сообщений +) +``` + +--- + +## 📊 Пример вывода + +``` +📋 ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ: + [personal]: + - Пользователя зовут Владимир + [technical]: + - Использует Python + - Работает с Telegram API + +💬 STM (ПОСЛЕДНИЕ СООБЩЕНИЯ): + Пользователь: Отлично, а в какой папке ты менял файлы?... + Ассистент: Владимир, я пока не менял файлы — мы только что смотрели конфигурацию SSH-хостов... + Пользователь: Ну вот. Ты только что меня код и снова забыл про это моментально... + Ассистент: Понимаю твою проблему, Владимир. Это серьёзный баг в моей логике... + Пользователь: Мне кажется последней реплики маловато. Надо чтобы ты помнил... + +🕰️ LTM (БОЛЕЕ СТАРЫЕ СООБЩЕНИЯ — КРАТКО): + Пользователь: Как в тумане - то что близко - четко видеть... + Ассистент: Понял задачу, Владимир. Это разумный подход — градиентная... + Пользователь: Что нового в Linux сегодня? + Ассистент: Сейчас проверю RSS-ленты... + +🔍 RAG (РЕЛЕВАНТНЫЕ СООБЩЕНИЯ ПО ЗАПРОСУ): + [0.85] Ассистент: У тебя уже есть продвинутая гибридная система памяти... + [0.78] Пользователь: Как в тумане - то что близко - четко видеть, то что дальше... + [0.72] Ассистент: Градиентная память с разной детализацией по времени... + +================================================== +🧠 ПАМЯТЬ: STM чётко → LTM размыто → RAG глубоко +================================================== +``` + +--- + +## ⚙️ Настройка + +### Изменить размеры уровней: + +```python +# В bot.py (строка ~436) +memory_context = get_context( + user_id, + query=text, + stm_size=5, # Последние 5 сообщений (полностью) + ltm_size=15 # Ещё 15 сообщений (сжато) +) +``` + +### Изменить степень сжатия LTM: + +```python +# В vector_memory.py (строка ~600) +preview = msg.content[:50].replace('\n', ' ').strip() + "..." +# Изменить 50 на нужное значение +``` + +--- + +## 📈 Производительность + +| Операция | Время | Токены | +|----------|-------|--------| +| STM (5 сообщений) | ~5ms | ~800 | +| LTM (15 сообщений) | ~5ms | ~600 | +| RAG поиск | ~100ms | ~800 | +| **Итого** | **~110ms** | **~2200** | + +**Экономия vs полный контекст:** +- Без градиентной памяти: 20 сообщений × 200 слов × 1.3 = ~5200 токенов +- С градиентной памятью: ~2200 токенов +- **Экономия: ~60%** ✅ + +--- + +## 🎯 Преимущества + +1. **Экономия токенов** — 60% меньше расход контекста +2. **Сохранение контекста** — ИИ помнит важное из диалога +3. **Гибкость** — RAG находит глубокую информацию +4. **Естественность** — похоже на человеческую память + +--- + +## 🔮 Планы + +- [ ] Автоматическая настройка STM/LTM размеров +- [ ] Умная суммаризация LTM через ИИ +- [ ] Приоритизация важных сообщений в LTM +- [ ] Эмоциональная память (важные события помним лучше) + +--- + +*Градиентная память — как у людей: чёткое недавнее, размытое прошлое, глубокий поиск по запросу.* diff --git a/HTML_ERROR_FIX.md b/HTML_ERROR_FIX.md new file mode 100644 index 0000000..59a3f90 --- /dev/null +++ b/HTML_ERROR_FIX.md @@ -0,0 +1,94 @@ +# 🐛 Исправление ошибки "Can't parse entities" + +## Проблема + +Бот получал ошибки при отправке длинных HTML-сообщений: + +``` +telegram.error.BadRequest: Can't parse entities: unexpected end tag at byte offset 1381 +telegram.error.BadRequest: Message is too long +``` + +## Причина + +1. **`send_long_message_html`** получал текст с HTML-тегами от Qwen +2. Пытался конвертировать его из Markdown → HTML (двойная конвертация) +3. При разбивке на части HTML-теги разрывались (`` в одной части, `` в другой) +4. Telegram отклонял сообщения с битыми тегами + +## Решение + +### 1. Улучшена обработка ошибок в `send_long_message` + +**Файл:** `bot/utils/formatters.py` + +Добавлен fallback для битых HTML-тегов: + +```python +except Exception as e: + logger.warning(f"Ошибка HTML (parse_mode={actual_parse_mode}): {e}") + + # Проверяем это ошибка парсинга HTML — пробуем экранировать + if "parse" in str(e).lower() or "tag" in str(e).lower(): + import html as html_lib + safe_part = html_lib.escape(part) + try: + await send_method(safe_part, parse_mode=None) + except Exception as e2: + # Последняя попытка — обрезать до безопасного размера + safe_part = safe_part[:4000] + "... (обрезано)" + await send_method(safe_part, parse_mode=None) +``` + +### 2. Улучшена обработка в `send_long_message_html` + +Добавлен многоуровневый fallback: + +```python +try: + return await send_long_message(update, html_text, parse_mode=ParseMode.HTML, ...) +except Exception as e: + logger.warning(f"Ошибка отправки HTML: {e}") + + # Если HTML не работает — экранируем и отправляем как plain text + escaped_text = html_lib.escape(html_text) + + try: + return await send_long_message(update, escaped_text, parse_mode=None, ...) + except Exception as e2: + # Последний fallback — обрезаем до безопасного размера + safe_text = escaped_text[:4000] + "... (обрезано)" + return await send_long_message(update, safe_text, parse_mode=None, ...) +``` + +## Уровни защиты + +| Уровень | Действие | Когда | +|---------|----------|-------| +| 1 | Отправка HTML | Нормальная работа | +| 2 | Экранирование HTML | Битые теги | +| 3 | Обрезка до 4000 символов | Слишком длинное сообщение | + +## Тестирование + +Проверьте отправку длинных сообщений: + +```bash +# Отправьте боту запрос который вернёт длинный ответ +"Напиши подробное руководство по Python asyncio" +``` + +**Ожидаемое поведение:** +- Короткие сообщения отправляются в HTML +- Длинные сообщения разбиваются на части +- При ошибках HTML — экранируются и отправляются как текст +- Сообщения >4000 символов обрезаются с уведомлением + +## Файлы изменены + +- `bot/utils/formatters.py` — улучшена обработка ошибок HTML + +--- + +**Дата исправления:** 2026-03-09 +**Версия бота:** 0.9.0 diff --git a/INSTRUMENTS_FIX.md b/INSTRUMENTS_FIX.md new file mode 100644 index 0000000..315e592 --- /dev/null +++ b/INSTRUMENTS_FIX.md @@ -0,0 +1,129 @@ +# 🔄 Исправление проблемы с инструментами ИИ + +## Проблема + +ИИ (Qwen Code / Opencode / GigaChat) **не хотел использовать инструменты** бота, потому что: + +1. **Не считал их своими** — пытался работать только через свои встроенные инструменты +2. **Говорил что инструменты недоступны** — хотя AI-агент их вызывал +3. **Пытался вызвать инструменты напрямую** — хотя технически не мог это сделать + +## Причина + +**Архитектурная проблема:** + +- **AI-агент бота** (`bot/ai_agent.py`) — принимает решение об использовании инструментов и вызывает их +- **ИИ (Qwen/другие)** — генерирует текст, но **не имел доступа к результатам инструментов** + +ИИ видел в системном промпте описание инструментов, но **не получал результаты их выполнения** — поэтому думал что они недоступны. + +## Решение + +### 1. Обновлён системный промпт (`system_prompt.md`) + +Добавлены явные указания: + +```markdown +## 🧠 АРХИТЕКТУРА: КТО ВЫЗЫВАЕТ ИНСТРУМЕНТЫ? + +**ВАЖНОЕ РАЗДЕЛЕНИЕ ОТВЕТСТВЕННОСТИ:** + +1. **AI-агент бота** (внешняя система) — **принимает решение** об использовании инструментов и **вызывает их** +2. **ТЫ (Qwen/ИИ)** — **получаешь результаты инструментов в контексте** и **анализируешь их** + +**ТЫ НЕ ВЫЗЫВАЕШЬ ИНСТРУМЕНТЫ НАПРЯМУЮ!** +``` + +### 2. Обновлена логика `bot.py` + +**До:** +```python +if tool_result.success: + # СРАЗУ формируем ответ из результатов + tool_result_formatted = await format_tool_result(...) + await send_long_message_html(update, tool_result_formatted) + return # Qwen НЕ получает результаты! +``` + +**После:** +```python +# Сохраняем результат для передачи в Qwen +tool_result_for_context = { + 'tool_name': agent_decision.tool_name, + 'result': tool_result, + 'args': agent_decision.tool_args +} + +# Формируем блок с результатами инструментов +if tool_result_for_context: + tool_result_block = f"=== РЕЗУЛЬТАТЫ ИНСТРУМЕНТА ===\n{result.data}" + +# Добавляем в полный промпт для Qwen +full_task = f"{system_prompt}\n\n{tool_result_block}\n\n=== ЗАПРОС ===\n{text}" +``` + +### 3. Поддержка всех провайдеров + +Обновлены все AI-провайдеры: +- ✅ **Qwen Code** — получает результаты в `full_task` +- ✅ **GigaChat** — получает результаты в `context_messages` +- ✅ **Opencode** — получает результаты в `context_messages` + +## Что изменилось + +| Было | Стало | +|------|-------| +| ИИ не видел результаты инструментов | ИИ получает результаты в контексте | +| ИИ пытался вызвать инструменты сам | ИИ знает что не может вызывать инструменты | +| Прямой ответ из инструментов | ИИ анализирует результаты и формирует ответ | +| Путаница с доступностью | Явное разделение ответственности | + +## Тестирование + +Проверьте работу с разными инструментами: + +```bash +# Поиск в интернете +"Найди информацию про Python asyncio" + +# RSS новости +"Почитай новости Linux" + +# SSH команды +"Проверь нагрузку на сервере" + +# Cron задачи +"Напомни каждый день делать бэкап" + +# Файловая система +"Покажи содержимое /home/mirivlad/git" +``` + +**Ожидаемое поведение:** +1. AI-агент распознаёт запрос и решает использовать инструмент +2. Инструмент выполняется +3. Результаты передаются в ИИ +4. ИИ анализирует результаты и формирует понятный ответ +5. ИИ **не пытается** вызвать инструмент повторно + +## Файлы изменены + +- `system_prompt.md` — добавлено явное разделение ответственности +- `bot.py` — передача результатов инструментов в ИИ + - `handle_ai_task()` — сохранение результатов в `tool_result_for_context` + - Формирование `tool_result_block` для разных провайдеров + - GigaChat и Opencode — поддержка результатов инструментов + +## Следующие шаги + +Если проблема сохранится: + +1. **Проверить логи** — посмотреть что видит ИИ в контексте +2. **Добавить больше примеров** в системный промпт +3. **Настроить temperature** — возможно ИИ слишком "креативный" +4. **Добавить few-shot примеры** — показать как правильно отвечать + +--- + +**Дата исправления:** 2026-03-09 +**Версия бота:** 0.9.0 diff --git a/MEMORY_CONTEXT.md b/MEMORY_CONTEXT.md new file mode 100644 index 0000000..29c47f2 --- /dev/null +++ b/MEMORY_CONTEXT.md @@ -0,0 +1,264 @@ +# 🧠 Система памяти Telegram бота + +## ✅ Реализовано сохранение контекста разговора + +Система памяти обеспечивает сохранение контекста диалога между перезапусками бота. + +--- + +## 🏗️ Архитектура памяти + +### Уровни памяти: + +1. **STM (Short-Term Memory)** — краткосрочная память + - Последние 5 сообщений в полном объёме + - Хранится в `state.ai_chat_history` (оперативная память) + - Загружается из БД при первом сообщении после перезапуска + +2. **LTM (Long-Term Memory)** — долгосрочная память + - Сообщения 5-20 в сжатом виде (первые 50 символов) + - Хранится в SQLite (`memory.db`) + - Загружается по мере необходимости + +3. **RAG (Retrieval-Augmented Generation)** — векторный поиск + - Семантический поиск по всем сообщениям + - Хранится в ChromaDB (`vector_db/`) + - Используется для релевантного контекста + +4. **Факты** — извлечённые знания о пользователе + - Личные данные (имя, город, профессия) + - Технические предпочтения (языки, инструменты) + - Проекты и директории + - Хранится в SQLite (`memory.db`) + +--- + +## 📁 Базы данных + +| Файл | Назначение | Технология | +|------|-----------|------------| +| `memory.db` | История сообщений, факты, сессии | SQLite | +| `vector_db/` | Векторные эмбеддинги для RAG-поиска | ChromaDB | +| `chroma.sqlite3` | Метаданные ChromaDB | SQLite | + +--- + +## 🔄 Как работает сохранение контекста + +### 1. При отправке сообщения пользователем: + +```python +# В handle_text_message (bot.py) +if not state_manager.is_history_loaded(user_id): + load_history_to_state(user_id, state, state_manager) +``` + +### 2. При обработке ИИ-запроса: + +```python +# В handle_ai_task (bot.py) +save_message(user_id, "user", text) # Сохранение в SQLite + ChromaDB +state.ai_chat_history.append(f"User: {text}") # STM +``` + +### 3. При получении ответа ИИ: + +```python +save_message(user_id, "assistant", full_output) # Сохранение в SQLite + ChromaDB +state.ai_chat_history.append(f"Assistant: {full_output[:500]}") # STM +``` + +### 4. Автоматическое извлечение фактов: + +Каждые 5 сообщений ИИ анализирует диалог и извлекает факты: +- Имя пользователя +- Город проживания +- Профессия +- Технологии +- Предпочтения + +--- + +## 📊 Форматирование контекста для ИИ + +Контекст формируется с градиентной памятью: + +``` +📋 ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ: + [personal]: + - Пользователя зовут Владимир + - Живёт в городе Ангарск + [technical]: + - Использует Python + - Работает с Telegram API + +💬 STM (ПОСЛЕДНИЕ СООБЩЕНИЯ): + Пользователь: Привет! Как дела? + Assistant: Отлично! Чем могу помочь? + ... + +🕰️ LTM (БОЛЕЕ СТАРЫЕ СООБЩЕНИЯ — КРАТКО): + Пользователь: Интересуюсь Python asyncio... + Assistant: asyncio — это библиотека для... + ... + +🔍 RAG (РЕЛЕВАНТНЫЕ СООБЩЕНИЯ ПО ЗАПРОСУ): + [0.85] Пользователь: Я работаю системным администратором... + [0.72] Assistant: Для автоматизации используйте Python... + +================================================== +🧠 ПАМЯТЬ: STM чётко → LTM размыто → RAG глубоко +================================================== +``` + +--- + +## 🛠️ Компоненты системы памяти + +### `memory_system.py` +- `SQLiteMemoryStorage` — хранение сообщений и фактов +- `MemoryManager` — управление памятью +- `Fact` — модель факта +- `Message` — модель сообщения +- `DialogSession` — модель сессии + +### `vector_memory.py` +- `VectorMemoryStorage` — ChromaDB хранилище +- `HybridMemoryManager` — гибридная память (SQLite + Vector) +- `load_history_to_state()` — загрузка истории в state +- `save_message()` — сохранение сообщения +- `get_context()` — получение контекста с градиентной памятью + +### `bot.py` +- `handle_text_message()` — загрузка истории при первом сообщении +- `handle_ai_task()` — обработка ИИ-запросов с памятью + +--- + +## 🧪 Тестирование + +Запуск тестов системы памяти: + +```bash +cd /home/mirivlad/telegram-bot +python test_memory.py +``` + +### Тесты: +1. ✅ Сохранение сообщений в SQLite +2. ✅ Сохранение сообщений в ChromaDB +3. ✅ Загрузка истории из БД в состояние +4. ✅ RAG-поиск по векторной базе +5. ✅ Извлечение фактов +6. ✅ Градиентная память (STM → LTM → RAG) + +--- + +## 🔧 Настройки + +### Размеры памяти: + +```python +# В vector_memory.py +stm_size = 5 # Размер краткосрочной памяти (сообщения) +ltm_size = 15 # Размер долгосрочной памяти (сообщения) +max_messages = 20 # Максимум сообщений в истории +``` + +### Модель эмбеддингов: + +```python +# В vector_memory.py +model_name = "all-MiniLM-L6-v2" # 384 измерения, 90MB +``` + +### Порог извлечения фактов: + +```python +# В bot.py +state.messages_since_fact_extract >= 5 # Каждые 5 сообщений +``` + +--- + +## 📈 Мониторинг + +Получить статистику памяти: + +```python +from vector_memory import get_memory_stats + +stats = get_memory_stats(user_id) +# { +# 'total_sessions': 10, +# 'total_messages': 250, +# 'total_facts': 15, +# 'hybrid_mode': True, +# 'vector_documents': 393, +# 'vector_model': 'all-MiniLM-L6-v2' +# } +``` + +--- + +## ⚠️ Важные замечания + +1. **История загружается один раз** — при первом сообщении после перезапуска бота +2. **Флаг `is_history_loaded`** — предотвращает повторную загрузку +3. **Автосохранение** — каждое сообщение сохраняется в БД немедленно +4. **RAG-поиск** — используется для релевантного контекста по запросу +5. **Компактификация** — при превышении лимита токенов запускается сжатие истории + +--- + +## 🔄 Восстановление контекста после перезапуска + +### До исправления: +- ❌ `state.ai_chat_history` сбрасывался при перезапуске +- ❌ Бот «забывал» предыдущие сообщения +- ❌ Контекст терялся + +### После исправления: +- ✅ История загружается из SQLite при первом сообщении +- ✅ `state.ai_chat_history` восстанавливается из БД +- ✅ RAG-поиск работает по всем сообщениям +- ✅ Факты извлекаются и сохраняются +- ✅ Контекст сохраняется между перезапусками + +--- + +## 📝 Пример использования + +```python +# Сохранение сообщения +from vector_memory import save_message + +save_message(user_id=123, role="user", content="Меня зовут Владимир") +save_message(user_id=123, role="assistant", content="Привет, Владимир!") + +# Загрузка истории +from vector_memory import load_history_to_state +from bot.models.user_state import UserState, StateManager + +state = UserState() +state_manager = StateManager() +load_history_to_state(user_id=123, state, state_manager) + +# Получение контекста с градиентной памятью +from vector_memory import get_context + +context = get_context(user_id=123, query="Python", stm_size=5, ltm_size=15) +print(context) + +# RAG-поиск +from vector_memory import search_memory + +results = search_memory(user_id=123, query="asyncio", limit=5) +for msg, score in results: + print(f"[{score:.2f}] {msg.role}: {msg.content}") +``` + +--- + +*Документация для разработчиков Telegram CLI Bot* +*Версия: 0.8.0* diff --git a/MEMORY_SYSTEM.md b/MEMORY_SYSTEM.md new file mode 100644 index 0000000..5b01bd9 --- /dev/null +++ b/MEMORY_SYSTEM.md @@ -0,0 +1,124 @@ +# 🧠 Система памяти для ИИ-чата + +Простая и надёжная система памяти на **SQLite** для Telegram CLI бота с ИИ-агентом. + +--- + +## 📋 Обзор + +Система памяти позволяет ИИ-агенту: +- Помнить контекст между сессиями +- Запоминать факты о пользователе (имя, предпочтения, проекты) +- Искать в истории диалогов по запросу +- Предоставлять персонализированные ответы + +--- + +## 🏗️ Архитектура + +``` +┌─────────────────────────────────────────────────────────┐ +│ Telegram Bot (bot.py) │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌───────────┐ │ +│ │ handle_ai_ │───▶│ memory_ │───▶│ qwen_ │ │ +│ │ task() │ │ system │ │ integration│ │ +│ └─────────────┘ └───────┬──────┘ └───────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ SQLiteStorage │ │ +│ │ (facts, │ │ +│ │ messages, │ │ +│ │ sessions) │ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 🗄️ Структура базы данных + +### Таблицы: + +**facts** — факты о пользователе: +- `user_id` — ID пользователя +- `fact_type` — тип факта (personal, technical, project, preference) +- `content` — текст факта +- `confidence` — уверенность (0.0–1.0) + +**messages** — история сообщений: +- `user_id` — ID пользователя +- `role` — "user" или "assistant" +- `content` — текст сообщения +- `session_id` — ID сессии + +**sessions** — сессии диалогов: +- `user_id` — ID пользователя +- `started_at` / `ended_at` — время сессии +- `message_count` — количество сообщений +- `summary` — краткое резюме (опционально) + +--- + +## 🔧 Использование + +### Сохранение сообщения: + +```python +from memory_system import save_ai_message + +# Сохранить сообщение пользователя +save_ai_message(user_id=123456, role="user", content="Меня зовут Владимир") + +# Сохранить ответ ИИ +save_ai_message(user_id=123456, role="assistant", content="Приятно познакомиться!") +``` + +### Получение контекста: + +```python +from memory_system import format_memory_context + +# Получить профиль + последние сообщения + релевантные факты +context = format_memory_context(user_id=123456, query="Где мои файлы?") +``` + +### Профиль пользователя: + +```python +from memory_system import get_user_profile_summary + +profile = get_user_profile_summary(user_id=123456) +# Профиль пользователя: +# • Пользователя зовут Владимир +# • Использует Python +# • Проект в ~/git/telegram-cli-bot +``` + +--- + +## 🎯 Извлечение фактов + +Система автоматически извлекает факты из сообщений: + +| Паттерн | Пример | Извлекаемый факт | +|---------|--------|------------------| +| `меня зовут ...` | "Меня зовут Владимир" | `PERSONAL: Пользователя зовут Владимир` | +| `я использую ...` | "Я использую Python" | `TECHNICAL: Использует Python` | +| `мой проект ...` | "Мой проект в ~/git/foo" | `PROJECT: Есть проект foo` | + +--- + +## 📁 Файлы + +- `memory_system.py` — основная система памяти +- `memory.db` — SQLite база данных (создаётся автоматически) + +--- + +## 🚀 Настройка + +Никакой дополнительной настройки не требуется! Система работает из коробки. + +При первом запуске автоматически создаётся `memory.db` с нужными таблицами. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a061276 --- /dev/null +++ b/README.md @@ -0,0 +1,409 @@ +# Telegram CLI Bot + +Бот для выполнения CLI команд на вашем ПК через Telegram с многоуровневым меню и гибкой настройкой. + +**Версия:** 0.8.0 + +## Возможности + +- 🖥️ **Выполнение CLI команд** - запуск любых команд от имени пользователя +- 📋 **Многоуровневое меню** - навигация через inline-кнопки +- ⚙️ **Настройка из бота** - изменение имени, описания, иконки прямо в диалоге +- 🎯 **Предустановленные команды** - готовые команды для файловой системы, поиска, системы и сети +- 👥 **Управление доступом** - ограничение круга пользователей +- 🔧 **Легкое добавление команд** - простая регистрация новых команд через код +- 🧠 **ИИ-агент с памятью** - чат с Qwen Code с контекстом и семантическим поиском +- 🔍 **Векторная память** - поиск по истории диалогов на ChromaDB (RAG) +- 📦 **Автоматическая установка** - скрипт install.sh ставит все зависимости + +--- + +## 🚀 Быстрый старт + +### 1. Установка (первый запуск) + +```bash +cd /path/to/telegram-cli-bot +./install.sh +``` + +Скрипт автоматически: +- Проверит Python, pip, Node.js, npm +- Установит qwen-code (для ИИ-агента) +- Создаст виртуальное окружение +- Установит все зависимости +- Создаст .env из примера + +### 2. Настройка + +Отредактируйте `.env` и укажите токен бота: + +```bash +nano .env +# TELEGRAM_BOT_TOKEN=ваш_токен_от_BotFather +``` + +### 3. Запуск + +```bash +./run.sh +``` + +--- + +## 🔧 Установка как systemd сервис (автозапуск) + +Для работы бота в фоновом режиме и автоматического запуска после перезагрузки: + +### 1. Настройка .env + +Убедитесь что `.env` файл существует и содержит все необходимые переменные: + +```bash +cp .env.example .env +nano .env +``` + +### 2. Установка сервиса + +```bash +sudo ./install-systemd-service.sh +``` + +Скрипт: +- Создаст systemd сервис в `/etc/systemd/system/telegram-bot.service` +- Включит автозапуск при загрузке +- Настроит логирование через journalctl + +### 3. Управление сервисом + +```bash +# Запуск +sudo systemctl start telegram-bot + +# Остановка +sudo systemctl stop telegram-bot + +# Перезапуск +sudo systemctl restart telegram-bot + +# Статус +sudo systemctl status telegram-bot + +# Автозапуск при загрузке +sudo systemctl enable telegram-bot + +# Просмотр логов +sudo journalctl -u telegram-bot -f +``` + +--- + +## 📦 Подробная установка + +### Требования + +- **Python 3.10+** +- **pip** (менеджер пакетов Python) +- **Node.js 18+** (для qwen-code, опционально) +- **npm** (менеджер пакетов Node.js) + +### Шаг 1: Клонирование + +```bash +cd /home/mirivlad/git +git clone telegram-cli-bot +cd telegram-cli-bot +``` + +### Шаг 2: Запуск установщика + +```bash +./install.sh +``` + +**Что делает install.sh:** + +| Шаг | Действие | +|-----|----------| +| 1 | Проверяет Python, pip | +| 2 | Проверяет Node.js, npm (предлагает установить если нет) | +| 3 | Устанавливает `qwen-code` через `npm install -g` | +| 4 | Создаёт/обновляет venv | +| 5 | Устанавливает pip зависимости из requirements.txt | +| 6 | Создаёт .env из .env.example (если нет) | +| 7 | Сохраняет версию в `.installed` | + +### Шаг 3: Настройка .env + +```bash +# Отредактируйте .env +nano .env +``` + +Обязательные параметры: +```bash +TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz +ALLOWED_USERS=ваш_telegram_id +``` + +Ваш Telegram ID можно узнать через @userinfobot. + +### Шаг 4: Запуск + +```bash +./run.sh +``` + +--- + +## 🔄 Обновление + +```bash +# Просто запустите установщик снова +./install.sh +``` + +Скрипт определит что это обновление и: +- Обновит qwen-code +- Обновит pip зависимости +- Сохранит новую версию + +--- + +### Команды бота + +| Команда | Описание | +|---------|----------| +| `/start` | Запустить бота, показать главное меню | +| `/help` | Показать справку | +| `/settings` | Открыть настройки бота | + +### Главное меню + +- **🖥️ Выполнить команду** - ввод произвольной CLI команды +- **📋 Предустановленные команды** - меню с готовыми командами +- **⚙️ Настройки бота** - конфигурация бота +- **ℹ️ О боте** - информация о боте + +### Предустановленные команды + +#### Файловая система +- `ls -la` - список файлов +- `pwd` - текущая директория +- `df -h` - свободное место +- `du -sh *` - размер папок + +#### Поиск +- `find . -name` - поиск файлов +- `grep пример` - поиск по содержимому +- `which command` - путь к командам + +#### Система +- `top -n 1` - процессы +- `ps aux` - список процессов +- `free -h` - использование памяти +- `uname -a` - информация о системе +- `uptime` - время работы + +#### Сеть +- `ip addr` - сетевые интерфейсы +- `ping google` - проверка связи +- `netstat` - сетевые подключения +- `curl ifconfig.me` - внешний IP + +### Настройка бота + +Через меню **⚙️ Настройки бота**: + +1. **📝 Изменить имя бота** - новое отображаемое имя +2. **📄 Изменить описание** - описание бота +3. **🎨 Изменить иконку** - emoji для бота +4. **👥 Управление доступом** - whitelist пользователей + +## Добавление новых команд + +### Быстрое добавление через меню + +Найдите функцию `init_menus()` в `bot.py` и добавьте новую кнопку: + +```python +# В нужное меню добавьте: +MenuItem("🔥 Ваша команда", "cmd_your", command="ваша_команда", icon="🔥"), +``` + +### Пример добавления команды для git: + +```python +# В init_menus() добавьте новое меню: +git_menu = [ + MenuItem("git status", "cmd_git_status", command="git status", icon="📊"), + MenuItem("git log", "cmd_git_log", command="git log --oneline -10", icon="📜"), + MenuItem("⬅️ Назад", "preset", icon="⬅️"), +] +menu_builder.add_menu("git", git_menu) + +# И добавьте кнопку в preset_menu: +MenuItem("🔗 Git", "git_menu", icon="🔗"), +``` + +### Продвинутое: регистрация через декоратор + +Используйте `command_registry` для сложной логики: + +```python +@command_registry.register("my_command") +async def my_custom_command(update, context): + # Ваша логика + pass +``` + +## Конфигурация + +Все настройки хранятся в файле `.env`: + +```bash +# Токен бота +TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz + +# Настройки бота +BOT_NAME=CLI Assistant +BOT_DESCRIPTION=Бот для выполнения CLI команд +BOT_ICON_EMOJI=🤖 + +# Разрешённые пользователи (список ID через запятую) +# Пустой список = доступ открыт для всех +ALLOWED_USERS=123456789,987654321 + +# Рабочая директория для команд +WORKING_DIRECTORY=/home/user +``` + +| Параметр | Описание | +|----------|----------| +| `TELEGRAM_BOT_TOKEN` | Токен бота от @BotFather | +| `BOT_NAME` | Отображаемое имя бота | +| `BOT_DESCRIPTION` | Описание бота | +| `BOT_ICON_EMOJI` | Emoji-иконка | +| `ALLOWED_USERS` | Список разрешённых user ID через запятую (пусто = все) | +| `WORKING_DIRECTORY` | Рабочая директория для выполнения команд | + +### Настройка GigaChat API (Сбер) + +Бот поддерживает альтернативный AI-провайдер — **GigaChat** от Сбера. Для использования: + +1. Получите credentials в [SberDevices Developer Portal](https://developers.sber.ru/docs/ru/gigachat) +2. Добавьте в `.env`: + +```bash +# GigaChat API (Сбер) +GIGACHAT_CLIENT_ID=ваш-client-id-uuid +GIGACHAT_CLIENT_SECRET=ваш-client-secret +GIGACHAT_SCOPE=GIGACHAT_API_PERS +GIGACHAT_AUTH_URL=https://ngw.devices.sberbank.ru:9443/api/v2/oauth +GIGACHAT_MODEL=GigaChat-Pro +``` + +3. Перезапустите бота + +**Параметры:** + +| Параметр | Описание | +|----------|----------| +| `GIGACHAT_CLIENT_ID` | ID клиента (UUID из SberDevices Portal) | +| `GIGACHAT_CLIENT_SECRET` | Секрет клиента | +| `GIGACHAT_SCOPE` | Область доступа (обычно `GIGACHAT_API_PERS`) | +| `GIGACHAT_AUTH_URL` | URL авторизации OAuth | +| `GIGACHAT_MODEL` | Модель: `GigaChat-Pro` или `GigaChat-Max` | + +**Инструмент GigaChat:** +- `gigachat` — генерация ответов через GigaChat API +- Используется как альтернатива Qwen Code +- Поддерживает системные промпты, температуру, лимит токенов + +### Настройка YandexGPT API (Яндекс) + +Для использования YandexGPT добавьте в `.env`: + +```bash +# YandexGPT API (Яндекс) +YANDEX_FOLDER_ID=ваш-folder-id +YANDEX_API_KEY=ваш-api-key +YANDEX_MODEL=yandexgpt/latest +``` + +Получите credentials в [Yandex Cloud Console](https://cloud.yandex.ru/docs/fundamentals/concepts/infrastructure). + +⚠️ **Важно:** После изменения `.env` требуется перезапуск бота. + +## Безопасность + +⚠️ **Важные предупреждения:** + +1. Бот выполняет команды от имени запустившего пользователя +2. Не запускайте бота от root +3. Ограничьте доступ через `ALLOWED_USERS` в `.env`: + ```bash + ALLOWED_USERS=123456789,987654321 + ``` + Ваш ID можно узнать через @userinfobot +4. Будьте осторожны с деструктивными командами (`rm`, `dd`, etc.) +5. **Никогда не передавайте файл `.env`** — он содержит токен бота +6. Добавьте `.env` в `.gitignore` (уже сделано) + +## Логи + +Логи сохраняются в `bot.log` в директории бота. + +## Структура проекта + +``` +telegram-cli-bot/ +├── bot.py # Точка входа (1411 строк) +├── bot/ # Модульная структура +│ ├── config.py # Конфигурация и глобальные объекты +│ ├── models/ # Модели данных +│ │ ├── server.py # Server, ServerManager +│ │ ├── session.py # SSH/Local сессии и менеджеры +│ │ └── user_state.py # UserState, StateManager +│ ├── utils/ # Утилиты +│ │ ├── cleaners.py # Очистка ANSI-кодов +│ │ ├── formatters.py # Форматирование сообщений +│ │ ├── decorators.py # Декоратор @check_access +│ │ └── ssh_readers.py # Чтение SSH/PTY вывода +│ ├── keyboards/ # Клавиатуры +│ │ └── menus.py # MenuItem, MenuBuilder +│ ├── handlers/ # Обработчики событий +│ │ ├── commands.py # /start, /menu, /help, /settings +│ │ └── callbacks.py # Callback от меню +│ └── services/ # Бизнес-логика +│ └── command_executor.py # Выполнение CLI команд +├── memory_system.py # Система памяти (SQLite) +├── vector_memory.py # Векторная память (ChromaDB + RAG) +├── qwen_integration.py # Интеграция с Qwen Code +├── install.sh # Универсальный установщик +├── run.sh # Скрипт запуска +├── requirements.txt # Зависимости Python +├── .env # Конфигурация (не коммитить!) +├── .env.example # Пример конфигурации +├── bot.log # Лог файл +└── README.md # Документация +``` + +**Модульная архитектура:** +- **models** — модели данных и менеджеры +- **utils** — вспомогательные функции +- **handlers** — обработчики команд Telegram +- **services** — бизнес-логика выполнения команд +- **keyboards** — построение inline-клавиатур + +## Требования + +- **Python 3.10+** +- **Node.js 18+** (опционально, для qwen-code) +- Библиотеки: `python-telegram-bot`, `chromadb`, `sentence-transformers` +- Доступ к Telegram API + +## Лицензия + +MIT diff --git a/SYSTEM_PROMPT.md b/SYSTEM_PROMPT.md new file mode 100644 index 0000000..33233d2 --- /dev/null +++ b/SYSTEM_PROMPT.md @@ -0,0 +1,183 @@ +# Системный промпт для Telegram CLI Bot + +## 📋 Обзор + +Системный промпт — это набор инструкций для Qwen AI, который определяет: +- Роль и задачи бота +- Доступные инструменты (capabilities) +- Правила автономного использования инструментов +- Форматы ответов + +## 🏗️ Архитектура + +``` +system_prompt.md # Файл с системным промптом + ↓ +qwen_integration.py # Загружает промпт при инициализации + ↓ +bot.py (handle_ai_task) # Добавляет промпт к каждому запросу + ↓ +Qwen Code CLI # Получает промпт + контекст + запрос +``` + +## 🔧 Как это работает + +### 1. Загрузка промпта + +При первом запросе к Qwen менеджер загружает промпт из файла: + +```python +system_prompt = qwen_manager.load_system_prompt() +``` + +Промпт кэшируется в памяти для последующих запросов. + +### 2. Формирование полного запроса + +Каждый запрос к Qwen включает: + +``` +[СИСТЕМНЫЙ ПРОМПТ] + ↓ +[КОНТЕКСТ ПАМЯТИ] (из ChromaDB RAG) + ↓ +[ИСТОРИЯ ДИАЛОГА] (последние 20 сообщений) + ↓ +[ЗАПРОС ПОЛЬЗОВАТЕЛЯ] +``` + +### 3. Автономное использование инструментов + +AI агент анализирует запрос пользователя и **сам решает** какой инструмент использовать: + +| Триггеры | Инструмент | Пример | +|----------|------------|--------| +| "прочитай", "покажи файл", "ls", "cat" | `file_system_tool` | "покажи файлы в директории" | +| "найди", "погугли", "узнай" | `ddgs_search` | "найди информацию про asyncio" | +| "новости", "rss", "лента" | `rss_reader` | "что нового в Linux?" | +| "выполни команду", "ssh" | `ssh_executor` | "проверь нагрузку на сервере" | +| "напомни", "запланируй" | `cron_manager` | "напомни каждый день в 9 утра" | + +## 📁 Структура system_prompt.md + +### Разделы промпта: + +1. **Роль и задачи** — кто такой бот, для кого работает +2. **Доступные инструменты** — описание каждого инструмента: + - Название и функция + - Когда использовать + - Параметры + - Примеры вызова +3. **Принципы работы** — автономность, контекст, прозрачность, приоритеты +4. **Форматы ответов** — как оформлять результаты +5. **Важные правила** — технические ограничения, форматирование +6. **Примеры диалогов** — few-shot prompting для лучшего понимания + +## 🎯 Приоритеты инструментов + +При принятии решения AI следует приоритету: + +1. **File System** — если операция с файлами/директориями +2. **SSH** — если явная системная задача на сервере +3. **Cron** — если планирование/напоминание +4. **Поиск (DDGS)** — если нужны свежие данные из интернета +5. **RSS** — если новости из подписанных лент + +## ⚠️ Блокировка: Реакция на действия бота + +**Важное правило:** Не активируй инструменты если пользователь говорит о **прошлых действиях бота**, а не просит сделать что-то новое. + +**❌ НЕ активируй инструменты если пользователь:** +- Комментирует прошлые действия: "ты опять ddgs запустил", "зачем ты rss включил" +- Критикует срабатывание: "перестань", "хватит", "не надо" +- Указывает на ошибку: "баг", "ошибка", "неправильно" +- Говорит о прошлом: "я не просил", "я не говорил" +- Реагирует на результат: "это не то", "я вижу что ты..." + +**✅ Активируй инструменты только если:** +- Пользователь явно просит сделать что-то **новое** ("найди...", "проверь...", "запусти...") +- В запросе есть **триггерные слова** из раздела инструментов +- Пользователь продолжает тему и нужен **новый запрос** к инструменту + +**Правильное поведение при ошибке:** Извиниться кратко, объяснить что исправишь логику, но **не запускать инструмент повторно**. + +## 🔄 Обновление промпта + +Для изменения поведения бота: + +1. Отредактируйте `system_prompt.md` +2. Перезапустите бота (или оставьте как есть — кэш обновится при следующем запросе) +3. Для сброса кэша: `qwen_manager._system_prompt = None` + +## 💡 Советы по настройке + +### Добавление нового инструмента + +1. Опишите инструмент в разделе "Доступные инструменты": + ```markdown + ### N. Название (`tool_name`) + + **Назначение:** ... + + **Когда использовать:** + - Триггер 1 + - Триггер 2 + + **Параметры:** + - `param1` (type): описание + + **Пример вызова:** + ```python + tool_name(param1="value") + ``` + ``` + +2. Добавьте триггеры в `bot/ai_agent.py`: + ```python + NEW_TOOL_TRIGGERS = ['триггер1', 'триггер2'] + ``` + +3. Реализуйте логику проверки в `_should_use_new_tool()` + +4. Добавьте обработку в `decide()` + +### Изменение поведения + +- **Более агрессивное использование инструментов:** уменьшите пороги confidence в `ai_agent.py` +- **Более консервативное:** увеличьте пороги или добавьте больше условий +- **Изменение формата ответов:** отредактируйте раздел "Формат ответов" в промпте + +## 📊 Мониторинг + +Для отслеживания использования инструментов: + +```python +# История использования инструментов +ai_agent.get_tool_history(limit=10) + +# Предпочтения пользователя +ai_agent.get_user_preference(user_id, 'preferred_tool') +``` + +## ⚠️ Ограничения + +- **Размер контекста:** до 200K токенов (безопасный лимит) +- **Время выполнения:** 5 минут максимум на задачу +- **Кэширование:** системный промпт кэшируется в памяти менеджера + +## 📈 Версии + +| Версия | Изменения | +|--------|-----------| +| 0.8.0 | Исправление OAuth + память файлов + совместимость PTB 20.7+ | +| 0.7.1 | AI Provider Manager (Qwen Code, GigaChat), блокировка на реакции бота | +| 0.7.0 | Векторная память (ChromaDB RAG), ИИ-агент с памятью, File System Tool | +| 0.6.0 | Автоматическая установка, SOCKS5 прокси | +| 0.5.3 | Базовая реализация системного промпта | +| 0.5.2 | AI агент с авто-выбором инструментов | +| 0.5.1 | Интеграция RSS reader | +| 0.5.0 | Интеграция DDGS search | + +--- + +*Документация для разработчиков Telegram CLI Bot* diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..dd3709d --- /dev/null +++ b/TODO.md @@ -0,0 +1,97 @@ +# TODO — Telegram CLI Bot + +## ✅ Реализовано в версии 0.8.0 + +### Команда /restart_bot +- [x] Запрос пароля sudo у пользователя +- [x] Отключение ИИ на время ввода пароля +- [x] Выполнение `sudo systemctl restart telegram-bot` +- [x] Отправка уведомления с меню после перезапуска + +### Исправление SSH +- [x] Исправлено чтение вывода SSH команд (wait_and_read_ssh) +- [x] Параллельное чтение stdout/stderr с ожиданием завершения +- [x] Корректная передача stderr в результат + +--- + +## 🧪 Тестирование + +### SSH серверы — CRUD операции + +- [ ] **Create** — Добавление нового сервера через меню + - [ ] Ввод имени (латиница, без пробелов) + - [ ] Ввод host (IP или домен) + - [ ] Ввод port + - [ ] Ввод user + - [ ] Ввод password (опционально) + - [ ] Ввод tags (опционально) + - [ ] Проверка сохранения в `.env` + - [ ] Проверка появления в меню серверов + +- [ ] **Read** — Просмотр списка серверов + - [ ] Отображение всех серверов в меню + - [ ] Корректное отображение `display_name` + - [ ] Отображение кнопок управления (⚙️) для не-local серверов + +- [ ] **Update** — Редактирование сервера + - [ ] Выбор сервера через кнопку ⚙️ + - [ ] Изменение host + - [ ] Изменение port + - [ ] Изменение user + - [ ] Изменение tags + - [ ] Изменение password + - [ ] Проверка применения изменений + +- [ ] **Delete** — Удаление сервера + - [ ] Выбор сервера через кнопку ⚙️ + - [ ] Подтверждение удаления + - [ ] Проверка удаления из `.env` + - [ ] Проверка исчезновения из меню + - [ ] Проверка невозможности удаления `local` сервера + +### SSH подключение — выполнение команд + +- [x] **Подключение к серверу** + - [x] Выбор сервера из меню + - [x] Проверка смены `current_server` в состоянии + - [x] Проверка сброса `working_directory` при смене сервера + +- [x] **Выполнение команд по SSH** + - [x] Предустановленные команды (`ls -la`, `pwd`, `df -h`, etc.) + - [x] Команды из сообщения (текстовые) + - [x] Проверка выполнения в правильной директории + - [ ] Проверка обработки `sudo` запросов пароля + - [ ] Проверка обработки `confirm` запросов (y/n) + +- [ ] **Обработка ошибок SSH** + - [ ] Недоступный сервер (timeout) + - [ ] Неверный пароль + - [ ] Отсутствующий SSH ключ + - [ ] Ошибки выполнения команд + +- [ ] **Длинный вывод** + - [ ] Разбивка на сообщения с кнопками "Продолжить/Отменить" + - [ ] Корректное экранирование Markdown + - [ ] Работа кнопок продолжения + - [ ] Отмена вывода + +--- + +## 💡 Идеи для будущих версий + +### v0.9.0 +- [ ] Синхронизация `.env` между несколькими экземплярами бота +- [ ] Экспорт/импорт конфигурации серверов +- [ ] Группировка серверов по тегам в меню +- [ ] Веб-интерфейс для управления ботом +- [ ] REST API для внешнего управления +- [ ] Плагины для расширения функциональности + +--- + +## 📝 Заметки + +- Версия 0.8.0 — стабильная, исправление SSH и команда /restart_bot +- Критические баги исправляются в hotfix ветках +- Новые функции — только в minor версиях diff --git a/TOOLS.md b/TOOLS.md new file mode 100644 index 0000000..046591b --- /dev/null +++ b/TOOLS.md @@ -0,0 +1,342 @@ +# 🛠️ Инструменты Telegram CLI Bot + +Инструменты — это capabilities, которые бот может использовать **автономно** для выполнения задач пользователя (Agentic AI подход). + +## 📋 Архитектура + +``` +bot/ +├── tools/ +│ ├── __init__.py # Реестр инструментов (ToolsRegistry) +│ ├── ddgs_tool.py # Поиск в интернете через DuckDuckGo +│ ├── rss_tool.py # Чтение RSS/Atom лент +│ ├── ssh_tool.py # Выполнение команд по SSH +│ └── cron_tool.py # Управление задачами по расписанию +└── ai_agent.py # AI агент для принятия решений +``` + +## 🏗️ Как это работает + +### 1. Реестр инструментов + +`ToolsRegistry` — синглтон, который хранит все доступные инструменты: + +```python +from bot.tools import tools_registry + +# Получить инструмент +tool = tools_registry.get('ddgs_tool') + +# Выполнить инструмент +result = await tools_registry.execute_tool('ddgs_tool', query='python tutorial', max_results=5) +``` + +### 2. AI Агент + +`AIAgent` анализирует сообщение пользователя и решает, какой инструмент использовать: + +```python +from bot.ai_agent import ai_agent + +# Принять решение +decision = await ai_agent.decide("Найди информацию про Python 3.12") + +if decision.should_use_tool: + result = await ai_agent.execute_tool(decision.tool_name, **decision.tool_args) +``` + +### 3. Автономное использование + +Бот **автоматически** проверяет решение агента при обработке сообщений в AI-режиме: + +1. Пользователь пишет сообщение в AI-режиме +2. Бот проверяет триггеры (ключевые слова) +3. Если найден триггер → выполняет инструмент +4. Возвращает результат пользователю + +## 📦 Доступные инструменты + +### 🔍 DDGS Search (`ddgs_tool`) + +Поиск информации в интернете через DuckDuckGo. + +**Использование:** +```python +result = await tools_registry.execute_tool( + 'ddgs_tool', + query='python async await', + max_results=10 +) +``` + +**Триггеры для авто-использования:** +- "найди", "поиск", "погугли", "узнай" +- "что нового", "последние новости" +- "как сделать", "руководство", "tutorial" +- "что такое", "как работает", "где найти" + +**Примеры:** +``` +Пользователь: Найди информацию про Rust programming +Бот: 🔍 **Результаты поиска:** + 1. **Rust - Official Website** + https://www.rust-lang.org + Rust is a programming language ... +``` + +### 📰 RSS Reader (`rss_tool`) + +Чтение RSS/Atom новостных лент. + +**Действия:** + +| Действие | Описание | Параметры | +|----------|----------|-----------| +| `fetch` | Получить свежие новости | - | +| `list` | Показать новости | `limit`, `feed_id`, `search`, `undigested_only` | +| `add_feed` | Добавить ленту | `url`, `title` | +| `list_feeds` | Список лент | - | +| `mark_digest` | Отметить как прочитанное | `news_id` | + +**Использование:** +```python +# Получить новости +result = await tools_registry.execute_tool( + 'rss_tool', + action='list', + limit=10, + undigested_only=True +) + +# Добавить ленту +result = await tools_registry.execute_tool( + 'rss_tool', + action='add_feed', + url='https://lwn.net/headlines/newrss', + title='LWN.net' +) +``` + +**Триггеры для авто-использования:** +- "новости", "rss", "лента", "feed" +- "дайджест", "что нового в linux" +- "новости it", "tech news" +- "почитай новости", "свежие статьи" + +**Примеры:** +``` +Пользователь: Почитай новости +Бот: 📰 **Последние новости:** + 1. Linux Kernel 6.8 Released + 📅 2024-03-10 14:30:00 + 🔗 https://... +``` + +### 🖥️ SSH Executor (`ssh_tool`) + +Выполнение команд на удалённых серверах по SSH. + +**Использование:** +```python +result = await tools_registry.execute_tool( + 'ssh_tool', + command='uptime', + server='home', + timeout=30 +) +``` + +**Конфигурация серверов:** +```python +# Серверы настраиваются в ssh_tool.py +servers = { + 'home': { + 'host': '192.168.1.54', + 'port': 22, + 'username': 'mirivlad', + 'password': '***' + } +} +``` + +**Триггеры для авто-использования:** +- "выполни команду", "ssh", "запусти на сервере" +- "проверь сервер", "посмотри логи" +- "покажи процесс", "сколько места", "df", "top" +- "systemctl", "journalctl", "uptime" + +**Примеры:** +``` +Пользователь: Проверь нагрузку на сервере +Бот: 🖥️ **SSH: home** + **Команда:** `uptime` + + **Вывод:** + ``` + 14:30:00 up 10 days, 2:30, 1 user, load average: 0.15, 0.10, 0.05 + ``` + + ✅ **Успешно** +``` + +### ⏰ Cron Manager (`cron_tool`) + +Управление периодическими задачами пользователя. + +**Действия:** + +| Действие | Описание | Параметры | +|----------|----------|-----------| +| `list` | Показать все задачи | - | +| `add` | Добавить задачу | `name`, `command`, `schedule` | +| `remove` | Удалить задачу | `job_id` | +| `toggle` | Включить/выключить | `job_id`, `enabled` | +| `run` | Выполнить немедленно | `job_id` | + +**Форматы расписаний:** +- `*/5 * * * *` — каждые 5 минут +- `@hourly` — каждый час +- `@daily` — каждый день +- `@weekly` — каждую неделю + +**Использование:** +```python +# Добавить задачу +result = await tools_registry.execute_tool( + 'cron_tool', + action='add', + name='Daily Backup', + command='/home/user/backup.sh', + schedule='@daily' +) +``` + +**Триггеры для авто-использования:** +- "напомни", "запланируй", "каждый день" +- "периодически", "по расписанию", "автоматически" +- "создай задачу", "добавь в cron" + +**Примеры:** +``` +Пользователь: Напомни мне каждый день делать бэкап +Бот: ⏰ **Ваши задачи:** + + ✅ **Daily Backup** + Команда: `/home/user/backup.sh` + Расписание: @daily + Следующий запуск: 2024-03-11 00:00:00 +``` + +## ➕ Добавление нового инструмента + +1. Создайте файл `bot/tools/your_tool.py`: + +```python +from bot.tools import BaseTool, ToolResult, register_tool + +@register_tool +class YourTool(BaseTool): + name = "your_tool" + description = "Описание инструмента" + category = "category" + + async def execute(self, **kwargs) -> ToolResult: + # Логика инструмента + return ToolResult(success=True, data={'result': 'data'}) +``` + +2. Инструмент автоматически зарегистрируется в реестре + +3. Добавьте триггеры в `bot/ai_agent.py`: + +```python +YOUR_TRIGGERS = ['триггер1', 'триггер2'] + +def _should_use_your_tool(self, message: str) -> tuple[bool, float]: + message_lower = message.lower() + for trigger in YOUR_TRIGGERS: + if trigger in message_lower: + return True, 0.9 + return False, 0.0 +``` + +4. Добавьте форматирование в `bot.py`: + +```python +elif tool_name == 'your_tool': + return f"Результат: {result.data}" +``` + +## 🔧 Установка зависимостей + +```bash +cd ~/git/telegram-cli-bot +pip install -r requirements.txt +``` + +## 🎯 Приоритеты инструментов + +Приоритет проверки (от высшего к низшему): + +1. **SSH Executor** — системные задачи +2. **Cron Manager** — планирование задач +3. **DDGS Search** — поиск информации +4. **RSS Reader** — чтение новостей + +## 📝 Примеры использования + +### Поиск в интернете + +``` +Пользователь: Найди информацию про Python 3.12 +Бот: 🔍 **Результаты поиска:** + 1. **Python 3.12.0 Documentation** + https://docs.python.org/3.12/ + The official home of the Python Programming Language +``` + +### Чтение новостей + +``` +Пользователь: Почитай новости +Бот: 📰 **Последние новости:** + 1. Linux Kernel 6.8 Released + 📅 2024-03-10 14:30:00 + 🔗 https://... +``` + +### SSH команда + +``` +Пользователь: Проверь нагрузку на сервере +Бот: 🖥️ **SSH: home** + **Команда:** `uptime` + + **Вывод:** + ``` + 14:30:00 up 10 days, 2:30, 1 user, load average: 0.15, 0.10, 0.05 + ``` + + ✅ **Успешно** +``` + +### Управление задачами + +``` +Пользователь: Напомни мне каждый день делать бэкап +Бот: ⏰ **Ваши задачи:** + + ✅ **Daily Backup** + Команда: `/home/user/backup.sh` + Расписание: @daily + Следующий запуск: 2024-03-11 00:00:00 +``` + +## 🔐 Безопасность + +Для персонального бота приоритет на **удобстве и функциональности**: +- Инструменты доступны всегда +- Бот использует их без прямой команды +- Agentic AI — бот сам решает когда нужен инструмент + +**Важно:** При добавлении новых серверов в SSH не забывайте обновлять конфигурацию в `bot/tools/ssh_tool.py`. diff --git a/VECTOR_RAG_MEMORY.md b/VECTOR_RAG_MEMORY.md new file mode 100644 index 0000000..effb90d --- /dev/null +++ b/VECTOR_RAG_MEMORY.md @@ -0,0 +1,244 @@ +# 🧠 Векторная память с RAG и градиентной памятью + +Гибридная система памяти для ИИ-чата на **SQLite + ChromaDB** с **градиентной детализацией**. + +--- + +## 📋 Обзор + +Система использует **трёхуровневую архитектуру** памяти: + +1. **STM (Short-Term Memory)** — последние 5 сообщений — **полностью** +2. **LTM (Long-Term Memory)** — сообщения 6-20 — **сжато (50 символов)** +3. **RAG (Retrieval-Augmented Generation)** — глубокий поиск через ChromaDB + +**Модель эмбеддингов:** `all-MiniLM-L6-v2` +- Размер: 90MB +- Измерения: 384 +- Скорость: ~1000 эмбеддингов/сек на CPU +- Потребление памяти: <200MB + +--- + +## 🏗️ Архитектура + +``` +┌─────────────────────────────────────────────────────────┐ +│ Telegram Bot (bot.py) │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌───────────┐ │ +│ │ handle_ai_ │───▶│ hybrid_ │───▶│ qwen_ │ │ +│ │ task() │ │ memory_ │ │ integration│ │ +│ └─────────────┘ │ manager │ └───────────┘ │ +│ └───────┬──────┘ │ +│ ┌────────────────┼────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ │ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ │ +│ │ SQLiteStorage │ │ VectorStorage │ │ │ +│ │ (facts, │ │ (ChromaDB, │ │ │ +│ │ messages, │ │ sentence- │ │ │ +│ │ sessions) │ │ transformers) │ │ │ +│ └──────────────────┘ └──────────────────┘ │ │ +│ │ │ +└────────────────────────────────────────────┼───────────┘ +``` + +### Градиентная память (как работает): + +``` +┌──────────────────────────────────────────────────────┐ +│ КОНТЕКСТ ДЛЯ ИИ │ +├──────────────────────────────────────────────────────┤ +│ 📋 ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ (факты из SQLite) │ +├──────────────────────────────────────────────────────┤ +│ 💬 STM: Последние 5 сообщений (ПОЛНОСТЬЮ) │ +│ • Чёткая память о недавнем │ +│ • Полный текст без сокращений │ +├──────────────────────────────────────────────────────┤ +│ 🕰️ LTM: Сообщения 6-20 (СЖАТО: 50 символов) │ +│ • Размытая память о прошлом │ +│ • Краткое содержание для контекста │ +├──────────────────────────────────────────────────────┤ +│ 🔍 RAG: Релевантные сообщения по запросу │ +│ • Глубокий поиск через ChromaDB │ +│ • Семантический поиск по всем сообщениям │ +└──────────────────────────────────────────────────────┘ +``` + +--- + +## 🔧 Компоненты + +### vector_memory.py + +**VectorMemoryStorage** — векторное хранилище: +- `add_message()` — добавить сообщение с эмбеддингом +- `search_similar()` — семантический поиск по запросу +- `search_by_session()` — поиск внутри сессии +- `get_stats()` — статистика + +**HybridMemoryManager** — гибридный менеджер: +- `add_message()` — сохранение в SQLite + ChromaDB +- `search_relevant()` — приоритет векторному поиску, фоллбэк на LIKE +- `format_context_for_ai()` — контекст для ИИ с профилем и релевантными сообщениями +- `extract_and_save_facts()` — извлечение фактов из сообщений + +### memory_system.py + +**SQLiteMemoryStorage** — реляционное хранилище: +- Таблицы: `facts`, `messages`, `sessions` +- Поиск через `LIKE` +- Извлечение фактов по эвристикам + +--- + +## 📊 Команды + +### /memory — Статистика памяти + +``` +🧠 Статистика памяти: + +📊 Сообщений: 42 +📌 Фактов: 5 +📁 Сессий: 3 + +🔮 Векторная память: + Документы: 42 + Модель: all-MiniLM-L6-v2 + +Память использует SQLite + ChromaDB с семантическим поиском. +``` + +--- + +## 🚀 Использование + +### Сохранение сообщения: + +```python +from vector_memory import save_message + +save_message(user_id=123456, role="user", content="Меня зовут Владимир") +save_message(user_id=123456, role="assistant", content="Приятно познакомиться!") +``` + +### Семантический поиск: + +```python +from vector_memory import search_memory + +# Найти сообщения по смыслу (не точное совпадение!) +results = search_memory(user_id=123456, query="как настроить сервер", limit=5) + +for msg, score in results: + print(f"{score:.2f}: {msg.content}") +``` + +### Контекст для ИИ (градиентная память): + +```python +from vector_memory import get_context + +# Градиентная память: STM=5, LTM=15 +context = get_context(user_id=123456, query="Где мои файлы?", stm_size=5, ltm_size=15) + +# Структура контекста: +# - 📋 Профиль пользователя (факты) +# - 💬 STM: Последние 5 сообщений (полностью) +# - 🕰️ LTM: Сообщения 6-20 (сжато, по 50 символов) +# - 🔍 RAG: Релевантные сообщения по запросу +``` + +### Команды бота: + +- `/memory` — статистика памяти +- `/memory_test` — тестирование градиентной памяти +- `/facts` — показать сохранённые факты +- `/compact` — запустить компактификацию истории + +--- + +## 📁 Файлы + +- `vector_memory.py` — векторная память (ChromaDB + sentence-transformers) +- `memory_system.py` — SQLite память +- `memory.db` — SQLite база данных +- `vector_db/` — ChromaDB хранилище + +--- + +## ⚙️ Настройка + +### Требования: + +```bash +pip install chromadb sentence-transformers +``` + +### Модель эмбеддингов: + +По умолчанию используется `all-MiniLM-L6-v2` (лёгкая, быстрая). + +Для изменения модели: + +```python +vector_storage = VectorMemoryStorage( + persist_directory="./vector_db", + model_name="all-mpnet-base-v2" # Более точная, но тяжелее +) +``` + +**Доступные модели:** +- `all-MiniLM-L6-v2` — 90MB, 384 dim (быстрая) +- `all-mpnet-base-v2` — 420MB, 768 dim (точная) +- `paraphrase-multilingual-MiniLM-L12-v2` — мультиязычная + +--- + +## 🎯 Как работает RAG + +1. **Пользователь отправляет сообщение** → сохраняется в SQLite + ChromaDB +2. **ИИ запрашивает контекст** → гибридный менеджер формирует промпт: + - Профиль пользователя (факты) + - Последние N сообщений + - Релевантные сообщения из векторного поиска +3. **ИИ получает контекст** → отвечает с учётом истории + +**Пример:** +``` +User: Помнишь, я спрашивал про настройку nginx? + +RAG находит: +- Сообщение 3-дневной давности про nginx config +- Факт: "Использует nginx" + +ИИ отвечает: +"Да, вы спрашивали про настройку nginx. Вот что мы обсуждали..." +``` + +--- + +## 📈 Производительность + +| Операция | Время | +|----------|-------| +| Добавление сообщения | ~50ms | +| Векторный поиск (5 результатов) | ~100ms | +| Извлечение фактов | ~5ms | +| Формирование контекста | ~20ms | + +**Потребление памяти:** +- Модель: ~200MB +- ChromaDB: ~100-500MB (зависит от количества сообщений) +- SQLite: ~10MB +- **Итого: <1GB** ✅ + +--- + +## 🔒 Безопасность + +- Данные хранятся локально +- Нет отправки третьим сторонам +- Можно удалить: `rm memory.db vector_db/` diff --git a/add_channels.py b/add_channels.py new file mode 100644 index 0000000..c892bd2 --- /dev/null +++ b/add_channels.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +""" +Утилита для пакетного добавления Telegram-каналов +""" + +import sys +import os +import json + +# Добавляем корень проекта в path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from bot.tools.telegram_web_tool import load_subscriptions, save_subscriptions + + +def add_channels(channel_names: list): + """Добавить список каналов""" + channels = load_subscriptions() + + added = [] + already_exists = [] + + for name in channel_names: + name = name.strip().lstrip('@') + if not name: + continue + + if name in channels: + already_exists.append(name) + else: + channels.append(name) + added.append(name) + + save_subscriptions(channels) + + print(f"✅ Добавлено каналов: {len(added)}") + for ch in added: + print(f" + @{ch}") + + if already_exists: + print(f"\n⚠️ Уже существуют: {len(already_exists)}") + for ch in already_exists: + print(f" - @{ch}") + + print(f"\n📊 Всего каналов: {len(channels)}") + + +if __name__ == '__main__': + # Каналы из запроса Владимира + channels_str = "bbbreaking, godnotech, localhost_public, angarsk38, itmemas, ithueti, ai_exee, Agitblog, pbdsu, bash_help, krxnotes" + + # Разделяем по запятой + channel_list = [ch.strip() for ch in channels_str.split(',')] + + add_channels(channel_list) diff --git a/authorize_qwen.sh b/authorize_qwen.sh new file mode 100644 index 0000000..91bcb97 --- /dev/null +++ b/authorize_qwen.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Скрипт для авторизации Qwen Code на сервере + +echo "🔐 Авторизация Qwen Code..." +echo "" +echo "Запускаю qwen-code в интерактивном режиме..." +echo "Следуй инструкциям в браузере." +echo "" + +cd /home/mirivlad/telegram-bot +source venv/bin/activate + +# Запускаем qwen-code с простой командой чтобы инициировать авторизацию +qwen -p "привет, это тест авторизации" + +echo "" +echo "✅ Если авторизация прошла успешно, токен сохранён в ~/.qwen/oauth_creds.json" +echo "" +echo "Проверка файла с токеном:" +ls -la ~/.qwen/oauth_creds.json 2>/dev/null || echo "❌ Файл с токеном не найден" diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..104b4d7 --- /dev/null +++ b/bot.py @@ -0,0 +1,2654 @@ +#!/usr/bin/env python3 +""" +Telegram CLI Bot - бот для выполнения CLI команд с многоуровневым меню. +Версия: 0.8.0 (Исправление OAuth + память файлов + совместимость PTB 20.7+) +""" + +import os +import sys +import asyncio +import subprocess +import logging +import pty +import select +import fcntl +from pathlib import Path +from typing import Optional, Callable, Dict, Any, List, Tuple +from datetime import datetime, timedelta + +import pexpect +import asyncssh +from qwen_integration import qwen_manager, QwenSessionState + +# Подавляем логи sentence-transformers и huggingface +logging.getLogger("sentence_transformers").setLevel(logging.WARNING) +logging.getLogger("huggingface_hub").setLevel(logging.WARNING) +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) +logging.getLogger("telegram.ext").setLevel(logging.WARNING) + +from vector_memory import ( + hybrid_memory_manager, + save_message, + get_context, + get_profile, + get_memory_stats, + load_history_to_state +) + +# Импорты компактификации +from bot.compaction import init_compactor, get_compactor, DialogueCompactor + +from dotenv import load_dotenv +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, BotCommand +from telegram.ext import ( + Application, + CommandHandler, + CallbackQueryHandler, + MessageHandler, + ContextTypes, + filters, +) + +# Загрузка переменных окружения из .env +load_dotenv() + +# --- Конфигурация --- +BASE_DIR = Path(__file__).parent + +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.DEBUG, + handlers=[ + logging.FileHandler(BASE_DIR / "bot.log"), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + + +# ============================================================================ +# ИМПОРТЫ ИЗ bot/ - модульная структура +# ============================================================================ +from bot.config import config, state_manager, menu_builder, command_registry, server_manager +from bot.models.server import Server +from bot.models.session import SSHSession, SSHSessionManager, LocalSession, LocalSessionManager, INPUT_PATTERNS +from bot.utils.cleaners import clean_ansi_codes, normalize_output +from bot.utils.formatters import escape_html, split_message, send_long_message, format_long_output, MAX_MESSAGE_LENGTH +from bot.utils.ssh_readers import detect_input_type, read_ssh_output, read_pty_output, wait_and_read_ssh +from bot.utils.decorators import check_access +from bot.keyboards.menus import MenuItem, init_menus + +# Импорты хендлеров из модулей +from bot.handlers.commands import start_command, menu_command, help_command, settings_command, cron_command, rss_command, ai_command +from bot.handlers.callbacks import menu_callback +from bot.services.command_executor import execute_cli_command + +# Импорты инструментов и AI агента +from bot.ai_agent import ai_agent +from bot.tools import tools_registry +from bot.services.cron_scheduler import init_scheduler, get_scheduler +from bot.ai_provider_manager import init_ai_provider_manager, get_ai_provider_manager + +# Глобальные менеджеры сессий +ssh_session_manager = SSHSessionManager() +local_session_manager = LocalSessionManager() +compactor: Optional[DialogueCompactor] = None + + +async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка текстовых сообщений как CLI команд.""" + user_id = update.effective_user.id + text = update.message.text.strip() + + # ПРОВЕРКА: игнорируем сообщения от самого бота (защита от зацикливания) + if update.effective_user.is_bot: + logger.debug(f"Игнорируем сообщение от бота: {text[:50]}") + return + + state = state_manager.get(user_id) + + # === ПРОВЕРКА: Загрузка истории из БД если ещё не загружена === + # Это обеспечивает сохранение контекста между перезапусками бота + # Загружаем историю для ВСЕХ пользователей при первом сообщении + if not state_manager.is_history_loaded(user_id): + logger.info(f"Загрузка истории диалога из БД для пользователя {user_id}") + load_history_to_state(user_id, state, state_manager) + logger.info(f"История загружена: {len(state.ai_chat_history)} сообщений") + + logger.info(f"handle_text_message: user_id={user_id}, ai_chat_mode={state.ai_chat_mode}, text={text[:50]}") + + # ПРОВЕРКА: если бот ждёт нажатия кнопки для продолжения вывода — прерываем ожидание + if state.waiting_for_output_control: + logger.info(f"Пользователь {user_id} отправил команду во время ожидания кнопки — прерываем вывод") + state.waiting_for_output_control = False + state.continue_output = False # Отменяем текущий вывод + # Не отвечаем на старое сообщение с кнопками — просто продолжаем обработку команды + + # Проверка: не в режиме ввода данных сервера ли мы + if state.waiting_for_input: + await handle_server_input(update, text) + return + + # Проверка: не активная ли SSH-сессия ожидает ввода + ssh_session = ssh_session_manager.get_session(user_id) + if ssh_session and ssh_session.waiting_for_input: + await handle_ssh_session_input(update, text, ssh_session) + return + + # Проверка: не активная ли локальная сессия ожидает ввода + local_session = local_session_manager.get_session(user_id) + if local_session and local_session.waiting_for_input: + await handle_local_session_input(update, text, local_session) + return + + # Проверка: не ждём ли пароль для перезапуска бота + if state.waiting_for_restart_password: + await handle_restart_password(update, text) + return + + # Проверка: не ждём ли завершения OAuth авторизации Qwen + if state.waiting_for_qwen_oauth: + await handle_qwen_oauth_completion(update, text) + return + + # ПРОВЕРКА: режим чата с ИИ агентом + if state.ai_chat_mode: + logger.info(f"Пользователь {user_id} отправил задачу ИИ: {text}") + await handle_ai_task(update, text) + return + + # Любое текстовое сообщение = CLI команда + logger.info(f"Пользователь {user_id} отправил команду: {text}") + + await execute_cli_command_from_message(update, text) + + +async def handle_ai_task(update: Update, text: str): + """Обработка задачи для ИИ агента с использованием системы памяти и инструментов.""" + user_id = update.effective_user.id + state = state_manager.get(user_id) + + # === ПРОВЕРКА: Загрузка истории из БД если ещё не загружена === + # Это обеспечивает сохранение контекста между перезапусками бота + if not state_manager.is_history_loaded(user_id): + logger.info(f"Загрузка истории диалога из БД для пользователя {user_id}") + load_history_to_state(user_id, state, state_manager) + logger.info(f"История загружена: {len(state.ai_chat_history)} сообщений, ai_chat_history={state.ai_chat_history[:3]}...") + + # === ПРОВЕРКА: AI-пресет === + ai_preset = state.ai_preset + + # Если ИИ отключен — пропускаем обработку, но сохраняем сообщение в память + if ai_preset == "off": + logger.info(f"Пользователь {user_id}: ИИ отключен, пропускаем обработку") + # Всё равно сохраняем сообщение в память для контекста + save_message(user_id, "user", text) + state.ai_chat_history.append(f"User: {text}") + if len(state.ai_chat_history) > 20: + state.ai_chat_history = state.ai_chat_history[-20:] + return + + # === ПРОВЕРКА: Нужна ли компактификация? === + # Проверяем порог заполненности контекста + if compactor.check_compaction_needed(): + logger.info("Запуск компактификации истории диалога...") + status_msg = await update.message.reply_text("🔄 **Запуск компактификации истории...**\n\n_Это может занять несколько секунд._", parse_mode="Markdown") + + result = await compactor.compact() + + await status_msg.delete() + + if result.success: + await update.message.reply_text( + f"✅ **Компактификация завершена!**\n\n" + f"📊 Сжато сообщений: {result.messages_compressed}\n" + f"📝 Длина summary: {result.summary_length} символов\n" + f"💾 Экономия токенов: ~{result.tokens_saved}", + parse_mode="Markdown" + ) + else: + logger.error(f"Компактификация не удалась: {result.error}") + await update.message.reply_text(f"⚠️ **Ошибка компактификации:** {result.error}", parse_mode="Markdown") + + # Сохраняем сообщение пользователя в памяти + save_message(user_id, "user", text) + + # Добавляем сообщение пользователя в историю сессии + state.ai_chat_history.append(f"User: {text}") + + # Ограничиваем историю последними 20 сообщениями + if len(state.ai_chat_history) > 20: + state.ai_chat_history = state.ai_chat_history[-20:] + + # === ПЕРЕМЕННЫЕ ДЛЯ QWEN (определяем ДО использования) === + output_buffer = [] # Буфер для потокового отображения + result_buffer = [] # Буфер для финального результата (без статусов) + stream_message = None # Сообщение для потокового вывода (статусы) + result_message = None # Финальное сообщение с результатом + current_status = "🤖 Думаю..." # Текущий статус для отображения + is_tool_output = False # Флаг: идёт ли вывод инструмента + + def on_output(text: str): + """Callback для накопления полного вывода (не используется для streaming).""" + pass + + async def on_oauth_url(url: str): + """Callback для OAuth URL — отправляем ссылку в чат.""" + try: + await update.message.reply_text( + "🔐 **Требуется авторизация Qwen Code**\n\n" + "Для работы с Qwen Code необходимо авторизоваться.\n\n" + "🔗 **Инструкция:**\n" + "1. Откройте ссылку: `{url}`\n" + "2. Войдите через Google/GitHub\n" + "3. После авторизации отправьте любое сообщение в чат\n\n" + "_Бот автоматически продолжит работу после авторизации._".format(url=url), + parse_mode="Markdown" + ) + logger.info(f"Отправлен OAuth URL пользователю {user_id}") + except Exception as e: + logger.error(f"Ошибка отправки OAuth URL: {e}") + + def on_event(event): + """Обработка событий stream-json для обновления статуса.""" + nonlocal current_status, is_tool_output + + from qwen_integration import QwenEventType + + if event.event_type == QwenEventType.SYSTEM: + if event.subtype == 'session_start': + current_status = "🤖 Запуск сессии..." + + elif event.event_type == QwenEventType.ASSISTANT: + message = event.message or {} + content_list = message.get('content', []) + for content_item in content_list: + if isinstance(content_item, dict) and content_item.get('type') == 'tool_use': + tool_name = content_item.get('name', 'инструмент') + current_status = f"🔧 Использую {tool_name}..." + is_tool_output = True # Начинается вывод инструмента + break + + elif event.event_type == QwenEventType.RESULT: + if event.is_error: + current_status = "❌ Ошибка" + else: + current_status = "✅ Готово" + + logger.debug(f"Событие Qwen: {event.event_type.value}, статус: {current_status}") + + async def on_chunk(chunk: str): + """Потоковая отправка chunks в Telegram.""" + nonlocal stream_message, current_status, is_tool_output + + chunk_text = chunk + if not chunk_text or not chunk_text.strip(): + return + + # Логируем для отладки + logger.debug(f"on_chunk: {repr(chunk_text[:50])}...") + + # Добавляем в потоковый буфер (всё) + output_buffer.append(chunk_text) + + # В result_buffer добавляем ТОЛЬКО если это не статус инструмента + # Статусы инструментов содержат "🔧 Использую инструмент:" + is_tool_status = "🔧 Использую инструмент:" in chunk_text + if not is_tool_status: + result_buffer.append(chunk_text) + + logger.debug(f"output_buffer: {len(output_buffer)}, result_buffer: {len(result_buffer)}, is_tool_status: {is_tool_status}") + + # Если сообщение ещё не создано - создаём + if stream_message is None: + stream_message = await update.message.reply_text( + f"⏳ {current_status}", + parse_mode="Markdown" + ) + await asyncio.sleep(0.1) + + # Формируем текущий вывод + current_output = "".join(output_buffer) + + # Обрезаем до безопасного размера + if len(current_output) > 3500: + current_output = current_output[-3500:] + + # Обновляем сообщение + try: + # Используем parse_mode="Markdown" для правильного отображения форматирования + await stream_message.edit_text( + f"⏳ {current_status}\n\n{current_output}", + parse_mode="Markdown" + ) + except Exception as e: + logger.debug(f"Ошибка редактирования: {e}") + await asyncio.sleep(0.3) + + # Отправляем статус + status_msg = await update.message.reply_text("⏳ 🤖 Думаю...") + + # === ПРОВЕРКА: Решение AI агента об использовании инструментов === + agent_decision = await ai_agent.decide(text, context={'user_id': user_id}) + + if agent_decision.should_use_tool: + logger.info(f"AI агент решил использовать инструмент: {agent_decision.tool_name} (confidence={agent_decision.confidence})") + + # Отправляем статус + await status_msg.edit_text(f"⏳ 🔧 Выполняю {agent_decision.tool_name}...") + + # Выполняем инструмент + tool_result = await ai_agent.execute_tool( + agent_decision.tool_name, + **agent_decision.tool_args + ) + + if tool_result.success: + # Передаём результаты в AI для анализа и формирования ответа + logger.info(f"Передаю результаты {agent_decision.tool_name} в AI для анализа") + + # Формируем промпт с результатами инструмента + tool_result_formatted = await format_tool_result(agent_decision.tool_name, tool_result) + + # Запрос в AI с результатами инструмента + ai_analysis_prompt = ( + f"Пользователь запросил: {text}\n\n" + f"Я выполнил поиск/запрос и получил следующие данные:\n\n" + f"{tool_result_formatted}\n\n" + f"Проанализируй эти данные и предоставь пользователю краткий, информативный ответ на его запрос. " + f"Не просто пересказывай результаты, а выдели главное, сделай выводы и предоставь полезную информацию. " + f"Если есть ссылки — упомяни ключевые источники." + ) + + # Получаем AI-провайдер + from bot.ai_provider_manager import get_ai_provider_manager + provider_manager = get_ai_provider_manager() + + # Получаем текущий пресет + state = state_manager.get(user_id) + ai_preset = state.ai_preset if state else 'qwen_auto' + + from bot.handlers.ai_presets import get_preset_provider + current_provider = get_preset_provider(ai_preset) + + # Собираем системный промпт + system_prompt = qwen_manager.load_system_prompt() + + # Формируем контекст + summary = state.summary if state and hasattr(state, 'summary') else None + memory_context = "" + history_context = "\n".join(state.ai_chat_history[-10:]) if state and hasattr(state, 'ai_chat_history') else "" + + if summary: + full_context = ( + f"{system_prompt}\n\n" + f"=== SUMMARY ДИАЛОГА ===\n{summary}\n\n" + f"=== ИСТОРИЯ ДИАЛОГА ===\n{history_context}\n\n" + f"=== ЗАПРОС С РЕЗУЛЬТАТАМИ ИНСТРУМЕНТА ===\n{ai_analysis_prompt}" + ) + else: + full_context = ( + f"{system_prompt}\n\n" + f"=== ИСТОРИЯ ДИАЛОГА ===\n{history_context}\n\n" + f"=== ЗАПРОС С РЕЗУЛЬТАТАМИ ИНСТРУМЕНТА ===\n{ai_analysis_prompt}" + ) + + # Выполняем через провайдер + if current_provider == "qwen": + result = await qwen_manager.run_task( + user_id, full_context, on_output, on_oauth_url, + use_system_prompt=False, on_chunk=on_chunk, on_event=on_event + ) + + full_output = "".join(result_buffer).strip() + + if not full_output and result: + import re + text_matches = re.findall(r'"text":"([^"]+)"', result) + if text_matches: + full_output = "\n".join(text_matches) + + if not full_output: + full_output = tool_result_formatted + + else: + response = await provider_manager.process_with_tools( + prompt=ai_analysis_prompt, + system_prompt=system_prompt, + context=state.ai_chat_history[-20:] if state and hasattr(state, 'ai_chat_history') else [], + user_id=user_id + ) + + if response.success and response.message: + full_output = response.message.content + else: + full_output = tool_result_formatted + + # Добавляем в историю + state.ai_chat_history.append(f"User: {text}") + state.ai_chat_history.append(f"Assistant: {full_output[:500]}") + save_message(user_id, "assistant", full_output) + + # Отправляем ответ + # Если была потоковая отправка - обновляем существующее сообщение + if stream_message and output_buffer: + try: + await stream_message.edit_text( + f"✅ Готово\n\n{''.join(output_buffer)}", + parse_mode="Markdown" + ) + except Exception as e: + logger.debug(f"Ошибка обновления потокового сообщения: {e}") + await send_long_message(update, full_output, parse_mode="Markdown") + else: + await send_long_message(update, full_output, parse_mode="Markdown") + + if status_msg: + await status_msg.delete() + return + else: + logger.warning(f"Инструмент {agent_decision.tool_name} вернул ошибку: {tool_result.error}") + await status_msg.edit_text(f"⚠️ Ошибка инструмента: {tool_result.error}") + # Продолжаем с обычным ИИ-ответом если инструмент не сработал + + # === ОБЫЧНЫЙ ИИ-ОТВЕТ через Qwen === + # Переменные и функции уже определены выше (output_buffer, result_buffer, on_chunk, etc.) + + # Формируем контекст с историей + памятью + summary + # Получаем summary и последние сообщения из ChromaDB + summary = None + recent_messages = [] + try: + if compactor is not None: + summary, recent_messages = compactor.get_context_with_summary(user_id, limit=20) + # Формируем историю из последних сообщений + history_context = "\n".join([f"{msg['role']}: {msg['content']}" for msg in recent_messages]) + except Exception as e: + logger.error(f"Ошибка загрузки summary: {e}") + # Fallback на старую логику + history_context = "\n".join(state.ai_chat_history) + + # Получаем контекст из системы памяти (профиль + релевантные факты) + memory_context = get_context(user_id, query=text) + + # Считаем токены в контексте (примерно: 1 слово ≈ 1.3 токена) + context_words = len((memory_context + "\n" + history_context).split()) + context_tokens = int(context_words * 1.3) + + # Максимальный контекст модели (Qwen поддерживает до 256K токенов) + # Для безопасности берём 200K + MAX_CONTEXT_TOKENS = 200_000 + context_percent = round((context_tokens / MAX_CONTEXT_TOKENS) * 100, 1) + + # Получаем текущий AI-пресет + ai_preset = state.ai_preset + + # Определяем провайдера и модель на основе пресета + from bot.models.user_state import ( + AI_PRESET_OFF, + AI_PRESET_QWEN, + AI_PRESET_GIGA_AUTO, + AI_PRESET_GIGA_LITE, + AI_PRESET_GIGA_PRO, + AI_PRESET_OPENCODE, + ) + + if ai_preset == AI_PRESET_OFF: + # ИИ отключен - не должны были сюда попасть + logger.warning(f"Попытка обработки AI-запроса при отключенном ИИ (пресет={ai_preset})") + return + + # Проверяем какой провайдер выбран в настройках + current_provider = state.current_ai_provider + + if current_provider == "qwen": + provider_display = "Qwen Code" + elif current_provider == "opencode": + provider_display = f"Opencode ({state.opencode_model})" + elif current_provider == "gigachat": + # Для GigaChat используем модель из состояния пользователя + from bot.tools.gigachat_tool import GigaChatConfig + gigachat_model = state.gigachat_model + + if gigachat_model == "lite": + GigaChatConfig.model = GigaChatConfig.model_lite + elif gigachat_model == "pro": + GigaChatConfig.model = GigaChatConfig.model_pro + elif gigachat_model == "max": + GigaChatConfig.model = GigaChatConfig.model_max + else: + # По умолчанию Lite + GigaChatConfig.model = GigaChatConfig.model_lite + + provider_display = f"GigaChat ({gigachat_model})" + else: + # По умолчанию Qwen + current_provider = "qwen" + provider_display = "Qwen Code" + + logger.info(f"AI-пресет: {ai_preset}, провайдер: {current_provider}") + + # Получаем менеджера провайдеров + from bot.ai_provider_manager import get_ai_provider_manager + provider_manager = get_ai_provider_manager() + + # Собираем полный промпт с системным промптом + system_prompt = qwen_manager.load_system_prompt() + + # Добавляем явное правило не здороваться если есть история + greeting_rule = "\n\nВАЖНОЕ ПРАВИЛО: Не здоровайся при каждом ответе! Если в истории диалога выше уже есть сообщения - отвечай сразу по существу, без приветствий." + system_prompt = system_prompt + greeting_rule + + # Формируем полный промпт с summary (если есть) + if summary: + full_task = ( + f"{system_prompt}\n\n" + f"=== SUMMARY ДИАЛОГА (контекст) ===\n" + f"{summary}\n\n" + f"=== КОНТЕКСТ ПАМЯТИ ===\n" + f"{memory_context}\n\n" + f"=== ИСТОРИЯ ДИАЛОГА (последние 20 сообщений) ===\n" + f"{history_context}\n\n" + f"=== ЗАПРОС ПОЛЬЗОВАТЕЛЯ ===\n" + f"{text}" + ) + else: + full_task = ( + f"{system_prompt}\n\n" + f"=== КОНТЕКСТ ПАМЯТИ ===\n" + f"{memory_context}\n\n" + f"=== ИСТОРИЯ ДИАЛОГА ===\n" + f"{history_context}\n\n" + f"=== ЗАПРОС ПОЛЬЗОВАТЕЛЯ ===\n" + f"{text}" + ) + + # Выполняем задачу через текущего провайдера + if current_provider == "qwen": + # Qwen Code - потоковый вывод через AIProviderManager с поддержкой инструментов + logger.info(f"Выполнение запроса через Qwen Code с поддержкой инструментов") + + # Формируем контекст для Qwen из памяти и истории + context_messages = [] + + # Добавляем summary если есть + if summary: + context_messages.append({ + "role": "system", + "content": f"=== SUMMARY ДИАЛОГА ===\n{summary}" + }) + + # Добавляем контекст памяти + if memory_context: + context_messages.append({ + "role": "system", + "content": f"=== КОНТЕКСТ ПАМЯТИ ===\n{memory_context}" + }) + + # Добавляем историю диалога + if history_context: + for line in history_context.split("\n"): + if line.startswith("user:"): + context_messages.append({"role": "user", "content": line[5:].strip()}) + elif line.startswith("assistant:"): + context_messages.append({"role": "assistant", "content": line[10:].strip()}) + + # Выполняем через provider_manager с поддержкой инструментов + result = await provider_manager.execute_request( + provider_id=current_provider, + user_id=user_id, + prompt=text, # Только запрос пользователя + system_prompt=system_prompt, # System prompt отдельно + context=context_messages, # Контекст из памяти и истории + on_chunk=on_chunk, # Потоковый вывод + use_tools=True # Включаем поддержку инструментов + ) + + if result.get("success"): + full_output = result.get("content", "") + # Получаем информацию о модели из metadata + model_name = result.get("metadata", {}).get("model") + if model_name: + provider_name = f"Qwen Code ({model_name})" + else: + provider_name = "Qwen Code" + else: + full_output = f"❌ **Ошибка {provider_manager.get_provider_info(current_provider).name}:**\n{result.get('error', 'Неизвестная ошибка')}" + provider_name = "Qwen Code" + + elif current_provider == "gigachat": + # GigaChat - ответ целиком (не потоковый) + # Обновляем статусное сообщение + try: + await status_msg.edit_text( + "⏳ 🤖 **GigaChat думает...**", + parse_mode="Markdown" + ) + except Exception as e: + logger.debug(f"Ошибка обновления статуса для GigaChat: {e}") + + # Формируем контекст для GigaChat из памяти и истории + # Это обеспечивает ту же функциональность что и для Qwen + context_messages = [] + + # Добавляем summary если есть + if summary: + context_messages.append({ + "role": "system", + "content": f"=== SUMMARY ДИАЛОГА ===\n{summary}" + }) + + # Добавляем контекст памяти + if memory_context: + context_messages.append({ + "role": "system", + "content": f"=== КОНТЕКСТ ПАМЯТИ ===\n{memory_context}" + }) + + # Добавляем историю диалога + if history_context: + for line in history_context.split("\n"): + if line.startswith("user:"): + context_messages.append({"role": "user", "content": line[5:].strip()}) + elif line.startswith("assistant:"): + context_messages.append({"role": "assistant", "content": line[10:].strip()}) + + result = await provider_manager.execute_request( + provider_id=current_provider, + user_id=user_id, + prompt=text, # Только запрос пользователя + system_prompt=system_prompt, # System prompt отдельно + context=context_messages, # Контекст из памяти и истории + on_chunk=None # GigaChat не поддерживает потоковый вывод + ) + + if result.get("success"): + full_output = result.get("content", "") + # Получаем информацию о модели + model_name = result.get("metadata", {}).get("model") + if model_name: + provider_name = f"GigaChat ({model_name})" + else: + provider_name = "GigaChat" + else: + full_output = f"❌ **Ошибка {provider_manager.get_provider_info(current_provider).name}:**\n{result.get('error', 'Неизвестная ошибка')}" + provider_name = "GigaChat" + + elif current_provider == "opencode": + # Opencode - использует provider_manager + try: + await status_msg.edit_text( + "⏳ 🚀 **Opencode думает...**", + parse_mode="Markdown" + ) + except Exception as e: + logger.debug(f"Ошибка обновления статуса для Opencode: {e}") + + # Формируем контекст для Opencode + context_messages = [] + + if summary: + context_messages.append({ + "role": "system", + "content": f"=== SUMMARY ДИАЛОГА ===\n{summary}" + }) + + if memory_context: + context_messages.append({ + "role": "system", + "content": f"=== КОНТЕКСТ ПАМЯТИ ===\n{memory_context}" + }) + + if history_context: + for line in history_context.split("\n"): + if line.startswith("user:"): + context_messages.append({"role": "user", "content": line[5:].strip()}) + elif line.startswith("assistant:"): + context_messages.append({"role": "assistant", "content": line[10:].strip()}) + + # Устанавливаем модель для пользователя (используем синглтон) + from bot.providers.opencode_provider import opencode_provider as global_opencode_provider + if hasattr(state, 'opencode_model'): + global_opencode_provider.set_model(user_id, state.opencode_model) + + result = await provider_manager.execute_request( + provider_id=current_provider, + user_id=user_id, + prompt=text, + system_prompt=system_prompt, + context=context_messages, + on_chunk=None + ) + + if result.get("success"): + full_output = result.get("content", "") + model_name = result.get("metadata", {}).get("model", state.opencode_model if hasattr(state, 'opencode_model') else 'minimax') + provider_name = f"Opencode ({model_name})" + else: + full_output = f"❌ **Ошибка {provider_manager.get_provider_info(current_provider).name}:**\n{result.get('error', 'Неизвестная ошибка')}" + provider_name = "Opencode" + + # Добавляем ответ ИИ в историю и память + if full_output and full_output != "⚠️ Не удалось получить ответ ИИ": + state.ai_chat_history.append(f"Assistant: {full_output[:500]}") + save_message(user_id, "assistant", full_output) + + # Автоматическое извлечение фактов каждые 5 сообщений + state.messages_since_fact_extract += 1 + if state.messages_since_fact_extract >= 5: + logger.info(f"Запуск извлечения фактов через ИИ для пользователя {user_id}") + dialog_context = "\n".join(state.ai_chat_history[-10:]) + asyncio.create_task(hybrid_memory_manager.extract_facts_with_ai(user_id, dialog_context)) + state.messages_since_fact_extract = 0 + + # Формируем сообщение с информацией о контексте и провайдере + context_info = f"📊 Контекст: {context_percent}%\n🤖 AI: {provider_name}" + + # Если была потоковая отправка - обновляем существующее сообщение + if stream_message and output_buffer: + # Текст уже отправлен потоково - только обновляем статус + try: + await stream_message.edit_text( + f"✅ {current_status}\n\n{''.join(output_buffer)}\n\n_{context_info}_", + parse_mode="Markdown" + ) + except Exception as e: + logger.debug(f"Ошибка обновления потокового сообщения: {e}") + # Если не получилось обновить - отправляем новым сообщением + response_text = f"{full_output}\n\n_{context_info}_" + await send_long_message(update, response_text, parse_mode="Markdown") + else: + # Потоковой отправки не было - отправляем новым сообщением + response_text = f"{full_output}\n\n_{context_info}_" + await send_long_message(update, response_text, parse_mode="Markdown") + + +async def translate_title(title: str, max_length: int = 100) -> str: + """ + Краткий перевод заголовка на русский через ИИ. + Если перевод не удался — возвращает оригинал. + """ + try: + # Быстрый промпт для перевода + prompt = f"Переведи на русский язык этот заголовок новости (максимум {max_length} символов, без кавычек и пояснений):\n{title[:200]}" + + # Используем qwen_manager для перевода + from qwen_integration import qwen_manager + + # Создаём временную сессию для перевода + import hashlib + temp_user_id = f"translator_{hashlib.md5(title.encode()).hexdigest()}" + + result = await qwen_manager.run_task(temp_user_id, prompt, on_output=lambda x: None, on_oauth_url=lambda x: None, use_system_prompt=False) + + # Извлекаем текст из результата + import re + text_matches = re.findall(r'"text":"([^"]+)"', result) + if text_matches: + translated = " ".join(text_matches).replace("\\n", " ").strip() + # Убираем кавычки если есть + translated = translated.strip('"\'') + if translated and len(translated) > 3: + return translated[:max_length] + + return title[:max_length] + except Exception as e: + logger.debug(f"Ошибка перевода заголовка: {e}") + return title[:max_length] + + +async def format_tool_result(tool_name: str, result: 'ToolResult') -> str: + """Форматировать результат выполнения инструмента.""" + from bot.tools import ToolResult + + if tool_name == 'ddgs_search': + if not result.data: + return "🔍 Ничего не найдено по вашему запросу." + + output = "🔍 *Результаты поиска:*\n\n" + for i, item in enumerate(result.data[:5], 1): + title = item.get('title', 'Без названия') + href = item.get('href', '') + body = item.get('body', '')[:200] + output += f"{i}. *{title}*\n" + if href: + output += f" {href}\n" + if body: + output += f" {body}\n\n" + + return output + + elif tool_name in ('rss_reader', 'rss_tool'): + action = result.metadata.get('action', 'list') + + if action == 'list' and result.data: + # Помечаем новости как прочитанные (digest_flag=1) + from bot.tools.rss_tool import RSSTool + rss_tool_instance = RSSTool(db_path='rss.db') + for item in result.data: + news_id = item.get('id') + if news_id: + await rss_tool_instance.mark_digest(news_id) + logger.debug(f"Новость {news_id} помечена как прочитанная") + + output = "📰 *Последние новости:*\n\n" + # Берём не более 15 новостей для читаемости + news_count = min(len(result.data), 15) + + for i in range(news_count): + item = result.data[i] + title = item.get('title', 'Без названия') + pub_date = item.get('pub_date', '') + link = item.get('link', '') + + # Переводим заголовок на русский + translated_title = await translate_title(title, max_length=100) + + # Форматируем дату + date_str = "" + if pub_date: + try: + # Преобразуем дату в более читаемый формат + dt = datetime.strptime(pub_date[:19], '%Y-%m-%d %H:%M:%S') + date_str = dt.strftime('%d.%m.%Y %H:%M') + except: + date_str = pub_date[:16] + + # Обрезаем заголовок если слишком длинный + if len(translated_title) > 120: + translated_title = translated_title[:117] + "..." + + output += f"*{i+1}. {translated_title}*\n" + if date_str: + output += f" 📅 {date_str}\n" + if link: + # Обрезаем ссылку для читаемости + short_link = link[:60] + "..." if len(link) > 63 else link + output += f" 🔗 {short_link}\n" + output += "\n" + + return output + + elif action == 'fetch': + total = result.data.get('total_new_items', 0) if result.data else 0 + return f"✅ Получено {total} новых элементов из лент." + + elif action == 'list_feeds' and result.data: + output = "📑 *Ваши RSS ленты:*\n\n" + for feed in result.data: + title = feed.get('title', feed.get('url', 'Unknown')) + url = feed.get('url', '') + last_fetch = feed.get('last_fetched', '') + + output += f"• *{title}*\n" + output += f" 🔗 {url}\n" + if last_fetch: + output += f" 🕐 Обновлено: {last_fetch[:16]}\n" + output += "\n" + return output + + return f"RSS: {result.data}" + + elif tool_name == 'ssh_tool': + if not result.success: + return f"❌ *Ошибка SSH:*\n```\n{result.error}\n```" + + data = result.data + server = result.metadata.get('server', 'unknown') + command = result.metadata.get('command', '') + + output = f"🖥️ *SSH: {server}*\n" + output += f"*Команда:* `{command}`\n\n" + + if data.get('stdout'): + output += f"*Вывод:*\n```\n{data['stdout']}\n```\n\n" + + if data.get('stderr'): + output += f"*Ошибки:*\n```\n{data['stderr']}\n```\n\n" + + if data.get('returncode') == 0: + output += "✅ *Успешно*" + else: + output += f"❌ *Код возврата:* {data.get('returncode', 'N/A')}" + + return output + + elif tool_name == 'cron_tool': + action = result.metadata.get('action', 'list') + + if action == 'list' and result.data: + output = "⏰ *Ваши задачи:*\n\n" + for job in result.data: + status = "✅" if job.get('enabled') else "❌" + notify_icon = "🔔" if job.get('notify') else "🔕" + log_icon = "📝" if job.get('log_results') else "🚫" + output += f"{status} *{job.get('name', 'Без названия')}* (ID: {job.get('id')})\n" + prompt_text = job.get('prompt', '')[:100] + output += f" {notify_icon}{log_icon} Промпт: _{prompt_text}_{'...' if len(job.get('prompt', '')) > 100 else ''}\n" + output += f" Расписание: `{job.get('schedule', '')}`\n" + if job.get('next_run'): + output += f" Следующий запуск: {job.get('next_run')}\n" + if job.get('last_run'): + output += f" Последний запуск: {job.get('last_run')}\n" + output += "\n" + return output + + elif action == 'add' and result.success: + data = result.data + notify_status = "🔔 Уведомлять" if result.metadata.get('notify') else "🔕 Без уведомлений" + log_status = "📝 Логировать" if result.metadata.get('log_results') else "🚫 Без логов" + return f"✅ *Задача добавлена:*\n• ID: {data.get('id')}\n• Название: {data.get('name')}\n• Расписание: {data.get('schedule')}\n• {notify_status}, {log_status}\n• Следующий запуск: {data.get('next_run', 'N/A')}" + + elif action == 'remove' and result.success: + return f"✅ *Задача удалена:* ID {result.data.get('id')}" + + elif action == 'run' and result.success: + result_text = result.metadata.get('result_text', 'Задача выполнена') + tool_used = result.data.get('tool_used', 'не указан') + return f"✅ *Задача выполнена:*\n\n{result_text}\n\n🔧 Инструмент: {tool_used}" + + elif action == 'run' and not result.success: + return f"❌ *Ошибка выполнения задачи:*\n{result.error}" + + return f"Cron: {result.data}" + + return f"Инструмент {tool_name}: {result.data}" + + +async def handle_ssh_session_input(update: Update, text: str, session: SSHSession): + """Обработка ввода пользователя в активную SSH-сессию.""" + user_id = update.effective_user.id + input_type = session.input_type + + logger.info(f"Пользователь {user_id} ввёл '{text}' в SSH-сессию (тип: {input_type})") + + try: + # Отправляем ввод в SSH-процесс + if input_type == "password": + # Пароль отправляем с newline + session.process.stdin.write(text + "\n") + elif input_type == "confirm": + # Подтверждение - y или n + answer = "y" if text.lower() in ("y", "yes", "да", "д") else "n" + session.process.stdin.write(answer + "\n") + else: + # Обычный ввод + session.process.stdin.write(text + "\n") + + await session.process.stdin.drain() + session.last_activity = datetime.now() + + # Читаем ответ + output, is_done = await read_ssh_output(session.process, timeout=3.0) + session.output_buffer += output + + # Проверяем тип ввода + new_input_type = detect_input_type(output) + + if new_input_type == "password": + session.waiting_for_input = True + session.input_type = "password" + await update.message.reply_text( + f"⏳ *Требуется ввод*\n\n" + f"🔐 *Запрошен пароль*\n\n" + f"```\n{output.strip()[-200:] if output else 'Ожидание...'}\n```\n\n" + f"Отправьте пароль в чат:", + parse_mode="Markdown" + ) + return + elif new_input_type == "confirm": + session.waiting_for_input = True + session.input_type = "confirm" + await update.message.reply_text( + f"⏳ *Требуется ввод*\n\n" + f"❓ *Требуется подтверждение*\n\n" + f"```\n{output.strip()[-200:] if output else 'Ожидание...'}\n```\n\n" + f"Отправьте `y` (да) или `n` (нет):", + parse_mode="Markdown" + ) + return + elif is_done or new_input_type == "prompt": + # Команда завершена + await update.message.reply_text( + f"✅ *Результат:*\n\n" + f"```\n{session.command}\n```\n\n" + f"```\n{session.output_buffer.strip()[-4000:]}\n```", + parse_mode="Markdown" + ) + ssh_session_manager.close_session(user_id) + return + else: + # Команда ещё выполняется + await update.message.reply_text( + f"⏳ *Выполнение...*\n\n" + f"```\n{output.strip()[-500:] if output else 'Выполняется...'}\n```", + parse_mode="Markdown" + ) + + # Читаем остаток + while not is_done: + more_output, is_done = await read_ssh_output(session.process, timeout=5.0) + output += more_output + session.output_buffer += more_output + session.last_activity = datetime.now() + + new_input_type = detect_input_type(output) + if new_input_type in ("password", "confirm"): + session.waiting_for_input = True + session.input_type = new_input_type + await update.message.reply_text( + f"⏳ *Требуется ввод*\n\n" + f"{'🔐 *Запрошен пароль*' if new_input_type == 'password' else '❓ *Требуется подтверждение'}\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"{'Отправьте пароль в чат:' if new_input_type == 'password' else 'Отправьте `y` (да) или `n` (нет):'}", + parse_mode="Markdown" + ) + return + + # Завершено + await update.message.reply_text( + f"✅ *Результат:*\n\n" + f"```\n{session.command}\n```\n\n" + f"```\n{session.output_buffer.strip()[-4000:]}\n```", + parse_mode="Markdown" + ) + ssh_session_manager.close_session(user_id) + + except Exception as e: + logger.error(f"Ошибка ввода в SSH-сессию: {e}") + ssh_session_manager.close_session(user_id) + await update.message.reply_text( + f"❌ *Ошибка:*\n```\n{str(e)}\n```", + parse_mode="Markdown" + ) + + +async def handle_local_session_input(update: Update, text: str, session: LocalSession): + """Обработка ввода пользователя в локальную сессию.""" + user_id = update.effective_user.id + input_type = session.input_type + + logger.info(f"Пользователь {user_id} ввёл '{text}' в локальную сессию (тип: {input_type})") + + try: + child = session.context.get('child') + if not child: + raise Exception("Сессия не содержит child объект") + + # Отправляем ввод + if input_type == "password": + child.sendline(text) + elif input_type == "confirm": + answer = "y" if text.lower() in ("y", "yes", "да", "д") else "n" + child.sendline(answer) + else: + child.sendline(text) + + session.last_activity = datetime.now() + + # Читаем ответ + logger.info("Чтение ответа...") + output = "" + + try: + while True: + line = child.read_nonblocking(size=4096, timeout=5.0) + if not line: + break + output += line + logger.debug(f"Прочитано: {len(line)} символов") + + # Проверяем запрос ввода + if detect_input_type(output): + break + + except pexpect.TIMEOUT: + pass + except pexpect.EOF: + pass + + logger.info(f"После ввода прочитано: {len(output)} символов") + session.output_buffer += output + + # Проверяем тип ввода + new_input_type = detect_input_type(output) + + if new_input_type == "password": + session.waiting_for_input = True + session.input_type = "password" + await update.message.reply_text( + f"⏳ *Требуется ввод*\n\n" + f"🔐 *Запрошен пароль*\n\n" + f"```\n{output.strip()[-200:] if output else 'Ожидание...'}\n```\n\n" + f"Отправьте пароль в чат:", + parse_mode="Markdown" + ) + return + elif new_input_type == "confirm": + session.waiting_for_input = True + session.input_type = "confirm" + await update.message.reply_text( + f"⏳ *Требуется ввод*\n\n" + f"❓ *Требуется подтверждение*\n\n" + f"```\n{output.strip()[-200:] if output else 'Ожидание...'}\n```\n\n" + f"Отправьте `y` (да) или `n` (нет):", + parse_mode="Markdown" + ) + return + else: + # Команда завершена + # Очищаем ANSI-коды и нормализуем вывод + cleaned_output = clean_ansi_codes(session.output_buffer) + cleaned_output = normalize_output(cleaned_output) + + # Формируем полный вывод БЕЗ обрезки — send_long_message сам разобьёт + full_output = f"✅ *Результат:*\n\n" \ + f"```\n{session.command}\n```\n\n" \ + f"```\n{cleaned_output.strip()}\n```" + + # Отправляем длинный вывод с разбивкой на сообщения + await send_long_message(update, full_output, parse_mode="Markdown") + local_session_manager.close_session(user_id) + + except Exception as e: + logger.error(f"Ошибка ввода в локальную сессию: {e}") + local_session_manager.close_session(user_id) + await update.message.reply_text( + f"❌ *Ошибка:*\n```\n{str(e)}\n```", + parse_mode="Markdown" + ) + + +async def handle_server_input(update: Update, text: str): + """Обработка ввода данных для CRUD операций с серверами.""" + user_id = update.effective_user.id + state = state_manager.get(user_id) + input_type = state.input_type + + if input_type == "add_server_name": + # Проверка имени + if not text.replace("-", "").replace("_", "").isalnum(): + await update.message.reply_text( + "❌ Неверный формат имени.\n\n" + "Используйте только латиницу, дефисы и подчёркивания.\n" + "Пример: `web-prod`, `db_backup`", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + return + + state.context["new_server"]["name"] = text + state.input_type = "add_server_host" + await update.message.reply_text( + f"✅ Имя: `{text}`\n\n" + "Введите *host* (IP или домен):\n" + "Пример: `192.168.1.10`, `example.com`", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + + elif input_type == "add_server_host": + state.context["new_server"]["host"] = text + state.input_type = "add_server_port" + await update.message.reply_text( + f"✅ Host: `{text}`\n\n" + "Введите *SSH порт* (обычно 22):", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + + elif input_type == "add_server_port": + try: + port = int(text) + if port < 1 or port > 65535: + raise ValueError() + state.context["new_server"]["port"] = port + state.input_type = "add_server_user" + await update.message.reply_text( + f"✅ Port: `{port}`\n\n" + "Введите *SSH пользователя*:\n" + "Пример: `root`, `admin`, `ubuntu`", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + except ValueError: + await update.message.reply_text( + "❌ Неверный формат порта.\n\n" + "Введите число от 1 до 65535:", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + + elif input_type == "add_server_user": + state.context["new_server"]["user"] = text + state.input_type = "add_server_password" + await update.message.reply_text( + f"✅ User: `{text}`\n\n" + "Введите *SSH пароль* (или нажмите Пропустить для подключения только по ключу):\n" + "⚠️ Пароль будет сохранён в .env файл в открытом виде!", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("⏭️ Пропустить", callback_data="srv_skip_password")], + [InlineKeyboardButton("❌ Отмена", callback_data="server_menu")] + ]) + ) + + elif input_type == "add_server_password": + state.context["new_server"]["password"] = text + state.input_type = "add_server_tags" + await update.message.reply_text( + "✅ Пароль сохранён\n\n" + "Введите *теги* через запятую (или нажмите Пропустить):\n" + "Пример: `web,prod`, `db,backup`\n\n" + "Теги помогают группировать серверы.", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("⏭️ Пропустить", callback_data="srv_skip_tags")], + [InlineKeyboardButton("❌ Отмена", callback_data="server_menu")] + ]) + ) + + elif input_type == "add_server_tags": + # Обработка ввода тегов (если пользователь ввёл текстом, а не нажал кнопку) + tags = [t.strip() for t in text.split(",") if t.strip()] + state.context["new_server"]["tags"] = tags + + # Завершение добавления + new_server = state.context.get("new_server", {}) + if server_manager.add_server( + name=new_server["name"], + host=new_server["host"], + port=new_server["port"], + user=new_server["user"], + tags=tags, + password=new_server.get("password", "") + ): + await update.message.reply_text( + "✅ *Сервер добавлен*\n\n" + f"Имя: `{new_server['name']}`\n" + f"Host: `{new_server['host']}`\n" + f"Port: `{new_server['port']}`\n" + f"User: `{new_server['user']}`\n" + f"Tags: `{','.join(tags)}`\n" + f"Password: {'установлен' if new_server.get('password') else 'не установлен'}\n\n" + f"Сервер сохранён в `.env` и доступен для выбора.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("server") + ) + else: + await update.message.reply_text( + "❌ Ошибка: сервер с таким именем уже существует", + reply_markup=menu_builder.get_keyboard("server") + ) + + state.waiting_for_input = False + state.input_type = None + state.context.clear() + + elif input_type == "edit_server_field": + # Выбор поля для редактирования + if text == "1": + state.input_type = "edit_server_host" + await update.message.reply_text( + "Введите новый *host*:", + parse_mode="Markdown" + ) + elif text == "2": + state.input_type = "edit_server_port" + await update.message.reply_text( + "Введите новый *port*:", + parse_mode="Markdown" + ) + elif text == "3": + state.input_type = "edit_server_user" + await update.message.reply_text( + "Введите нового *user*:", + parse_mode="Markdown" + ) + elif text == "4": + state.input_type = "edit_server_tags" + await update.message.reply_text( + "Введите новые *теги* через запятую:", + parse_mode="Markdown" + ) + elif text == "5": + state.input_type = "edit_server_password" + await update.message.reply_text( + "Введите новый *password* (или оставьте пустым для подключения только по ключу):\n" + "⚠️ Пароль будет сохранён в .env файл в открытом виде!", + parse_mode="Markdown" + ) + else: + await update.message.reply_text( + "❌ Введите номер поля (1-5):", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + return + + elif input_type == "edit_server_host": + server_manager.update_server(state.editing_server, host=text) + await finish_edit_server(update, state) + + elif input_type == "edit_server_port": + try: + port = int(text) + server_manager.update_server(state.editing_server, port=port) + await finish_edit_server(update, state) + except ValueError: + await update.message.reply_text("❌ Неверный формат порта") + return + + elif input_type == "edit_server_user": + server_manager.update_server(state.editing_server, user=text) + await finish_edit_server(update, state) + + elif input_type == "edit_server_tags": + tags = [t.strip() for t in text.split(",") if t.strip()] + server_manager.update_server(state.editing_server, tags=tags) + await finish_edit_server(update, state) + + elif input_type == "edit_server_password": + server_manager.update_server(state.editing_server, password=text) + await finish_edit_server(update, state) + + else: + # Неизвестный тип ввода - выполняем команду + await execute_cli_command_from_message(update, text) + return + + # Сброс состояния после завершения + if not state.waiting_for_input or input_type.startswith("add_server_tags"): + state.waiting_for_input = False + state.input_type = None + state.context.clear() + + +async def finish_edit_server(update: Update, state): + """Завершение редактирования сервера.""" + server_name = state.editing_server + state.waiting_for_input = False + state.input_type = None + state.editing_server = None + + server = server_manager.get(server_name) + if server: + await update.message.reply_text( + "✅ *Сервер обновлён*\n\n" + f"{server.display_name}\n" + f"📍 `{server.description}`", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("server") + ) + else: + await update.message.reply_text( + "❌ Ошибка при обновлении сервера", + reply_markup=menu_builder.get_keyboard("server") + ) + + +async def execute_cli_command_from_message(update: Update, command: str): + """Выполнение CLI команды из сообщения.""" + user_id = update.effective_user.id + state = state_manager.get(user_id) + server_name = state.current_server + server = server_manager.get(server_name) + + # Определяем рабочую директорию + working_dir = state.working_directory or config.working_directory + + # Обработка команды cd - меняем директорию пользователя + # Работает только с простыми командами cd, не с составными + cmd_stripped = command.strip() + if cmd_stripped.startswith("cd ") and "&&" not in cmd_stripped and ";" not in cmd_stripped and "|" not in cmd_stripped: + parts = cmd_stripped.split(maxsplit=1) + if len(parts) == 2: + target_dir = parts[1] + + # Обработка ~ и относительных путей + if target_dir.startswith("~"): + target_dir = str(Path.home()) + target_dir[1:] + elif not target_dir.startswith("/"): + target_dir = str(Path(working_dir) / target_dir) + + # Проверка существования директории + if Path(target_dir).is_dir(): + state.working_directory = target_dir + await update.message.reply_text( + f"📁 *Директория изменена:*\n`{target_dir}`\n" + f"🖥️ Сервер: `{server_name}`", + parse_mode="Markdown" + ) + else: + await update.message.reply_text( + f"❌ *Директория не найдена:*\n`{target_dir}`", + parse_mode="Markdown" + ) + return + + # Для составных команд с cd — выполняем через SSH или локально + if "cd " in cmd_stripped and ("&&" in cmd_stripped or ";" in cmd_stripped): + if server_name == "local" or server is None: + await _execute_composite_command_local(update, cmd_stripped, working_dir) + else: + await _execute_composite_command_ssh(update, cmd_stripped, server, working_dir) + return + + # Обычное выполнение + if server_name == "local" or server is None: + await _execute_local_command_message(update, cmd_stripped, working_dir) + else: + await _execute_ssh_command_message(update, cmd_stripped, server, working_dir) + + +async def _execute_composite_command_local(update: Update, command: str, working_dir: str): + """Выполнение составной команды локально.""" + command_with_pwd = f"{command} && pwd" + logger.info(f"Выполнение составной команды с cd: {command_with_pwd} в директории: {working_dir}") + + try: + process = await asyncio.create_subprocess_shell( + command_with_pwd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=working_dir + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30) + output = stdout.decode("utf-8", errors="replace").strip() + error = stderr.decode("utf-8", errors="replace") + + # Последняя строка - это pwd + if output and process.returncode == 0: + lines = output.split('\n') + final_dir = lines[-1].strip() + if Path(final_dir).is_dir(): + state_manager.get(update.effective_user.id).working_directory = final_dir + output = '\n'.join(lines[:-1]) + + await _show_result_message(update, command, output, error, process.returncode) + + except asyncio.TimeoutError: + await update.message.reply_text("❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд.", parse_mode="Markdown") + except Exception as e: + logger.error(f"Ошибка: {e}") + await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown") + + +async def _execute_composite_command_ssh(update: Update, command: str, server: Server, working_dir: str): + """Выполнение составной команды через SSH с интерактивной сессией.""" + user_id = update.effective_user.id + command_with_pwd = f"{command} && pwd" + + try: + client_keys = [server_manager.ssh_key_path] if server_manager.ssh_key_path else None + + # Подготовка параметров подключения + connect_kwargs = { + "host": server.host, + "port": server.port, + "username": server.user, + "client_host_keys": None, + "known_hosts": None + } + + if client_keys: + connect_kwargs["client_keys"] = client_keys + if server.password: + connect_kwargs["password"] = server.password + + logger.info(f"SSH подключение к {server.host}:{server.port} как {server.user}") + + conn = await asyncssh.connect(**connect_kwargs) + + # Выполнение команды с cd в рабочую директорию + full_command = f"cd {working_dir} && {command_with_pwd}" if working_dir else command_with_pwd + + # Создаем интерактивный процесс с PTY для поддержки ввода + # TERM环境变量设置 для корректной кодировки + process = await conn.create_process( + full_command, + term_type='xterm-256color', + env={'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'} + ) + + # Создаём сессию + session = ssh_session_manager.create_session( + user_id=user_id, + server=server, + working_dir=working_dir, + conn=conn, + process=process, + command=command + ) + + # Читаем вывод с ожиданием завершения процесса + # wait_and_read_ssh решает проблему с returncode, который доступен только после завершения + output, error_output, returncode = await wait_and_read_ssh(process, timeout=30.0) + session.output_buffer = output + session.last_activity = datetime.now() + + # Проверяем тип ввода + input_type = detect_input_type(output) + + if input_type == "password": + session.waiting_for_input = True + session.input_type = "password" + await update.message.reply_text( + f"⏳ *Требуется ввод*\n\n" + f"Команда: `{command}`\n\n" + f"🔐 *Запрошен пароль*\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте пароль в чат:", + parse_mode="Markdown" + ) + return + elif input_type == "confirm": + session.waiting_for_input = True + session.input_type = "confirm" + await update.message.reply_text( + f"⏳ *Требуется ввод*\n\n" + f"Команда: `{command}`\n\n" + f"❓ *Требуется подтверждение*\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте `y` (да) или `n` (нет):", + parse_mode="Markdown" + ) + return + else: + # Обработка pwd для смены директории + if output: + lines = output.strip().split('\n') + final_dir = lines[-1].strip() + if final_dir.startswith('/'): + state_manager.get(user_id).working_directory = final_dir + output = '\n'.join(lines[:-1]) + + ssh_session_manager.close_session(user_id) + await _show_result_message(update, command, output, error_output, returncode) + return + + except asyncssh.Error as e: + logger.error(f"SSH ошибка: {e}") + ssh_session_manager.close_session(user_id) + await update.message.reply_text(f"❌ *SSH ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown") + except asyncio.TimeoutError: + logger.error("Таймаут SSH подключения") + ssh_session_manager.close_session(user_id) + await update.message.reply_text("❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд.", parse_mode="Markdown") + except Exception as e: + logger.error(f"Ошибка: {e}") + ssh_session_manager.close_session(user_id) + await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown") + + +async def _execute_local_command_message(update: Update, command: str, working_dir: str): + """Выполнение локальной команды из сообщения.""" + user_id = update.effective_user.id + + # Для простых команд используем subprocess (быстро и надёжно) + # Для интерактивных команд (sudo, ssh и т.д.) нужен pexpect + logger.info(f"Выполнение локальной команды: {command} в {working_dir}") + + # Проверяем, нужна ли интерактивность + needs_interactive = any(cmd in command for cmd in ['sudo', 'ssh', 'su ', 'passwd', 'login']) + + if needs_interactive: + logger.info("Команда требует интерактивного ввода, используем pexpect") + await _execute_local_command_interactive(update, command, working_dir) + else: + logger.info("Выполняю команду через subprocess") + await _execute_local_command_subprocess(update, command, working_dir) + + +async def _execute_local_command_subprocess(update: Update, command: str, working_dir: str): + """Выполнение локальной команды через subprocess (без интерактивности).""" + try: + logger.info(f"Создаю subprocess: {command}") + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=working_dir + ) + logger.info(f"Process PID: {process.pid}, жду выполнения...") + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30) + logger.info(f"Process завершен с кодом: {process.returncode}") + + output = stdout.decode("utf-8", errors="replace").strip() + error = stderr.decode("utf-8", errors="replace").strip() + + logger.info(f"Output length: {len(output)}, Error length: {len(error)}") + + await _show_result_message(update, command, output, error, process.returncode) + + except asyncio.TimeoutError: + logger.error("Таймаут выполнения команды") + await update.message.reply_text("❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд.", parse_mode="Markdown") + except Exception as e: + logger.error(f"Ошибка: {e}") + await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown") + + +async def _execute_local_command_interactive(update: Update, command: str, working_dir: str): + """Выполнение локальной команды через pexpect (с поддержкой интерактивности).""" + user_id = update.effective_user.id + + try: + logger.info(f"Запуск команды через pexpect: {command}") + + # Создаём интерактивный процесс + child = pexpect.spawn( + '/bin/bash', + ['-c', command], + cwd=working_dir, + encoding='utf-8', + codec_errors='replace', + echo=False, + timeout=30 + ) + + # Создаём сессию + session = local_session_manager.create_session( + user_id=user_id, + command=command, + master_fd=child.child_fd, + pid=child.pid + ) + session.context = {'child': child} + + # Читаем начальный вывод + logger.info("Чтение вывода...") + output = "" + + try: + # Пробуем прочитать с таймаутом + while True: + line = child.read_nonblocking(size=4096, timeout=2.0) + if not line: + break + output += line + logger.debug(f"Прочитано: {len(line)} символов") + + # Проверяем запрос ввода + if detect_input_type(output): + break + + except pexpect.TIMEOUT: + pass + except pexpect.EOF: + pass + + logger.info(f"Прочитано: {len(output)} символов") + session.output_buffer = output + session.last_activity = datetime.now() + + # Проверяем тип ввода + input_type = detect_input_type(output) + logger.info(f"Тип ввода: {input_type}") + + if input_type == "password": + session.waiting_for_input = True + session.input_type = "password" + await update.message.reply_text( + f"⏳ *Требуется ввод*\n\n" + f"Команда: `{command}`\n\n" + f"🔐 *Запрошен пароль*\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте пароль в чат:", + parse_mode="Markdown" + ) + return + elif input_type == "confirm": + session.waiting_for_input = True + session.input_type = "confirm" + await update.message.reply_text( + f"⏳ *Требуется ввод*\n\n" + f"Команда: `{command}`\n\n" + f"❓ *Требуется подтверждение*\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте `y` (да) или `n` (нет):", + parse_mode="Markdown" + ) + return + else: + # Команда завершена + local_session_manager.close_session(user_id) + await _show_result_message(update, command, output, "", 0) + + except Exception as e: + logger.error(f"Ошибка выполнения команды: {e}") + local_session_manager.close_session(user_id) + await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown") + + +async def _execute_ssh_command_message(update: Update, command: str, server: Server, working_dir: str): + """Выполнение команды через SSH из сообщения с интерактивной сессией.""" + user_id = update.effective_user.id + + try: + client_keys = [server_manager.ssh_key_path] if server_manager.ssh_key_path else None + + # Подготовка параметров подключения + connect_kwargs = { + "host": server.host, + "port": server.port, + "username": server.user, + "client_host_keys": None, + "known_hosts": None + } + + if client_keys: + connect_kwargs["client_keys"] = client_keys + if server.password: + connect_kwargs["password"] = server.password + + logger.info(f"SSH подключение к {server.host}:{server.port} как {server.user}") + + conn = await asyncssh.connect(**connect_kwargs) + + # Выполнение команды с cd в рабочую директорию + full_command = f"cd {working_dir} && {command}" if working_dir else command + + # Создаем интерактивный процесс с PTY для поддержки ввода + # TERM环境变量设置 для корректной кодировки + process = await conn.create_process( + full_command, + term_type='xterm-256color', + env={'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'} + ) + + # Создаём сессию + session = ssh_session_manager.create_session( + user_id=user_id, + server=server, + working_dir=working_dir, + conn=conn, + process=process, + command=command + ) + + # Читаем вывод с ожиданием завершения процесса + # wait_and_read_ssh решает проблему с returncode, который доступен только после завершения + output, error_output, returncode = await wait_and_read_ssh(process, timeout=30.0) + session.output_buffer = output + session.last_activity = datetime.now() + + # Проверяем тип ввода + input_type = detect_input_type(output) + + if input_type == "password": + session.waiting_for_input = True + session.input_type = "password" + await update.message.reply_text( + f"⏳ *Требуется ввод*\n\n" + f"Команда: `{command}`\n\n" + f"🔐 *Запрошен пароль*\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте пароль в чат:", + parse_mode="Markdown" + ) + return + elif input_type == "confirm": + session.waiting_for_input = True + session.input_type = "confirm" + await update.message.reply_text( + f"⏳ *Требуется ввод*\n\n" + f"Команда: `{command}`\n\n" + f"❓ *Требуется подтверждение*\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте `y` (да) или `n` (нет):", + parse_mode="Markdown" + ) + return + else: + # Команда завершена, показываем результат + ssh_session_manager.close_session(user_id) + await _show_result_message(update, command, output, error_output, returncode) + return + + except asyncssh.Error as e: + logger.error(f"SSH ошибка: {e}") + ssh_session_manager.close_session(user_id) + await update.message.reply_text(f"❌ *SSH ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown") + except asyncio.TimeoutError: + logger.error("Таймаут SSH подключения") + ssh_session_manager.close_session(user_id) + await update.message.reply_text("❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд.", parse_mode="Markdown") + except Exception as e: + logger.error(f"Ошибка: {e}") + ssh_session_manager.close_session(user_id) + await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown") + + +async def _show_result_message(update: Update, command: str, output: str, error: str, returncode: int): + """Показ результата выполнения команды.""" + logger.info(f"_show_result_message: output_len={len(output)}, error_len={len(error)}") + + # Очистка ANSI-кодов и нормализация + if output: + output = clean_ansi_codes(output) + logger.info(f"После clean_ansi_codes: output_len={len(output)}") + output = normalize_output(output) + logger.info(f"После normalize_output: output_len={len(output)}") + else: + output = "" + + error = clean_ansi_codes(error) if error else "" + + result = f"✅ **Результат:**\n\n" + + if output: + # Показываем ВЕСЬ вывод, разбивая на сообщения если нужно + # НЕ экранируем backticks — send_long_message сам разобьёт на блоки + result += f"```\n{output}\n```\n" + logger.info(f"Добавлен output в результат, длина result={len(result)}") + else: + logger.warning("output пустой после обработки!") + + if error: + # НЕ экранируем backticks + result += f"**Ошибки:**\n```\n{error}\n```\n" + + result += f"\n**Код возврата:** `{returncode}`" + + # Экранируем специальные символы Markdown ТОЛЬКО вне блоков кода + result = smart_escape_markdown(result) + logger.info(f"Отправляю сообщение, длина={len(result)}") + await send_long_message(update, result, parse_mode="Markdown", pause_every=3) + logger.info("Сообщение отправлено") + + +def smart_escape_markdown(text: str) -> str: + """ + Умное экранирование Markdown-символов — только вне блоков кода. + Не трогает уже существующую разметку **, _, `. + """ + # Разбиваем на части: внутри ``` и снаружи + parts = text.split("```") + escaped_parts = [] + + for i, part in enumerate(parts): + if i % 2 == 0: + # Вне блоков кода — экранируем специальные символы Markdown + # Экранируем *, _, `, [, ], (, ), #, +, -, ., ! + escaped = part.replace('\\', '\\\\') # Сначала экранируем backslash + escaped = escaped.replace('*', '\\*') + escaped = escaped.replace('_', '\\_') + escaped = escaped.replace('`', '\\`') + escaped = escaped.replace('[', '\\[') + escaped = escaped.replace(']', '\\]') + escaped = escaped.replace('(', '\\(') + escaped = escaped.replace(')', '\\)') + escaped = escaped.replace('#', '\\#') + escaped = escaped.replace('+', '\\+') + escaped = escaped.replace('-', '\\-') + escaped = escaped.replace('.', '\\.') + escaped = escaped.replace('!', '\\!') + escaped_parts.append(escaped) + else: + # Внутри блоков кода — не трогаем + escaped_parts.append(part) + + return "```".join(escaped_parts) + + +async def post_init(application: Application): + """Инициализация после запуска бота.""" + # Установка команд бота + commands = [ + BotCommand("start", "Запустить бота"), + BotCommand("menu", "Главное меню с кнопками"), + BotCommand("help", "Справка"), + BotCommand("settings", "Настройки"), + BotCommand("cron", "Управление задачами"), + BotCommand("stop", "Прервать SSH-сессию"), + BotCommand("restart_bot", "Перезапустить бота"), + BotCommand("qwen_auth", "Авторизовать Qwen Code"), + BotCommand("ai_presets", "🎛️ Выбор AI-провайдера"), + BotCommand("ai_off", "⌨️ ИИ Отключен (CLI режим)"), + BotCommand("ai_qwen", "💻 Qwen Code (бесплатно)"), + BotCommand("ai_giga_auto", "🧠 GigaChat Авто (Lite/Pro)"), + BotCommand("ai_giga_lite", "🚀 GigaChat Lite (дешево)"), + BotCommand("ai_giga_pro", "👑 GigaChat Pro (максимум)"), + BotCommand("memory", "Статистика памяти ИИ"), + BotCommand("facts", "Показать сохранённые факты"), + BotCommand("forget", "Удалить факт по номеру"), + ] + await application.bot.set_my_commands(commands) + + # Инициализация компактора диалогов + from bot.compaction import init_compactor + global compactor + compactor = init_compactor(qwen_manager, hybrid_memory_manager.vector) + logger.info("🔄 Компактор диалогов инициализирован") + + # Инициализация планировщика cron-задач + cron_tool = tools_registry.get('cron_tool') + if cron_tool: + scheduler = init_scheduler(cron_tool, ai_agent, send_notification=send_cron_notification) + await scheduler.start() + logger.info("🕐 Планировщик cron-задач инициализирован") + else: + logger.warning("⚠️ Cron инструмент не найден, планировщик не запущен") + + logger.info("Бот инициализирован") + + # Проверяем, был ли запрошен перезапуск пользователем + await check_restart_and_notify(application) + + +async def check_restart_and_notify(application): + """Проверить файл перезапуска и отправить уведомление пользователю.""" + import os + import json + + restart_file = "/tmp/telegram_bot_restart.json" + + try: + if os.path.exists(restart_file): + with open(restart_file, 'r', encoding='utf-8') as f: + data = json.load(f) + + user_id = data.get('user_id') + + if user_id: + logger.info(f"📢 Отправка уведомления о запуске пользователю {user_id}") + + # Получаем состояние пользователя + state = state_manager.get(user_id) + + # Показываем текущую директорию и сервер + working_dir = state.working_directory or config.working_directory + server = server_manager.get(state.current_server) + server_desc = server.description if server else state.current_server + + # Отправляем сообщение напрямую + await application.bot.send_message( + chat_id=user_id, + text=f"✅ Бот перезапущен!\n\n🖥️ Сервер: {server_desc}\n📁 Директория: {working_dir}\n\nГотов к работе! Отправьте команду или выберите действие в меню:", + reply_markup=menu_builder.get_keyboard("main", user_id=user_id, state=state) + ) + logger.info(f"✅ Уведомление отправлено пользователю {user_id}") + + # Удаляем файл + os.remove(restart_file) + + except Exception as e: + logger.error(f"❌ Ошибка при отправке уведомления о перезапуске: {e}") + + +async def send_cron_notification(user_id: int, message: str): + """ + Отправить уведомление пользователю о результате cron-задачи. + + Args: + user_id: ID пользователя в Telegram + message: Текст уведомления + """ + try: + # Получаем application из контекста + from telegram.ext import Application + app = Application.get_instance() + + if app: + await app.bot.send_message( + chat_id=user_id, + text=message, + parse_mode="Markdown" + ) + logger.info(f"🔔 Уведомление отправлено пользователю {user_id}") + else: + logger.warning("Application не инициализирован, уведомление не отправлено") + except Exception as e: + logger.exception(f"Ошибка отправки уведомления: {e}") + + +@check_access +async def stop_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /stop - прерывание активной SSH-сессии.""" + user_id = update.effective_user.id + + session = ssh_session_manager.get_session(user_id) + if session: + ssh_session_manager.close_session(user_id) + await update.message.reply_text( + "❌ *SSH-сессия прервана*\n\n" + f"Команда `{session.command}` была остановлена.", + parse_mode="Markdown" + ) + else: + await update.message.reply_text( + "ℹ️ *Нет активных SSH-сессий*\n\n" + "У вас нет выполняющихся команд.", + parse_mode="Markdown" + ) + + + + +# ============================================ +# КОМАНДА ПЕРЕЗАПУСКА БОТА +# ============================================ + +@check_access +async def restart_bot_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /restart_bot - перезапуск бота через systemctl.""" + user_id = update.effective_user.id + state = state_manager.get(user_id) + + # Устанавливаем флаг ожидания пароля + state.waiting_for_restart_password = True + + # Отключаем ИИ на время ввода пароля + state.ai_chat_mode = False + + await update.message.reply_text( + "🔄 **Перезапуск бота**\n\n" + "Для перезапуска требуется ввести пароль `sudo`.\n\n" + "🔐 *Отправьте пароль в чат:*\n\n" + "_После ввода пароль будет использован для команды:_\n" + "`sudo systemctl restart telegram-bot`\n\n" + "⚠️ *Бот будет перезапущен, соединение прервётся.*", + parse_mode="Markdown" + ) + + +# ============================================ +# QWEN OAUTH АВТОРИЗАЦИЯ +# ============================================ + +@check_access +async def qwen_auth_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /qwen_auth - авторизация Qwen Code.""" + user_id = update.effective_user.id + state = state_manager.get(user_id) + + from bot.utils.qwen_oauth import get_authorization_url, is_authorized + + # Проверяем есть ли уже валидный токен + if await is_authorized(): + await update.message.reply_text( + "✅ **Qwen уже авторизован!**\n\n" + "Токен действителен и готов к использованию.", + parse_mode="Markdown" + ) + return + + # Получаем OAuth URL + oauth_url = await get_authorization_url() + + if not oauth_url: + await update.message.reply_text( + "❌ **Ошибка получения OAuth URL**\n\n" + "Не удалось получить ссылку для авторизации. Попробуйте позже.", + parse_mode="Markdown" + ) + return + + # Устанавливаем флаг ожидания + state.waiting_for_qwen_oauth = True + + await update.message.reply_text( + "🔐 **Авторизация Qwen Code**\n\n" + "Для работы с Qwen Code необходимо авторизоваться.\n\n" + "🔗 **Откройте ссылку:**\n" + f"`{oauth_url}`\n\n" + "📋 **Инструкция:**\n" + "1. Нажмите на ссылку или скопируйте её в браузер\n" + "2. Войдите через Google или GitHub\n" + "3. Разрешите доступ\n" + "4. Вернитесь в Telegram и отправьте любое сообщение\n\n" + "_Бот автоматически проверит завершение авторизации._", + parse_mode="Markdown" + ) + + +async def handle_qwen_oauth_completion(update: Update, text: str): + """Проверка завершения OAuth авторизации.""" + user_id = update.effective_user.id + state = state_manager.get(user_id) + + from bot.utils.qwen_oauth import check_authorization_complete, is_authorized + + # Проверяем завершение авторизации + if await check_authorization_complete(): + state.waiting_for_qwen_oauth = False + + if await is_authorized(): + await update.message.reply_text( + "✅ **Авторизация успешна!**\n\n" + "Qwen Code готов к работе. Отправьте задачу.", + parse_mode="Markdown" + ) + return + + # Если авторизация ещё не завершена — показываем статус + await update.message.reply_text( + "⏳ **Проверка авторизации...**\n\n" + "Если вы уже авторизовались, бот продолжит работу.\n" + "Если нет — откройте ссылку для авторизации.", + parse_mode="Markdown" + ) + + +async def handle_restart_password(update: Update, text: str): + """Обработка пароля для перезапуска бота.""" + user_id = update.effective_user.id + state = state_manager.get(user_id) + password = text.strip() + + logger.info(f"Пользователь {user_id} ввёл пароль для перезапуска бота") + + # Сбрасываем флаг + state.waiting_for_restart_password = False + + try: + # Сохраняем user_id в файл для уведомления после перезапуска + import json + import os + restart_file = "/tmp/telegram_bot_restart.json" + with open(restart_file, 'w', encoding='utf-8') as f: + json.dump({'user_id': user_id}, f, ensure_ascii=False) + + # Отправляем сообщение о начале перезапуска + await update.message.reply_text( + "⏳ *Выполнение перезапуска...*\n\n" + f"Пароль принят, выполняю команду.\n\n" + "_Бот будет недоступен несколько секунд._", + parse_mode="Markdown" + ) + + # Создаём временный скрипт с паролем + import tempfile + script_file = tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False) + # Используем script для создания псевдо-терминала + script_file.write(f"""#!/bin/bash +printf '%s\\n' '{password}' | sudo -S systemctl restart telegram-bot +""") + script_file.close() + os.chmod(script_file.name, 0o755) + + # Запускаем через script для PTY + process = await asyncio.create_subprocess_exec( + 'script', '-q', '-c', f'/bin/bash {script_file.name}', '/dev/null', + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15) + + # Удаляем скрипт + os.remove(script_file.name) + + if process.returncode == 0: + logger.info(f"Бот успешно перезапущен пользователем {user_id}") + else: + error_msg = stderr.decode('utf-8', errors='replace').strip() + logger.error(f"Ошибка перезапуска: {error_msg}") + # Удаляем файл если ошибка + if os.path.exists(restart_file): + os.remove(restart_file) + await update.message.reply_text( + f"❌ *Ошибка перезапуска:*\n```\n{error_msg}\n```", + parse_mode="Markdown" + ) + + except asyncio.TimeoutError: + logger.error("Таймаут при перезапуске бота") + await update.message.reply_text( + "❌ *Ошибка*\n\n" + "Таймаут выполнения команды перезапуска.", + parse_mode="Markdown" + ) + if os.path.exists("/tmp/telegram_bot_restart.json"): + os.remove("/tmp/telegram_bot_restart.json") + except Exception as e: + logger.exception(f"Ошибка при перезапуске бота: {e}") + await update.message.reply_text( + "❌ *Ошибка*\n\n" + f"```\n{str(e)}\n```", + parse_mode="Markdown" + ) + if os.path.exists("/tmp/telegram_bot_restart.json"): + os.remove("/tmp/telegram_bot_restart.json") + + +# ============================================ +# КОМАНДЫ ДЛЯ РАБОТЫ С QWEN CODE (ИИ) +# ============================================ + +@check_access +async def ai_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /ai - выполнение задачи через Qwen Code.""" + user_id = update.effective_user.id + task = " ".join(context.args).strip() + + if not task: + await update.message.reply_text( + "🤖 *Qwen Code AI*\n\n" + "Использование:\n" + "`/ai <задача>`\n\n" + "Примеры:\n" + "`/ai создай функцию Python для сортировки списка`\n" + "`/ai объясни код в файле main.py`\n" + "`/ai найди баги в этом коде`\n\n" + "Команды:\n" + "`/ai status` — статус сессии\n" + "`/ai stop` — завершить сессию\n" + "`/ai clear` — очистить историю диалога", + parse_mode="Markdown" + ) + return + + # Специальные команды + if task == "status": + session = qwen_manager.get_session(user_id) + if session: + await update.message.reply_text( + f"🤖 *Статус сессии Qwen Code*\n\n" + f"Состояние: `{session.state.value}`\n" + f"Последняя активность: {session.last_activity.strftime('%H:%M:%S')}\n" + f"Задача: `{session.pending_task or 'Нет'}`", + parse_mode="Markdown" + ) + else: + await update.message.reply_text("ℹ️ Активных сессий нет") + return + + if task == "stop": + qwen_manager.close_session(user_id) + await update.message.reply_text("✅ Сессия Qwen Code завершена") + return + + if task == "clear": + state = state_manager.get(user_id) + state.ai_chat_history.clear() + await update.message.reply_text("✅ История диалога с ИИ очищена") + return + + # Отправляем задачу в ИИ + status_msg = await update.message.reply_text("⏳ 🤖 Думаю...", parse_mode="Markdown") + + output_buffer = [] + + def on_output(text: str): + output_buffer.append(text) + + def on_oauth_url(url: str): + pass # OAuth обрабатывается автоматически при первом запуске + + # Выполняем задачу + result = await qwen_manager.run_task(user_id, task, on_output, on_oauth_url) + + # Показываем результат + full_output = "".join(output_buffer).strip() + + if not full_output: + full_output = result + + if len(full_output) > 4000: + full_output = full_output[:4000] + "\n... (вывод обрезан)" + + await status_msg.edit_text( + f"🤖 *Результат:*\n\n" + f"```\n{full_output}\n```", + parse_mode="Markdown" + ) + + +@check_access +async def compact_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /compact — ручная компактификация истории диалога.""" + user_id = update.effective_user.id + + logger.info(f"Пользователь {user_id} запросил ручную компактификацию") + + status_msg = await update.message.reply_text( + "🔄 **Запуск компактификации истории...**\n\n" + "_Сжатие старой истории в структурированный summary._\n" + "_Это может занять несколько секунд._", + parse_mode="Markdown" + ) + + result = await compactor.compact() + + await status_msg.delete() + + if result.success: + if result.messages_compressed > 0: + await update.message.reply_text( + f"✅ **Компактификация завершена!**\n\n" + f"📊 Сжато сообщений: `{result.messages_compressed}`\n" + f"📝 Длина summary: `{result.summary_length}` символов\n" + f"💾 Экономия токенов: ~`{result.tokens_saved}`\n\n" + f"_Summary автоматически используется в контексте диалога._", + parse_mode="Markdown" + ) + else: + await update.message.reply_text( + "ℹ️ **Компактификация не требуется**\n\n" + "_Недостаточно сообщений для сжатия или summary уже актуален._", + parse_mode="Markdown" + ) + else: + logger.error(f"Компактификация не удалась: {result.error}") + await update.message.reply_text( + f"⚠️ **Ошибка компактификации:**\n`{result.error}`", + parse_mode="Markdown" + ) + + +@check_access +async def memory_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /memory — статистика памяти ИИ.""" + user_id = update.effective_user.id + + stats = get_memory_stats(user_id) + + if not stats: + await update.message.reply_text("ℹ️ Память не инициализирована") + return + + # Форматируем статистику + total_messages = stats.get("total_messages", 0) + total_facts = stats.get("total_facts", 0) + total_sessions = stats.get("total_sessions", 0) + vector_docs = stats.get("vector_documents", "N/A") + vector_model = stats.get("vector_model", "N/A") + hybrid_mode = stats.get("hybrid_mode", False) + + text = ( + "🧠 *Статистика памяти:*\n\n" + f"📊 Сообщений: `{total_messages}`\n" + f"📌 Фактов: `{total_facts}`\n" + f"📁 Сессий: `{total_sessions}`\n" + ) + + if hybrid_mode: + text += ( + f"\n🔮 *Векторная память:*\n" + f" Документы: `{vector_docs}`\n" + f" Модель: `{vector_model}`\n" + ) + + text += "\n_Память использует SQLite + ChromaDB с семантическим поиском._" + + await update.message.reply_text(text, parse_mode="Markdown") + + +@check_access +async def facts_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /facts — показать сохранённые факты.""" + user_id = update.effective_user.id + + # Получаем факты из SQLite + from memory_system import memory_manager + facts = memory_manager.storage.get_facts(user_id) + + if not facts: + await update.message.reply_text( + "📋 *Ваши факты*\n\n" + "Пока нет сохранённых фактов.\n" + "Общайтесь с ИИ в чате — он автоматически запомнит важное!", + parse_mode="Markdown" + ) + return + + # Группируем по типам + from memory_system import FactType + grouped = {} + for fact in facts: + type_name = fact.fact_type.value + if type_name not in grouped: + grouped[type_name] = [] + grouped[type_name].append(fact) + + # Формируем сообщение + type_names_ru = { + "personal": "👤 Личное", + "technical": "💻 Технологии", + "project": "📁 Проекты", + "preference": "⭐ Предпочтения", + "other": "📌 Другое" + } + + text = "📋 *Ваши сохранённые факты:*\n\n" + + for type_name, type_facts in grouped.items(): + type_title = type_names_ru.get(type_name, type_name) + text += f"*{type_title}* ({len(type_facts)}):\n" + + for i, fact in enumerate(type_facts, 1): + # Обрезаем длинные факты + content = fact.content + if len(content) > 100: + content = content[:100] + "..." + text += f" {i}. {content}\n" + + text += "\n" + + text += f"_Всего: {len(facts)} фактов_\n" + text += "_Для удаления факта используйте `/forget <номер>`_" + + await update.message.reply_text(text, parse_mode="Markdown") + + +@check_access +async def forget_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /forget — удалить факт.""" + user_id = update.effective_user.id + + if not context.args or not context.args[0].isdigit(): + await update.message.reply_text( + "❌ *Использование:*\n" + "`/forget <номер>`\n\n" + "Сначала вызовите `/facts` чтобы увидеть список.", + parse_mode="Markdown" + ) + return + + # Получаем факты + from memory_system import memory_manager + facts = memory_manager.storage.get_facts(user_id) + + fact_index = int(context.args[0]) - 1 + + if fact_index < 0 or fact_index >= len(facts): + await update.message.reply_text( + f"❌ Факт с номером {fact_index + 1} не найден.\n" + f"Всего фактов: {len(facts)}", + parse_mode="Markdown" + ) + return + + # Удаляем факт + fact_to_delete = facts[fact_index] + memory_manager.storage.update_fact(fact_to_delete.id, is_active=False) + + await update.message.reply_text( + f"✅ Факт удалён:\n_{fact_to_delete.content}_", + parse_mode="Markdown" + ) + + +PID_FILE = "/tmp/telegram_bot.pid" + + +def _check_pid_file(): + """Проверить и создать PID-файл. Возвращает False если бот уже запущен.""" + pid_file = PID_FILE + if os.path.exists(pid_file): + try: + with open(pid_file, 'r') as f: + old_pid = int(f.read().strip()) + # Проверяем процесс + if os.path.exists(f'/proc/{old_pid}'): + print(f"❌ Бот уже запущен (PID: {old_pid})") + print(" Остановите: sudo systemctl stop telegram-bot") + return False + else: + print(f"⚠️ Stale PID файл (процесс {old_pid} не существует), удаляю") + os.remove(pid_file) + except (ValueError, FileNotFoundError): + os.remove(pid_file) + + # Записываем текущий PID + with open(pid_file, 'w') as f: + f.write(str(os.getpid())) + return True + + +def _remove_pid_file(): + """Удалить PID-файл при выходе.""" + try: + if os.path.exists(PID_FILE): + os.remove(PID_FILE) + except Exception: + pass + + +def _setup_signal_handlers(loop, application): + """Настроить обработчики сигналов для graceful shutdown.""" + import signal + + def signal_handler(sig): + logger.info(f"Получен сигнал {sig}, завершаю работу...") + loop.create_task(application.stop()) + + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, lambda s=sig: signal_handler(s)) + + +def main(): + """Точка входа.""" + # Проверка PID-файла + if not _check_pid_file(): + sys.exit(1) + + # Чтение токена только из переменной окружения + token = os.getenv("TELEGRAM_BOT_TOKEN") + + if not token: + print("❌ Ошибка: не установлен TELEGRAM_BOT_TOKEN") + print("\nСпособы установки токена:") + print(" 1. Создайте файл .env по примеру .env.example") + print(" 2. Или задайте переменную окружения:") + print(" export TELEGRAM_BOT_TOKEN='your_token_here'") + print("\nИли запустите ./run.sh для интерактивной настройки") + _remove_pid_file() + sys.exit(1) + + # Проверка настроек прокси + use_proxy = os.getenv("USE_PROXY", "false").lower() == "true" + proxy_url = None + + if use_proxy: + proxy_host = os.getenv("PROXY_HOST", "127.0.0.1") + proxy_port = os.getenv("PROXY_PORT", "1080") + proxy_username = os.getenv("PROXY_USERNAME", "") + proxy_password = os.getenv("PROXY_PASSWORD", "") + + # Формируем URL прокси: socks5://user:pass@host:port + if proxy_username and proxy_password: + proxy_url = f"socks5://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}" + else: + proxy_url = f"socks5://{proxy_host}:{proxy_port}" + + print(f"✅ Прокси включён: {proxy_url.split('@')[0]}@{proxy_host}:{proxy_port}") + + # Загрузка серверов из env + server_manager.load_from_env() + + # Инициализация меню + init_menus(menu_builder) + + # Инициализация AIProviderManager + from qwen_integration import qwen_manager + from bot.tools import tools_registry + init_ai_provider_manager(qwen_manager, tools_registry) + + # Создание приложения с таймаутами и прокси + builder = ( + Application.builder() + .token(token) + .post_init(post_init) + .read_timeout(30) + .write_timeout(30) + .connect_timeout(30) + .pool_timeout(30) + ) + + # Добавляем прокси если включён + if use_proxy and proxy_url: + builder = builder.proxy_url(proxy_url) + logger.info(f"Используется SOCKS5 прокси: {proxy_host}:{proxy_port}") + + application = builder.build() + + # Регистрация хендлеров + application.add_handler(CommandHandler("start", start_command)) + application.add_handler(CommandHandler("help", help_command)) + application.add_handler(CommandHandler("settings", settings_command)) + application.add_handler(CommandHandler("cron", cron_command)) + application.add_handler(CommandHandler("rss", rss_command)) + application.add_handler(CommandHandler("menu", menu_command)) + application.add_handler(CommandHandler("stop", stop_command)) + application.add_handler(CommandHandler("restart_bot", restart_bot_command)) + application.add_handler(CommandHandler("qwen_auth", qwen_auth_command)) + application.add_handler(CommandHandler("memory", memory_command)) + application.add_handler(CommandHandler("compact", compact_command)) + application.add_handler(CommandHandler("facts", facts_command)) + application.add_handler(CommandHandler("forget", forget_command)) + application.add_handler(CommandHandler("ai", ai_command)) + + # AI-пресеты + from bot.handlers.ai_presets import register_ai_preset_handlers + register_ai_preset_handlers(application) + + # Обработчики файлов + from bot.handlers.files import register_file_handlers + register_file_handlers(application) + + application.add_handler(CallbackQueryHandler(menu_callback)) + application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text_message)) + + # Запуск + logger.info("Запуск бота...") + print(f"🤖 {config.name} запущен!") + print(f"📝 Описание: {config.description}") + print(f"🎨 Иконка: {config.icon}") + print("\nОстановка: Ctrl+C") + + # Настройка обработки сигналов для graceful shutdown + import signal + + def shutdown_handler(sig): + logger.info(f"Получен сигнал {sig.name}, завершаю работу...") + asyncio.create_task(application.stop()) + + for sig in (signal.SIGTERM, signal.SIGINT): + signal.signal(sig, lambda s, f: asyncio.create_task(application.stop())) + + try: + application.run_polling(allowed_updates=Update.ALL_TYPES) + finally: + _remove_pid_file() + logger.info("Бот остановлен, PID файл удалён") + + +if __name__ == "__main__": + main() diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..1964561 --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +""" +Telegram CLI Bot - модульная структура. + +Пакет bot содержит все компоненты бота: +- models: модели данных (Server, UserState, сессии) +- handlers: обработчики событий (команды, сообщения, callback) +- services: бизнес-логика (выполнение команд) +- keyboards: Inline-клавиатуры +- utils: утилиты (очистка текста, форматирование) +- config: конфигурация и глобальные объекты +""" + +from bot.config import config, state_manager, menu_builder, command_registry, server_manager + +__all__ = [ + "config", + "state_manager", + "menu_builder", + "command_registry", + "server_manager", +] diff --git a/bot/ai_agent.py b/bot/ai_agent.py new file mode 100644 index 0000000..d344aa7 --- /dev/null +++ b/bot/ai_agent.py @@ -0,0 +1,786 @@ +#!/usr/bin/env python3 +""" +AI Agent Module - автономный агент с инструментами. + +Агент может самостоятельно принимать решения об использовании инструментов +на основе контекста запроса пользователя. +""" + +import logging +import re +from typing import Optional, List, Dict, Any +from dataclasses import dataclass +from datetime import datetime + +from bot.tools import tools_registry, ToolResult + +logger = logging.getLogger(__name__) + + +@dataclass +class AgentDecision: + """Решение агента об использовании инструмента.""" + should_use_tool: bool + tool_name: Optional[str] = None + tool_args: Optional[Dict[str, Any]] = None + confidence: float = 0.0 + reasoning: str = "" + + +class AIAgent: + """ + AI-агент с доступом к инструментам. + + Агент анализирует запрос и решает, нужно ли использовать + какой-либо инструмент для выполнения задачи. + """ + + # Триггеры для поиска в интернете + SEARCH_TRIGGERS = [ + 'найди', 'поиск', 'погугли', 'узнай', 'проверь в интернете', + 'что нового', 'последние новости', 'свежая информация', + 'как сделать', 'руководство', 'документация', 'tutorial', + 'weather', 'news', 'search', 'find', 'look up', + 'что это', 'кто такой', 'где находится', 'когда выйдет', + 'скачай', 'загрузи', 'найди информацию', 'посмотри в сети' + ] + + # Триггеры для RSS — ТОЛЬКО явные запросы новостей + # Используем полные фразы чтобы избежать ложных срабатываний + RSS_TRIGGERS = [ + 'почитай новости', 'покажи новости', 'что нового в linux', + 'новости it', 'tech news', 'opensource новости', 'linux новости', + 'новости технологий', 'rss лента', 'дайджест новостей', + 'свежие новости it', 'последние новости it', 'новости linux', + 'it новости', 'новости opensource', 'лента новостей' + ] + + # Триггеры для SSH-команд + SSH_TRIGGERS = [ + 'выполни команду', 'ssh', 'запусти на сервере', 'проверь сервер', + 'посмотри логи', 'покажи процесс', 'сколько места', 'df', 'top', + 'перезапусти', 'останови', 'запусти сервис', 'systemctl', + 'проверь нагрузку', 'uptime', 'кто залогинен', 'who', 'last', + 'посмотри в /var/log', 'проверь диск', 'мониторинг', + 'выполни на 192.168.1', 'запусти скрипт', 'cron' + ] + + # Триггеры для Cron-задач + CRON_TRIGGERS = [ + 'напомни', 'запланируй', 'каждый день', 'каждый час', + 'периодически', 'по расписанию', 'автоматически', + 'создай задачу', 'добавь в cron', 'регулярно', + 'повторяй', 'каждую неделю', 'ежедневно', 'ежечасно' + ] + + # Триггеры для работы с файлами (File System Tool) + FILE_SYSTEM_TRIGGERS = [ + 'прочитай файл', 'покажи файл', 'открой файл', 'посмотри файл', + 'создай файл', 'запиши в файл', 'сохрани в файл', + 'скопируй файл', 'перемести файл', 'удали файл', + 'создай директорию', 'создай папку', 'покажи директорию', + 'список файлов', 'что в папке', 'что в директории', + 'найди файл', 'поиск файла', 'переименуй файл', + 'посмотри содержимое', 'содержимое файла', 'cat ', + 'ls ', 'mkdir ', 'cp ', 'mv ', 'rm ', 'touch ', + 'сохрани текст', 'запиши текст', 'скопируй', 'перемести', + 'удали директорию', 'удали папку', 'покажи файлы' + ] + + # Триггеры для Telegram Web Tool (каналы) + TELEGRAM_TRIGGERS = [ + 'добавь канал', 'добавь телеграм', 'подпишись на канал', + 'покажи каналы', 'список каналов', 'каналы telegram', + 'прочитай канал', 'сообщения из канала', 'что в канале', + 'telegram канал', 'телеграм канал', 't.me/', + 'добавь список каналов', 'каналы:' + ] + + def __init__(self): + self.registry = tools_registry + self._tool_use_history: List[Dict] = [] + self._user_preferences: Dict[int, Dict] = {} # preferences per user + + def _should_search(self, message: str) -> tuple[bool, float]: + """Проверить, нужен ли поиск в интернете.""" + message_lower = message.lower() + score = 0.0 + + # ❌ БЛОКИРОВКА: Если пользователь реагирует на действие бота — НЕ активируем + # Это предотвращает циклические запуски когда пользователь критикует бота + reaction_patterns = [ + r'ты\s+\w+\s+запусти', # "ты ddgs запустил" + r'ты\s+опять', # "ты опять" + r'ты\s+снова', # "ты снова" + r'зачем\s+ты', # "зачем ты" + r'почему\s+ты', # "почему ты" + r'перестань', # "перестань" + r'хватит', # "хватит" + r'не\s+надо', # "не надо" + r'не\s+нужно', # "не нужно" + r'я\s+не\s+просил', # "я не просил" + r'я\s+не\s+говорил', # "я не говорил" + r'реакци', # "реакция", "реакцию" + r'критик', # "критика", "критикуешь" + r'баг', # "баг", "баги" + r'ошибк', # "ошибка", "ошибся" + r'проблем', # "проблема", "проблему" + r'нерелевант', # "нерелевантно", "нерелевантный запрос" + r'неправильн', # "неправильно", "неправильный ответ" + r'не то', # "не то", "не то искал" + r'глюк', # "глюк", "глючит" + r'ложн', # "ложное срабатывание" + r'срабатыва', # "срабатывает", "срабатывание" + ] + for pattern in reaction_patterns: + if re.search(pattern, message_lower): + logger.debug(f"Блокировка search: пользователь реагирует на действие бота (паттерн: {pattern})") + return False, 0.0 + + # Прямые триггеры — высокий приоритет + # Используем паттерн с границами для избежания частичных совпадений + for trigger in self.SEARCH_TRIGGERS: + escaped_trigger = re.escape(trigger) + pattern = rf'(?:^|[\s,\.!?;:])({escaped_trigger})(?:$|[\s,\.!?;:])' + if re.search(pattern, message_lower): + return True, 0.9 + + # Вопросы с "что", "как", "где", "когда" о внешних фактах + question_words = ['что такое', 'как сделать', 'где найти', 'когда будет'] + for qword in question_words: + if qword in message_lower: + score = max(score, 0.7) + + # Упоминания текущих событий + current_events = ['сегодня', 'сейчас', 'в этом году', 'recent', 'latest', '2024', '2025', '2026'] + for event in current_events: + if event in message_lower: + score = max(score, 0.6) + + # Если есть вопросительные слова + внешние факты + if any(word in message_lower for word in ['почему', 'зачем', 'как работает']): + score = max(score, 0.65) + + return score >= 0.65, score + + def _should_read_rss(self, message: str) -> tuple[bool, float]: + """Проверить, нужно ли читать RSS ленты. + + ВАЖНО: Используем ТОЛЬКО полные фразы-триггеры. + Отдельные слова (типа "новости") НЕ активируют RSS — это предотвращает + ложные срабатывания когда пользователь просто упоминает слово в контексте. + """ + message_lower = message.lower() + + # ❌ БЛОКИРОВКА: Если пользователь реагирует на действие бота — НЕ активируем + reaction_patterns = [ + r'ты\s+\w+\s+запусти', # "ты rss запустил" + r'ты\s+опять', # "ты опять" + r'ты\s+снова', # "ты снова" + r'зачем\s+ты', # "зачем ты" + r'почему\s+ты', # "почему ты" + r'перестань', # "перестань" + r'хватит', # "хватит" + r'не\s+надо', # "не надо" + r'не\s+нужно', # "не нужно" + r'я\s+не\s+просил', # "я не просил" + r'я\s+не\s+говорил', # "я не говорил" + r'реакци', # "реакция", "реакцию" + r'критик', # "критика", "критикуешь" + r'баг', # "баг", "баги" + r'ошибк', # "ошибка", "ошибся" + r'проблем', # "проблема", "проблему" + r'нерелевант', # "нерелевантно", "нерелевантный запрос" + r'неправильн', # "неправильно", "неправильный ответ" + r'не то', # "не то", "не то искал" + r'глюк', # "глюк", "глючит" + r'ложн', # "ложное срабатывание" + r'срабатыва', # "срабатывает", "срабатывание" + ] + for pattern in reaction_patterns: + if re.search(pattern, message_lower): + logger.debug(f"Блокировка rss: пользователь реагирует на действие бота (паттерн: {pattern})") + return False, 0.0 + + # Только прямые фразы-триггеры — высокий порог + # Проверяем чтобы триггер был словом/фразой в контексте, а не частью слова + for trigger in self.RSS_TRIGGERS: + escaped_trigger = re.escape(trigger) + # Паттерн: начало строки ИЛИ пробел/знак препинания перед триггером, + # и конец строки ИЛИ пробел/знак препинания после + pattern = rf'(?:^|[\s,\.!?;:])({escaped_trigger})(?:$|[\s,\.!?;:])' + if re.search(pattern, message_lower): + return True, 0.95 + + # Отдельные ключевые слова НЕ проверяем — только явные фразы! + # Это предотвращает срабатывание на сообщения типа: + # - "новости" (просто упомянул слово) + # - "н.овости" (разбитое слово) + # - "я читал новости вчера" (прошедшее время, не запрос) + + return False, 0.0 + + def _should_use_ssh(self, message: str) -> tuple[bool, float]: + """Проверить, нужна ли SSH-команда.""" + message_lower = message.lower() + score = 0.0 + + # ❌ БЛОКИРОВКА: Если пользователь реагирует на действие бота — НЕ активируем + reaction_patterns = [ + r'ты\s+\w+\s+запусти', # "ты ssh запустил" + r'ты\s+опять', # "ты опять" + r'ты\s+снова', # "ты снова" + r'зачем\s+ты', # "зачем ты" + r'почему\s+ты', # "почему ты" + r'перестань', # "перестань" + r'хватит', # "хватит" + r'не\s+надо', # "не надо" + r'не\s+нужно', # "не нужно" + r'я\s+не\s+просил', # "я не просил" + r'я\s+не\s+говорил', # "я не говорил" + r'реакци', # "реакция", "реакцию" + r'критик', # "критика", "критикуешь" + r'баг', # "баг", "баги" + r'ошибк', # "ошибка", "ошибся" + r'проблем', # "проблема", "проблему" + r'нерелевант', # "нерелевантно", "нерелевантный запрос" + r'неправильн', # "неправильно", "неправильный ответ" + r'не то', # "не то", "не то искал" + r'глюк', # "глюк", "глючит" + r'ложн', # "ложное срабатывание" + r'срабатыва', # "срабатывает", "срабатывание" + ] + for pattern in reaction_patterns: + if re.search(pattern, message_lower): + logger.debug(f"Блокировка ssh: пользователь реагирует на действие бота (паттерн: {pattern})") + return False, 0.0 + + # Прямые триггеры + # Используем паттерн с границами для избежания частичных совпадений + for trigger in self.SSH_TRIGGERS: + escaped_trigger = re.escape(trigger) + pattern = rf'(?:^|[\s,\.!?;:])({escaped_trigger})(?:$|[\s,\.!?;:])' + if re.search(pattern, message_lower): + return True, 0.9 + + # Команды системного администрирования + sysadmin_tasks = ['проверь', 'посмотри', 'покажи', 'выполни', 'запусти'] + sysadmin_objects = ['сервер', 'лог', 'процесс', 'диск', 'память', 'сервис', 'демон'] + + has_task = any(task in message_lower for task in sysadmin_tasks) + has_object = any(obj in message_lower for obj in sysadmin_objects) + + if has_task and has_object: + score = max(score, 0.75) + + # Упоминания конкретных утилит + utils = ['systemctl', 'journalctl', 'top', 'htop', 'df', 'du', 'free', 'ps', 'netstat'] + for util in utils: + if util in message_lower: + score = max(score, 0.8) + + return score >= 0.75, score + + def _should_use_cron(self, message: str) -> tuple[bool, float]: + """Проверить, нужна ли cron-задача.""" + message_lower = message.lower() + score = 0.0 + + # ❌ БЛОКИРОВКА: Если пользователь реагирует на действие бота — НЕ активируем + reaction_patterns = [ + r'ты\s+\w+\s+запусти', # "ты cron запустил" + r'ты\s+опять', # "ты опять" + r'ты\s+снова', # "ты снова" + r'зачем\s+ты', # "зачем ты" + r'почему\s+ты', # "почему ты" + r'перестань', # "перестань" + r'хватит', # "хватит" + r'не\s+надо', # "не надо" + r'не\s+нужно', # "не нужно" + r'я\s+не\s+просил', # "я не просил" + r'я\s+не\s+говорил', # "я не говорил" + r'реакци', # "реакция", "реакцию" + r'критик', # "критика", "критикуешь" + r'баг', # "баг", "баги" + r'ошибк', # "ошибка", "ошибся" + r'проблем', # "проблема", "проблему" + r'нерелевант', # "нерелевантно", "нерелевантный запрос" + r'неправильн', # "неправильно", "неправильный ответ" + r'не то', # "не то", "не то искал" + r'глюк', # "глюк", "глючит" + r'ложн', # "ложное срабатывание" + r'срабатыва', # "срабатывает", "срабатывание" + ] + for pattern in reaction_patterns: + if re.search(pattern, message_lower): + logger.debug(f"Блокировка cron: пользователь реагирует на действие бота (паттерн: {pattern})") + return False, 0.0 + + # Прямые триггеры + # Используем паттерн с границами для избежания частичных совпадений + for trigger in self.CRON_TRIGGERS: + escaped_trigger = re.escape(trigger) + pattern = rf'(?:^|[\s,\.!?;:])({escaped_trigger})(?:$|[\s,\.!?;:])' + if re.search(pattern, message_lower): + return True, 0.85 + + # Расписания + schedules = ['каждый', 'каждую', 'ежедневно', 'ежечасно', 'еженедельно', 'раз в'] + for sched in schedules: + if sched in message_lower: + score = max(score, 0.8) + + # Напоминания и периодические задачи + if any(word in message_lower for word in ['напомни', 'запланируй', 'повторяй']): + score = max(score, 0.85) + + return score >= 0.8, score + + def _should_use_file_system(self, message: str) -> tuple[bool, float]: + """Проверить, нужна ли операция с файловой системой.""" + message_lower = message.lower() + score = 0.0 + + # ❌ БЛОКИРОВКА: Если пользователь реагирует на действие бота — НЕ активируем + reaction_patterns = [ + r'ты\s+\w+\s+запусти', # "ты file_system запустил" + r'ты\s+опять', # "ты опять" + r'ты\s+снова', # "ты снова" + r'зачем\s+ты', # "зачем ты" + r'почему\s+ты', # "почему ты" + r'перестань', # "перестань" + r'хватит', # "хватит" + r'не\s+надо', # "не надо" + r'не\s+нужно', # "не нужно" + r'я\s+не\s+просил', # "я не просил" + r'я\s+не\s+говорил', # "я не говорил" + r'реакци', # "реакция", "реакцию" + r'критик', # "критика", "критикуешь" + r'баг', # "баг", "баги" + r'ошибк', # "ошибка", "ошибся" + r'проблем', # "проблема", "проблему" + r'нерелевант', # "нерелевантно", "нерелевантный запрос" + r'неправильн', # "неправильно", "неправильный ответ" + r'не то', # "не то", "не то искал" + r'глюк', # "глюк", "глючит" + r'ложн', # "ложное срабатывание" + r'срабатыва', # "срабатывает", "срабатывание" + ] + for pattern in reaction_patterns: + if re.search(pattern, message_lower): + logger.debug(f"Блокировка file_system: пользователь реагирует на действие бота (паттерн: {pattern})") + return False, 0.0 + + # Прямые триггеры + # Используем паттерн с границами для избежания частичных совпадений + for trigger in self.FILE_SYSTEM_TRIGGERS: + escaped_trigger = re.escape(trigger) + pattern = rf'(?:^|[\s,\.!?;:])({escaped_trigger})(?:$|[\s,\.!?;:])' + if re.search(pattern, message_lower): + return True, 0.9 + + # Операции с файлами + file_operations = ['прочитай', 'покажи', 'создай', 'запиши', 'скопируй', 'перемести', 'удали', 'открой'] + file_objects = ['файл', 'директорию', 'папку', 'документ', 'текст', 'содержимое'] + + has_op = any(op in message_lower for op in file_operations) + has_obj = any(obj in message_lower for obj in file_objects) + + if has_op and has_obj: + score = max(score, 0.75) + + # Упоминания конкретных команд + commands = ['cat', 'ls', 'mkdir', 'cp', 'mv', 'rm', 'touch', 'pwd'] + for cmd in commands: + if f'{cmd} ' in message_lower or message_lower.endswith(cmd): + score = max(score, 0.85) + + return score >= 0.75, score + + def _should_use_telegram_web(self, message: str) -> tuple[bool, float]: + """Проверить, нужна ли операция с Telegram-каналами.""" + message_lower = message.lower() + score = 0.0 + + # ❌ БЛОКИРОВКА: Если пользователь реагирует на действие бота — НЕ активируем + reaction_patterns = [ + r'ты\s+\w+\s+запусти', # "ты telegram запустил" + r'ты\s+опять', # "ты опять" + r'ты\s+снова', # "ты снова" + r'зачем\s+ты', # "зачем ты" + r'почему\s+ты', # "почему ты" + r'перестань', # "перестань" + r'хватит', # "хватит" + r'не\s+надо', # "не надо" + r'не\s+нужно', # "не нужно" + r'я\s+не\s+просил', # "я не просил" + r'я\s+не\s+говорил', # "я не говорил" + r'реакци', # "реакция", "реакцию" + r'критик', # "критика", "критикуешь" + r'баг', # "баг", "баги" + r'ошибк', # "ошибка", "ошибся" + r'проблем', # "проблема", "проблему" + r'нерелевант', # "нерелевантно", "нерелевантный запрос" + r'неправильн', # "неправильно", "неправильный ответ" + r'не то', # "не то", "не то искал" + r'глюк', # "глюк", "глючит" + r'ложн', # "ложное срабатывание" + r'срабатыва', # "срабатывает", "срабатывание" + ] + for pattern in reaction_patterns: + if re.search(pattern, message_lower): + logger.debug(f"Блокировка telegram: пользователь реагирует на действие бота (паттерн: {pattern})") + return False, 0.0 + + # Прямые триггеры + for trigger in self.TELEGRAM_TRIGGERS: + escaped_trigger = re.escape(trigger) + pattern = rf'(?:^|[\s,\.!?;:])({escaped_trigger})(?:$|[\s,\.!?;:])' + if re.search(pattern, message_lower): + return True, 0.9 + + # Если есть упоминание каналов через запятую (формат: "канал1, канал2, ...") + if 'канал' in message_lower and ',' in message: + # Проверяем есть ли слова похожие на имена каналов (латиница/цифры) + import re as re_std + channel_pattern = r'\b[a-zA-Z0-9_]+\b' + channels = re_std.findall(channel_pattern, message) + if len(channels) > 0: + return True, 0.85 + + return score >= 0.75, score + + async def decide(self, message: str, context: Optional[Dict] = None) -> AgentDecision: + """ + Принять решение об использовании инструмента. + + Args: + message: Сообщение пользователя + context: Дополнительный контекст (история, состояние) + + Returns: + AgentDecision с решением агента + """ + user_id = context.get('user_id') if context else None + + # Приоритет: File System > SSH > Cron > Telegram > Поиск > RSS + # Проверяем в порядке приоритета + + # 1. Проверка на операции с файловой системой (ВЫСОКИЙ ПРИОРИТЕТ) + should_fs, fs_conf = self._should_use_file_system(message) + if should_fs and fs_conf > 0.75: + return AgentDecision( + should_use_tool=True, + tool_name='file_system_tool', + tool_args=self._extract_file_system_args(message), + confidence=fs_conf, + reasoning='Пользователю нужно выполнить операцию с файлами' + ) + + # 2. Проверка на SSH-команды (системные задачи) + should_ssh, ssh_conf = self._should_use_ssh(message) + if should_ssh and ssh_conf > 0.75: + return AgentDecision( + should_use_tool=True, + tool_name='ssh_tool', + tool_args={'command': self._extract_ssh_command(message)}, + confidence=ssh_conf, + reasoning='Пользователю нужно выполнить команду на сервере' + ) + + # 3. Проверка на Cron-задачи (планирование) + should_cron, cron_conf = self._should_use_cron(message) + if should_cron and cron_conf > 0.75: + return AgentDecision( + should_use_tool=True, + tool_name='cron_tool', + tool_args={'action': 'list'}, # Показываем список задач + confidence=cron_conf, + reasoning='Пользователь хочет создать или управлять задачей' + ) + + # 4. Проверка на Telegram Web (каналы) + should_tg, tg_conf = self._should_use_telegram_web(message) + if should_tg and tg_conf > 0.75: + # Парсим имена каналов из сообщения + channels = self._extract_channels(message) + if channels: + # Возвращаем первый канал для добавления + return AgentDecision( + should_use_tool=True, + tool_name='telegram_web_tool', + tool_args={'action': 'add', 'username': channels[0]}, + confidence=tg_conf, + reasoning='Пользователь хочет добавить Telegram-каналы' + ) + else: + # Просто показать список + return AgentDecision( + should_use_tool=True, + tool_name='telegram_web_tool', + tool_args={'action': 'list'}, + confidence=tg_conf, + reasoning='Пользователь хочет увидеть список каналов' + ) + + # 5. Проверка на поиск + should_search, search_conf = self._should_search(message) + if should_search and search_conf > 0.7: + query = self._extract_search_query(message) + return AgentDecision( + should_use_tool=True, + tool_name='ddgs_tool', + tool_args={'query': query, 'max_results': 5}, + confidence=search_conf, + reasoning='Пользователю нужна информация из интернета' + ) + + # 5. Проверка на RSS — только явные запросы + should_rss, rss_conf = self._should_read_rss(message) + if should_rss: # Порог уже проверен в _should_read_rss (0.95) + return AgentDecision( + should_use_tool=True, + tool_name='rss_tool', + tool_args={'action': 'list', 'limit': 10, 'undigested_only': True}, + confidence=rss_conf, + reasoning='Пользователь хочет прочитать новости из лент' + ) + + # Инструменты не нужны + return AgentDecision( + should_use_tool=False, + confidence=0.0, + reasoning='Инструменты не требуются' + ) + + def _extract_search_query(self, message: str) -> str: + """Извлечь поисковый запрос из сообщения.""" + triggers_to_remove = self.SEARCH_TRIGGERS + ['покажи', 'напиши', 'дай', 'расскажи', 'хочу', 'надо', 'нужно'] + + query = message.lower() + for trigger in triggers_to_remove: + query = query.replace(trigger, '') + + query = query.strip(' ?:.,!') + + if not query: + query = message + + return query.strip() + + def _extract_ssh_command(self, message: str) -> str: + """Извлечь SSH-команду из сообщения.""" + message_lower = message.lower() + + # Если есть явная команда в кавычках + import re + quoted = re.search(r'["\']([^"\']+)["\']', message) + if quoted: + return quoted.group(1).strip() + + # Если команда после триггера + for trigger in ['выполни команду', 'запусти', 'ssh']: + if trigger in message_lower: + idx = message_lower.find(trigger) + return message[idx + len(trigger):].strip() + + # Возвращаем оригинальное сообщение как команду + return message + + def _extract_file_system_args(self, message: str) -> Dict[str, Any]: + """ + Извлечь аргументы для file_system_tool из сообщения. + + Возвращает dict с operation и другими параметрами. + """ + import re + message_lower = message.lower() + + # Определяем операцию по триггерам + operation_map = { + 'прочитай файл': 'read', + 'покажи файл': 'read', + 'открой файл': 'read', + 'посмотри файл': 'read', + 'посмотри содержимое': 'read', + 'содержимое файла': 'read', + 'cat ': 'read', + + 'создай файл': 'write', + 'запиши в файл': 'write', + 'сохрани в файл': 'write', + 'сохрани текст': 'write', + 'запиши текст': 'write', + 'touch ': 'write', + + 'скопируй файл': 'copy', + 'скопируй': 'copy', + 'cp ': 'copy', + + 'перемести файл': 'move', + 'перемести': 'move', + 'mv ': 'move', + 'переименуй файл': 'move', # Переименование = перемещение + + 'удали файл': 'delete', + 'удали директорию': 'delete', + 'удали папку': 'delete', + 'rm ': 'delete', + + 'создай директорию': 'mkdir', + 'создай папку': 'mkdir', + 'mkdir ': 'mkdir', + + 'покажи директорию': 'list', + 'список файлов': 'list', + 'что в папке': 'list', + 'что в директории': 'list', + 'покажи файлы': 'list', + 'ls ': 'list', + + 'найди файл': 'search', + 'поиск файла': 'search', + } + + # Определяем операцию + operation = 'shell' # по умолчанию + for trigger, op in operation_map.items(): + if trigger in message_lower: + operation = op + break + + # Извлекаем путь (после команды) + path = None + source = None + destination = None + content = None + + # Паттерн для извлечения пути после команды + for cmd in ['cat', 'ls', 'mkdir', 'rm', 'touch']: + match = re.search(rf'{cmd}\s+([^\s]+)', message_lower) + if match: + path = match.group(1).strip() + break + + # Для copy/move ищем два пути + if operation in ('copy', 'move'): + # Ищем паттерн "X в Y" или "X Y" + match = re.search(r'([^\s]+)\s+(?:в|into|to)\s+([^\s]+)', message_lower) + if match: + source = match.group(1).strip() + destination = match.group(2).strip() + else: + # Просто два слова подряд + parts = message.split() + for i, part in enumerate(parts): + if part.lower() in ['cp', 'mv', 'copy', 'move', 'скопируй', 'перемести']: + if i + 2 < len(parts): + source = parts[i + 1].strip() + destination = parts[i + 2].strip() + break + + # Для write пытаемся извлечь содержимое + if operation == 'write': + # Ищем текст после "сохрани" или "запиши" + match = re.search(r'(?:сохрани|запиши)\s*(?:в файл|текст)?\s*[:\-]?\s*(.+)', message, re.IGNORECASE) + if match: + content = match.group(1).strip() + # Если есть кавычки - извлекаем содержимое + quoted = re.search(r'["\']([^"\']+)["\']', message) + if quoted: + content = quoted.group(1) + + # Для search ищем паттерн + pattern = '*' + if operation == 'search': + match = re.search(r'pattern\s*[=:]\s*([^\s]+)', message_lower) + if match: + pattern = match.group(1).strip() + # Или ищем *.extension + glob_match = re.search(r'\*\.[^\s]+', message_lower) + if glob_match: + pattern = glob_match.group(0).strip() + + # Формируем аргументы + args = {'operation': operation} + + if path: + args['path'] = path + if source: + args['source'] = source + if destination: + args['destination'] = destination + if content: + args['content'] = content + if pattern and operation == 'search': + args['pattern'] = pattern + + # Если путь не найден, пробуем извлечь общее слово после операции + if not path and not source: + words = message.split() + for i, word in enumerate(words): + if word.lower() in ['cat', 'ls', 'mkdir', 'rm', 'touch', 'read', 'write', 'delete', 'list']: + if i + 1 < len(words): + args['path'] = words[i + 1].strip() + break + + logger.info(f"Извлечены аргументы file_system: {args}") + return args + + def _extract_channels(self, message: str) -> List[str]: + """ + Извлечь имена Telegram-каналов из сообщения. + + Ищет каналы в формате: username1, username2, username3 + """ + import re as re_std + + # Паттерн для поиска слов из латиницы, цифр и подчёркиваний + channel_pattern = r'\b[a-zA-Z][a-zA-Z0-9_]*\b' + channels = re_std.findall(channel_pattern, message) + + # Фильтруем короткие и служебные слова + exclude_words = {'add', 'list', 'read', 'the', 'and', 'or', 'in', 'on', 'at', 'to', 'for'} + channels = [ch for ch in channels if ch.lower() not in exclude_words and len(ch) > 1] + + return channels + + async def execute_tool(self, tool_name: str, **kwargs) -> ToolResult: + """Выполнить инструмент и сохранить историю.""" + logger.info(f"🤖 AI-агент выполняет инструмент: {tool_name} с аргументами: {kwargs}") + + result = await self.registry.execute_tool(tool_name, **kwargs) + + # Сохраняем историю использования + self._tool_use_history.append({ + 'tool_name': tool_name, + 'args': kwargs, + 'result': result.to_dict(), + 'timestamp': datetime.now().isoformat() + }) + + # Ограничиваем историю + if len(self._tool_use_history) > 100: + self._tool_use_history = self._tool_use_history[-50:] + + logger.info(f"✅ Инструмент {tool_name} выполнен: success={result.success}") + return result + + def get_tool_history(self, limit: int = 10) -> List[Dict]: + """Получить историю использования инструментов.""" + return self._tool_use_history[-limit:] + + def set_user_preference(self, user_id: int, preference: str, value: Any): + """Установить предпочтение пользователя для инструментов.""" + if user_id not in self._user_preferences: + self._user_preferences[user_id] = {} + self._user_preferences[user_id][preference] = value + logger.info(f"Установлено предпочтение для пользователя {user_id}: {preference} = {value}") + + def get_user_preference(self, user_id: int, preference: str, default: Any = None) -> Any: + """Получить предпочтение пользователя.""" + return self._user_preferences.get(user_id, {}).get(preference, default) + + +# Глобальный агент +ai_agent = AIAgent() diff --git a/bot/ai_provider_manager.py b/bot/ai_provider_manager.py new file mode 100644 index 0000000..4927fef --- /dev/null +++ b/bot/ai_provider_manager.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +AI Provider Manager - управление переключением между AI-провайдерами. + +Поддерживаемые провайдеры: +- qwen: Qwen Code CLI (основной) +- gigachat: GigaChat API (Сбер) + +Использует единый интерфейс BaseAIProvider для всех провайдеров. +""" + +import logging +from typing import Optional, Dict, Any, Callable, List +from dataclasses import dataclass +from enum import Enum + +from bot.base_ai_provider import BaseAIProvider, ProviderResponse + +logger = logging.getLogger(__name__) + + +class AIProvider(Enum): + """Доступные AI-провайдеры.""" + QWEN = "qwen" + GIGACHAT = "gigachat" + OPENCODE = "opencode" + + +@dataclass +class ProviderInfo: + """Информация о провайдере.""" + id: str + name: str + description: str + available: bool + is_active: bool + + +class AIProviderManager: + """ + Менеджер управления AI-провайдерами. + + Позволяет переключаться между провайдерами и выполнять запросы + через активного провайдера с поддержкой инструментов. + """ + + def __init__(self, qwen_manager=None): + self._qwen_manager = qwen_manager + self._provider_status: Dict[str, bool] = {} + self._providers: Dict[str, BaseAIProvider] = {} + self._tools_registry: Dict[str, Any] = {} + + # Инициализируем провайдеров + self._init_providers() + + # Проверяем доступность провайдеров при инициализации + self._check_provider_status() + + def _init_providers(self): + """Инициализировать AI-провайдеров.""" + # Qwen Code Provider + if self._qwen_manager: + from bot.providers.qwen_provider import QwenCodeProvider + self._providers[AIProvider.QWEN.value] = QwenCodeProvider(self._qwen_manager) + logger.info("Qwen Code Provider инициализирован") + + # GigaChat Provider - создаём новый экземпляр напрямую + from bot.providers.gigachat_provider import GigaChatProvider + self._providers[AIProvider.GIGACHAT.value] = GigaChatProvider() + logger.info("GigaChat Provider инициализирован") + + # Opencode Provider + from bot.providers.opencode_provider import OpencodeProvider + self._providers[AIProvider.OPENCODE.value] = OpencodeProvider() + logger.info("Opencode Provider инициализирован") + + def set_tools_registry(self, tools_registry: Dict[str, Any]): + """Установить реестр инструментов для всех провайдеров.""" + self._tools_registry = tools_registry + + def get_provider(self, provider_id: str) -> Optional[BaseAIProvider]: + """Получить экземпляр провайдера.""" + return self._providers.get(provider_id) + + def _check_provider_status(self): + """Проверка доступности провайдеров.""" + # Проверяем Qwen + self._provider_status[AIProvider.QWEN.value] = True # Qwen всегда доступен + + # Проверяем GigaChat + gigachat_provider = self._providers.get(AIProvider.GIGACHAT.value) + if gigachat_provider: + self._provider_status[AIProvider.GIGACHAT.value] = gigachat_provider.is_available() + else: + self._provider_status[AIProvider.GIGACHAT.value] = False + + # Проверяем Opencode + opencode_provider = self._providers.get(AIProvider.OPENCODE.value) + if opencode_provider: + self._provider_status[AIProvider.OPENCODE.value] = opencode_provider.is_available() + else: + self._provider_status[AIProvider.OPENCODE.value] = False + + def get_available_providers(self) -> List[str]: + """Получить список доступных провайдеров.""" + return [ + provider_id + for provider_id, available in self._provider_status.items() + if available + ] + + def is_provider_available(self, provider_id: str) -> bool: + """Проверить доступен ли провайдер.""" + return self._provider_status.get(provider_id, False) + + def get_provider_info(self, provider_id: str, is_active: bool = False) -> ProviderInfo: + """Получить информацию о провайдере.""" + providers = { + AIProvider.QWEN.value: ProviderInfo( + id=AIProvider.QWEN.value, + name="Qwen Code", + description="Alibaba Qwen Code CLI — мощный AI-ассистент с поддержкой инструментов", + available=self.is_provider_available(AIProvider.QWEN.value), + is_active=is_active + ), + AIProvider.GIGACHAT.value: ProviderInfo( + id=AIProvider.GIGACHAT.value, + name="GigaChat", + description="Sber GigaChat API — российская AI-модель от Сбера", + available=self.is_provider_available(AIProvider.GIGACHAT.value), + is_active=is_active + ), + AIProvider.OPENCODE.value: ProviderInfo( + id=AIProvider.OPENCODE.value, + name="Opencode", + description="Opencode AI — бесплатные модели (minimax, big-pickle, gpt-5-nano)", + available=self.is_provider_available(AIProvider.OPENCODE.value), + is_active=is_active + ) + } + return providers.get(provider_id, ProviderInfo( + id=provider_id, + name=provider_id, + description="Unknown provider", + available=False, + is_active=is_active + )) + + def get_all_providers_info(self, active_provider_id: str) -> List[ProviderInfo]: + """Получить информацию обо всех провайдерах.""" + return [ + self.get_provider_info(AIProvider.QWEN.value, AIProvider.QWEN.value == active_provider_id), + self.get_provider_info(AIProvider.GIGACHAT.value, AIProvider.GIGACHAT.value == active_provider_id), + self.get_provider_info(AIProvider.OPENCODE.value, AIProvider.OPENCODE.value == active_provider_id) + ] + + def switch_provider(self, user_id: int, provider_id: str, state_manager) -> tuple[bool, str]: + """ + Переключить AI-провайдер для пользователя. + + Args: + user_id: ID пользователя + provider_id: ID провайдера ("qwen" или "gigachat") + state_manager: Менеджер состояний для обновления состояния пользователя + + Returns: + (success: bool, message: str) + """ + if not self.is_provider_available(provider_id): + return False, f"❌ Провайдер {provider_id} недоступен" + + state = state_manager.get(user_id) + state.current_ai_provider = provider_id + + provider_info = self.get_provider_info(provider_id) + + logger.info(f"Пользователь {user_id} переключен на {provider_id}") + + return True, f"✅ Переключен на {provider_info.name}" + + def get_current_provider(self, state) -> str: + """Получить текущего провайдера пользователя.""" + return state.current_ai_provider + + async def execute_request( + self, + provider_id: str, + user_id: int, + prompt: str, + system_prompt: Optional[str] = None, + on_output: Optional[Callable[[str], Any]] = None, + on_chunk: Optional[Callable[[str], Any]] = None, + on_event: Optional[Callable[[Any], Any]] = None, + context: Optional[List[Dict[str, str]]] = None, + use_tools: bool = True + ) -> Dict[str, Any]: + """ + Выполнить запрос через указанного провайдера с поддержкой инструментов. + + Args: + provider_id: ID провайдера + user_id: ID пользователя + prompt: Запрос + system_prompt: Системный промпт + on_output: Callback для вывода + on_chunk: Callback для потокового вывода + on_event: Callback для событий + context: История диалога + use_tools: Использовать ли инструменты + + Returns: + Dict с результатом: + - success: bool + - content: str + - error: str (если ошибка) + - provider: str + - metadata: dict + """ + provider = self._providers.get(provider_id) + + if not provider: + return { + "success": False, + "error": f"Провайдер {provider_id} не найден", + "provider": provider_id + } + + try: + # Используем универсальный метод process_with_tools + response = await provider.process_with_tools( + prompt=prompt, + system_prompt=system_prompt, + context=context, + tools_registry=self._tools_registry if use_tools else None, + on_chunk=on_chunk, + user_id=user_id + ) + + if response.success: + # Получаем информацию о модели из metadata ответа + model_name = None + if response.message and response.message.metadata: + model_name = response.message.metadata.get("model") + + return { + "success": True, + "content": response.message.content if response.message else "", + "provider": provider_id, + "metadata": { + "provider_name": response.provider_name, + "usage": response.usage, + "tool_calls": len(response.message.tool_calls) if response.message and response.message.tool_calls else 0, + "model": model_name # Добавляем модель + } + } + else: + return { + "success": False, + "error": response.error, + "provider": provider_id + } + + except Exception as e: + logger.error(f"Ошибка выполнения запроса через {provider_id}: {e}") + return { + "success": False, + "error": str(e), + "provider": provider_id + } + + +# Глобальный менеджер (будет инициализирован в bot.py) +ai_provider_manager: Optional[AIProviderManager] = None + + +def init_ai_provider_manager(qwen_manager, tools_registry=None) -> AIProviderManager: + """Инициализировать глобальный AIProviderManager.""" + global ai_provider_manager + ai_provider_manager = AIProviderManager(qwen_manager) + + # Устанавливаем реестр инструментов если предоставлен + if tools_registry: + ai_provider_manager.set_tools_registry(tools_registry) + + logger.info(f"AIProviderManager инициализирован. Доступные провайдеры: {ai_provider_manager.get_available_providers()}") + return ai_provider_manager + + +def get_ai_provider_manager() -> AIProviderManager: + """Получить глобальный AIProviderManager.""" + global ai_provider_manager + if ai_provider_manager is None: + raise RuntimeError("AIProviderManager не инициализирован. Вызовите init_ai_provider_manager().") + return ai_provider_manager diff --git a/bot/base_ai_provider.py b/bot/base_ai_provider.py new file mode 100644 index 0000000..1b5b961 --- /dev/null +++ b/bot/base_ai_provider.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +""" +Base AI Provider Protocol - универсальный интерфейс для всех AI-провайдеров. + +Определяет общий протокол который должен реализовать каждый AI-провайдер +для работы с инструментами (tools). +""" + +import json +from abc import ABC, abstractmethod +from typing import Optional, Dict, Any, Callable, List, AsyncGenerator +from dataclasses import dataclass, field +from enum import Enum + + +class ToolCallStatus(Enum): + """Статус выполнения инструмента.""" + SUCCESS = "success" + ERROR = "error" + PENDING = "pending" + + +@dataclass +class ToolCall: + """Вызов инструмента.""" + tool_name: str + tool_args: Dict[str, Any] + tool_call_id: Optional[str] = None + status: ToolCallStatus = ToolCallStatus.PENDING + result: Optional[Any] = None + error: Optional[str] = None + + +@dataclass +class AIMessage: + """Сообщение от AI-провайдера.""" + content: str + tool_calls: List[ToolCall] = field(default_factory=list) + metadata: Dict[str, Any] = field(default_factory=dict) + is_streaming: bool = False + + +@dataclass +class ProviderResponse: + """Ответ от AI-провайдера.""" + success: bool + message: Optional[AIMessage] = None + error: Optional[str] = None + provider_name: str = "" + usage: Optional[Dict[str, Any]] = None + raw_response: Optional[Any] = None + + +class BaseAIProvider(ABC): + """ + Базовый класс для всех AI-провайдеров. + + Каждый провайдер (Qwen, GigaChat, OpenAI, etc.) должен реализовать + этот интерфейс для поддержки инструментов и единого формата ответов. + """ + + @property + @abstractmethod + def provider_name(self) -> str: + """Название провайдера (например, 'Qwen Code', 'GigaChat').""" + pass + + @property + @abstractmethod + def supports_tools(self) -> bool: + """Поддерживает ли провайдер инструменты нативно.""" + pass + + @property + @abstractmethod + def supports_streaming(self) -> bool: + """Поддерживает ли провайдер потоковый вывод.""" + pass + + @abstractmethod + async def chat( + self, + prompt: str, + system_prompt: Optional[str] = None, + context: Optional[List[Dict[str, str]]] = None, + tools: Optional[List[Dict[str, Any]]] = None, + on_chunk: Optional[Callable[[str], Any]] = None, + **kwargs + ) -> ProviderResponse: + """ + Отправить запрос AI-провайдеру. + + Args: + prompt: Запрос пользователя + system_prompt: Системный промпт + context: История диалога + tools: Доступные инструменты (схема) + on_chunk: Callback для потокового вывода + **kwargs: Дополнительные параметры + + Returns: + ProviderResponse с ответом и возможными вызовами инструментов + """ + pass + + @abstractmethod + async def execute_tool( + self, + tool_name: str, + tool_args: Dict[str, Any], + tool_call_id: Optional[str] = None, + **kwargs + ) -> ToolCall: + """ + Выполнить инструмент (если провайдер поддерживает нативно). + + Для провайдеров без нативной поддержки инструментов, + этот метод может быть заглушкой. + + Args: + tool_name: Имя инструмента + tool_args: Аргументы инструмента + tool_call_id: ID вызова + + Returns: + ToolCall с результатом выполнения + """ + pass + + def is_available(self) -> bool: + """Проверить доступность провайдера.""" + return True + + def get_tools_schema(self, tools_registry: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Получить схему инструментов для промпта. + + По умолчанию возвращает описание всех доступных инструментов. + Провайдеры могут переопределить для кастомизации. + + Args: + tools_registry: Словарь инструментов {name: tool_instance} или объект реестра + + Returns: + Список схем инструментов + """ + schema = [] + + # Обрабатываем разные типы tools_registry + if tools_registry is None: + return schema + + # Если это ToolsRegistry с методом get_all() + if hasattr(tools_registry, 'get_all') and callable(getattr(tools_registry, 'get_all')): + items = tools_registry.get_all().items() + # Если это dict - используем .items() + elif isinstance(tools_registry, dict): + items = tools_registry.items() + # Если это объект с атрибутом tools + elif hasattr(tools_registry, 'tools'): + items = tools_registry.tools.items() if isinstance(tools_registry.tools, dict) else [] + # Если это объект поддерживающий .items() + elif hasattr(tools_registry, 'items'): + items = tools_registry.items() + else: + logger.warning(f"Неизвестный тип tools_registry: {type(tools_registry)}") + return schema + + for name, tool in items: + if hasattr(tool, 'get_schema'): + schema.append(tool.get_schema()) + elif hasattr(tool, 'description'): + schema.append({ + "name": name, + "description": tool.description, + "parameters": getattr(tool, 'parameters', {}) + }) + return schema + + async def process_with_tools( + self, + prompt: str, + system_prompt: Optional[str] = None, + context: Optional[List[Dict[str, str]]] = None, + tools_registry: Optional[Dict[str, Any]] = None, + on_chunk: Optional[Callable[[str], Any]] = None, + max_iterations: int = 5, + **kwargs + ) -> ProviderResponse: + """ + Универсальный метод для обработки запросов с инструментами. + + Реализует цикл: + 1. Отправить запрос провайдеру + 2. Если есть вызовы инструментов - выполнить их + 3. Отправить результаты обратно провайдеру + 4. Повторить пока не будет финального ответа + + Args: + prompt: Запрос пользователя + system_prompt: Системный промпт + context: История диалога + tools_registry: Словарь инструментов + on_chunk: Callback для потокового вывода + max_iterations: Максимум итераций цикла + + Returns: + ProviderResponse с финальным ответом + """ + if not tools_registry: + # Без инструментов - простой запрос + return await self.chat( + prompt=prompt, + system_prompt=system_prompt, + context=context, + on_chunk=on_chunk, + **kwargs + ) + + # Формируем базовый контекст — БЕЗ system message + # System message будет передаваться отдельным параметром + base_messages = [] + if context: + # Фильтруем system messages из context — они будут переданы через system_prompt + for msg in context: + if msg.get("role") != "system": + base_messages.append(msg) + + base_messages.append({"role": "user", "content": prompt}) + + tools_schema = self.get_tools_schema(tools_registry) if self.supports_tools else None + + # Копируем сообщения для каждой итерации + messages = base_messages.copy() + + for iteration in range(max_iterations): + # Отправляем запрос провайдеру + # system_prompt передаётся всегда — провайдер сам решит как его использовать + response = await self.chat( + prompt=None, # Уже в messages + system_prompt=system_prompt, + context=messages, + tools=tools_schema, + on_chunk=on_chunk, + **kwargs + ) + + if not response.success: + return response + + message = response.message + if not message: + return ProviderResponse( + success=False, + error="Пустой ответ от провайдера", + provider_name=self.provider_name + ) + + # Если нет вызовов инструментов - возвращаем ответ + if not message.tool_calls: + return response + + # Выполняем инструменты + tool_results = [] + for tool_call in message.tool_calls: + # Проверяем наличие инструмента через метод .get() для поддержки ToolsRegistry + if hasattr(tools_registry, 'get'): + tool = tools_registry.get(tool_call.tool_name) + elif isinstance(tools_registry, dict): + tool = tools_registry.get(tool_call.tool_name) + else: + tool = None + + if tool is not None: + try: + if hasattr(tool, 'execute'): + result = await tool.execute( + **tool_call.tool_args, + user_id=kwargs.get('user_id') + ) + elif hasattr(tool, '__call__'): + result = await tool(**tool_call.tool_args) + else: + result = f"Инструмент {tool_call.tool_name} не имеет метода execute" + + tool_call.result = result + tool_call.status = ToolCallStatus.SUCCESS + except Exception as e: + tool_call.error = str(e) + tool_call.status = ToolCallStatus.ERROR + result = f"Ошибка: {e}" + + # Преобразуем результат в JSON-сериализуемый формат + # ToolResult имеет метод to_dict(), строки оставляем как есть + if hasattr(result, 'to_dict'): + result_serializable = result.to_dict() + else: + result_serializable = result + + tool_results.append({ + "tool": tool_call.tool_name, + "args": tool_call.tool_args, + "result": result_serializable, + "status": tool_call.status.value + }) + else: + tool_call.error = f"Инструмент {tool_call.tool_name} не найден" + tool_call.status = ToolCallStatus.ERROR + tool_results.append({ + "tool": tool_call.tool_name, + "error": tool_call.error + }) + + # Добавляем результаты в контекст для следующей итерации + messages.append({ + "role": "assistant", + "content": message.content, + "tool_calls": [ + { + "id": tc.tool_call_id, + "name": tc.tool_name, + "arguments": tc.tool_args + } + for tc in message.tool_calls + ] + }) + + # GigaChat требует валидный JSON в tool messages, а не Python repr строку + # Используем json.dumps для корректного форматирования + messages.append({ + "role": "tool", + "content": json.dumps(tool_results, ensure_ascii=False) + }) + + # Обновляем системный промпт для следующей итерации + system_prompt = system_prompt or "" + + # Достигли максимума итераций + return ProviderResponse( + success=True, + message=AIMessage( + content=message.content + "\n\n[Достигнут максимум итераций выполнения инструментов]", + metadata={"iterations": max_iterations} + ), + provider_name=self.provider_name, + usage=response.usage + ) diff --git a/bot/compaction.py b/bot/compaction.py new file mode 100644 index 0000000..a6f2829 --- /dev/null +++ b/bot/compaction.py @@ -0,0 +1,520 @@ +#!/usr/bin/env python3 +""" +Модуль компактификации истории диалога. + +Сжимает старую историю диалога в структурированный summary, +сохраняя важные факты, URL, настройки и договорённости. + +Архитектура: +1. Извлекаем все сообщения кроме последних 20 из ChromaDB +2. Сжимаем их в структурированный summary через Qwen Code +3. Сохраняем summary как отдельный документ в ChromaDB (type=summary) +4. При загрузке контекста — берём summary + последние 20 сообщений +""" + +import logging +from pathlib import Path +from datetime import datetime +from typing import Optional, List, Dict, Any, Tuple +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +# Константы компактификации +COMPACTION_THRESHOLD_PERCENT = 70 # Порог запуска (70% контекста) +COMPACTION_KEEP_LAST = 20 # Сколько последних сообщений сохранять +COMPACTION_SUMMARY_ID = "dialogue_summary" # ID для summary документа + + +@dataclass +class CompactionResult: + """Результат компактификации.""" + success: bool + messages_compressed: int = 0 + summary_length: int = 0 + tokens_saved: int = 0 + error: Optional[str] = None + + +class DialogueCompactor: + """ + Менеджер компактификации истории диалога. + + Использует Qwen Code для сжатия истории в структурированный summary. + """ + + def __init__(self, qwen_manager=None, vector_memory=None): + """ + Инициализация компактора. + + Args: + qwen_manager: QwenCodeManager для выполнения сжатия + vector_memory: VectorMemoryStorage для работы с ChromaDB + """ + self.qwen_manager = qwen_manager + self.vector_memory = vector_memory + self._chroma_client = None + self._collection = None + + def _init_chroma(self): + """Инициализация ChromaDB клиента.""" + if self._collection is not None: + return + + import chromadb + from chromadb.config import Settings + + # Путь к векторной БД + persist_dir = str(Path(__file__).parent / "vector_db") + + self._client = chromadb.PersistentClient( + path=persist_dir, + settings=Settings( + anonymized_telemetry=False, + allow_reset=True + ) + ) + + self._collection = self._client.get_or_create_collection( + name="telegram_messages", + metadata={"description": "История диалогов Telegram бота"} + ) + + logger.info(f"ChromaDB инициализирован для компактификации: {persist_dir}") + + def _get_summary(self) -> Optional[str]: + """ + Получить существующий summary из ChromaDB. + + Returns: + existing summary или None если не найден + """ + self._init_chroma() + + try: + result = self._collection.get( + ids=[COMPACTION_SUMMARY_ID], + include=["documents"] + ) + + if result["documents"] and len(result["documents"]) > 0: + return result["documents"][0] + return None + except Exception as e: + logger.error(f"Ошибка получения summary: {e}") + return None + + def _save_summary(self, summary: str): + """ + Сохранить summary в ChromaDB. + + Args: + summary: структурированный summary для сохранения + """ + self._init_chroma() + + try: + from sentence_transformers import SentenceTransformer + import os + os.environ["TRANSFORMERS_OFFLINE"] = "1" + os.environ["HF_HUB_OFFLINE"] = "1" + + model = SentenceTransformer("all-MiniLM-L6-v2", local_files_only=True) + embedding = model.encode(summary, convert_to_numpy=True).tolist() + + # Проверяем есть ли уже summary + existing = self._collection.get(ids=[COMPACTION_SUMMARY_ID]) + + if existing["ids"]: + # Обновляем существующий + self._collection.update( + ids=[COMPACTION_SUMMARY_ID], + embeddings=[embedding], + documents=[summary], + metadatas=[{ + "type": "summary", + "timestamp": datetime.now().isoformat(), + "user_id": "system" + }] + ) + logger.info(f"Summary обновлён в ChromaDB (длина: {len(summary)})") + else: + # Добавляем новый + self._collection.add( + ids=[COMPACTION_SUMMARY_ID], + embeddings=[embedding], + documents=[summary], + metadatas=[{ + "type": "summary", + "timestamp": datetime.now().isoformat(), + "user_id": "system" + }] + ) + logger.info(f"Summary сохранён в ChromaDB (длина: {len(summary)})") + + except Exception as e: + logger.error(f"Ошибка сохранения summary: {e}") + raise + + def _get_old_messages(self, keep_last: int = COMPACTION_KEEP_LAST) -> List[Dict[str, Any]]: + """ + Получить все сообщения кроме последних keep_last. + + Args: + keep_last: количество последних сообщений для сохранения + + Returns: + список сообщений для сжатия + """ + self._init_chroma() + + try: + # Получаем все сообщения + result = self._collection.get( + include=["documents", "metadatas"] + ) + + if not result["documents"]: + return [] + + # Собираем сообщения с метаданными + messages = [] + for i, doc in enumerate(result["documents"]): + # Пропускаем summary + if result["metadatas"][i].get("type") == "summary": + continue + + messages.append({ + "content": doc, + "role": result["metadatas"][i].get("role", "unknown"), + "timestamp": result["metadatas"][i].get("timestamp", ""), + "user_id": result["metadatas"][i].get("user_id", "unknown") + }) + + # Сортируем по timestamp + messages.sort(key=lambda x: x["timestamp"]) + + # Возвращаем все кроме последних keep_last + if len(messages) <= keep_last: + return [] + + return messages[:-keep_last] + + except Exception as e: + logger.error(f"Ошибка получения сообщений: {e}") + return [] + + def _build_compaction_prompt(self, messages: List[Dict[str, Any]]) -> str: + """ + Построить промпт для сжатия истории. + + Args: + messages: список сообщений для сжатия + + Returns: + промпт для Qwen Code + """ + # Форматируем сообщения в читаемый вид + dialogue_text = "" + for msg in messages: + role = "Пользователь" if msg["role"] == "user" else "Ассистент" + dialogue_text += f"{role}: {msg['content']}\n\n" + + prompt = f""" +Ты — ассистент для сжатия истории диалога в структурированный summary. + +## ЗАДАЧА +Сожми историю диалога в компактный структурированный summary для сохранения контекста. + +## ВАЖНО СОХРАНИТЬ: +- 📌 ФАКТЫ: имена, названия, версии, даты, числа +- 🔗 URL: адреса сайтов, домены, ссылки на репозитории +- 🖥 СЕРВЕРЫ: IP-адреса, логины, пароли, имена серверов +- ⚙️ НАСТРОЙКИ: конфигурации, пути к файлам, переменные окружения +- 📋 РЕШЕНИЯ: договорённости, выводы, утверждённые планы +- 🎯 ЗАДАЧИ: что планировалось, что сделано, что в процессе +- 🛠 ИНСТРУМЕНТЫ: названия утилит, команд, библиотек + +## ФОРМАТ SUMMARY: + +``` +=== SUMMARY ДИАЛОГА === +📅 Период: {datetime.now().strftime('%Y-%m-%d')} + +🎯 ОСНОВНАЯ ТЕМА: +[Краткий пересказ основной темы диалога (3-5 предложений)] + +📌 КЛЮЧЕВЫЕ ФАКТЫ: +• Факт 1 +• Факт 2 +• ... + +🔗 URL И РЕСУРСЫ: +• https://... +• ... + +🖥 СЕРВЕРЫ И ДОСТУПЫ: +• server: IP, login, password +• ... + +⚙️ НАСТРОЙКИ И КОНФИГУРАЦИИ: +• path/to/config: значение +• ... + +📋 ПРИНЯТЫЕ РЕШЕНИЯ: +• Решение 1 +• Решение 2 +• ... + +🎯 ЗАДАЧИ: +✅ Сделано: ... +🔄 В процессе: ... +⏳ Запланировано: ... + +🛠 ИНСТРУМЕНТЫ И КОМАНДЫ: +• команда1 — описание +• команда2 — описание +``` + +## ДИАЛОГ ДЛЯ СЖАТИЯ: +{dialogue_text} + +## ТРЕБОВАНИЯ: +1. Будь краток но информативен +2. Сохраняй все технические детали (команды, пути, URL) +3. Используй маркированные списки для читаемости +4. Не добавляй информацию которой не было в диалоге +5. Выделяй важное эмодзи для быстрого поиска + +Сожми диалог в summary согласно формату выше: +""" + return prompt + + async def compact(self, keep_last: int = COMPACTION_KEEP_LAST) -> CompactionResult: + """ + Выполнить компактификацию истории диалога. + + Args: + keep_last: количество последних сообщений для сохранения + + Returns: + CompactionResult с результатами операции + """ + logger.info(f"Начало компактификации (сохраняем последние {keep_last} сообщений)") + + try: + # Получаем старые сообщения + old_messages = self._get_old_messages(keep_last) + + if not old_messages: + logger.info("Нет сообщений для компактификации") + return CompactionResult( + success=True, + messages_compressed=0, + summary_length=0, + tokens_saved=0 + ) + + messages_count = len(old_messages) + logger.info(f"Найдено {messages_count} сообщений для сжатия") + + # Строим промпт + prompt = self._build_compaction_prompt(old_messages) + + # Проверяем наличие Qwen Code + if not self.qwen_manager: + logger.error("Qwen manager не инициализирован") + return CompactionResult( + success=False, + error="Qwen manager не инициализирован" + ) + + # Выполняем сжатие через Qwen Code + output_parts = [] + + async def on_output(text: str): + output_parts.append(text) + + async def on_oauth_url(url: str): + logger.warning(f"OAuth URL: {url}") + + logger.info("Запуск Qwen Code для сжатия...") + + # Запускаем задачу + result = await self.qwen_manager.run_task( + user_id=999, # Системный user_id для компактификации + task=prompt, + on_output=on_output, + on_oauth_url=on_oauth_url, + use_system_prompt=False # Не добавляем системный промпт бота + ) + + # Парсим результат - извлекаем текст из JSON как в основном коде + import re + summary = "".join(output_parts).strip() + + # Извлекаем текст из JSON ответа (как в bot.py) + text_matches = re.findall(r'"text":"([^"]+)"', summary) + if text_matches: + summary = " ".join(text_matches).replace("\\n", "\n") + else: + # Fallback: пробуем найти result поле + try: + import json + for line in summary.split('\n'): + line = line.strip() + if line.startswith('{'): + data = json.loads(line) + if data.get('type') == 'result': + summary = data.get('result', summary) + break + if data.get('result'): + summary = data.get('result', summary) + except Exception: + pass + + summary = summary.strip() + + if not summary: + logger.error("Пустой summary после сжатия") + return CompactionResult( + success=False, + error="Пустой summary после сжатия" + ) + + # Сохраняем summary в ChromaDB + self._save_summary(summary) + + # Оцениваем экономию токенов (примерно) + original_tokens = sum(len(msg["content"]) for msg in old_messages) // 4 + summary_tokens = len(summary) // 4 + tokens_saved = original_tokens - summary_tokens + + logger.info( + f"Компактификация завершена: " + f"сообщений={messages_count}, " + f"длина summary={len(summary)}, " + f"экономия токенов≈{tokens_saved}" + ) + + return CompactionResult( + success=True, + messages_compressed=messages_count, + summary_length=len(summary), + tokens_saved=tokens_saved + ) + + except Exception as e: + logger.error(f"Ошибка компактификации: {e}", exc_info=True) + return CompactionResult( + success=False, + error=str(e) + ) + + def get_context_with_summary(self, user_id: int, limit: int = 20) -> Tuple[Optional[str], List[Dict[str, Any]]]: + """ + Получить контекст с использованием summary. + + Args: + user_id: ID пользователя для фильтрации + limit: количество последних сообщений для загрузки + + Returns: + (summary, recent_messages) кортеж + """ + # Получаем summary + summary = self._get_summary() + + # Получаем последние сообщения + self._init_chroma() + + try: + result = self._collection.get( + include=["documents", "metadatas"], + where={"user_id": str(user_id)}, + limit=limit + ) + + messages = [] + if result["documents"]: + for i, doc in enumerate(result["documents"]): + # Пропускаем summary + if result["metadatas"][i].get("type") == "summary": + continue + + messages.append({ + "content": doc, + "role": result["metadatas"][i].get("role", "unknown"), + "timestamp": result["metadatas"][i].get("timestamp", ""), + "user_id": result["metadatas"][i].get("user_id", "unknown") + }) + + logger.info(f"Загружен контекст: summary={summary is not None}, сообщений={len(messages)}") + return summary, messages + + except Exception as e: + logger.error(f"Ошибка загрузки контекста: {e}") + return None, [] + + def check_compaction_needed(self, threshold_percent: int = COMPACTION_THRESHOLD_PERCENT) -> bool: + """ + Проверить нужна ли компактификация. + + Args: + threshold_percent: порог заполненности контекста (%) + + Returns: + True если нужна компактификация + """ + self._init_chroma() + + try: + # Получаем количество сообщений + result = self._collection.get(include=[]) + + if not result["ids"]: + return False + + # Считаем сообщения без summary + message_count = 0 + for i, id_ in enumerate(result["ids"]): + if id_ != COMPACTION_SUMMARY_ID: + message_count += 1 + + # Оцениваем заполненность контекста + # Примерный расчёт: 1 сообщение ≈ 100 токенов ≈ 400 символов + # Максимум контекста ≈ 200K токенов ≈ 800K символов + # Для простоты: 1000 сообщений = 100% контекста + + context_percent = (message_count / 1000) * 100 + + logger.info( + f"Проверка компактификации: " + f"сообщений={message_count}, " + f"заполненность={context_percent:.1f}%, " + f"порог={threshold_percent}%" + ) + + return context_percent >= threshold_percent + + except Exception as e: + logger.error(f"Ошибка проверки компактификации: {e}") + return False + + +# Глобальный экземпляр для использования в боте +compactor: Optional[DialogueCompactor] = None + + +def init_compactor(qwen_manager=None, vector_memory=None) -> DialogueCompactor: + """Инициализировать глобальный компактор.""" + global compactor + compactor = DialogueCompactor(qwen_manager, vector_memory) + return compactor + + +def get_compactor() -> DialogueCompactor: + """Получить глобальный компактор.""" + if compactor is None: + raise RuntimeError("Compactor не инициализирован. Вызовите init_compactor().") + return compactor diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..c1bff5c --- /dev/null +++ b/bot/config.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Конфигурация бота и глобальные объекты.""" + +import os +import logging +import getpass +from pathlib import Path +from typing import Optional + +from dotenv import load_dotenv + +# Загрузка переменных окружения из .env +load_dotenv() + +logger = logging.getLogger(__name__) + +# --- Конфигурация бота --- +class BotConfig: + """Конфигурация бота из переменных окружения.""" + + def __init__(self): + self.name = os.getenv("BOT_NAME", "CLI Assistant") + self.description = os.getenv("BOT_DESCRIPTION", "Бот для выполнения CLI команд") + self.icon = os.getenv("BOT_ICON_EMOJI", "🤖") + self.working_directory = os.getenv("WORKING_DIRECTORY", str(Path.home())) + + # Парсинг списка разрешённых пользователей + allowed_users_str = os.getenv("ALLOWED_USERS", "") + if allowed_users_str.strip(): + self.allowed_users = [ + int(uid.strip()) + for uid in allowed_users_str.split(",") + if uid.strip().isdigit() + ] + else: + self.allowed_users = [] + + @property + def is_access_restricted(self) -> bool: + """Проверка: ограничен ли доступ.""" + return len(self.allowed_users) > 0 + + +# Импортируем модели и создаём глобальные объекты +from bot.models.server import ServerManager +from bot.models.user_state import StateManager +from bot.keyboards.menus import MenuBuilder, CommandRegistry + +# Глобальные объекты +config = BotConfig() +state_manager = StateManager() +menu_builder = MenuBuilder() +command_registry = CommandRegistry() +server_manager = ServerManager() diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..1cbfb9a --- /dev/null +++ b/bot/handlers/__init__.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Обработчики событий бота.""" + +from bot.handlers.commands import ( + start_command, + menu_command, + help_command, + settings_command, +) +from bot.handlers.callbacks import menu_callback + +__all__ = [ + "start_command", + "menu_command", + "help_command", + "settings_command", + "menu_callback", +] diff --git a/bot/handlers/ai_presets.py b/bot/handlers/ai_presets.py new file mode 100644 index 0000000..9473416 --- /dev/null +++ b/bot/handlers/ai_presets.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +""" +Обработчики команд для переключения AI-пресетов. + +Доступные пресеты: +- off: ИИ отключен, режим CLI команд +- qwen: Qwen Code (бесплатно, локально) +- giga_auto: GigaChat авто-переключение (Lite/Pro) +- giga_lite: GigaChat Lite (дешевле) +- giga_pro: GigaChat Pro (максимальное качество) +""" + +import logging +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import CommandHandler, CallbackQueryHandler + +from bot.models.user_state import ( + AI_PRESET_OFF, + AI_PRESET_QWEN, + AI_PRESET_GIGA_AUTO, + AI_PRESET_GIGA_LITE, + AI_PRESET_GIGA_PRO, + AI_PRESET_GIGA_MAX, + AI_PRESET_OPENCODE, +) +from bot.config import state_manager + +logger = logging.getLogger(__name__) + +# Описание пресетов +PRESET_DESCRIPTIONS = { + AI_PRESET_OFF: { + "name": "❌ ИИ Отключен", + "description": "Режим CLI команд. Бот выполняет команды напрямую.", + "icon": "⌨️" + }, + AI_PRESET_QWEN: { + "name": "🤖 Qwen Code", + "description": "Бесплатно, локально. Лучший для кода и работы с файлами.", + "icon": "💻" + }, + AI_PRESET_GIGA_AUTO: { + "name": "🔄 GigaChat Авто", + "description": "Умное переключение Lite/Pro. Простые → Lite, сложные → Pro.", + "icon": "🧠" + }, + AI_PRESET_GIGA_LITE: { + "name": "⚡ GigaChat Lite", + "description": "Быстро и дёшево. Для простых вопросов и чата.", + "icon": "🚀" + }, + AI_PRESET_GIGA_PRO: { + "name": "🔥 GigaChat Pro", + "description": "Максимальное качество. Для сложных творческих задач.", + "icon": "👑" + }, + AI_PRESET_GIGA_MAX: { + "name": "💎 GigaChat Max", + "description": "Топовая модель для самых сложных задач.", + "icon": "💎" + }, + AI_PRESET_OPENCODE: { + "name": "⚡ Opencode", + "description": "Бесплатные модели (minimax, big-pickle, gpt-5-nano).", + "icon": "🚀" + }, +} + + +def get_preset_display_name(preset: str) -> str: + """Получить отображаемое имя пресета.""" + desc = PRESET_DESCRIPTIONS.get(preset, {}) + return f"{desc.get('icon', '❓')} {desc.get('name', preset)}" + + +async def ai_presets_command(update: Update, context): + """Показать меню выбора AI-пресета.""" + user_id = update.effective_user.id + state = state_manager.get(user_id) + current_preset = state.ai_preset + + # Формируем меню - Opencode и GigaChat теперь открывают подменю выбора моделей + keyboard = [ + [ + InlineKeyboardButton( + f"{'✅' if current_preset == AI_PRESET_OFF else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_OFF]['icon']} ИИ Отключен", + callback_data=f"ai_preset_{AI_PRESET_OFF}" + ) + ], + [ + InlineKeyboardButton( + f"{'✅' if current_preset == AI_PRESET_QWEN else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['icon']} Qwen Code", + callback_data=f"ai_preset_{AI_PRESET_QWEN}" + ) + ], + [ + InlineKeyboardButton( + f"{'✅' if current_preset == AI_PRESET_GIGA_AUTO else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['icon']} GigaChat Авто", + callback_data=f"ai_preset_{AI_PRESET_GIGA_AUTO}" + ) + ], + # Кнопка GigaChat с подменю - убираем Lite и Pro из основного меню + [ + InlineKeyboardButton( + f"{'✅' if current_preset in [AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO, AI_PRESET_GIGA_MAX] else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat ▶", + callback_data="gigachat_submenu" + ) + ], + # Кнопка Opencode с подменю + [ + InlineKeyboardButton( + f"{'✅' if current_preset == AI_PRESET_OPENCODE else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} Opencode ▶", + callback_data="opencode_submenu" + ) + ], + ] + + reply_markup = InlineKeyboardMarkup(keyboard) + + current_name = get_preset_display_name(current_preset) + + output = f"🎛️ **Панель управления AI**\n\n" + output += f"**Текущий пресет:** {current_name}\n\n" + output += "Выберите AI-провайдер:\n\n" + output += "ℹ️ **Описание пресетов:**\n" + output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_OFF]['icon']} **ИИ Отключен** — {PRESET_DESCRIPTIONS[AI_PRESET_OFF]['description']}\n" + output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['icon']} **Qwen Code** — {PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['description']}\n" + output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['icon']} **GigaChat Авто** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['description']}\n" + output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} **GigaChat** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['description']}\n" + output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} **Opencode** — {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['description']}" + + await update.message.reply_text(output, parse_mode="Markdown", reply_markup=reply_markup) + + +async def ai_preset_callback(update: Update, context): + """Обработка выбора пресета из инлайн-меню.""" + user_id = update.effective_user.id + query = update.callback_query + await query.answer() + + # Извлекаем название пресета из callback_data + preset = query.data.replace("ai_preset_", "") + + if preset not in PRESET_DESCRIPTIONS: + await query.edit_message_text("❌ Неверный пресет") + return + + state = state_manager.get(user_id) + old_preset = state.ai_preset + state.ai_preset = preset + + # Обновляем ai_chat_mode и current_ai_provider для совместимости + if preset == AI_PRESET_OFF: + state.ai_chat_mode = False + state.current_ai_provider = "none" + else: + state.ai_chat_mode = True + # Для совместимости с существующим кодом + if preset == AI_PRESET_QWEN: + state.current_ai_provider = "qwen" + elif preset == AI_PRESET_OPENCODE: + state.current_ai_provider = "opencode" + else: # Любой GigaChat + state.current_ai_provider = "gigachat" + + preset_name = get_preset_display_name(preset) + + output = f"✅ **Переключено на:** {preset_name}\n\n" + output += f"{PRESET_DESCRIPTIONS[preset]['description']}" + + # Обновляем инлайн-меню - с подменю для Opencode и GigaChat + keyboard = [ + [ + InlineKeyboardButton( + f"{'✅' if preset == AI_PRESET_OFF else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_OFF]['icon']} ИИ Отключен", + callback_data=f"ai_preset_{AI_PRESET_OFF}" + ) + ], + [ + InlineKeyboardButton( + f"{'✅' if preset == AI_PRESET_QWEN else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['icon']} Qwen Code", + callback_data=f"ai_preset_{AI_PRESET_QWEN}" + ) + ], + [ + InlineKeyboardButton( + f"{'✅' if preset == AI_PRESET_GIGA_AUTO else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['icon']} GigaChat Авто", + callback_data=f"ai_preset_{AI_PRESET_GIGA_AUTO}" + ) + ], + [ + InlineKeyboardButton( + f"{'✅' if preset in [AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO, AI_PRESET_GIGA_MAX] else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat ▶", + callback_data="gigachat_submenu" + ) + ], + [ + InlineKeyboardButton( + f"{'✅' if preset == AI_PRESET_OPENCODE else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} Opencode ▶", + callback_data="opencode_submenu" + ) + ], + ] + + reply_markup = InlineKeyboardMarkup(keyboard) + await query.edit_message_text(output, parse_mode="Markdown", reply_markup=reply_markup) + + logger.info(f"Пользователь {user_id} переключил AI-пресет: {old_preset} → {preset}") + + +# Быстрые команды для переключения одним сообщением +async def ai_off_command(update: Update, context): + """Быстрое переключение на ИИ отключен.""" + await switch_preset(update, AI_PRESET_OFF) + + +async def ai_qwen_command(update: Update, context): + """Быстрое переключение на Qwen Code.""" + await switch_preset(update, AI_PRESET_QWEN) + + +async def ai_giga_auto_command(update: Update, context): + """Быстрое переключение на GigaChat Авто.""" + await switch_preset(update, AI_PRESET_GIGA_AUTO) + + +async def ai_giga_lite_command(update: Update, context): + """Быстрое переключение на GigaChat Lite.""" + await switch_preset(update, AI_PRESET_GIGA_LITE) + + +async def ai_giga_pro_command(update: Update, context): + """Быстрое переключение на GigaChat Pro.""" + await switch_preset(update, AI_PRESET_GIGA_PRO) + + +async def ai_giga_max_command(update: Update, context): + """Быстрое переключение на GigaChat Max.""" + await switch_preset(update, AI_PRESET_GIGA_MAX) + + +async def ai_opencode_command(update: Update, context): + """Быстрое переключение на Opencode.""" + await switch_preset(update, AI_PRESET_OPENCODE) + + +async def switch_preset(update: Update, preset: str): + """Переключить пресет и показать уведомление.""" + user_id = update.effective_user.id + state = state_manager.get(user_id) + old_preset = state.ai_preset + state.ai_preset = preset + + # Обновляем совместимость + if preset == AI_PRESET_OFF: + state.ai_chat_mode = False + state.current_ai_provider = "none" + else: + state.ai_chat_mode = True + if preset == AI_PRESET_QWEN: + state.current_ai_provider = "qwen" + elif preset == AI_PRESET_OPENCODE: + state.current_ai_provider = "opencode" + else: + state.current_ai_provider = "gigachat" + + preset_name = get_preset_display_name(preset) + + output = f"✅ **AI-пресет переключен**\n\n" + output += f"**Текущий:** {preset_name}\n" + output += f"_{PRESET_DESCRIPTIONS[preset]['description']}_\n\n" + + if old_preset != preset: + output += f"~{get_preset_display_name(old_preset)}~ → ✅ {preset_name}" + + await update.message.reply_text(output, parse_mode="Markdown") + + logger.info(f"Пользователь {user_id} переключил AI-пресет: {old_preset} → {preset}") + + +def register_ai_preset_handlers(dispatcher): + """Зарегистрировать обработчики AI-пресетов.""" + # Основное меню + dispatcher.add_handler(CommandHandler("ai_presets", ai_presets_command)) + + # Callback для инлайн-меню + dispatcher.add_handler(CallbackQueryHandler(ai_preset_callback, pattern="^ai_preset_")) + + # Быстрые команды + dispatcher.add_handler(CommandHandler("ai_off", ai_off_command)) + dispatcher.add_handler(CommandHandler("ai_qwen", ai_qwen_command)) + dispatcher.add_handler(CommandHandler("ai_giga_auto", ai_giga_auto_command)) + dispatcher.add_handler(CommandHandler("ai_giga_lite", ai_giga_lite_command)) + dispatcher.add_handler(CommandHandler("ai_giga_pro", ai_giga_pro_command)) + dispatcher.add_handler(CommandHandler("ai_giga_max", ai_giga_max_command)) + dispatcher.add_handler(CommandHandler("ai_opencode", ai_opencode_command)) + + logger.info("Обработчики AI-пресетов зарегистрированы") diff --git a/bot/handlers/callbacks.py b/bot/handlers/callbacks.py new file mode 100644 index 0000000..acd2c09 --- /dev/null +++ b/bot/handlers/callbacks.py @@ -0,0 +1,878 @@ +#!/usr/bin/env python3 +"""Обработчик callback-запросов от меню.""" + +import logging +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ContextTypes + +from bot.config import config, state_manager, server_manager, menu_builder +from bot.utils.decorators import check_access +from bot.services.command_executor import execute_cli_command +from bot.models.user_state import ( + AI_PRESET_OFF, + AI_PRESET_QWEN, + AI_PRESET_GIGA_AUTO, + AI_PRESET_GIGA_LITE, + AI_PRESET_GIGA_PRO, + AI_PRESET_GIGA_MAX, + AI_PRESET_OPENCODE, +) +from memory_system import memory_manager, get_user_profile_summary + +logger = logging.getLogger(__name__) + + +async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка нажатий на кнопки меню.""" + query = update.callback_query + user_id = query.from_user.id + state = state_manager.get(user_id) + + await query.answer() + + callback = query.data + logger.info(f"Callback: {callback} от пользователя {user_id}") + + # Обработка навигации + if callback == "main": + state.current_menu = "main" + + # Проверяем режим чата с ИИ для обновления текста кнопки + ai_status = "✅ ВКЛ" if state.ai_chat_mode else "❌ ВЫКЛ" + await query.edit_message_text( + f"🏠 **Главное меню**\n\n" + f"💬 **Чат с ИИ:** {ai_status}", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("main", user_id=query.from_user.id, state=state) + ) + + elif callback == "ai_presets": + # Открываем меню AI-пресетов + state = state_manager.get(user_id) + from bot.handlers.ai_presets import ai_presets_command + # Создаём фейковое сообщение для совместимости + class FakeMessage: + async def reply_text(self, text, parse_mode=None, reply_markup=None): + # Вместо отправки сообщения редактируем callback + await query.edit_message_text(text, parse_mode=parse_mode, reply_markup=reply_markup) + return None + + fake_update = type('FakeUpdate', (), {'message': FakeMessage(), 'effective_user': query.from_user})() + await ai_presets_command(fake_update, context) + return + + # Обработчики подменю AI-пресетов + elif callback == "opencode_submenu": + # Подменю выбора моделей Opencode из AI-пресетов + state = state_manager.get(user_id) + current_model = state.opencode_model + state.ai_preset = AI_PRESET_OPENCODE + state.current_ai_provider = "opencode" + + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_model == 'minimax' else '⬜'} ⚡ minimax", callback_data="opencode_model_minimax")], + [InlineKeyboardButton(f"{'✅' if current_model == 'big_pickle' else '⬜'} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")], + [InlineKeyboardButton(f"{'✅' if current_model == 'gpt5' else '⬜'} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")], + [InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")], + ] + + model_descriptions = { + "minimax": "Быстрая, бесплатная модель. Хорошо справляется с простыми задачами.", + "big_pickle": "Большая бесплатная модель. Лучше для сложных задач.", + "gpt5": "Самая мощная бесплатная модель. Требует больше времени." + } + + await query.edit_message_text( + f"📡 **Выбор модели Opencode**\n\n" + f"Текущая модель: **{current_model}**\n\n" + f"ℹ️ Описание моделей:\n" + f"• ⚡ **minimax** — {model_descriptions['minimax']}\n" + f"• 🗃️ **big-pickle** — {model_descriptions['big_pickle']}\n" + f"• 🔬 **gpt-5-nano** — {model_descriptions['gpt5']}\n\n" + f"Выберите модель для использования:", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback == "gigachat_submenu": + # Подменю выбора моделей GigaChat из AI-пресетов + state = state_manager.get(user_id) + current_model = state.gigachat_model + + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_model == 'lite' else '⬜'} 📱 GigaChat Lite", callback_data="ai_preset_giga_lite")], + [InlineKeyboardButton(f"{'✅' if current_model == 'pro' else '⬜'} 🚀 GigaChat Pro", callback_data="ai_preset_giga_pro")], + [InlineKeyboardButton(f"{'✅' if current_model == 'max' else '⬜'} 💎 GigaChat Max", callback_data="ai_preset_giga_max")], + [InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")], + ] + + model_descriptions = { + "lite": "Быстрая и экономичная модель для простых задач", + "pro": "Баланс скорости и качества для большинства задач", + "max": "Самая мощная модель для сложных задач" + } + + await query.edit_message_text( + f"🧠 **Выбор модели GigaChat**\n\n" + f"Текущая модель: **{current_model.upper()}**\n\n" + f"ℹ️ Описание моделей:\n" + f"• 📱 **Lite** — {model_descriptions['lite']}\n" + f"• 🚀 **Pro** — {model_descriptions['pro']}\n" + f"• 💎 **Max** — {model_descriptions['max']}\n\n" + f"Выберите модель для использования:", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback.startswith("continue_output_"): + # Пользователь нажал "Продолжить" + parts = callback.replace("continue_output_", "").split("_") + remaining = int(parts[0]) + next_index = int(parts[1]) if len(parts) > 1 else 0 + + state = state_manager.get(user_id) + logger.info(f"callback continue_output: remaining={remaining}, next_index={next_index}, user_id={user_id}") + + # Сначала отвечаем на callback + await query.answer() + + # Удаляем сообщение с кнопками + try: + if state.output_wait_message: + await state.output_wait_message.delete() + except: + pass + + # Продолжаем отправку сообщений + if state.output_text: + from bot.utils.formatters import send_long_message + + # Создаём фейковый update для совместимости + class FakeMessage: + async def reply_text(self, text, parse_mode=None, reply_markup=None): + return await query.message.reply_text(text, parse_mode=parse_mode, reply_markup=reply_markup) + + fake_update = type('FakeUpdate', (), { + 'message': FakeMessage(), + 'effective_user': query.from_user + })() + + # Продолжаем отправку + has_more = await send_long_message( + fake_update, + state.output_text, + parse_mode=state.output_parse_mode, + start_from=next_index + ) + + # Если ещё есть сообщения — сохраняем состояние + if has_more: + logger.info(f"Продолжение отправлено, ещё есть пауза") + else: + logger.info(f"Все сообщения отправлены") + state.output_text = None + else: + logger.warning(f"output_text не найден в состоянии") + + return + + elif callback == "cancel_output": + # Пользователь нажал "Отменить" + logger.info(f"callback cancel_output: user_id={user_id}") + state = state_manager.get(user_id) + + # Сначала отвечаем на callback + await query.answer() + + # Удаляем сообщение с кнопками + try: + if state.output_wait_message: + await state.output_wait_message.delete() + except: + pass + + # Очищаем состояние + state.waiting_for_output_control = False + state.output_remaining = None + state.output_wait_message = None + state.output_text = None + state.output_next_index = None + + await query.message.reply_text("❌ Вывод отменён пользователем") + return + + elif callback == "preset_menu": + state.current_menu = "preset" + await query.edit_message_text( + "📋 **Предустановленные команды**", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("preset") + ) + + elif callback == "fs_menu": + await query.edit_message_text( + "📁 **Файловая система**", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("fs") + ) + + elif callback == "search_menu": + await query.edit_message_text( + "🔍 **Поиск**", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("search") + ) + + elif callback == "system_menu": + await query.edit_message_text( + "📊 **Система**", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("system") + ) + + elif callback == "network_menu": + await query.edit_message_text( + "🌐 **Сеть**", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("network") + ) + + elif callback == "opencode_models_menu": + # Меню выбора моделей Opencode в предустановленных командах + state = state_manager.get(user_id) + current_model = state.opencode_model + state.current_ai_provider = "opencode" + + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_model == 'minimax' else '⬜'} ⚡ minimax", callback_data="opencode_model_minimax")], + [InlineKeyboardButton(f"{'✅' if current_model == 'big_pickle' else '⬜'} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")], + [InlineKeyboardButton(f"{'✅' if current_model == 'gpt5' else '⬜'} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")], + [InlineKeyboardButton("⬅️ Назад", callback_data="preset_menu")], + ] + + model_descriptions = { + "minimax": "Быстрая, бесплатная модель", + "big_pickle": "Большая бесплатная модель", + "gpt5": "Самая мощная бесплатная модель" + } + + await query.edit_message_text( + f"🤖 **AI модели Opencode**\n\n" + f"Текущая модель: **{current_model}**\n\n" + f"ℹ️ Описание:\n" + f"• ⚡ **minimax** — {model_descriptions['minimax']}\n" + f"• 🗃️ **big-pickle** — {model_descriptions['big_pickle']}\n" + f"• 🔬 **gpt-5-nano** — {model_descriptions['gpt5']}\n\n" + f"Выберите модель:", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback == "server_menu": + # Сброс состояния редактирования/добавления сервера + state.waiting_for_input = False + state.input_type = None + state.editing_server = None + state.context.clear() + + # Динамическое обновление меню серверов с кнопками управления + servers = server_manager.list_servers() + keyboard = [] + + for srv in servers: + # Кнопка выбора сервера + кнопка управления (для не-local) + row = [InlineKeyboardButton( + srv.display_name, + callback_data=f"server_select_{srv.name}" + )] + if srv.name != "local": + row.append(InlineKeyboardButton( + "⚙️", + callback_data=f"server_manage_{srv.name}" + )) + keyboard.append(row) + + keyboard.append([ + InlineKeyboardButton("➕ Добавить", callback_data="server_add"), + InlineKeyboardButton("⬅️ Назад", callback_data="main") + ]) + + state.current_menu = "server" + await query.edit_message_text( + "🖥️ **Управление серверами**\n\n" + "Выберите сервер для подключения или добавьте новый.\n" + "⚙️ — редактировать/удалить сервер", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback == "server_add": + state.waiting_for_input = True + state.input_type = "add_server_name" + state.context["new_server"] = {} + await query.edit_message_text( + "➕ **Добавление сервера**\n\n" + "Введите **имя сервера** (латиница, без пробелов):\n" + "Пример: `web-prod`, `db-backup`", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + + elif callback.startswith("server_manage_"): + server_name = callback.replace("server_manage_", "") + server = server_manager.get(server_name) + + if server and server_name != "local": + state.editing_server = server_name + await query.edit_message_text( + f"⚙️ **Управление сервером**\n\n" + f"{server.display_name}\n" + f"📍 `{server.description}`\n" + f"🏷️ Теги: `{','.join(server.tags) if server.tags else 'нет'}`\n\n" + f"Выберите действие:", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("✏️ Редактировать", callback_data=f"server_edit_{server_name}")], + [InlineKeyboardButton("🗑️ Удалить", callback_data=f"server_delete_{server_name}")], + [InlineKeyboardButton("⬅️ Назад", callback_data="server_menu")] + ]) + ) + else: + await query.edit_message_text( + f"❌ **Сервер не найден**\n\n" + f"Сервер `{server_name}` отсутствует в конфигурации.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("server") + ) + + elif callback.startswith("server_edit_"): + server_name = callback.replace("server_edit_", "") + server = server_manager.get(server_name) + + if server and server_name != "local": + state.editing_server = server_name + state.waiting_for_input = True + state.input_type = "edit_server_field" + password_status = "установлен" if server.password else "не установлен" + await query.edit_message_text( + f"✏️ **Редактирование сервера: {server_name}**\n\n" + f"Текущие значения:\n" + f"• Host: `{server.host}`\n" + f"• Port: `{server.port}`\n" + f"• User: `{server.user}`\n" + f"• Tags: `{','.join(server.tags) if server.tags else 'нет'}`\n" + f"• Password: {password_status}\n\n" + f"Введите номер поля для изменения:\n" + f"1 — Host\n" + f"2 — Port\n" + f"3 — User\n" + f"4 — Tags\n" + f"5 — Password", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + else: + await query.edit_message_text( + "❌ Ошибка: сервер не найден", + reply_markup=menu_builder.get_keyboard("server") + ) + + elif callback.startswith("server_delete_"): + server_name = callback.replace("server_delete_", "") + server = server_manager.get(server_name) + + if server and server_name != "local": + # Удаляем сразу с подтверждением + if server_manager.delete_server(server_name): + await query.edit_message_text( + f"🗑️ **Сервер удалён**\n\n" + f"Сервер `{server_name}` успешно удалён из конфигурации.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("server") + ) + else: + await query.edit_message_text( + "❌ Ошибка при удалении сервера", + reply_markup=menu_builder.get_keyboard("server") + ) + else: + await query.edit_message_text( + "❌ Нельзя удалить local сервер", + reply_markup=menu_builder.get_keyboard("server") + ) + + elif callback == "srv_skip_password": + # Пропуск пароля при добавлении сервера + user_id = query.from_user.id + state = state_manager.get(user_id) + + state.context["new_server"]["password"] = "" + state.input_type = "add_server_tags" + await query.edit_message_text( + "✅ Пароль пропущен (будет использоваться только ключ)\n\n" + "Введите **теги** через запятую (или нажмите Пропустить):\n" + "Пример: `web,prod`, `db,backup`\n\n" + "Теги помогают группировать серверы.", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("⏭️ Пропустить", callback_data="srv_skip_tags")], + [InlineKeyboardButton("❌ Отмена", callback_data="server_menu")] + ]) + ) + + elif callback == "srv_skip_tags": + # Пропуск тегов при добавлении сервера + user_id = query.from_user.id + state = state_manager.get(user_id) + + new_server = state.context.get("new_server", {}) + if new_server.get("name") and new_server.get("host") and new_server.get("port") and new_server.get("user"): + if server_manager.add_server( + name=new_server["name"], + host=new_server["host"], + port=new_server["port"], + user=new_server["user"], + tags=[], + password=new_server.get("password", "") + ): + await query.edit_message_text( + "✅ **Сервер добавлен**\n\n" + f"Имя: `{new_server['name']}`\n" + f"Host: `{new_server['host']}`\n" + f"Port: `{new_server['port']}`\n" + f"User: `{new_server['user']}`\n" + f"Tags: нет\n" + f"Password: {'установлен' if new_server.get('password') else 'не установлен'}\n\n" + f"Сервер сохранён в `.env` и доступен для выбора.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("server") + ) + else: + await query.edit_message_text( + "❌ Ошибка: сервер с таким именем уже существует", + reply_markup=menu_builder.get_keyboard("server") + ) + else: + await query.edit_message_text( + "❌ Ошибка: неполные данные сервера", + reply_markup=menu_builder.get_keyboard("server") + ) + + state.waiting_for_input = False + state.input_type = None + state.context.clear() + + elif callback.startswith("server_select_"): + server_name = callback.replace("server_select_", "") + server = server_manager.get(server_name) + + if server: + state.current_server = server_name + # Сброс рабочей директории при смене сервера + state.working_directory = None + + await query.edit_message_text( + f"✅ **Сервер изменён**\n\n" + f"{server.display_name}\n" + f"📍 `{server.description}`\n\n" + f"Теперь команды выполняются на этом сервере.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("main", user_id=query.from_user.id, state=state) + ) + state.current_menu = "main" + else: + await query.edit_message_text( + f"❌ **Сервер не найден**\n\n" + f"Сервер `{server_name}` отсутствует в конфигурации.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("server") + ) + + elif callback == "settings_menu": + state.current_menu = "settings" + await query.edit_message_text( + "⚙️ **Настройки бота**", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("settings") + ) + + # Обработка команд выполнения + elif callback.startswith("cmd_"): + # Поиск команды в меню + command = None + for menu_items in menu_builder._menus.values(): + for item in menu_items: + if item.callback == callback and item.command: + command = item.command + break + + if command: + await execute_cli_command(query, command) + else: + await query.edit_message_text("❌ Команда не найдена") + + # Настройки бота - только просмотр, изменение через .env + elif callback == "set_name": + await query.edit_message_text( + "📝 **Изменение имени бота**\n\n" + f"Текущее имя: `{config.name}`\n\n" + "Для изменения отредактируйте `.env`:\n" + "```\nBOT_NAME=Ваше имя\n```", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("settings") + ) + + elif callback == "set_description": + await query.edit_message_text( + "📄 **Изменение описания бота**\n\n" + f"Текущее описание: `{config.description}`\n\n" + "Для изменения отредактируйте `.env`:\n" + "```\nBOT_DESCRIPTION=Ваше описание\n```", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("settings") + ) + + elif callback == "set_icon": + await query.edit_message_text( + "🎨 **Изменение иконки бота**\n\n" + f"Текущая иконка: `{config.icon}`\n\n" + "Для изменения отредактируйте `.env`:\n" + "```\nBOT_ICON_EMOJI=🤖\n```", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("settings") + ) + + elif callback == "about": + await query.edit_message_text( + f"ℹ️ **О боте**\n\n" + f"**{config.icon} {config.name}**\n" + f"_{config.description}_\n\n" + f"**Версия:** `2.1.0`\n\n" + f"**Возможности:**\n" + f"• Выполнение CLI команд через Telegram\n" + f"• Поддержка локальных команд и SSH\n" + f"• Интерактивный ввод пароля (sudo)\n" + f"• Предустановленные команды\n" + f"• Управление серверами\n" + f"• Очистка ANSI-кодов и прогресс-баров\n" + f"• Форматирование длинного вывода\n" + f"• 💬 Чат с ИИ агентом (Qwen Code)\n\n" + f"**Рабочая директория:**\n" + f"`{config.working_directory}`\n\n" + f"Бот позволяет безопасно выполнять команды\n" + f"на вашем сервере через интерфейс Telegram.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("main", user_id=query.from_user.id, state=state) + ) + state.current_menu = "main" + + elif callback in ["toggle_ai_chat", "toggle_ai_chat_on", "toggle_ai_chat_off"]: + # Переключаем режим чата с ИИ + state.ai_chat_mode = not state.ai_chat_mode + logger.info(f"toggle_ai_chat: user_id={user_id}, new_mode={state.ai_chat_mode}") + + ai_status = "✅ ВКЛЮЧЕН" if state.ai_chat_mode else "❌ ВЫКЛЮЧЕН" + action = "включён" if state.ai_chat_mode else "выключен" + + await query.edit_message_text( + f"🏠 **Главное меню**\n\n" + f"💬 **Чат с ИИ:** {ai_status}\n\n" + f"Режим чата с агентом {action}.\n" + f"Теперь все сообщения будут отправляться в Qwen Code.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("main", user_id=query.from_user.id, state=state) + ) + state.current_menu = "main" + + # --- Обработчики меню памяти --- + elif callback == "memory_menu": + state.current_menu = "memory" + await query.edit_message_text( + "🧠 **Память ИИ**\n\n" + "Управление памятью чата с ИИ:\n" + "• Профиль — факты о вас, которые запомнил ИИ\n" + "• Статистика — количество сообщений и сессий\n" + "• Очистить — удалить историю переписки", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("memory") + ) + + elif callback == "memory_profile": + profile_summary = get_user_profile_summary(user_id) + if not profile_summary: + profile_summary = "📭 Профиль пуст\n\nФакты ещё не извлечены.\nНачните общаться с ИИ в чате." + + await query.edit_message_text( + f"📋 **Ваш профиль**\n\n{profile_summary}", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("memory") + ) + + elif callback == "memory_stats": + stats = memory_manager.get_stats(user_id) + await query.edit_message_text( + f"📊 **Статистика памяти**\n\n" + f"• Сессий: `{stats['total_sessions']}`\n" + f"• Сообщений: `{stats['total_messages']}`\n" + f"• Фактов: `{stats['total_facts']}`", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("memory") + ) + + elif callback == "memory_clear": + # Показываем подтверждение + await query.edit_message_text( + "🗑️ **Очистка истории**\n\n" + "Вы уверены?\n" + "Это удалит всю историю сообщений.\n" + "Факты останутся (их можно удалить отдельно).", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("🗑️ Да, очистить", callback_data="memory_clear_confirm")], + [InlineKeyboardButton("❌ Отмена", callback_data="memory_menu")] + ]) + ) + + elif callback == "memory_clear_confirm": + # Очищаем историю сообщений (в будущем можно добавить метод в memory_manager) + from memory_system import MemoryStorage + # Пока просто уведомляем + await query.edit_message_text( + "✅ **История очищена**\n\n" + "Функция полной очистки будет добавлена в следующей версии.\n" + "Пока очищается только история сессии в памяти бота.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("memory") + ) + # Сбрасываем историю чата в состоянии + state.ai_chat_history = [] + + elif callback == "memory_compact": + # Вызываем команду /compact через send_message + await query.edit_message_text( + "🔄 **Запуск компактификации истории...**\n\n" + "_Сжатие старой истории в структурированный summary._\n" + "_Это может занять несколько секунд._", + parse_mode="Markdown" + ) + + # Получаем compactor и выполняем компактификацию + from bot.compaction import get_compactor + try: + compactor = get_compactor() + result = await compactor.compact() + + if result.success: + if result.messages_compressed > 0: + await query.edit_message_text( + f"✅ **Компактификация завершена!**\n\n" + f"📊 Сжато сообщений: `{result.messages_compressed}`\n" + f"📝 Длина summary: `{result.summary_length}` символов\n" + f"💾 Экономия токенов: ~`{result.tokens_saved}`\n\n" + f"_Summary автоматически используется в контексте диалога._", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("memory") + ) + else: + await query.edit_message_text( + "ℹ️ **Компактификация не требуется**\n\n" + "История пуста или уже компактная.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("memory") + ) + else: + await query.edit_message_text( + f"❌ **Ошибка компактификации:**\n\n{result.error}", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("memory") + ) + except Exception as e: + await query.edit_message_text( + f"❌ **Ошибка:** {e}", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("memory") + ) + + elif callback == "opencode_model_menu": + state = state_manager.get(user_id) + current_model = state.opencode_model + state.current_ai_provider = "opencode" + + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_model == 'minimax' else '⬜'} ⚡ minimax", callback_data="opencode_model_minimax")], + [InlineKeyboardButton(f"{'✅' if current_model == 'big_pickle' else '⬜'} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")], + [InlineKeyboardButton(f"{'✅' if current_model == 'gpt5' else '⬜'} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")], + [InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")], + ] + + model_descriptions = { + "minimax": "Быстрая, бесплатная модель. Хорошо справляется с простыми задачами.", + "big_pickle": "Большая бесплатная модель. Лучше для сложных задач.", + "gpt5": "Самая мощная бесплатная модель. Требует больше времени." + } + + await query.edit_message_text( + f"📡 **Выбор модели Opencode**\n\n" + f"Текущая модель: **{current_model}**\n\n" + f"ℹ️ Описание моделей:\n" + f"• ⚡ **minimax** — {model_descriptions['minimax']}\n" + f"• 🗃️ **big-pickle** — {model_descriptions['big_pickle']}\n" + f"• 🔬 **gpt-5-nano** — {model_descriptions['gpt5']}", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback.startswith("opencode_model_"): + model = callback.replace("opencode_model_", "") + state = state_manager.get(user_id) + state.opencode_model = model + + # Обновляем модель в OpencodeProvider + from bot.providers.opencode_provider import OpencodeProvider + provider = OpencodeProvider() + provider.set_model(user_id, model) + + model_names = { + "minimax": "⚡ minimax", + "big_pickle": "🗃️ big-pickle", + "gpt5": "🔬 gpt-5-nano" + } + + await query.answer(f"✅ Модель изменена на {model_names.get(model, model)}") + + # Показываем меню снова + keyboard = [ + [InlineKeyboardButton(f"{'✅' if model == 'minimax' else '⬜'} ⚡ minimax", callback_data="opencode_model_minimax")], + [InlineKeyboardButton(f"{'✅' if model == 'big_pickle' else '⬜'} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")], + [InlineKeyboardButton(f"{'✅' if model == 'gpt5' else '⬜'} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")], + [InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")], + ] + + await query.edit_message_text( + f"📡 **Выбор модели Opencode**\n\n" + f"Текущая модель: **{model_names.get(model, model)}**\n\n" + f"✅ **Модель изменена!**\n\n" + f"Выберите модель или вернитесь назад:", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + # --- Обработчики меню выбора AI-провайдера --- + elif callback == "ai_provider_selection_menu": + state = state_manager.get(user_id) + current_provider = state.current_ai_provider + + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_provider == 'qwen' else '⬜'} 🔄 Qwen Code", callback_data="ai_provider_qwen")], + [InlineKeyboardButton(f"{'✅' if current_provider == 'opencode' else '⬜'} 📡 Opencode ▶", callback_data="opencode_model_menu")], + [InlineKeyboardButton(f"{'✅' if current_provider == 'gigachat' else '⬜'} 🧠 GigaChat ▶", callback_data="gigachat_model_menu")], + [InlineKeyboardButton("⬅️ Назад", callback_data="settings")], + ] + + provider_descriptions = { + "qwen": "Бесплатный локальный AI от Alibaba", + "opencode": "Бесплатные модели (minimax, big-pickle, gpt-5-nano)", + "gigachat": "Российский AI от Сбера (Lite, Pro, Max)" + } + + await query.edit_message_text( + f"🤖 **Выбор AI-провайдера**\n\n" + f"Текущий провайдер: **{current_provider.upper()}**\n\n" + f"ℹ️ Описание провайдеров:\n" + f"• 🔄 **Qwen Code** — {provider_descriptions['qwen']}\n" + f"• 📡 **Opencode** — {provider_descriptions['opencode']}\n" + f"• 🧠 **GigaChat** — {provider_descriptions['gigachat']}", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback == "ai_provider_qwen": + state = state_manager.get(user_id) + state.current_ai_provider = "qwen" + + await query.answer("✅ Переключено на Qwen Code") + + current_provider = "qwen" + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_provider == 'qwen' else '⬜'} 🔄 Qwen Code", callback_data="ai_provider_qwen")], + [InlineKeyboardButton(f"{'✅' if current_provider == 'opencode' else '⬜'} 📡 Opencode ▶", callback_data="opencode_model_menu")], + [InlineKeyboardButton(f"{'✅' if current_provider == 'gigachat' else '⬜'} 🧠 GigaChat ▶", callback_data="gigachat_model_menu")], + [InlineKeyboardButton("⬅️ Назад", callback_data="settings")], + ] + + await query.edit_message_text( + f"🤖 **Выбор AI-провайдера**\n\n" + f"Текущий провайдер: **QWEN CODE**\n\n" + f"✅ **Провайдер изменён!**\n\n" + f"ℹ️ Описание провайдеров:\n" + f"• 🔄 **Qwen Code** — Бесплатный локальный AI от Alibaba\n" + f"• 📡 **Opencode** — Бесплатные модели (minimax, big-pickle, gpt-5-nano)\n" + f"• 🧠 **GigaChat** — Российский AI от Сбера (Lite, Pro, Max)", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback == "gigachat_model_menu": + state = state_manager.get(user_id) + current_model = state.gigachat_model + state.current_ai_provider = "gigachat" + + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_model == 'lite' else '⬜'} 📱 GigaChat Lite", callback_data="gigachat_model_lite")], + [InlineKeyboardButton(f"{'✅' if current_model == 'pro' else '⬜'} 🚀 GigaChat Pro", callback_data="gigachat_model_pro")], + [InlineKeyboardButton(f"{'✅' if current_model == 'max' else '⬜'} 💎 GigaChat Max", callback_data="gigachat_model_max")], + [InlineKeyboardButton("⬅️ Назад", callback_data="ai_provider_selection_menu")], + ] + + model_descriptions = { + "lite": "Быстрая и экономичная модель для простых задач", + "pro": "Баланс скорости и качества для большинства задач", + "max": "Самая мощная модель для сложных задач" + } + + await query.edit_message_text( + f"🧠 **Выбор модели GigaChat**\n\n" + f"Текущая модель: **{current_model.upper()}**\n\n" + f"ℹ️ Описание моделей:\n" + f"• 📱 **Lite** — {model_descriptions['lite']}\n" + f"• 🚀 **Pro** — {model_descriptions['pro']}\n" + f"• 💎 **Max** — {model_descriptions['max']}", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback.startswith("gigachat_model_"): + model = callback.replace("gigachat_model_", "") + state = state_manager.get(user_id) + state.gigachat_model = model + state.current_ai_provider = "gigachat" + + model_names = { + "lite": "📱 GigaChat Lite", + "pro": "🚀 GigaChat Pro", + "max": "💎 GigaChat Max" + } + + await query.answer(f"✅ Модель изменена на {model_names.get(model, model)}") + + current_model = model + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_model == 'lite' else '⬜'} 📱 GigaChat Lite", callback_data="gigachat_model_lite")], + [InlineKeyboardButton(f"{'✅' if current_model == 'pro' else '⬜'} 🚀 GigaChat Pro", callback_data="gigachat_model_pro")], + [InlineKeyboardButton(f"{'✅' if current_model == 'max' else '⬜'} 💎 GigaChat Max", callback_data="gigachat_model_max")], + [InlineKeyboardButton("⬅️ Назад", callback_data="ai_provider_selection_menu")], + ] + + await query.edit_message_text( + f"🧠 **Выбор модели GigaChat**\n\n" + f"Текущая модель: **{model_names.get(model, model)}**\n\n" + f"✅ **Модель изменена!**\n\n" + f"Теперь используется GigaChat с выбранной моделью.", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + diff --git a/bot/handlers/commands.py b/bot/handlers/commands.py new file mode 100644 index 0000000..b108509 --- /dev/null +++ b/bot/handlers/commands.py @@ -0,0 +1,528 @@ +#!/usr/bin/env python3 +"""Обработчики команд бота (/start, /menu, /help, /settings, /cron).""" + +import logging +from datetime import datetime +from telegram import Update +from telegram.ext import ContextTypes + +# Импорты из модулей bot/ +from bot.config import config, state_manager, server_manager, menu_builder +from bot.utils.decorators import check_access +from bot.utils.formatters import escape_html +from bot.tools import tools_registry +from bot.ai_agent import ai_agent + +logger = logging.getLogger(__name__) + + +@check_access +async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /start.""" + user = update.effective_user + logger.info(f"Пользователь {user.username} ({user.id}) запустил бота") + + # Сбрасываем состояние НО сохраняем ai_chat_mode (по умолчанию True) + state = state_manager.get(user.id) + state_manager.reset(user.id) + state = state_manager.get(user.id) + # ai_chat_mode уже True по умолчанию из UserState + + # Показать текущую директорию и сервер + working_dir = config.working_directory + server = server_manager.get("local") + server_desc = server.description if server else "localhost" + + await update.message.reply_text( + f"👋 Привет, {user.first_name}!\n\n" + f"{config.icon} *{config.name}*\n" + f"_{config.description}_\n\n" + f"*Просто отправьте CLI команду в чат* — я её выполню!\n\n" + f"🖥️ *Текущий сервер:* `{server_desc}`\n" + f"📁 *Рабочая директория:* `{working_dir}`\n\n" + f"Используйте `cd путь` для смены директории.\n" + f"Или выберите сервер в меню.\n" + f"Команда /help покажет справку.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("main", user_id=update.effective_user.id, state=state) + ) + + +@check_access +async def menu_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /menu - показывает главное меню.""" + user = update.effective_user + state = state_manager.get(user.id) + + # Не сбрасываем состояние - сохраняем ai_chat_mode и другие настройки + state.current_menu = "main" + + # Показать текущую директорию и сервер + working_dir = state.working_directory or config.working_directory + server = server_manager.get(state.current_server) + server_desc = server.description if server else state.current_server + + await update.message.reply_text( + f"🏠 *Главное меню*\n\n" + f"🖥️ *Сервер:* `{server_desc}`\n" + f"📁 *Директория:* `{working_dir}`\n\n" + f"Выберите действие:", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("main", user_id=update.effective_user.id, state=state) + ) + + +@check_access +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /help.""" + help_text = f""" +📖 *Справка по боту {config.name}* + +*Как использовать:* +Просто отправьте любую CLI команду в чат — бот выполнит её! + +*Примеры:* +• `ls -la` — список файлов +• `pwd` — текущая директория +• `df -h` — свободное место на диске +• `git status` — статус git + +*Навигация по директориям:* +• `cd путь` — сменить директорию (например, `cd git/project`) +• `cd ..` — на уровень вверх +• `cd ~` — в домашнюю директорию +• `pwd` — показать текущую директорию + +*Кнопки меню:* +• 📋 Предустановленные команды — быстрые команды по категориям +• ⚙️ Настройки бота — изменение имени, описания, иконки +• ℹ️ О боте — информация + +*Команды управления:* +/start — Запустить бота, главное меню +/menu — Показать главное меню с кнопками +/help — Эта справка +/settings — Настройки + +*Безопасность:* +Команды выполняются от вашего имени. +Будьте осторожны с деструктивными командами! +""" + await update.message.reply_text(help_text, parse_mode="Markdown") + + +@check_access +async def settings_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /settings.""" + state = state_manager.get(update.effective_user.id) + state.current_menu = "settings" + + await update.message.reply_text( + "⚙️ *Настройки бота*", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("settings") + ) + + +@check_access +async def cron_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Обработка команды /cron - управление задачами. + + Использование: + /cron list - показать все задачи + /cron add - добавить задачу + /cron run - выполнить задачу немедленно + /cron remove - удалить задачу + /cron toggle - включить/выключить задачу + """ + user_id = update.effective_user.id + args = context.args + + # Получаем cron инструмент + cron_tool = tools_registry.get('cron_tool') + if not cron_tool: + await update.message.reply_text("❌ Ошибка: cron инструмент не найден") + return + + # Парсим команду + if not args: + # По умолчанию показываем список задач + action = 'list' + else: + action = args[0].lower() + + try: + if action == 'list': + # Показать все задачи пользователя + result = await cron_tool.execute(action='list', user_id=user_id) + + if result.success and result.data: + output = "⏰ **Ваши задачи:**\n\n" + for job in result.data: + status = "✅" if job.get('enabled') else "❌" + notify_icon = "🔔" if job.get('notify') else "🔕" + log_icon = "📝" if job.get('log_results') else "🚫" + output += f"{status} **{job.get('name', 'Без названия')}** (ID: {job.get('id')})\n" + output += f" {notify_icon}{log_icon} Промпт: _{job.get('prompt', '')[:100]}_{'...' if len(job.get('prompt', '')) > 100 else ''}\n" + output += f" Расписание: `{job.get('schedule', '')}`\n" + if job.get('next_run'): + output += f" Следующий запуск: {job.get('next_run')}\n" + if job.get('last_run'): + output += f" Последний запуск: {job.get('last_run')}\n" + output += "\n" + + if not output.strip(): + output = "📭 У вас пока нет задач.\n\nДобавьте задачу командой:\n`/cron add `" + + await update.message.reply_text(output, parse_mode="Markdown") + else: + await update.message.reply_text("📭 У вас пока нет задач.") + + elif action == 'add': + if len(args) < 4: + await update.message.reply_text( + "❌ **Недостаточно аргументов**\n\n" + "**Использование:**\n" + "`/cron add [notify] [log]`\n\n" + "**Примеры:**\n" + "`/cron check_disk Ежедневно проверять диск на сервере`\n" + "`/cron news hourly Что нового в Linux сегодня`\n\n" + "**Расписание:**\n" + "• `@hourly` - каждый час\n" + "• `@daily` - каждый день\n" + "• `@weekly` - каждую неделю\n" + "• `*/5 * * * *` - каждые 5 минут", + parse_mode="Markdown" + ) + return + + name = args[1] + schedule = args[2] + # Промпт может содержать пробелы - берём всё после schedule + prompt = ' '.join(args[3:]) + + # Парсим опциональные параметры + notify = 'notify' in prompt.lower() + log_results = 'no_log' not in prompt.lower() and 'без_лога' not in prompt.lower() + + result = await cron_tool.execute( + action='add', + name=name, + prompt=prompt, + schedule=schedule, + user_id=user_id, + notify=notify, + log_results=log_results + ) + + if result.success: + notify_status = "🔔 Уведомлять" if notify else "🔕 Без уведомлений" + log_status = "📝 Логировать" if log_results else "🚫 Без логов" + await update.message.reply_text( + f"✅ **Задача добавлена:**\n" + f"• ID: {result.data.get('id')}\n" + f"• Название: {name}\n" + f"• Промпт: _{prompt}_\n" + f"• Расписание: `{schedule}`\n" + f"• {notify_status}, {log_status}\n" + f"• Следующий запуск: {result.data.get('next_run', 'N/A')}", + parse_mode="Markdown" + ) + else: + await update.message.reply_text(f"❌ Ошибка: {result.error}") + + elif action == 'run': + if len(args) < 2: + await update.message.reply_text("❌ Укажите ID задачи: `/cron run `") + return + + try: + job_id = int(args[1]) + except ValueError: + await update.message.reply_text("❌ ID должен быть числом") + return + + status_msg = await update.message.reply_text("⏳ Выполняю задачу...") + + # Выполняем задачу через AI-агент + result = await cron_tool.execute( + action='run', + job_id=job_id, + ai_agent=ai_agent, + user_id=user_id + ) + + await status_msg.delete() + + if result.success: + result_text = result.metadata.get('result_text', 'Задача выполнена') + tool_used = result.data.get('tool_used', 'не указан') + await update.message.reply_text( + f"✅ **Задача выполнена:**\n\n{result_text}\n\n🔧 Инструмент: {tool_used}", + parse_mode="Markdown" + ) + else: + await update.message.reply_text(f"❌ Ошибка: {result.error}") + + elif action == 'remove': + if len(args) < 2: + await update.message.reply_text("❌ Укажите ID задачи: `/cron remove `") + return + + try: + job_id = int(args[1]) + except ValueError: + await update.message.reply_text("❌ ID должен быть числом") + return + + result = await cron_tool.execute(action='remove', job_id=job_id) + + if result.success: + await update.message.reply_text(f"✅ Задача удалена: ID {job_id}") + else: + await update.message.reply_text(f"❌ Ошибка: {result.error}") + + elif action == 'toggle': + if len(args) < 2: + await update.message.reply_text("❌ Укажите ID задачи: `/cron toggle `") + return + + try: + job_id = int(args[1]) + except ValueError: + await update.message.reply_text("❌ ID должен быть числом") + return + + # Получаем текущее состояние задачи + list_result = await cron_tool.execute(action='list', user_id=user_id) + current_state = True + for job in list_result.data: + if job['id'] == job_id: + current_state = job.get('enabled', True) + break + + new_state = not current_state + result = await cron_tool.execute(action='toggle', job_id=job_id, enabled=new_state) + + if result.success: + state_text = "включена" if new_state else "выключена" + await update.message.reply_text(f"✅ Задача ID {job_id} {state_text}") + else: + await update.message.reply_text(f"❌ Ошибка: {result.error}") + + else: + await update.message.reply_text( + "❌ Неизвестная команда.\n\n" + "**Доступные команды:**\n" + "• `/cron list` - показать все задачи\n" + "• `/cron add ` - добавить задачу\n" + "• `/cron run ` - выполнить задачу\n" + "• `/cron remove ` - удалить задачу\n" + "• `/cron toggle ` - включить/выключить задачу", + parse_mode="Markdown" + ) + + except Exception as e: + logger.exception(f"Ошибка в команде /cron: {e}") + await update.message.reply_text(f"❌ Ошибка: {e}") + + +@check_access +async def rss_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Обработка команды /rss - показывает последние 15 новостей с AI-переводом. + + Формат: + Заголовок (переведенный ИИ) + Описание (переведенный ИИ) + Ссылка на полную новость + """ + user_id = update.effective_user.id + + # Отправляем статус + status_msg = await update.message.reply_text("📰 **Загрузка новостей...**\n\n_Получение лент и перевод заголовков_") + + try: + # Получаем rss_tool + rss_tool = tools_registry.get('rss_tool') + if not rss_tool: + await status_msg.delete() + await update.message.reply_text("❌ Ошибка: RSS инструмент не найден") + return + + # Сначала обновляем ленты + fetch_result = await rss_tool.execute(action='fetch') + if fetch_result.success: + logger.info(f"Получено {fetch_result.data.get('total_new_items', 0)} новых элементов") + + # Получаем последние 15 новостей (undigested_only=False чтобы все новости) + news_result = await rss_tool.execute(action='list', limit=15, undigested_only=False) + + if not news_result.success or not news_result.data: + await status_msg.delete() + await update.message.reply_text("📭 **Нет новостей**\n\n_Пока нет новостей в базах данных._\n_Добавьте RSS ленту через AI: \"добавь RSS ленту \"_") + return + + news_list = news_result.data + + # Помечаем новости как прочитанные + for item in news_list: + news_id = item.get('id') + if news_id: + await rss_tool.mark_digest(news_id) + + # Формируем вывод с AI-переводом заголовков и описаний + output = "📰 **Последние новости:**\n\n" + + for i, item in enumerate(news_list, 1): + title = item.get('title', 'Без названия') + link = item.get('link', '') + pub_date = item.get('pub_date', '') + description = item.get('description', '') + + # Переводим заголовок через AI + translated_title = await _translate_text(title, max_length=120) + # Экранируем специальные символы HTML + translated_title = escape_html(translated_title) + + # Форматируем дату + date_str = "" + if pub_date: + try: + dt = datetime.strptime(pub_date[:19], '%Y-%m-%d %H:%M:%S') + date_str = dt.strftime('%d.%m.%Y %H:%M') + except: + date_str = pub_date[:16] + + # Обрезаем заголовок если слишком длинный + if len(translated_title) > 120: + translated_title = translated_title[:117] + "..." + + output += f"**{i}. {translated_title}**\n" + if date_str: + output += f"📅 {date_str}\n" + + # Переводим описание если есть + if description: + translated_desc = await _translate_text(description, max_length=300) + # Экранируем специальные символы Markdown + translated_desc = escape_markdown(translated_desc) + if translated_desc: + output += f"{translated_desc}\n" + + if link: + short_link = link[:60] + "..." if len(link) > 63 else link + output += f"🔗 {short_link}\n" + + output += "\n" + + await status_msg.delete() + await update.message.reply_text(output, parse_mode="Markdown") + + except Exception as e: + logger.exception(f"Ошибка в команде /rss: {e}") + await status_msg.delete() + await update.message.reply_text(f"❌ **Ошибка:** {e}") + + +async def _translate_text(text: str, max_length: int = 300) -> str: + """ + Краткий перевод текста на русский через ИИ. + Если перевод не удался — возвращает оригинал. + """ + if not text or not text.strip(): + return "" + + try: + # Быстрый промпт для перевода + prompt = f"Переведи на русский язык этот текст (максимум {max_length} символов, без кавычек и пояснений, только перевод):\n{text[:400]}" + + # Используем qwen_manager для перевода + from qwen_integration import qwen_manager + + # Создаём временную сессию для перевода + import hashlib + temp_user_id = f"translator_{hashlib.md5(text.encode()).hexdigest()}" + + result = await qwen_manager.run_task( + temp_user_id, + prompt, + on_output=lambda x: None, + on_oauth_url=lambda x: None, + use_system_prompt=False + ) + + # Извлекаем текст из результата + import re + text_matches = re.findall(r'"text":"([^"]+)"', result) + if text_matches: + translated = " ".join(text_matches).replace("\\n", " ").strip() + # Убираем кавычки если есть + translated = translated.strip('"\'') + if translated and len(translated) > 3: + # Экранируем специальные символы HTML + translated = escape_html(translated[:max_length]) + return translated + + return text[:max_length] + except Exception as e: + logger.debug(f"Ошибка перевода: {e}") + return text[:max_length] + + +@check_access +async def ai_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /ai - переключение AI-провайдера.""" + user_id = update.effective_user.id + state = state_manager.get(user_id) + + # Получаем менеджер провайдеров + from bot.ai_provider_manager import get_ai_provider_manager + provider_manager = get_ai_provider_manager() + + # Если нет аргументов - показываем текущий статус + if not context.args: + current_provider = provider_manager.get_current_provider(state) + providers_info = provider_manager.get_all_providers_info(current_provider) + + output = "🤖 **AI-провайдеры**\n\n" + output += f"**Текущий:** " + + for info in providers_info: + icon = "✅" if info.is_active else "⬜" + status = "✓" if info.available else "✗" + output += f"\n{icon} **{info.name}** {status}\n" + output += f" _{info.description}_\n" + + output += "\n**Использование:**\n" + output += "`/ai qwen` - переключиться на Qwen Code\n" + output += "`/ai gigachat` - переключиться на GigaChat\n" + + await update.message.reply_text(output, parse_mode="Markdown") + return + + # Переключаем провайдер + new_provider = context.args[0].lower() + + if new_provider not in ["qwen", "gigachat"]: + await update.message.reply_text( + f"❌ Неизвестный провайдер: `{new_provider}`\n\n" + f"Доступные: `qwen`, `gigachat`", + parse_mode="Markdown" + ) + return + + success, message = provider_manager.switch_provider(user_id, new_provider, state_manager) + + if success: + # Получаем информацию о новом провайдере + provider_info = provider_manager.get_provider_info(new_provider, is_active=True) + await update.message.reply_text( + f"{message}\n\n" + f"**{provider_info.name}**\n" + f"_{provider_info.description}_", + parse_mode="Markdown" + ) + else: + await update.message.reply_text(message) diff --git a/bot/handlers/files.py b/bot/handlers/files.py new file mode 100644 index 0000000..3ba92fc --- /dev/null +++ b/bot/handlers/files.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +"""Обработчики файлов для Telegram бота. + +Функционал: +- Прием файлов от пользователя с сохранением в uploads/YYYY-MM-DD/filename +- Команда /get filename — отправка файла пользователю +- Команда /files [date] — список файлов за дату (сегодня по умолчанию) +- Проверка размера файлов (max 20MB) +- Логирование операций в uploads/files.log +""" + +import os +import logging +from pathlib import Path +from datetime import datetime, timedelta +from typing import Optional + +from telegram import Update +from telegram.ext import CommandHandler, MessageHandler, filters, ContextTypes + +from bot.config import state_manager +from bot.utils.decorators import check_access +from vector_memory import save_message + +logger = logging.getLogger(__name__) + +# ============================================================================ +# КОНСТАНТЫ +# ============================================================================ + +# Максимальный размер файла: 20MB (ограничение Telegram) +MAX_FILE_SIZE_DOWNLOAD = 20 * 1024 * 1024 # 20 MB в байтах +MAX_FILE_SIZE = 20 * 1024 * 1024 # 20 MB в байтах + +# Базовая директория для загрузок +BASE_DIR = Path(__file__).parent.parent.parent +UPLOADS_DIR = BASE_DIR / "uploads" + +# Файл лога операций +LOG_FILE = UPLOADS_DIR / "files.log" + +# ============================================================================ +# ЛОГИРОВАНИЕ +# ============================================================================ + +def log_operation(operation: str, user_id: int, username: str, details: str = ""): + """Логирование операций с файлами.""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + log_entry = f"[{timestamp}] {operation} | user_id={user_id} | username={username}" + if details: + log_entry += f" | {details}" + + try: + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(log_entry + "\n") + except Exception as e: + logger.error(f"Ошибка записи в лог файлов: {e}") + + +def setup_file_logging(): + """Настройка отдельного логгера для операций с файлами.""" + UPLOADS_DIR.mkdir(parents=True, exist_ok=True) + + file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") + file_handler.setFormatter(logging.Formatter( + "[%(asctime)s] %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + )) + + file_logger = logging.getLogger("files") + file_logger.addHandler(file_handler) + file_logger.setLevel(logging.INFO) + + return file_logger + + +file_logger = setup_file_logging() + + +# ============================================================================ +# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ +# ============================================================================ + +def get_date_dir(date: Optional[datetime] = None) -> Path: + """Получить директорию для указанной даты.""" + if date is None: + date = datetime.now() + + date_str = date.strftime("%Y-%m-%d") + date_dir = UPLOADS_DIR / date_str + + # Создаем директорию если нет + date_dir.mkdir(parents=True, exist_ok=True) + + return date_dir + + +def get_file_path(filename: str, date: Optional[datetime] = None) -> Path: + """Получить полный путь к файлу.""" + date_dir = get_date_dir(date) + return date_dir / filename + + +def file_exists(filename: str, date: Optional[datetime] = None) -> bool: + """Проверить существование файла.""" + file_path = get_file_path(filename, date) + return file_path.exists() + + +def list_files_for_date(date: Optional[datetime] = None) -> list: + """Получить список файлов за указанную дату.""" + date_dir = get_date_dir(date) + + if not date_dir.exists(): + return [] + + files = [] + for item in date_dir.iterdir(): + if item.is_file() and item.name != "files.log": + stat = item.stat() + files.append({ + "name": item.name, + "size": stat.st_size, + "path": str(item), + "modified": datetime.fromtimestamp(stat.st_mtime) + }) + + # Сортируем по имени + files.sort(key=lambda x: x["name"]) + + return files + + +def format_file_size(size_bytes: int) -> str: + """Форматировать размер файла в человекочитаемом виде.""" + for unit in ["B", "KB", "MB", "GB"]: + if size_bytes < 1024: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024 + return f"{size_bytes:.1f} TB" + + +def parse_date_string(date_str: Optional[str]) -> Optional[datetime]: + """Распарсить строку даты.""" + if not date_str: + return None + + # Поддерживаемые форматы + formats = [ + "%Y-%m-%d", # 2024-01-15 + "%d.%m.%Y", # 15.01.2024 + "%d-%m-%Y", # 15-01-2024 + "%Y%m%d", # 20240115 + ] + + for fmt in formats: + try: + return datetime.strptime(date_str, fmt) + except ValueError: + continue + + # Относительные даты + if date_str.lower() in ["yesterday", "вчера"]: + return datetime.now() - timedelta(days=1) + elif date_str.lower() in ["today", "сегодня"]: + return datetime.now() + + return None + + +# ============================================================================ +# ОБРАБОТЧИК ФАЙЛОВ +# ============================================================================ + +@check_access +async def handle_file_message(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка входящих файлов от пользователя.""" + user_id = update.effective_user.id + username = update.effective_user.username or str(user_id) + + # Определяем тип файла и получаем файл + file = None + file_type = None + + if update.message.document: + file = update.message.document + file_type = "document" + elif update.message.photo: + # Берем фото в максимальном разрешении (последнее в списке) + file = update.message.photo[-1] + file_type = "photo" + elif update.message.audio: + file = update.message.audio + file_type = "audio" + elif update.message.voice: + file = update.message.voice + file_type = "voice" + elif update.message.video: + file = update.message.video + file_type = "video" + elif update.message.video_note: + file = update.message.video_note + file_type = "video_note" + elif update.message.sticker: + file = update.message.sticker + file_type = "sticker" + elif update.message.animation: + file = update.message.animation + file_type = "animation" + + if not file: + return + + # Получаем информацию о файле + file_name = file.file_name or f"file_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + file_size = file.file_size or 0 + + logger.info(f"Получен файл: {file_name}, размер: {file_size}, тип: {file_type}") + + # Проверка размера файла + if file_size > MAX_FILE_SIZE: + log_operation("REJECTED_SIZE", user_id, username, f"file={file_name}, size={file_size}") + await update.message.reply_text( + f"❌ **Файл слишком большой**\n\n" + f"Максимальный размер: {format_file_size(MAX_FILE_SIZE)}\n" + f"Размер файла: {format_file_size(file_size)}", + parse_mode="Markdown" + ) + return + + # Создаем директорию для сегодняшней даты + date_dir = get_date_dir() + file_path = date_dir / file_name + + # Обработка дубликатов имен + if file_path.exists(): + base, ext = os.path.splitext(file_name) + counter = 1 + while file_path.exists(): + new_name = f"{base}_{counter}{ext}" + file_path = date_dir / new_name + counter += 1 + file_name = file_path.name + + logger.info(f"Сохранение файла: {file_path}") + + # Отправляем статус + status_msg = await update.message.reply_text( + f"⏳ **Загрузка файла...**\n\n" + f"📁 {file_name}\n" + f"📊 Размер: {format_file_size(file_size)}", + parse_mode="Markdown" + ) + + try: + # Скачиваем файл + telegram_file = await context.bot.get_file(file.file_id) + await telegram_file.download_to_drive(file_path) + + # Логирование + log_operation("UPLOAD", user_id, username, f"file={file_name}, size={file_size}, path={file_path}") + file_logger.info(f"UPLOAD: user={username}, file={file_name}, size={file_size}") + + # === СОХРАНЕНИЕ В ПАМЯТЬ ИИ === + # Добавляем информацию о файле в историю диалога и векторную память + state = state_manager.get(user_id) + if state: + # Формируем сообщение о файле с ПОЛНЫМ абсолютным путём! + # Это важно чтобы ИИ правильно понимал где файл + absolute_path = str(file_path.absolute()) + file_info = f"Пользователь загрузил файл: {file_name} (тип: {file_type}, размер: {format_file_size(file_size)}, полный путь: {absolute_path})" + + # Добавляем в историю диалога + state.ai_chat_history.append(f"User: {file_info}") + + # Сохраняем в векторную память + save_message(user_id, "user", file_info) + + logger.info(f"Информация о файле сохранена в памяти ИИ: {file_name}, путь: {absolute_path}") + # =============================== + + # Обновляем статус + await status_msg.edit_text( + f"✅ **Файл сохранен!**\n\n" + f"📁 **Имя:** `{file_name}`\n" + f"📊 **Размер:** {format_file_size(file_size)}\n" + f"📂 **Директория:** `{date_dir.name}/`\n" + f"📅 **Дата:** {datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n" + f"Используйте `/files` для просмотра всех файлов за сегодня.", + parse_mode="Markdown" + ) + + except Exception as e: + logger.exception(f"Ошибка сохранения файла: {e}") + log_operation("UPLOAD_ERROR", user_id, username, f"file={file_name}, error={str(e)}") + + await status_msg.edit_text( + f"❌ **Ошибка сохранения файла**\n\n" + f"Файл: `{file_name}`\n" + f"Ошибка: `{str(e)}`", + parse_mode="Markdown" + ) + + +# ============================================================================ +# КОМАНДА /GET - ПОЛУЧЕНИЕ ФАЙЛА +# ============================================================================ + +@check_access +async def get_file_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Команда /get filename - отправка файла пользователю. + + Использование: + /get filename.txt - получить файл за сегодня + /get filename.txt 2024-01-15 - получить файл за указанную дату + """ + user_id = update.effective_user.id + username = update.effective_user.username or str(user_id) + + if not context.args: + await update.message.reply_text( + "❌ *Использование:*\n\n" + "`/get <имя_файла>` - получить файл за сегодня\n" + "`/get <имя_файла> <дата>` - получить файл за указанную дату\n\n" + "*Примеры:*\n" + "`/get document.pdf`\n" + "`/get photo.jpg 2024-01-15`\n" + "`/get data.csv вчера`", + parse_mode="Markdown" + ) + return + + # Парсим аргументы + filename = context.args[0] + date_arg = context.args[1] if len(context.args) > 1 else None + file_date = parse_date_string(date_arg) + + # Проверяем существование файла + file_path = get_file_path(filename, file_date) + + if not file_path.exists(): + # Ищем файл во всех директориях за сегодня + date_dir = get_date_dir(file_date) + await update.message.reply_text( + f"❌ *Файл не найден*\n\n" + f"Файл: `{filename}`\n" + f"Директория: `{date_dir.name}/`\n\n" + f"Используйте `/files` для просмотра доступных файлов.", + parse_mode="Markdown" + ) + log_operation("GET_NOT_FOUND", user_id, username, f"file={filename}, date={file_date}") + return + + # Проверка размера перед отправкой + file_size = file_path.stat().st_size + if file_size > MAX_FILE_SIZE: + await update.message.reply_text( + f"❌ *Файл слишком большой для отправки*\n\n" + f"Максимальный размер: {format_file_size(MAX_FILE_SIZE)}\n" + f"Размер файла: {format_file_size(file_size)}", + parse_mode="Markdown" + ) + log_operation("GET_TOO_LARGE", user_id, username, f"file={filename}, size={file_size}") + return + + # Отправляем статус + status_msg = await update.message.reply_text( + f"⏳ **Отправка файла...**\n\n" + f"📁 `{filename}`\n" + f"📊 {format_file_size(file_size)}", + parse_mode="Markdown" + ) + + try: + # Отправляем файл + with open(file_path, "rb") as f: + await update.message.reply_document( + document=f, + filename=filename, + caption=f"📁 **{filename}**\n📊 {format_file_size(file_size)}", + parse_mode="Markdown" + ) + + # Логирование + log_operation("GET", user_id, username, f"file={filename}, size={file_size}") + file_logger.info(f"GET: user={username}, file={filename}, size={file_size}") + + # Удаляем статус + await status_msg.delete() + + except Exception as e: + logger.exception(f"Ошибка отправки файла: {e}") + log_operation("GET_ERROR", user_id, username, f"file={filename}, error={str(e)}") + + await status_msg.edit_text( + f"❌ **Ошибка отправки файла**\n\n" + f"Файл: `{filename}`\n" + f"Ошибка: `{str(e)}`", + parse_mode="Markdown" + ) + + +# ============================================================================ +# КОМАНДА /FILES - СПИСОК ФАЙЛОВ +# ============================================================================ + +@check_access +async def list_files_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Команда /files [date] - список файлов за указанную дату. + + Использование: + /files - список файлов за сегодня + /files 2024-01-15 - список файлов за указанную дату + /files вчера - список файлов за вчера + """ + user_id = update.effective_user.id + username = update.effective_user.username or str(user_id) + + # Парсим дату из аргументов + date_arg = context.args[0] if context.args else None + file_date = parse_date_string(date_arg) + + if file_date is None and date_arg: + await update.message.reply_text( + f"❌ **Неверный формат даты:** `{date_arg}`\n\n" + f"**Поддерживаемые форматы:**\n" + f"• `2024-01-15` (YYYY-MM-DD)\n" + f"• `15.01.2024` (DD.MM.YYYY)\n" + f"• `сегодня` / `today`\n" + f"• `вчера` / `yesterday`", + parse_mode="Markdown" + ) + return + + # Получаем список файлов + files = list_files_for_date(file_date) + + date_str = (file_date or datetime.now()).strftime("%Y-%m-%d") + date_dir = get_date_dir(file_date) + + if not files: + await update.message.reply_text( + f"📭 **Нет файлов за {date_str}**\n\n" + f"Директория: `{date_dir.name}/`\n\n" + f"Отправьте файл в чат чтобы сохранить его.", + parse_mode="Markdown" + ) + log_operation("LIST_EMPTY", user_id, username, f"date={date_str}") + return + + # Формируем вывод + total_size = sum(f["size"] for f in files) + + output = f"📁 **Файлы за {date_str}**\n\n" + output += f"📂 Директория: `{date_dir.name}/`\n" + output += f"📊 Всего файлов: {len(files)}\n" + output += f"💾 Общий размер: {format_file_size(total_size)}\n\n" + + for i, file_info in enumerate(files, 1): + name = file_info["name"] + size = format_file_size(file_info["size"]) + modified = file_info["modified"].strftime("%H:%M") + + # Обрезаем длинные имена + if len(name) > 40: + display_name = name[:37] + "..." + else: + display_name = name + + output += f"{i}. `{display_name}` - {size} ({modified})\n" + + output += f"\n💡 *Использование:*\n" + output += f"`/get <имя_файла>` - скачать файл\n" + output += f"`/get <имя_файла> {date_str}` - скачать файл за дату" + + await update.message.reply_text(output, parse_mode="Markdown") + log_operation("LIST", user_id, username, f"date={date_str}, count={len(files)}") + + +# ============================================================================ +# РЕГИСТРАЦИЯ ХЕНДЛЕРОВ +# ============================================================================ + +def register_file_handlers(application): + """Регистрация обработчиков файлов в приложении.""" + # Обработчик входящих файлов (документы, фото, аудио, видео и т.д.) + application.add_handler(MessageHandler( + filters.Document.ALL | + filters.PHOTO | + filters.AUDIO | + filters.VOICE | + filters.VIDEO | + filters.VIDEO_NOTE | + filters.Sticker.ALL | + filters.ANIMATION, + handle_file_message + )) + + # Команда /get - получение файла + application.add_handler(CommandHandler("get", get_file_command)) + + # Команда /files - список файлов + application.add_handler(CommandHandler("files", list_files_command)) + + logger.info("Обработчики файлов зарегистрированы") diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py new file mode 100644 index 0000000..2831208 --- /dev/null +++ b/bot/keyboards/__init__.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""Клавиатуры бота.""" + +from bot.keyboards.menus import MenuItem, MenuBuilder, CommandRegistry, init_menus + +__all__ = [ + "MenuItem", + "MenuBuilder", + "CommandRegistry", + "init_menus", +] diff --git a/bot/keyboards/menus.py b/bot/keyboards/menus.py new file mode 100644 index 0000000..a2344a1 --- /dev/null +++ b/bot/keyboards/menus.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Построитель многоуровневого меню.""" + +import logging +from typing import Dict, List, Optional, Callable +from dataclasses import dataclass, field +from telegram import InlineKeyboardButton, InlineKeyboardMarkup + +# Импортируем модели и утилиты +from bot.models.user_state import UserState + +logger = logging.getLogger(__name__) + + +@dataclass +class MenuItem: + """Элемент меню.""" + label: str + callback: str # callback_data для кнопки + description: str = "" + icon: str = "" + children: List["MenuItem"] = field(default_factory=list) + command: Optional[str] = None # CLI команда для выполнения + is_command: bool = False + + +class MenuBuilder: + """Построитель InlineKeyboard для меню.""" + + def __init__(self): + self._menus: Dict[str, List[MenuItem]] = {} + + def add_menu(self, menu_name: str, items: List[MenuItem]): + self._menus[menu_name] = items + + def get_menu(self, menu_name: str) -> List[MenuItem]: + return self._menus.get(menu_name, []) + + def get_keyboard(self, menu_name: str, user_id: int = None, state: UserState = None) -> InlineKeyboardMarkup: + """Создает InlineKeyboard для меню.""" + items = self._menus.get(menu_name, []) + keyboard = [] + + # Для главного меню — динамически меняем кнопку ИИ + if menu_name == "main" and state: + # Используем переданное состояние + logger.info(f"get_keyboard: user_id={user_id}, ai_chat_mode={state.ai_chat_mode}") + + for item in items: + # Проверяем базовый callback и его варианты с _on/_off + is_ai_toggle = item.callback in ["toggle_ai_chat", "toggle_ai_chat_on", "toggle_ai_chat_off"] + + if is_ai_toggle: + # Меняем текст кнопки и callback_data в зависимости от статуса + if state.ai_chat_mode: + label = f"✅ Выключить чат с ИИ" + callback = "toggle_ai_chat_off" + else: + label = f"❌ Включить чат с ИИ" + callback = "toggle_ai_chat_on" + logger.info(f"get_keyboard: label={label}, callback={callback}") + button = InlineKeyboardButton(label, callback_data=callback) + else: + button = InlineKeyboardButton(item.label, callback_data=item.callback) + keyboard.append([button]) + else: + for item in items: + button = InlineKeyboardButton( + item.label, + callback_data=item.callback + ) + keyboard.append([button]) + + return InlineKeyboardMarkup(keyboard) + + +class CommandRegistry: + """Реестр команд для легкого добавления.""" + + def __init__(self): + self._commands: Dict[str, Callable] = {} + + def register(self, name: str): + """Декоратор для регистрации команды.""" + def decorator(func: Callable): + self._commands[name] = func + return func + return decorator + + def get(self, name: str) -> Optional[Callable]: + return self._commands.get(name) + + def list_commands(self) -> List[str]: + return list(self._commands.keys()) + + +def init_menus(menu_builder: MenuBuilder): + """Инициализация структуры меню.""" + + # Главное меню + main_menu = [ + MenuItem("🖥️ Выбор сервера", "server_menu", icon="🖥️"), + MenuItem("📋 Предустановленные команды", "preset_menu", icon="📋"), + MenuItem("🎛️ AI-пресеты", "ai_presets", icon="🎛️"), + MenuItem("💬 Чат с ИИ агентом", "toggle_ai_chat", icon="💬"), + MenuItem("⚙️ Настройки бота", "settings_menu", icon="⚙️"), + MenuItem("ℹ️ О боте", "about", icon="ℹ️"), + ] + menu_builder.add_menu("main", main_menu) + + # Меню серверов + server_menu = [ + MenuItem("💻 local (localhost)", "server_select_local", icon="💻"), + MenuItem("➕ Добавить сервер", "server_add", icon="➕"), + MenuItem("⬅️ Назад", "main", icon="⬅️"), + ] + menu_builder.add_menu("server", server_menu) + + # Меню предустановленных команд + preset_menu = [ + MenuItem("📁 Файловая система", "fs_menu", icon="📁"), + MenuItem("🔍 Поиск", "search_menu", icon="🔍"), + MenuItem("📊 Система", "system_menu", icon="📊"), + MenuItem("🌐 Сеть", "network_menu", icon="🌐"), + MenuItem("🤖 AI модели Opencode", "opencode_models_menu", icon="🤖"), + MenuItem("⬅️ Назад", "main", icon="⬅️"), + ] + menu_builder.add_menu("preset", preset_menu) + + # Меню моделей Opencode + opencode_models_menu = [ + MenuItem("⚡ minimax (по умолчанию)", "opencode_model_minimax", icon="⚡"), + MenuItem("🗃️ big-pickle", "opencode_model_big_pickle", icon="🗃️"), + MenuItem("🔬 gpt-5-nano", "opencode_model_gpt5", icon="🔬"), + MenuItem("⬅️ Назад", "preset", icon="⬅️"), + ] + menu_builder.add_menu("opencode_models", opencode_models_menu) + + # Файловая система + fs_menu = [ + MenuItem("ls -la", "cmd_ls_la", command="ls -la", icon="📄"), + MenuItem("pwd", "cmd_pwd", command="pwd", icon="📍"), + MenuItem("df -h", "cmd_df", command="df -h", icon="💾"), + MenuItem("du -sh *", "cmd_du", command="du -sh * 2>/dev/null | sort -hr | head -20", icon="📊"), + MenuItem("⬅️ Назад", "preset", icon="⬅️"), + ] + menu_builder.add_menu("fs", fs_menu) + + # Поиск + search_menu = [ + MenuItem("find . -name", "cmd_find_name", command="find . -maxdepth 3 -name '*.txt' 2>/dev/null", icon="🔎"), + MenuItem("grep пример", "cmd_grep", command="grep -r 'example' . 2>/dev/null | head -20", icon="🔍"), + MenuItem("which command", "cmd_which", command="which python3 bash git", icon="📍"), + MenuItem("⬅️ Назад", "preset", icon="⬅️"), + ] + menu_builder.add_menu("search", search_menu) + + # Система + system_menu = [ + MenuItem("top -n 1", "cmd_top", command="top -bn1 | head -20", icon="📈"), + MenuItem("ps aux", "cmd_ps", command="ps aux | head -20", icon="🔄"), + MenuItem("free -h", "cmd_free", command="free -h", icon="💾"), + MenuItem("uname -a", "cmd_uname", command="uname -a", icon="ℹ️"), + MenuItem("uptime", "cmd_uptime", command="uptime", icon="⏱️"), + MenuItem("⬅️ Назад", "preset", icon="⬅️"), + ] + menu_builder.add_menu("system", system_menu) + + # Сеть + network_menu = [ + MenuItem("ip addr", "cmd_ip", command="ip addr 2>/dev/null || ifconfig 2>/dev/null", icon="🌐"), + MenuItem("ping google", "cmd_ping", command="ping -c 4 google.com 2>&1 | head -10", icon="📡"), + MenuItem("netstat", "cmd_netstat", command="ss -tuln 2>/dev/null || netstat -tuln 2>/dev/null | head -20", icon="🔌"), + MenuItem("curl ifconfig.me", "cmd_curl_ip", command="curl -s ifconfig.me 2>&1", icon="📍"), + MenuItem("⬅️ Назад", "preset", icon="⬅️"), + ] + menu_builder.add_menu("network", network_menu) + + # Настройки + settings_menu = [ + MenuItem("📝 Изменить имя бота", "set_name", icon="📝"), + MenuItem("📄 Изменить описание", "set_description", icon="📄"), + MenuItem("🎨 Изменить иконку", "set_icon", icon="🎨"), + MenuItem("🧠 Память ИИ", "memory_menu", icon="🧠"), + MenuItem("🤖 Выбор AI-провайдера", "ai_provider_selection_menu", icon="🤖"), + MenuItem("⬅️ Назад", "main", icon="⬅️"), + ] + menu_builder.add_menu("settings", settings_menu) + + # Меню выбора AI-провайдера + ai_provider_selection_menu = [ + MenuItem("🔄 Qwen Code", "ai_provider_qwen", icon="🔄"), + MenuItem("📡 Opencode ▶", "opencode_model_menu", icon="📡"), + MenuItem("🧠 GigaChat ▶", "gigachat_model_menu", icon="🧠"), + MenuItem("⬅️ Назад", "settings", icon="⬅️"), + ] + menu_builder.add_menu("ai_provider_selection", ai_provider_selection_menu) + + # Модели Opencode + opencode_model_menu = [ + MenuItem("⚡ minimax (по умолчанию)", "opencode_model_minimax", icon="⚡"), + MenuItem("🗃️ big-pickle", "opencode_model_big_pickle", icon="🗃️"), + MenuItem("🔬 gpt-5-nano", "opencode_model_gpt5", icon="🔬"), + MenuItem("⬅️ Назад", "ai_provider_selection", icon="⬅️"), + ] + menu_builder.add_menu("opencode_model", opencode_model_menu) + + # Модели GigaChat + gigachat_model_menu = [ + MenuItem("📱 GigaChat Lite (по умолчанию)", "gigachat_model_lite", icon="📱"), + MenuItem("🚀 GigaChat Pro", "gigachat_model_pro", icon="🚀"), + MenuItem("💎 GigaChat Max", "gigachat_model_max", icon="💎"), + MenuItem("⬅️ Назад", "ai_provider_selection", icon="⬅️"), + ] + menu_builder.add_menu("gigachat_model", gigachat_model_menu) + + # Меню AI-провайдера + ai_provider_menu = [ + MenuItem("🔄 Переключить AI-провайдер", "ai_provider_toggle", icon="🔄"), + MenuItem("ℹ️ Информация о провайдерах", "ai_provider_info", icon="ℹ️"), + MenuItem("⬅️ Назад", "settings", icon="⬅️"), + ] + menu_builder.add_menu("ai_provider", ai_provider_menu) + + # Память ИИ + memory_menu = [ + MenuItem("📋 Мой профиль", "memory_profile", icon="📋"), + MenuItem("📊 Статистика", "memory_stats", icon="📊"), + MenuItem("🗑️ Очистить историю", "memory_clear", icon="🗑️"), + MenuItem("🔄 Компактификация", "memory_compact", icon="🔄"), + MenuItem("⬅️ Назад", "settings", icon="⬅️"), + ] + menu_builder.add_menu("memory", memory_menu) + + # Доступ + access_menu = [ + MenuItem("📋 Показать разрешённых", "show_access", icon="📋"), + MenuItem("➕ Добавить пользователя", "add_access", icon="➕"), + MenuItem("➖ Удалить пользователя", "remove_access", icon="➖"), + MenuItem("⬅️ Назад", "settings", icon="⬅️"), + ] + menu_builder.add_menu("access", access_menu) diff --git a/bot/models/__init__.py b/bot/models/__init__.py new file mode 100644 index 0000000..4a3fb9e --- /dev/null +++ b/bot/models/__init__.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Модели данных бота.""" + +from bot.models.server import Server, ServerManager +from bot.models.user_state import UserState, StateManager +from bot.models.session import ( + SSHSession, + SSHSessionManager, + LocalSession, + LocalSessionManager, + INPUT_PATTERNS +) + +__all__ = [ + "Server", + "ServerManager", + "UserState", + "StateManager", + "SSHSession", + "SSHSessionManager", + "LocalSession", + "LocalSessionManager", + "INPUT_PATTERNS", +] diff --git a/bot/models/server.py b/bot/models/server.py new file mode 100644 index 0000000..c0a00e5 --- /dev/null +++ b/bot/models/server.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Модели серверов и управление ими.""" + +import os +import logging +import getpass +from pathlib import Path +from typing import Dict, List, Optional +from dataclasses import dataclass, field +from dotenv import load_dotenv +from telegram import InlineKeyboardButton, InlineKeyboardMarkup + +logger = logging.getLogger(__name__) + + +@dataclass +class Server: + """Конфигурация сервера.""" + name: str + host: str + port: int + user: str + tags: List[str] = field(default_factory=list) + password: str = "" + + @property + def display_name(self) -> str: + """Отображаемое имя с иконкой.""" + icon = "🖥️" + if "local" in self.tags: + icon = "💻" + elif "db" in self.tags: + icon = "🗄️" + elif "web" in self.tags: + icon = "🌐" + return f"{icon} {self.name}" + + @property + def description(self) -> str: + """Краткое описание сервера.""" + return f"{self.user}@{self.host}:{self.port}" + + +class ServerManager: + """Управление серверами.""" + + def __init__(self): + self._servers: Dict[str, Server] = {} + self._default_server: str = "local" + self._ssh_key_path: Optional[str] = None + + # Локальный сервер всегда доступен + try: + local_user = getpass.getuser() + except Exception: + local_user = "user" + + self._servers["local"] = Server( + name="local", + host="localhost", + port=22, + user=local_user, + tags=["local", "dev"] + ) + + def load_from_env(self): + """Загрузка серверов из переменных окружения.""" + self._ssh_key_path = os.getenv("SSH_KEY_PATH") + self._default_server = os.getenv("DEFAULT_SERVER", "local") + + servers_str = os.getenv("SERVERS", "") + if not servers_str.strip(): + return + + # Парсинг формата: name|host|port|user|tags|password,name|host|port|user|tags|password + for server_line in servers_str.split(","): + if not server_line.strip(): + continue + + parts = server_line.strip().split("|") + if len(parts) < 4: + continue + + try: + name = parts[0].strip() + host = parts[1].strip() + port = int(parts[2].strip()) + user = parts[3].strip() + + # Теги (часть 4) и пароль (часть 5) могут отсутствовать + tags = [] + password = "" + + if len(parts) >= 5 and parts[4].strip(): + tags = [t.strip() for t in parts[4].split(",") if t.strip()] + + if len(parts) >= 6: + password = parts[5].strip() + + server = Server(name=name, host=host, port=port, user=user, tags=tags, password=password) + self._servers[name] = server + logger.info(f"Загружен сервер: {server.display_name} ({server.description})") + except ValueError as e: + logger.warning(f"Ошибка парсинга сервера: {parts} - {e}") + + def get(self, name: str) -> Optional[Server]: + """Получить сервер по имени.""" + return self._servers.get(name) + + def list_servers(self) -> List[Server]: + """Список всех серверов.""" + return list(self._servers.values()) + + def get_by_tags(self, tags: List[str]) -> List[Server]: + """Получить серверы по тегам.""" + result = [] + for server in self._servers.values(): + if any(tag in server.tags for tag in tags): + result.append(server) + return result + + @property + def default_server(self) -> str: + """Имя сервера по умолчанию.""" + return self._default_server + + @property + def ssh_key_path(self) -> Optional[str]: + """Путь к SSH ключу.""" + return self._ssh_key_path + + def get_keyboard(self, exclude_local: bool = False) -> InlineKeyboardMarkup: + """Создать клавиатуру с выбором сервера.""" + keyboard = [] + for server in self._servers.values(): + if exclude_local and server.name == "local": + continue + button = InlineKeyboardButton( + server.display_name, + callback_data=f"server_select_{server.name}" + ) + keyboard.append([button]) + return InlineKeyboardMarkup(keyboard) + + def add_server(self, name: str, host: str, port: int, user: str, tags: List[str] = None, password: str = "") -> bool: + """Добавить сервер.""" + if name in self._servers: + return False + self._servers[name] = Server(name=name, host=host, port=port, user=user, tags=tags or [], password=password) + self.save_to_env() + return True + + def update_server(self, name: str, host: str = None, port: int = None, + user: str = None, tags: List[str] = None, password: str = None) -> bool: + """Обновить сервер.""" + if name not in self._servers or name == "local": + return False + server = self._servers[name] + if host: + server.host = host + if port: + server.port = port + if user: + server.user = user + if tags is not None: + server.tags = tags + if password is not None: + server.password = password + self.save_to_env() + return True + + def rename_server(self, old_name: str, new_name: str, state_manager=None) -> bool: + """Переименовать сервер.""" + if old_name not in self._servers or old_name == "local": + return False + if new_name in self._servers or new_name == "local": + logger.warning(f"Сервер с именем '{new_name}' уже существует") + return False + + # Получаем старый сервер и создаём копию с новым именем + old_server = self._servers[old_name] + new_server = Server( + name=new_name, + host=old_server.host, + port=old_server.port, + user=old_server.user, + tags=old_server.tags.copy(), + password=old_server.password + ) + + # Удаляем старый и добавляем новый + del self._servers[old_name] + self._servers[new_name] = new_server + + # Обновляем current_server у всех пользователей кто подключён к этому серверу + if state_manager: + for user_id, user_state in state_manager._states.items(): + if user_state.current_server == old_name: + user_state.current_server = new_name + logger.debug(f"У пользователя {user_id} current_server обновлён с '{old_name}' на '{new_name}'") + if user_state.editing_server == old_name: + user_state.editing_server = new_name + logger.debug(f"У пользователя {user_id} editing_server обновлён с '{old_name}' на '{new_name}'") + + self.save_to_env() + logger.info(f"Сервер '{old_name}' переименован в '{new_name}'") + return True + + def delete_server(self, name: str, state_manager=None) -> bool: + """Удалить сервер.""" + if name not in self._servers or name == "local": + return False + + # Сбрасываем current_server у всех пользователей кто подключён к этому серверу + if state_manager: + for user_id, user_state in state_manager._states.items(): + if user_state.current_server == name: + user_state.current_server = "local" + logger.debug(f"У пользователя {user_id} current_server сброшен на 'local' после удаления сервера '{name}'") + if user_state.editing_server == name: + user_state.editing_server = None + logger.debug(f"У пользователя {user_id} editing_server сброшен после удаления сервера '{name}'") + + del self._servers[name] + self.save_to_env() + return True + + def save_to_env(self): + """Сохранить серверы в .env файл.""" + env_file = Path(__file__).parent.parent.parent / ".env" + + # Читаем существующий файл + lines = [] + if env_file.exists(): + with open(env_file, "r", encoding="utf-8") as f: + lines = f.readlines() + + # Формируем строку серверов + server_parts = [] + for server in self._servers.values(): + if server.name == "local": + continue + tags_str = ",".join(server.tags) if server.tags else "" + # Формат: name|host|port|user|tags|password + server_parts.append(f"{server.name}|{server.host}|{server.port}|{server.user}|{tags_str}|{server.password}") + + servers_line = f"SERVERS={','.join(server_parts)}\n" + + # Ищем и обновляем или добавляем строку SERVERS + found = False + for i, line in enumerate(lines): + if line.startswith("SERVERS="): + lines[i] = servers_line + found = True + break + + if not found: + lines.append("\n" + servers_line) + + # Записываем обратно + with open(env_file, "w", encoding="utf-8") as f: + f.writelines(lines) + + logger.debug(f"Серверы сохранены в {env_file}") diff --git a/bot/models/session.py b/bot/models/session.py new file mode 100644 index 0000000..035a4c9 --- /dev/null +++ b/bot/models/session.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Модели интерактивных сессий (SSH и локальные).""" + +import os +import logging +from typing import Dict, Optional +from dataclasses import dataclass, field +from datetime import datetime, timedelta +import asyncssh + +logger = logging.getLogger(__name__) + + +# Импортируем Server из соседнего модуля +from bot.models.server import Server + + +@dataclass +class SSHSession: + """Интерактивная SSH-сессия.""" + user_id: int + server: Server + working_dir: str + conn: asyncssh.SSHClientConnection + process: asyncssh.SSHClientProcess + output_buffer: str = "" + waiting_for_input: bool = False + input_type: str = "" # "password", "confirm", "text" + last_activity: datetime = field(default_factory=datetime.now) + command: str = "" + + SESSION_TIMEOUT = timedelta(minutes=5) # Таймаут неактивности + + def is_expired(self) -> bool: + """Проверка истечения таймаута сессии.""" + return datetime.now() - self.last_activity > self.SESSION_TIMEOUT + + +class SSHSessionManager: + """Менеджер интерактивных SSH-сессий.""" + + def __init__(self): + self._sessions: Dict[int, SSHSession] = {} + + def create_session(self, user_id: int, server: Server, working_dir: str, + conn: asyncssh.SSHClientConnection, process: asyncssh.SSHClientProcess, + command: str = "") -> SSHSession: + """Создать новую сессию.""" + session = SSHSession( + user_id=user_id, + server=server, + working_dir=working_dir, + conn=conn, + process=process, + command=command + ) + self._sessions[user_id] = session + logger.info(f"Создана SSH-сессия для пользователя {user_id} на сервере {server.name}") + return session + + def get_session(self, user_id: int) -> Optional[SSHSession]: + """Получить сессию пользователя.""" + session = self._sessions.get(user_id) + if session and session.is_expired(): + self.close_session(user_id) + return None + return session + + def close_session(self, user_id: int): + """Закрыть сессию пользователя.""" + session = self._sessions.pop(user_id, None) + if session: + try: + if session.process: + session.process.stdin.close() + session.process.stdout.feed_eof() + if session.conn: + session.conn.close() + logger.info(f"Закрыта SSH-сессия для пользователя {user_id}") + except Exception as e: + logger.warning(f"Ошибка при закрытии сессии: {e}") + + def has_active_session(self, user_id: int) -> bool: + """Проверка наличия активной сессии.""" + return self.get_session(user_id) is not None + + def cleanup_expired(self): + """Очистка истёкших сессий.""" + expired = [uid for uid, s in self._sessions.items() if s.is_expired()] + for uid in expired: + self.close_session(uid) + + +@dataclass +class LocalSession: + """Интерактивная локальная сессия.""" + user_id: int + command: str + master_fd: int + pid: int + output_buffer: str = "" + waiting_for_input: bool = False + input_type: str = "" + last_activity: datetime = field(default_factory=datetime.now) + context: Dict = field(default_factory=dict) # Для хранения pexpect child и другого + + SESSION_TIMEOUT = timedelta(minutes=5) + + def is_expired(self) -> bool: + return datetime.now() - self.last_activity > self.SESSION_TIMEOUT + + +class LocalSessionManager: + """Менеджер локальных интерактивных сессий.""" + + def __init__(self): + self._sessions: Dict[int, LocalSession] = {} + + def create_session(self, user_id: int, command: str, master_fd: int, pid: int) -> LocalSession: + session = LocalSession( + user_id=user_id, + command=command, + master_fd=master_fd, + pid=pid + ) + self._sessions[user_id] = session + logger.info(f"Создана локальная сессия для пользователя {user_id}") + return session + + def get_session(self, user_id: int) -> Optional[LocalSession]: + session = self._sessions.get(user_id) + if session and session.is_expired(): + self.close_session(user_id) + return None + return session + + def close_session(self, user_id: int): + session = self._sessions.pop(user_id, None) + if session: + try: + # Закрываем pexpect процесс если есть + child = session.context.get('child') if session.context else None + if child: + child.close(force=True) + else: + # Старый способ для PTY + os.close(session.master_fd) + os.kill(session.pid, 9) + except: + pass + logger.info(f"Закрыта локальная сессия для пользователя {user_id}") + + def has_active_session(self, user_id: int) -> bool: + return self.get_session(user_id) is not None + + +# Паттерны для детектирования запросов ввода +INPUT_PATTERNS = { + "password": [ + r"[Pp]assword[:\s]*$", + r"[Pp]assphrase[:\s]*$", + r"Enter password[:\s]*$", + r"sudo password[:\s]*$", + r"\[sudo\] password for .*:", + r"[Пп]ароль[:\s]*$", + r"\[sudo\] пароль для .*:", + r"Введите пароль[:\s]*$", + ], + "confirm": [ + r"[Yy]es/[Nn]o[?:\s]*$", + r"\[?[Yy]\]?/?\[?[Nn]\]?", + r"Do you want to continue", + r"Continue\?", + r"Are you sure", + r"Is this OK", + r"[Yy]es or [Nn]o", + r"[Дд]а/[Нн]ет", + r"[Пп]родолжить", + ], + "shell_prompt": [ + r"[$#]\s*$", + r"[>$]\s*$", + r"[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+:.*[$#]\s*$", + ], +} + +# Глобальные менеджеры сессий +ssh_session_manager = SSHSessionManager() +local_session_manager = LocalSessionManager() diff --git a/bot/models/user_state.py b/bot/models/user_state.py new file mode 100644 index 0000000..4488edd --- /dev/null +++ b/bot/models/user_state.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Модели состояния пользователя.""" + +from typing import Dict, List, Optional, Any +from dataclasses import dataclass, field + + +# Пресеты AI-провайдеров +AI_PRESET_OFF = "off" # ИИ отключен, режим CLI команд +AI_PRESET_QWEN = "qwen" # Qwen Code (бесплатно, локально) +AI_PRESET_GIGA_AUTO = "giga_auto" # GigaChat авто-переключение (Lite/Pro) +AI_PRESET_GIGA_LITE = "giga_lite" # GigaChat Lite (дешевле) +AI_PRESET_GIGA_PRO = "giga_pro" # GigaChat Pro (максимальное качество) +AI_PRESET_GIGA_MAX = "giga_max" # GigaChat Max (топовая модель) +AI_PRESET_OPENCODE = "opencode" # Opencode (бесплатно, локально) + +# Модели Opencode +OPENCODE_MODEL_MINIMAX = "minimax" +OPENCODE_MODEL_BIG_PICKLE = "big_pickle" +OPENCODE_MODEL_GPT5 = "gpt5" + +# Модели GigaChat +GIGACHAT_MODEL_LITE = "lite" +GIGACHAT_MODEL_PRO = "pro" +GIGACHAT_MODEL_MAX = "max" + + +@dataclass +class UserState: + """Состояние пользователя в диалоге.""" + current_menu: str = "main" + waiting_for_input: bool = False + input_type: Optional[str] = None # "name", "host", "port", "user", "tags", "server_action" + parent_menu: Optional[str] = None + context: Dict[str, Any] = field(default_factory=dict) + working_directory: Optional[str] = None + current_server: str = "local" # Имя текущего сервера + editing_server: Optional[str] = None # Имя сервера, который редактируем + ai_chat_mode: bool = True # Режим чата с ИИ агентом (включен по умолчанию) + ai_chat_history: List[str] = field(default_factory=list) # История диалога с ИИ + messages_since_fact_extract: int = 0 # Счётчик для извлечения фактов + ai_preset: str = AI_PRESET_QWEN # Текущий AI-пресет + current_ai_provider: str = "qwen" # Текущий AI-провайдер (для совместимости) + opencode_model: str = OPENCODE_MODEL_MINIMAX # Модель Opencode + gigachat_model: str = GIGACHAT_MODEL_LITE # Модель GigaChat + + # Для управления длинным выводом + waiting_for_output_control: bool = False # Ожидание решения пользователя + output_remaining: int = 0 # Сколько сообщений осталось + output_wait_message = None # Сообщение с кнопками + output_continue_event = None # asyncio.Event для разблокировки + continue_output: bool = True # Решение пользователя + output_next_index: Optional[int] = None # Индекс следующего сообщения для отправки + output_text: Optional[str] = None # Текст для продолжения отправки + output_parse_mode: Optional[str] = None # Parse mode для продолжения + output_prev_code_closed: bool = True # Был ли закрыт блок кода в предыдущем сообщении + + # Для команды /restart_bot + waiting_for_restart_password: bool = False # Ожидание пароля sudo для перезапуска + + # Для OAuth авторизации Qwen + waiting_for_qwen_oauth: bool = False # Ожидание завершения OAuth авторизации + + +class StateManager: + """Управление состояниями пользователей.""" + + def __init__(self): + self._states: Dict[int, UserState] = {} + self._history_loaded: Dict[int, bool] = {} # Флаг загрузки истории из БД + + def get(self, user_id: int) -> UserState: + if user_id not in self._states: + self._states[user_id] = UserState() + # Помечаем что история ещё не загружена + self._history_loaded[user_id] = False + return self._states[user_id] + + def mark_history_loaded(self, user_id: int): + """Пометить что история для пользователя загружена из БД.""" + self._history_loaded[user_id] = True + + def is_history_loaded(self, user_id: int) -> bool: + """Проверить загружена ли история из БД.""" + return self._history_loaded.get(user_id, False) + + def reset(self, user_id: int): + self._states[user_id] = UserState() + self._history_loaded[user_id] = False diff --git a/bot/providers/__init__.py b/bot/providers/__init__.py new file mode 100644 index 0000000..212fc79 --- /dev/null +++ b/bot/providers/__init__.py @@ -0,0 +1,14 @@ +""" +AI Providers - адаптеры для различных AI-провайдеров. + +Каждый провайдер реализует интерфейс BaseAIProvider для единой работы +с инструментами и контекстом. +""" + +from bot.providers.qwen_provider import QwenCodeProvider +from bot.providers.gigachat_provider import GigaChatProvider + +__all__ = [ + "QwenCodeProvider", + "GigaChatProvider", +] diff --git a/bot/providers/gigachat_provider.py b/bot/providers/gigachat_provider.py new file mode 100644 index 0000000..c73b9ed --- /dev/null +++ b/bot/providers/gigachat_provider.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +""" +GigaChat AI Provider - адаптер GigaChat для работы с инструментами. + +Реализует интерфейс BaseAIProvider для единой работы с инструментами +независимо от AI-провайдера. + +Использует нативный GigaChat Function Calling API: +https://developers.sber.ru/docs/ru/gigachat/guides/functions/overview +""" + +import logging +from typing import Optional, Dict, Any, Callable, List +import json + +from bot.base_ai_provider import ( + BaseAIProvider, + ProviderResponse, + AIMessage, + ToolCall, + ToolCallStatus, +) +from bot.tools.gigachat_tool import GigaChatTool, GigaChatMessage, GigaChatConfig + +logger = logging.getLogger(__name__) + + +class GigaChatProvider(BaseAIProvider): + """ + GigaChat AI Provider с нативной поддержкой function calling. + + Использует официальный GigaChat Function Calling API вместо + эмуляции через текстовые блоки. + """ + + def __init__(self, config: Optional[GigaChatConfig] = None): + self._tool = GigaChatTool(config) + self._available: Optional[bool] = None + self._functions_state_id: Optional[str] = None + + @property + def provider_name(self) -> str: + return "GigaChat" + + @property + def supports_tools(self) -> bool: + # GigaChat поддерживает нативные function calls + return True + + @property + def supports_streaming(self) -> bool: + return False + + def is_available(self) -> bool: + """Проверить доступность GigaChat.""" + if self._available is not None: + return self._available + + try: + import os + client_id = os.getenv("GIGACHAT_CLIENT_ID") + client_secret = os.getenv("GIGACHAT_CLIENT_SECRET") + + self._available = bool(client_id and client_secret) + + if not self._available: + logger.warning("GigaChat недоступен: не настроены GIGACHAT_CLIENT_ID или GIGACHAT_CLIENT_SECRET") + else: + logger.info("GigaChat доступен") + except Exception as e: + self._available = False + logger.error(f"Ошибка проверки доступности GigaChat: {e}") + + return self._available + + def get_error(self) -> Optional[str]: + """Получить последнюю ошибку.""" + if self._available is False: + return "GigaChat недоступен: проверьте GIGACHAT_CLIENT_ID и GIGACHAT_CLIENT_SECRET" + return None + + def get_functions_schema(self, tools_registry: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Получить схему функций для GigaChat API в правильном формате. + + Формат GigaChat: + { + "name": "function_name", + "description": "Описание функции", + "parameters": { + "type": "object", + "properties": {...}, + "required": [...] + }, + "return_parameters": {...} # опционально + } + """ + schema = [] + + if tools_registry is None: + return schema + + # Обрабатываем разные типы tools_registry + items = [] + if hasattr(tools_registry, 'get_all') and callable(getattr(tools_registry, 'get_all')): + items = list(tools_registry.get_all().items()) + elif isinstance(tools_registry, dict): + items = list(tools_registry.items()) + elif hasattr(tools_registry, 'tools'): + items = list(tools_registry.tools.items()) if isinstance(tools_registry.tools, dict) else [] + + for name, tool in items: + if hasattr(tool, 'get_schema'): + tool_schema = tool.get_schema() + # Преобразуем в формат GigaChat с гарантией наличия properties + parameters = tool_schema.get("parameters", {}) + if not parameters: + parameters = {"type": "object", "properties": {}} + elif "properties" not in parameters: + parameters["properties"] = {} + + giga_schema = { + "name": name, + "description": tool_schema.get("description", ""), + "parameters": parameters + } + # Добавляем return_parameters если есть + if hasattr(tool, 'get_return_schema'): + giga_schema["return_parameters"] = tool.get_return_schema() + schema.append(giga_schema) + elif hasattr(tool, 'description'): + schema.append({ + "name": name, + "description": tool.description, + "parameters": {"type": "object", "properties": {}} # Пустая но валидная схема + }) + + logger.info(f"📋 GigaChat functions schema: {[f['name'] for f in schema]}") + return schema + + def _parse_function_call(self, function_call: Dict[str, Any]) -> ToolCall: + """ + Преобразовать function_call из ответа GigaChat в ToolCall. + + GigaChat возвращает: + { + "name": "function_name", + "arguments": {"arg1": "value1", ...} + } + """ + try: + # Аргументы могут быть строкой JSON или уже dict + args = function_call.get("arguments", {}) + if isinstance(args, str): + args = json.loads(args) + except (json.JSONDecodeError, TypeError) as e: + logger.warning(f"Ошибка парсинга аргументов function_call: {e}") + args = {} + + return ToolCall( + tool_name=function_call.get("name", "unknown"), + tool_args=args, + tool_call_id=function_call.get("name", "fc_0") # Используем name как ID + ) + + async def process_with_tools( + self, + prompt: str, + system_prompt: Optional[str] = None, + context: Optional[List[Dict[str, str]]] = None, + tools_registry: Optional[Dict[str, Any]] = None, + on_chunk: Optional[Callable[[str], Any]] = None, + max_iterations: int = 5, + **kwargs + ) -> ProviderResponse: + """ + Обработка запросов с function calling для GigaChat. + + Использует нативный GigaChat Function Calling API: + 1. Отправляем запрос с functions массивом + 2. Получаем function_call из ответа + 3. Выполняем инструмент + 4. Отправляем результат с role: "function" + 5. Повторяем пока не будет финального ответа + + Формат сообщений: + - user: {"role": "user", "content": "..."} + - assistant: {"role": "assistant", "function_call": {...}} + - function: {"role": "function", "name": "...", "content": "..."} + """ + if not tools_registry: + return await self.chat( + prompt=prompt, + system_prompt=system_prompt, + context=context, + on_chunk=on_chunk, + **kwargs + ) + + # Формируем базовые сообщения + messages = [] + + # Добавляем системный промпт если есть + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + + # Добавляем контекст (историю диалога) + if context: + for msg in context: + role = msg.get("role") + # Пропускаем system messages — они уже добавлены + if role == "system": + continue + # Преобразуем tool messages в function messages + if role == "tool": + role = "function" + if role in ("user", "assistant", "function"): + messages.append({ + "role": role, + "content": msg.get("content", ""), + "name": msg.get("name") # Для function messages + }) + + # Добавляем текущий запрос пользователя + if prompt: + messages.append({"role": "user", "content": prompt}) + + # Получаем схему функций + functions = self.get_functions_schema(tools_registry) if self.supports_tools else None + + logger.info(f"🔍 GigaChat process_with_tools: {len(messages)} сообщений, {len(functions) if functions else 0} функций") + + for iteration in range(max_iterations): + logger.info(f"🔄 Итерация {iteration + 1}/{max_iterations}") + + # Логируем сообщения перед отправкой + for i, msg in enumerate(messages[-3:]): # Последние 3 сообщения + content_preview = msg.get("content", "")[:100] + logger.info(f" 📨 [{i}] role={msg.get('role')}, content='{content_preview}...'") + + # Отправляем запрос с functions + response = await self._chat_with_functions( + messages=messages, + functions=functions, + user_id=kwargs.get('user_id'), + temperature=kwargs.get("temperature", 0.7), + max_tokens=kwargs.get("max_tokens", 2000), + ) + + if not response.get("success"): + return ProviderResponse( + success=False, + error=response.get("error", "Неизвестная ошибка"), + provider_name=self.provider_name + ) + + # Проверяем наличие function_call + function_call = response.get("function_call") + content = response.get("content", "") + + logger.info(f"📬 Ответ GigaChat: content_len={len(content) if content else 0}, function_call={function_call is not None}") + + # Если нет function_call — возвращаем финальный ответ + if not function_call: + return ProviderResponse( + success=True, + message=AIMessage( + content=content, + tool_calls=[], + metadata={ + "model": response.get("model", "GigaChat"), + "usage": response.get("usage", {}), + "functions_state_id": response.get("functions_state_id") + } + ), + provider_name=self.provider_name, + usage=response.get("usage") + ) + + # Есть function_call — парсим и выполняем инструмент + tool_call = self._parse_function_call(function_call) + logger.info(f"🛠️ Function call: {tool_call.tool_name}({tool_call.tool_args})") + + # Выполняем инструмент + if hasattr(tools_registry, 'get'): + tool = tools_registry.get(tool_call.tool_name) + elif isinstance(tools_registry, dict): + tool = tools_registry.get(tool_call.tool_name) + else: + tool = None + + if tool is not None: + try: + if hasattr(tool, 'execute'): + result = await tool.execute( + **tool_call.tool_args, + user_id=kwargs.get('user_id') + ) + elif hasattr(tool, '__call__'): + result = await tool(**tool_call.tool_args) + else: + result = f"Инструмент {tool_call.tool_name} не имеет метода execute" + + tool_call.result = result + tool_call.status = ToolCallStatus.SUCCESS + except Exception as e: + logger.exception(f"Ошибка выполнения инструмента {tool_call.tool_name}: {e}") + tool_call.error = str(e) + tool_call.status = ToolCallStatus.ERROR + result = {"error": str(e)} + else: + tool_call.error = f"Инструмент {tool_call.tool_name} не найден" + tool_call.status = ToolCallStatus.ERROR + result = {"error": tool_call.error} + + # Сериализуем результат + if hasattr(result, 'to_dict'): + result_dict = result.to_dict() + elif isinstance(result, dict): + result_dict = result + else: + result_dict = {"result": str(result)} + + result_json = json.dumps(result_dict, ensure_ascii=False) + + # Добавляем assistant message с function_call + messages.append({ + "role": "assistant", + "content": "", # Пустой content при function_call + "function_call": function_call + }) + + # Добавляем function message с результатом + messages.append({ + "role": "function", + "name": tool_call.tool_name, + "content": result_json + }) + + logger.info(f"✅ Добавлен function result: {tool_call.tool_name}, result_len={len(result_json)}") + + # Сохраняем functions_state_id для следующей итерации + if response.get("functions_state_id"): + self._functions_state_id = response["functions_state_id"] + + # Достигли максимума итераций + return ProviderResponse( + success=True, + message=AIMessage( + content=content + "\n\n[Достигнут максимум итераций выполнения функций]", + metadata={"iterations": max_iterations} + ), + provider_name=self.provider_name, + usage=response.get("usage") + ) + + async def _chat_with_functions( + self, + messages: List[Dict[str, Any]], + functions: Optional[List[Dict[str, Any]]] = None, + user_id: Optional[int] = None, + temperature: float = 0.7, + max_tokens: int = 2000, + ) -> Dict[str, Any]: + """ + Отправить запрос в GigaChat API с поддержкой function calling. + + Возвращает: + { + "success": bool, + "content": str, + "function_call": {"name": str, "arguments": dict} или None, + "model": str, + "usage": dict, + "functions_state_id": str или None + } + """ + try: + # Формируем сообщения в формате GigaChat + gc_messages = [] + for msg in messages: + gc_msg = {"role": msg["role"], "content": msg.get("content", "")} + if msg.get("name"): + gc_msg["name"] = msg["name"] + if msg.get("function_call"): + gc_msg["function_call"] = msg["function_call"] + gc_messages.append(gc_msg) + + # Выполняем запрос через GigaChatTool + result = await self._tool.chat_with_functions( + messages=gc_messages, + functions=functions, + user_id=str(user_id) if user_id else None, + temperature=temperature, + max_tokens=max_tokens, + ) + + # Извлекаем function_call из ответа + function_call = None + if result.get("choices"): + choice = result["choices"][0] + message = choice.get("message", {}) + function_call = message.get("function_call") + + return { + "success": True, + "content": result.get("content", ""), + "function_call": function_call, + "model": result.get("model", "GigaChat"), + "usage": result.get("usage", {}), + "functions_state_id": result.get("functions_state_id") + } + + except Exception as e: + logger.exception(f"Ошибка _chat_with_functions: {e}") + return { + "success": False, + "error": str(e), + "function_call": None + } + + async def chat( + self, + prompt: str, + system_prompt: Optional[str] = None, + context: Optional[List[Dict[str, str]]] = None, + tools: Optional[List[Dict[str, Any]]] = None, + on_chunk: Optional[Callable[[str], Any]] = None, + user_id: Optional[int] = None, + **kwargs + ) -> ProviderResponse: + """ + Отправить запрос GigaChat (без function calling). + + Используется когда tools не переданы. + """ + try: + # Формируем сообщения + messages = [] + + if system_prompt: + messages.append(GigaChatMessage(role="system", content=system_prompt)) + + if context: + for msg in context: + role = msg.get("role", "user") + content = msg.get("content", "") + if role == "system": + continue + if role in ("user", "assistant"): + messages.append(GigaChatMessage(role=role, content=content)) + + if prompt: + messages.append(GigaChatMessage(role="user", content=prompt)) + + # Выполняем запрос + result = await self._tool.chat( + messages=messages, + user_id=str(user_id) if user_id else None, + temperature=kwargs.get("temperature", 0.7), + max_tokens=kwargs.get("max_tokens", 2000), + ) + + if not result.get("content"): + if result.get("error"): + return ProviderResponse( + success=False, + error=result["error"], + provider_name=self.provider_name + ) + else: + return ProviderResponse( + success=False, + error="Пустой ответ от GigaChat", + provider_name=self.provider_name + ) + + content = result["content"] + + return ProviderResponse( + success=True, + message=AIMessage( + content=content, + tool_calls=[], + metadata={ + "model": result.get("model", "GigaChat"), + "usage": result.get("usage", {}) + } + ), + provider_name=self.provider_name, + usage=result.get("usage") + ) + + except Exception as e: + logger.error(f"Ошибка GigaChat провайдера: {e}") + return ProviderResponse( + success=False, + error=str(e), + provider_name=self.provider_name + ) + + async def execute_tool( + self, + tool_name: str, + tool_args: Dict[str, Any], + tool_call_id: Optional[str] = None, + **kwargs + ) -> ToolCall: + """ + Выполнить инструмент (заглушка). + + Инструменты выполняются через process_with_tools. + """ + return ToolCall( + tool_name=tool_name, + tool_args=tool_args, + tool_call_id=tool_call_id, + status=ToolCallStatus.PENDING + ) diff --git a/bot/providers/opencode_provider.py b/bot/providers/opencode_provider.py new file mode 100644 index 0000000..72cc9f9 --- /dev/null +++ b/bot/providers/opencode_provider.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +Opencode AI Provider - интеграция с opencode CLI. + +Использует opencode run для выполнения задач с бесплатными моделями: +- opencode/minimax-m2.5-free +- opencode/big-pickle +- opencode/gpt-5-nano + +Поддерживает RAG через память бота. +""" + +import os +import re +import asyncio +import logging +import subprocess +from pathlib import Path +from typing import Optional, Dict, Any, Callable, List +from dataclasses import dataclass, field + +from bot.base_ai_provider import ( + BaseAIProvider, + ProviderResponse, + AIMessage, + ToolCall, + ToolCallStatus, +) + +logger = logging.getLogger(__name__) + +OPENCODE_BIN = os.environ.get("OPENCODE_BIN", "/home/mirivlad/.opencode/bin/opencode") + +AVAILABLE_MODELS = { + "minimax": "opencode/minimax-m2.5-free", + "big_pickle": "opencode/big-pickle", + "gpt5": "opencode/gpt-5-nano", +} + +DEFAULT_MODEL = "minimax" + + +@dataclass +class OpencodeSession: + """Сессия пользователя с opencode.""" + user_id: int + model: str = DEFAULT_MODEL + history: List[Dict[str, str]] = field(default_factory=list) + + +class OpencodeProvider(BaseAIProvider): + """ + Opencode AI Provider. + + Использует opencode CLI для генерации ответов. + Поддерживает несколько бесплатных моделей. + """ + + def __init__(self): + self._sessions: Dict[int, OpencodeSession] = {} + self._default_model = DEFAULT_MODEL + self._initialized = False + + @property + def provider_name(self) -> str: + return "Opencode" + + @property + def supports_tools(self) -> bool: + return True + + @property + def supports_streaming(self) -> bool: + return False + + def is_available(self) -> bool: + """Проверка доступности opencode CLI.""" + return Path(OPENCODE_BIN).exists() + + def get_session(self, user_id: int) -> OpencodeSession: + """Получить или создать сессию пользователя.""" + if user_id not in self._sessions: + self._sessions[user_id] = OpencodeSession( + user_id=user_id, + model=self._default_model + ) + return self._sessions[user_id] + + def set_model(self, user_id: int, model_key: str): + """Установить модель для пользователя.""" + session = self.get_session(user_id) + if model_key in AVAILABLE_MODELS: + session.model = AVAILABLE_MODELS[model_key] + logger.info(f"User {user_id} switched to model: {session.model}") + + def get_model(self, user_id: int) -> str: + """Получить текущую модель пользователя (полное имя).""" + session = self.get_session(user_id) + # Возвращаем полное имя модели из AVAILABLE_MODELS + return AVAILABLE_MODELS.get(session.model, session.model) + + def get_available_models(self) -> Dict[str, str]: + """Получить список доступных моделей.""" + return AVAILABLE_MODELS.copy() + + def _build_context( + self, + system_prompt: Optional[str], + context: Optional[List[Dict[str, str]]], + memory_context: str = "" + ) -> str: + """Собрать полный контекст для opencode.""" + parts = [] + + if system_prompt: + parts.append(f"=== SYSTEM PROMPT ===\n{system_prompt}") + + if memory_context: + parts.append(f"=== MEMORY CONTEXT ===\n{memory_context}") + + if context: + history_text = "\n".join([ + f"{msg.get('role', 'user')}: {msg.get('content', '')}" + for msg in context + if msg.get('role') != 'system' + ]) + if history_text: + parts.append(f"=== CONVERSATION HISTORY ===\n{history_text}") + + return "\n\n".join(parts) + + async def _run_opencode( + self, + prompt: str, + model: str, + on_chunk: Optional[Callable[[str], Any]] = None + ) -> str: + """ + Выполнить запрос через opencode CLI. + + Args: + prompt: Запрос пользователя + model: Модель для использования + on_chunk: Callback для потокового вывода (не используется) + + Returns: + Ответ от opencode + """ + try: + logger.info(f"Opencode _run_opencode: model={model}, prompt_len={len(prompt) if prompt else 0}") + + # Используем stdin для передачи промпта + cmd = [ + OPENCODE_BIN, + "run", + "-m", model + ] + + logger.info(f"Running opencode cmd: {cmd}") + + # Кодируем промпт для stdin + prompt_bytes = prompt.encode('utf-8') if prompt else b'' + + process = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=str(Path.home()), + ) + + # Отправляем промпт в stdin + stdout, _ = await asyncio.wait_for( + process.communicate(input=prompt_bytes), + timeout=120.0 + ) + + full_output = stdout.decode('utf-8', errors='replace') + + # Очищаем от ANSI кодов и служебных символов + full_output = self._clean_output(full_output) + + return full_output + + except asyncio.TimeoutError: + logger.error("Opencode timeout") + return "⏱️ Таймаут выполнения (2 минуты)" + except Exception as e: + logger.error(f"Opencode error: {e}") + return f"❌ Ошибка opencode: {str(e)}" + + def _clean_output(self, output: str) -> str: + """Очистить вывод от служебных символов.""" + # Убираем ANSI escape последовательности + ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') + output = ansi_escape.sub('', output) + + # Убираем служебные строки + lines = output.split('\n') + cleaned_lines = [] + + for line in lines: + # Пропускаем служебные строки + if any(x in line.lower() for x in ['build', 'minimax', 'gpt', 'elapsed', 'rss', 'bun v']): + continue + if line.startswith('>'): + continue + if not line.strip(): + continue + cleaned_lines.append(line) + + return "\n".join(cleaned_lines).strip() + + async def chat( + self, + prompt: str, + system_prompt: Optional[str] = None, + context: Optional[List[Dict[str, str]]] = None, + tools: Optional[List[Dict[str, Any]]] = None, + on_chunk: Optional[Callable[[str], Any]] = None, + user_id: Optional[int] = None, + memory_context: Optional[str] = None, + **kwargs + ) -> ProviderResponse: + """ + Отправить запрос к Opencode. + + Args: + prompt: Запрос пользователя + system_prompt: Системный промпт + context: История диалога + tools: Доступные инструменты (схема) - пока не используется + on_chunk: Callback для потокового вывода + user_id: ID пользователя + memory_context: Контекст из памяти бота + + Returns: + ProviderResponse с ответом + """ + if not self.is_available(): + return ProviderResponse( + success=False, + error="Opencode CLI не найден", + provider_name=self.provider_name + ) + + if user_id is None: + return ProviderResponse( + success=False, + error="user_id обязателен для Opencode", + provider_name=self.provider_name + ) + + try: + # Получаем текущую модель + model = self.get_model(user_id) + logger.info(f"Opencode: user_id={user_id}, model={model}, session={self._sessions.get(user_id)}") + + # Собираем контекст + full_context = self._build_context( + system_prompt=system_prompt, + context=context, + memory_context=memory_context or "" + ) + + # Формируем полный промпт + # Когда prompt=None (из process_with_tools), используем контекст напрямую + if prompt is None: + full_prompt = full_context if full_context else "" + elif full_context: + full_prompt = f"{full_context}\n\n=== CURRENT REQUEST ===\n{prompt}" + else: + full_prompt = prompt + + # Добавляем информацию об инструментах если есть + if tools: + tools_info = self._format_tools_for_prompt(tools) + full_prompt = f"{full_prompt}\n\n=== AVAILABLE TOOLS ===\n{tools_info}" + + logger.info(f"Opencode request (model={model}): {str(prompt)[:50] if prompt else 'from context'}...") + + # Выполняем запрос + result = await self._run_opencode( + prompt=full_prompt, + model=model, + on_chunk=on_chunk + ) + + if not result: + result = "⚠️ Пустой ответ от Opencode" + + return ProviderResponse( + success=True, + message=AIMessage( + content=result, + metadata={"model": model} + ), + provider_name=self.provider_name + ) + + except Exception as e: + logger.error(f"Opencode provider error: {e}") + return ProviderResponse( + success=False, + error=str(e), + provider_name=self.provider_name + ) + + def _format_tools_for_prompt(self, tools: List[Dict[str, Any]]) -> str: + """Форматировать инструменты для промпта.""" + if not tools: + return "" + + lines = ["У тебя есть следующие инструменты:\n"] + + for tool in tools: + name = tool.get('name', 'unknown') + desc = tool.get('description', 'Нет описания') + params = tool.get('parameters', {}) + + lines.append(f"- {name}: {desc}") + if params: + props = params.get('properties', {}) + if props: + lines.append(f" Параметры: {', '.join(props.keys())}") + + return "\n".join(lines) + + async def execute_tool( + self, + tool_name: str, + tool_args: Dict[str, Any], + tool_call_id: Optional[str] = None, + **kwargs + ) -> ToolCall: + """Выполнить инструмент (заглушка).""" + return ToolCall( + tool_name=tool_name, + tool_args=tool_args, + tool_call_id=tool_call_id, + status=ToolCallStatus.PENDING + ) + + +# Глобальный экземпляр +opencode_provider = OpencodeProvider() diff --git a/bot/providers/qwen_provider.py b/bot/providers/qwen_provider.py new file mode 100644 index 0000000..2466d64 --- /dev/null +++ b/bot/providers/qwen_provider.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +""" +Qwen Code AI Provider - адаптер Qwen Code для работы с инструментами. + +Реализует интерфейс BaseAIProvider для единой работы с инструментами +независимо от AI-провайдера. +""" + +import logging +import re +import json +from typing import Optional, Dict, Any, Callable, List + +from bot.base_ai_provider import ( + BaseAIProvider, + ProviderResponse, + AIMessage, + ToolCall, + ToolCallStatus, +) + +logger = logging.getLogger(__name__) + + +class QwenCodeProvider(BaseAIProvider): + """ + Qwen Code AI Provider с нативной поддержкой инструментов. + + Использует Qwen Code CLI с потоковым выводом и парсингом tool calls. + """ + + def __init__(self, qwen_manager): + self._qwen_manager = qwen_manager + + @property + def provider_name(self) -> str: + return "Qwen Code" + + @property + def supports_tools(self) -> bool: + return True + + @property + def supports_streaming(self) -> bool: + return True + + def is_available(self) -> bool: + """Qwen Code всегда доступен (локальный CLI).""" + return True + + # Список инструментов бота - только их пропускаем + ALLOWED_TOOLS = { + 'ddgs_tool', + 'rss_tool', + 'ssh_tool', + 'cron_tool', + 'file_system_tool', + 'telegram_web_tool', + } + + def _parse_qwen_result(self, raw_result: str) -> tuple[str, List[ToolCall]]: + """ + Распарсить результат от Qwen Code. + + Извлекает текст и вызовы инструментов из stream-json вывода. + + Формат stream-json от Qwen Code: + {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}} + {"type":"assistant","message":{"content":[{"type":"tool_use","name":"ssh_tool","args":{...}}]}} + {"type":"result","result":"...","duration_ms":1234} + + Returns: + (content, tool_calls) + """ + content_parts = [] + tool_calls = [] + + # Пытаемся распарсить JSON lines + try: + lines = raw_result.strip().split('\n') + + for line in lines: + line = line.strip() + if not line: + continue + + # Пробуем распарсить как JSON + try: + data = json.loads(line) + + # Обрабатываем разные типы событий + event_type = data.get('type') + + if event_type == 'assistant': + message = data.get('message', {}) + content_list = message.get('content', []) + + # Обрабатываем только если content - это список + if isinstance(content_list, list): + for content_item in content_list: + if isinstance(content_item, dict): + if content_item.get('type') == 'text': + text_content = content_item.get('text', '') + logger.debug(f"Text chunk: {text_content[:50]}...") + content_parts.append(text_content) + + elif content_item.get('type') == 'tool_use': + # Извлекаем tool call + tool_name = content_item.get('name', '') + tool_args = content_item.get('args', {}) + tool_call_id = content_item.get('id', None) + + # 🔥 ФИЛЬТР: пропускаем только инструменты бота + if tool_name not in self.ALLOWED_TOOLS: + logger.warning(f"⚠️ Игнорируем MCP инструмент Qwen Code: {tool_name}") + continue + + logger.info(f"Обнаружен tool_use: {tool_name}") + + tool_calls.append(ToolCall( + tool_name=tool_name, + tool_args=tool_args, + tool_call_id=tool_call_id, + status=ToolCallStatus.PENDING + )) + + elif event_type == 'result': + # Result event может содержать финальный текст + result_text = data.get('result', '') + if result_text: + content_parts.append(result_text) + + except json.JSONDecodeError: + # Не JSON - считаем текстом + if line.strip(): + content_parts.append(line) + + except Exception as e: + logger.warning(f"Ошибка парсинга Qwen результата: {e}") + + # Фоллбэк: ищем текст в кавычках + text_matches = re.findall(r'"text":"([^"]+)"', raw_result) + if text_matches: + content_parts.extend([t.replace('\\n', '\n') for t in text_matches]) + + # Собираем контент + content = ''.join(content_parts).strip() + + return content, tool_calls + + async def chat( + self, + prompt: str, + system_prompt: Optional[str] = None, + context: Optional[List[Dict[str, str]]] = None, + tools: Optional[List[Dict[str, Any]]] = None, + on_chunk: Optional[Callable[[str], Any]] = None, + user_id: Optional[int] = None, + **kwargs + ) -> ProviderResponse: + """ + Отправить запрос Qwen Code. + + Args: + prompt: Запрос пользователя + system_prompt: Системный промпт + context: История диалога + tools: Доступные инструменты (схема) - пока не используется + on_chunk: Callback для потокового вывода + user_id: ID пользователя + **kwargs: Дополнительные параметры + + Returns: + ProviderResponse с ответом и возможными вызовами инструментов + """ + if not self._qwen_manager: + return ProviderResponse( + success=False, + error="Qwen менеджер не инициализирован", + provider_name=self.provider_name + ) + + if user_id is None: + return ProviderResponse( + success=False, + error="user_id обязателен для Qwen Code", + provider_name=self.provider_name + ) + + try: + # Формируем полный промпт + full_prompt = prompt or "" + + if system_prompt and kwargs.get('use_system_prompt', True): + full_prompt = f"{system_prompt}\n\n{full_prompt}" + + # Добавляем контекст если есть + if context: + context_text = "\n".join([ + f"{msg.get('role', 'user')}: {msg.get('content', '')}" + for msg in context + ]) + full_prompt = f"{context_text}\n\n{full_prompt}" + + # Выполняем через Qwen Manager + output_buffer = [] + + def on_output(text: str): + output_buffer.append(text) + + async def on_chunk_wrapper(text: str): + if on_chunk: + await on_chunk(text) + + result = await self._qwen_manager.run_task( + user_id=user_id, + task=full_prompt, + on_output=on_output, + on_oauth_url=lambda x: None, + use_system_prompt=False, # Уже добавили в full_prompt + on_chunk=on_chunk_wrapper, + on_event=None + ) + + # Парсим результат + content, tool_calls = self._parse_qwen_result(result) + + if not content and not tool_calls: + # Если ничего не распарсили, возвращаем сырой результат + content = result + + return ProviderResponse( + success=True, + message=AIMessage( + content=content, + tool_calls=tool_calls, + metadata={"raw_result": result} + ), + provider_name=self.provider_name + ) + + except Exception as e: + logger.error(f"Ошибка Qwen Code провайдера: {e}") + return ProviderResponse( + success=False, + error=str(e), + provider_name=self.provider_name + ) + + async def execute_tool( + self, + tool_name: str, + tool_args: Dict[str, Any], + tool_call_id: Optional[str] = None, + **kwargs + ) -> ToolCall: + """ + Выполнить инструмент (заглушка). + + Qwen Code не выполняет инструменты напрямую - это делает + AIProviderManager через process_with_tools. + """ + return ToolCall( + tool_name=tool_name, + tool_args=tool_args, + tool_call_id=tool_call_id, + status=ToolCallStatus.PENDING + ) diff --git a/bot/services/__init__.py b/bot/services/__init__.py new file mode 100644 index 0000000..bc83a98 --- /dev/null +++ b/bot/services/__init__.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Сервисы бота.""" + +from bot.services.command_executor import ( + execute_cli_command, + _execute_local_command, + _execute_ssh_command, + _show_result, +) + +__all__ = [ + "execute_cli_command", + "_execute_local_command", + "_execute_ssh_command", + "_show_result", +] diff --git a/bot/services/command_executor.py b/bot/services/command_executor.py new file mode 100644 index 0000000..1751051 --- /dev/null +++ b/bot/services/command_executor.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Сервис выполнения CLI команд (локальных и SSH).""" + +import asyncio +import logging +import os +import pty +from datetime import datetime +from typing import Tuple + +import asyncssh +from telegram import Update + +from bot.config import config, state_manager, server_manager +from bot.models.server import Server +from bot.models.session import ssh_session_manager, local_session_manager +from bot.utils.ssh_readers import read_ssh_output, read_pty_output, detect_input_type +from bot.utils.formatters import format_long_output, escape_html, send_long_message +from bot.utils.cleaners import clean_ansi_codes, normalize_output + +logger = logging.getLogger(__name__) + + +async def execute_cli_command(query, command: str): + """Выполнение CLI команды из кнопки меню.""" + user_id = query.from_user.id + state = state_manager.get(user_id) + server_name = state.current_server + server = server_manager.get(server_name) + + # Определяем рабочую директорию + working_dir = state.working_directory or config.working_directory + + logger.info(f"Выполнение команды: {command} на сервере: {server_name}, в директории: {working_dir}") + + # Если локальный сервер — выполняем локально + if server_name == "local" or server is None: + await _execute_local_command(query, command, working_dir) + else: + # Выполняем через SSH + await _execute_ssh_command(query, command, server, working_dir) + + +async def _execute_local_command(query, command: str, working_dir: str): + """Выполнение локальной команды через PTY.""" + user_id = query.from_user.id + + try: + logger.info(f"Создание PTY для команды: {command}") + # Создаём PTY + master_fd, slave_fd = pty.openpty() + logger.info(f"PTY создан: master_fd={master_fd}") + + # Запускаем процесс в PTY + pid = os.fork() + if pid == 0: + # Дочерний процесс + os.close(master_fd) + os.setsid() + os.dup2(slave_fd, 0) # stdin + os.dup2(slave_fd, 1) # stdout + os.dup2(slave_fd, 2) # stderr + os.close(slave_fd) + + os.chdir(working_dir) + os.execvp("/bin/bash", ["/bin/bash", "-c", command]) + else: + # Родительский процесс + os.close(slave_fd) + logger.info(f"Процесс запущен: pid={pid}") + + # Создаём сессию + session = local_session_manager.create_session( + user_id=user_id, + command=command, + master_fd=master_fd, + pid=pid + ) + + # Читаем начальный вывод + logger.info("Чтение вывода из PTY...") + output, is_done = read_pty_output(master_fd, timeout=3.0) + logger.info(f"Прочитано: {len(output)} байт, is_done={is_done}") + logger.debug(f"Вывод: {output[:500] if output else '(пусто)'}") + + session.output_buffer = output + session.last_activity = datetime.now() + + # Проверяем тип ввода + input_type = detect_input_type(output) + logger.info(f"Тип ввода: {input_type}") + + if input_type == "password": + session.waiting_for_input = True + session.input_type = "password" + await query.answer() + await query.message.reply_text( + f"⏳ **Требуется ввод**\n\n" + f"Команда: `{command}`\n\n" + f"**🔐 Запрошен пароль**\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте пароль в чат:", + parse_mode="Markdown" + ) + return + elif input_type == "confirm": + session.waiting_for_input = True + session.input_type = "confirm" + await query.answer() + await query.message.reply_text( + f"⏳ **Требуется ввод**\n\n" + f"Команда: `{command}`\n\n" + f"**❓ Требуется подтверждение**\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте `y` (да) или `n` (нет):", + parse_mode="Markdown" + ) + return + elif is_done: + local_session_manager.close_session(user_id) + await _show_result(query, command, output.encode(), b"", 0) + return + else: + # Команда ещё выполняется + await query.answer() + await query.message.reply_text( + f"⏳ **Выполнение...**\n\n" + f"Команда: `{command}`\n\n" + f"```\n{output.strip()[-500:] if output else 'Выполняется...'}\n```", + parse_mode="Markdown" + ) + + max_iterations = 60 # Максимум 60 итераций (5 минут при timeout=5.0) + iteration_count = 0 + + while not is_done and iteration_count < max_iterations: + more_output, is_done = read_pty_output(master_fd, timeout=5.0) + output += more_output + session.output_buffer = output + session.last_activity = datetime.now() + iteration_count += 1 + + input_type = detect_input_type(output) + if input_type in ("password", "confirm"): + session.waiting_for_input = True + session.input_type = input_type + await query.answer() + await query.message.reply_text( + f"⏳ **Требуется ввод**\n\n" + f"Команда: `{command}`\n\n" + f"{'**🔐 Запрошен пароль**' if input_type == 'password' else '**❓ Требуется подтверждение**'}\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"{'Отправьте пароль в чат:' if input_type == 'password' else 'Отправьте `y` (да) или `n` (нет):'}", + parse_mode="Markdown" + ) + return + + if iteration_count >= max_iterations: + logger.warning(f"Превышено максимальное количество итераций ({max_iterations}) для команды {command}") + local_session_manager.close_session(user_id) + await _show_result(query, command, output.encode(), "Превышено время выполнения команды".encode(), 1) + return + + local_session_manager.close_session(user_id) + await _show_result(query, command, output.encode(), b"", 0) + + except Exception as e: + logger.error(f"Ошибка выполнения команды: {e}") + local_session_manager.close_session(user_id) + await query.edit_message_text( + f"❌ **Ошибка:**\n```\n{str(e)}\n```", + parse_mode="Markdown" + ) + + +async def _execute_ssh_command(query, command: str, server: Server, working_dir: str): + """Выполнение команды через SSH с интерактивной сессией.""" + user_id = query.from_user.id + + try: + # Подготовка SSH ключа + client_keys = [server_manager.ssh_key_path] if server_manager.ssh_key_path else None + + # Подготовка параметров подключения + connect_kwargs = { + "host": server.host, + "port": server.port, + "username": server.user, + "client_host_keys": None, + "known_hosts": None + } + + # Добавляем ключ или пароль + if client_keys: + connect_kwargs["client_keys"] = client_keys + if server.password: + connect_kwargs["password"] = server.password + + logger.info(f"SSH подключение к {server.host}:{server.port} как {server.user}") + + # Подключение к серверу + conn = await asyncssh.connect(**connect_kwargs) + + # Выполнение команды с cd в рабочую директорию + full_command = f"cd {working_dir} && {command}" if working_dir else command + + # Создаем интерактивный процесс с PTY для поддержки ввода + # TERM环境变量设置 для корректной кодировки + process = await conn.create_process( + full_command, + term_type='xterm-256color', + env={'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'} + ) + + # Создаём сессию + session = ssh_session_manager.create_session( + user_id=user_id, + server=server, + working_dir=working_dir, + conn=conn, + process=process, + command=command + ) + + # Читаем начальный вывод + output, is_done = await read_ssh_output(process, timeout=3.0) + session.output_buffer = output + session.last_activity = datetime.now() + + # Читаем пока процесс не завершится + while not is_done: + more_output, is_done = await read_ssh_output(process, timeout=2.0) + output += more_output + session.output_buffer = output + session.last_activity = datetime.now() + + # Проверяем тип ввода + input_type = detect_input_type(output) + + if input_type == "password": + # Запрос пароля + session.waiting_for_input = True + session.input_type = "password" + await query.answer() + await query.message.reply_text( + f"⏳ **Требуется ввод**\n\n" + f"Команда: `{command}`\n\n" + f"**🔐 Запрошен пароль**\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте пароль в чат:", + parse_mode="Markdown" + ) + return + elif input_type == "confirm": + # Запрос подтверждения + session.waiting_for_input = True + session.input_type = "confirm" + await query.answer() + await query.message.reply_text( + f"⏳ **Требуется ввод**\n\n" + f"Команда: `{command}`\n\n" + f"**❓ Требуется подтверждение**\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте `y` (да) или `n` (нет):", + parse_mode="Markdown" + ) + return + else: + # Команда завершена, показываем результат + ssh_session_manager.close_session(user_id) + await _show_result(query, command, output.encode(), "", 0) + return + + except asyncssh.Error as e: + logger.error(f"SSH ошибка: {e}") + ssh_session_manager.close_session(user_id) + await query.answer() + await query.message.reply_text( + f"❌ **SSH ошибка:**\n```\n{str(e)}\n```", + parse_mode="Markdown" + ) + except asyncio.TimeoutError: + logger.error("Таймаут SSH подключения") + ssh_session_manager.close_session(user_id) + await query.answer() + await query.message.reply_text( + "❌ **Таймаут**\n\nКоманда выполнялась дольше 30 секунд и была прервана.", + parse_mode="Markdown" + ) + except Exception as e: + logger.error(f"Ошибка выполнения команды: {e}") + ssh_session_manager.close_session(user_id) + await query.answer() + await query.message.reply_text( + f"❌ **Ошибка:**\n```\n{str(e)}\n```", + parse_mode="Markdown" + ) + + +async def _show_result(query, command: str, stdout: bytes | str, stderr: bytes | str, returncode: int): + """Показ результата выполнения команды.""" + # Обрабатываем как bytes так и str + if isinstance(stdout, bytes): + output = clean_ansi_codes(stdout.decode("utf-8", errors="replace")) + else: + output = clean_ansi_codes(str(stdout)) + output = normalize_output(output) + + if isinstance(stderr, bytes): + error = clean_ansi_codes(stderr.decode("utf-8", errors="replace")) + else: + error = clean_ansi_codes(str(stderr)) + + result = f"✅ **Результат:**\n\n" + + if output: + # Форматируем длинный вывод + output = format_long_output(output) + result += f"```\n{output}\n```\n" + + if error: + result += f"**Ошибки:**\n```\n{error}\n```\n" + + result += f"\n**Код возврата:** `{returncode}`" + + # Экранируем специальные символы Markdown вне блоков кода + from bot.utils.formatters import escape_markdown + result = escape_markdown(result) + + # Отправляем с разбивкой на части если нужно + await send_long_message(query, result, parse_mode="Markdown") + diff --git a/bot/services/cron_scheduler.py b/bot/services/cron_scheduler.py new file mode 100644 index 0000000..83e73fd --- /dev/null +++ b/bot/services/cron_scheduler.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +Cron Scheduler - планировщик задач для автоматического выполнения. + +Проверяет задачи каждую минуту и выполняет те, у которых наступило время. +""" + +import logging +import asyncio +from datetime import datetime +from typing import Optional, Callable, Awaitable +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class CronScheduler: + """ + Планировщик cron-задач. + + Автоматически проверяет задачи и выполняет их через AI-агент. + """ + + def __init__( + self, + cron_tool, + ai_agent, + send_notification: Optional[Callable[[int, str], Awaitable[None]]] = None + ): + """ + Инициализировать планировщик. + + Args: + cron_tool: Экземпляр CronTool + ai_agent: Экземпляр AI-агента для выполнения задач + send_notification: Асинхронная функция для отправки уведомлений (user_id, message) + """ + self.cron_tool = cron_tool + self.ai_agent = ai_agent + self.send_notification = send_notification + self._running = False + self._task: Optional[asyncio.Task] = None + self._check_interval = 60 # Проверка каждую минуту + + async def start(self): + """Запустить планировщик в фоновом режиме.""" + if self._running: + logger.warning("Планировщик уже запущен") + return + + self._running = True + self._task = asyncio.create_task(self._run_loop()) + logger.info("🕐 Планировщик cron-задач запущен") + + async def stop(self): + """Остановить планировщик.""" + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("🕐 Планировщик cron-задач остановлен") + + async def _run_loop(self): + """Основной цикл планировщика.""" + while self._running: + try: + await self._check_and_run_tasks() + except Exception as e: + logger.exception(f"Ошибка в цикле планировщика: {e}") + + await asyncio.sleep(self._check_interval) + + async def _check_and_run_tasks(self): + """Проверить задачи и выполнить те, у которых наступило время.""" + now = datetime.now() + logger.debug(f"🕐 Проверка задач на {now.strftime('%Y-%m-%d %H:%M:%S')}") + + # Получаем список всех задач + result = await self.cron_tool.list_jobs() + + if not result.success: + logger.error(f"Ошибка получения списка задач: {result.error}") + return + + jobs = result.data + executed_count = 0 + + for job in jobs: + if not job.get('enabled'): + continue + + next_run_str = job.get('next_run') + if not next_run_str: + continue + + try: + next_run = datetime.strptime(next_run_str, '%Y-%m-%d %H:%M:%S') + except ValueError: + logger.error(f"Ошибка парсинга next_run для задачи {job['id']}: {next_run_str}") + continue + + # Если время пришло + if now >= next_run: + # ЗАЩИТА ОТ DUPLICATE: проверяем last_run + last_run_str = job.get('last_run') + if last_run_str: + try: + last_run = datetime.strptime(last_run_str, '%Y-%m-%d %H:%M:%S') + # Если задача уже выполнялась в этом окне (менее минуты назад) - пропускаем + if (now - last_run).total_seconds() < 60: + logger.debug(f"⏭️ Задача #{job['id']} уже выполнена в этом окне, пропускаем") + continue + except ValueError: + pass # Игнорируем ошибку парсинга last_run + + logger.info(f"⏰ Время задачи #{job['id']}: {job['name']}") + await self._execute_job(job) + executed_count += 1 + + if executed_count > 0: + logger.info(f"✅ Выполнено задач: {executed_count}") + + async def _execute_job(self, job: dict): + """ + Выполнить задачу. + + Args: + job: Словарь с данными задачи + """ + job_id = job['id'] + job_name = job['name'] + notify = job.get('notify', False) + log_results = job.get('log_results', True) + user_id = job.get('user_id') # ID пользователя который создал задачу + schedule = job.get('schedule', '') + + # Выполняем задачу через AI-агент + result = await self.cron_tool.run_job( + job_id=job_id, + ai_agent=self.ai_agent, + user_id=user_id + ) + + if result.success: + logger.info(f"✅ Задача '{job_name}' выполнена успешно") + + # ПЕРЕСЧЁТ NEXT_RUN: обновляем время следующего выполнения + await self.cron_tool.update_next_run(job_id) + + # Отправляем уведомление если нужно + if notify and self.send_notification and user_id: + result_text = result.metadata.get('result_text', 'Задача выполнена') + await self.send_notification(user_id, result_text) + else: + logger.error(f"❌ Задача '{job_name}' не выполнена: {result.error}") + + if notify and self.send_notification and user_id: + await self.send_notification( + user_id, + f"❌ **Ошибка задачи '{job_name}':**\n{result.error}" + ) + + def set_notification_callback(self, callback: Callable[[int, str], Awaitable[None]]): + """Установить callback для отправки уведомлений.""" + self.send_notification = callback + + +# Глобальный планировщик +scheduler: Optional[CronScheduler] = None + + +def init_scheduler(cron_tool, ai_agent, send_notification=None) -> CronScheduler: + """Инициализировать глобальный планировщик.""" + global scheduler + scheduler = CronScheduler(cron_tool, ai_agent, send_notification) + return scheduler + + +def get_scheduler() -> Optional[CronScheduler]: + """Получить глобальный планировщик.""" + return scheduler diff --git a/bot/tools/__init__.py b/bot/tools/__init__.py new file mode 100644 index 0000000..6b6b023 --- /dev/null +++ b/bot/tools/__init__.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +Реестр инструментов для Telegram CLI Bot. + +Инструменты - это capabilities, которые бот может использовать автономно +для выполнения задач пользователя (Agentic AI подход). +""" + +import logging +from abc import ABC, abstractmethod +from typing import Dict, List, Any, Optional +from dataclasses import dataclass, field +from datetime import datetime + +logger = logging.getLogger(__name__) + + +@dataclass +class ToolResult: + """Результат выполнения инструмента.""" + success: bool + data: Any = None + error: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict: + return { + 'success': self.success, + 'data': self.data, + 'error': self.error, + 'metadata': self.metadata + } + + +class BaseTool(ABC): + """Базовый класс для всех инструментов.""" + + name: str = "base_tool" + description: str = "Базовый инструмент" + category: str = "general" + + @abstractmethod + async def execute(self, **kwargs) -> ToolResult: + """Выполнить инструмент.""" + pass + + def get_capabilities(self) -> Dict: + """Вернуть описание возможностей инструмента.""" + return { + 'name': self.name, + 'description': self.description, + 'category': self.category + } + + +class ToolsRegistry: + """Реестр всех доступных инструментов.""" + + _instance: Optional['ToolsRegistry'] = None + _tools: Dict[str, BaseTool] = {} + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def register(self, tool: BaseTool): + """Зарегистрировать инструмент.""" + self._tools[tool.name] = tool + logger.info(f"Зарегистрирован инструмент: {tool.name}") + + def unregister(self, tool_name: str): + """От-register инструмент.""" + if tool_name in self._tools: + del self._tools[tool_name] + logger.info(f"Удален инструмент: {tool_name}") + + def get(self, tool_name: str) -> Optional[BaseTool]: + """Получить инструмент по имени.""" + return self._tools.get(tool_name) + + def get_all(self) -> Dict[str, BaseTool]: + """Получить все инструменты.""" + return self._tools.copy() + + def get_capabilities_list(self) -> List[Dict]: + """Получить список всех возможностей для ИИ.""" + return [tool.get_capabilities() for tool in self._tools.values()] + + async def execute_tool(self, tool_name: str, **kwargs) -> ToolResult: + """Выполнить инструмент по имени.""" + tool = self.get(tool_name) + if not tool: + return ToolResult( + success=False, + error=f"Инструмент '{tool_name}' не найден" + ) + + logger.info(f"Выполнение инструмента: {tool_name} с аргументами: {kwargs}") + try: + result = await tool.execute(**kwargs) + result.metadata['tool_name'] = tool_name + result.metadata['timestamp'] = datetime.now().isoformat() + return result + except Exception as e: + logger.exception(f"Ошибка выполнения инструмента {tool_name}: {e}") + return ToolResult( + success=False, + error=str(e), + metadata={'tool_name': tool_name} + ) + + +# Глобальный экземпляр реестра +tools_registry = ToolsRegistry() + + +def register_tool(tool_class: type) -> type: + """Декоратор для автоматической регистрации инструмента.""" + tool_instance = tool_class() + tools_registry.register(tool_instance) + return tool_class + + +# Авто-импорт инструментов для регистрации +# Импортируем после определения register_tool чтобы декоратор сработал +from bot.tools import ddgs_tool, rss_tool, ssh_tool, cron_tool, gigachat_tool, file_system_tool, telegram_web_tool diff --git a/bot/tools/cron_tool.py b/bot/tools/cron_tool.py new file mode 100644 index 0000000..514bad5 --- /dev/null +++ b/bot/tools/cron_tool.py @@ -0,0 +1,704 @@ +#!/usr/bin/env python3 +""" +Cron Tool - инструмент для управления интеллектуальными задачами. + +Позволяет создавать, планировать и выполнять периодические задачи через AI-агент. +Задачи хранятся как промпты для ИИ, а не как команды. +""" + +import logging +import sqlite3 +import json +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Optional, Callable +from dataclasses import dataclass, field + +from croniter import croniter +from bot.tools import BaseTool, ToolResult, register_tool + +logger = logging.getLogger(__name__) + + +async def _translate_title(title: str, max_length: int = 100) -> str: + """ + Перевести заголовок на русский через Qwen. + + Args: + title: Заголовок для перевода + max_length: Максимальная длина + + Returns: + Переведённый заголовок + """ + try: + import subprocess + import json + + # Создаём временный промпт для перевода + translate_prompt = f"Translate this news title to Russian. Keep it concise, natural, and informative. Maximum {max_length} characters. Return ONLY the translation, no quotes or explanations.\n\nTitle: {title[:200]}" + + # Используем qwen-cli если доступен + result = subprocess.run( + ['qwen', 'chat', '--json', '--prompt', translate_prompt], + capture_output=True, + text=True, + timeout=15 + ) + + if result.returncode == 0: + # Парсим JSON ответ + try: + response = json.loads(result.stdout) + translated = response.get('content', response.get('response', title)) + except json.JSONDecodeError: + translated = result.stdout.strip() + + # Очищаем от кавычек + translated = translated.strip('"\'') + return translated[:max_length] + except Exception as e: + logger.debug(f"Ошибка перевода заголовка: {e}") + + # Fallback - обрезаем оригинал + return title[:max_length] + + +@dataclass +class CronJob: + """ + Интеллектуальная задача cron. + + Attributes: + id: ID задачи + name: Название задачи + prompt: Промпт для ИИ-агента (вместо команды) + schedule: Расписание (cron format: "*/5 * * * *" или "@daily", "@hourly") + enabled: Включена ли задача + user_id: ID пользователя Telegram + notify: Уведомлять ли пользователя в Telegram о результате + log_results: Сохранять ли результат в лог-файл + last_run: Время последнего выполнения + next_run: Время следующего выполнения + created_at: Время создания + """ + id: Optional[int] + name: str + prompt: str # Промпт для ИИ вместо команды + schedule: str + user_id: Optional[int] = None # ID пользователя Telegram + enabled: bool = True + notify: bool = False # Уведомлять пользователя в Telegram + log_results: bool = True # Сохранять в лог + last_run: Optional[datetime] = None + next_run: Optional[datetime] = None + created_at: datetime = field(default_factory=datetime.now) + + +class CronTool(BaseTool): + """Инструмент для управления интеллектуальными задачами пользователя.""" + + name = "cron_tool" + description = "Управление периодическими задачами через AI-агент. Создание, планирование и выполнение задач по расписанию." + category = "automation" + + def __init__(self, db_path: str = None, log_dir: str = None): + self.db_path = Path(db_path) if db_path else Path(__file__).parent.parent.parent / "cron.db" + self.log_dir = Path(log_dir) if log_dir else Path(__file__).parent.parent.parent / "cron_logs" + self.log_dir.mkdir(parents=True, exist_ok=True) + self._jobs: Dict[int, CronJob] = {} + self._init_db() + + def _init_db(self): + """Инициализировать БД.""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + + # Создаём таблицу со всеми колонками + c.execute(''' + CREATE TABLE IF NOT EXISTS cron_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + prompt TEXT NOT NULL, + schedule TEXT NOT NULL, + user_id INTEGER, + enabled INTEGER DEFAULT 1, + notify INTEGER DEFAULT 0, + log_results INTEGER DEFAULT 1, + last_run DATETIME, + next_run DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Проверяем наличие всех колонок (для обратной совместимости) + c.execute("PRAGMA table_info(cron_jobs)") + columns = [col[1] for col in c.fetchall()] + + # Миграции для старых БД + migrations = { + 'prompt': 'ALTER TABLE cron_jobs ADD COLUMN prompt TEXT DEFAULT ""', + 'user_id': 'ALTER TABLE cron_jobs ADD COLUMN user_id INTEGER', + 'enabled': 'ALTER TABLE cron_jobs ADD COLUMN enabled INTEGER DEFAULT 1', + 'notify': 'ALTER TABLE cron_jobs ADD COLUMN notify INTEGER DEFAULT 0', + 'log_results': 'ALTER TABLE cron_jobs ADD COLUMN log_results INTEGER DEFAULT 1', + 'last_run': 'ALTER TABLE cron_jobs ADD COLUMN last_run DATETIME', + 'next_run': 'ALTER TABLE cron_jobs ADD COLUMN next_run DATETIME' + } + + for col_name, alter_query in migrations.items(): + if col_name not in columns: + logger.info(f"Добавление колонки {col_name} в таблицу cron_jobs") + try: + c.execute(alter_query) + except sqlite3.OperationalError as e: + # Игнорируем ошибку если колонка уже существует (race condition) + if "duplicate column" not in str(e).lower(): + raise + + conn.commit() + conn.close() + + def _parse_schedule(self, schedule: str, base_time: datetime = None) -> Optional[datetime]: + """ + Распарсить расписание и вернуть следующее время выполнения. + + Поддерживает полноценный cron-синтаксис через croniter: + - "*/5 * * * *" - каждые 5 минут + - "0 * * * *" - каждый час в 0 минут + - "0 5 * * *" - каждый день в 05:00 + - "0 0 1 * *" - каждый месяц 1 числа в 00:00 + - "0 0 * * 0" - каждое воскресенье в 00:00 + - "@hourly", "@daily", "@weekly", "@monthly", "@yearly" + + Args: + schedule: Cron-выражение или special string + base_time: Базовое время для расчёта (по умолчанию сейчас) + + Returns: + Следующее время выполнения или None если ошибка парсинга + """ + if base_time is None: + base_time = datetime.now() + + # Поддержка special strings + special_schedules = { + '@hourly': '0 * * * *', + '@daily': '0 0 * * *', + '@midnight': '0 0 * * *', + '@weekly': '0 0 * * 0', + '@monthly': '0 0 1 * *', + '@yearly': '0 0 1 1 *', + '@annually': '0 0 1 1 *' + } + + cron_expr = special_schedules.get(schedule.lower(), schedule) + + try: + # croniter возвращает следующее время выполнения + cron = croniter(cron_expr, base_time) + next_run = cron.get_next(datetime) + return next_run + except Exception as e: + logger.error(f"Ошибка парсинга cron-расписания '{schedule}': {e}") + return None + + def _calculate_next_run(self, schedule: str, last_run: datetime = None) -> Optional[datetime]: + """ + Рассчитать следующее время выполнения на основе last_run. + + Args: + schedule: Cron-выражение + last_run: Время последнего выполнения (по умолчанию сейчас) + + Returns: + Следующее время выполнения + """ + base_time = last_run if last_run else datetime.now() + return self._parse_schedule(schedule, base_time) + + async def add_job(self, name: str, prompt: str, schedule: str, user_id: int = None, notify: bool = False, log_results: bool = True) -> ToolResult: + """ + Добавить интеллектуальную задачу. + + Args: + name: Название задачи + prompt: Промпт для ИИ-агента + schedule: Расписание (cron format или @daily, @hourly, @weekly) + user_id: ID пользователя Telegram + notify: Уведомлять ли пользователя в Telegram + log_results: Сохранять ли результат в лог + """ + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + + try: + next_run = self._calculate_next_run(schedule) + next_run_str = next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else None + + c.execute(''' + INSERT INTO cron_jobs (name, prompt, schedule, user_id, notify, log_results, next_run) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', (name, prompt, schedule, user_id, 1 if notify else 0, 1 if log_results else 0, next_run_str)) + + job_id = c.lastrowid + conn.commit() + + self._jobs[job_id] = CronJob( + id=job_id, + name=name, + prompt=prompt, + schedule=schedule, + user_id=user_id, + notify=notify, + log_results=log_results, + next_run=next_run + ) + + return ToolResult( + success=True, + data={'id': job_id, 'name': name, 'prompt': prompt, 'schedule': schedule, 'user_id': user_id, 'next_run': next_run_str}, + metadata={'status': 'added', 'notify': notify, 'log_results': log_results} + ) + + except Exception as e: + logger.exception(f"Ошибка добавления задачи: {e}") + return ToolResult( + success=False, + error=str(e) + ) + finally: + conn.close() + + async def update_next_run(self, job_id: int) -> ToolResult: + """ + Пересчитать время следующего выполнения после успешного запуска. + + Args: + job_id: ID задачи + + Returns: + ToolResult с новым next_run + """ + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + + try: + # Получаем текущие данные задачи + c.execute("SELECT schedule, last_run FROM cron_jobs WHERE id = ?", (job_id,)) + row = c.fetchone() + + if not row: + return ToolResult( + success=False, + error=f"Задача не найдена: {job_id}" + ) + + schedule, last_run_str = row + + # Рассчитываем следующее время выполнения на основе last_run + last_run = datetime.strptime(last_run_str, '%Y-%m-%d %H:%M:%S') if last_run_str else datetime.now() + next_run = self._calculate_next_run(schedule, last_run) + + if not next_run: + return ToolResult( + success=False, + error=f"Не удалось рассчитать next_run для расписания '{schedule}'" + ) + + next_run_str = next_run.strftime('%Y-%m-%d %H:%M:%S') + + # Обновляем next_run в БД + c.execute("UPDATE cron_jobs SET next_run = ? WHERE id = ?", (next_run_str, job_id)) + conn.commit() + + return ToolResult( + success=True, + data={'id': job_id, 'next_run': next_run_str}, + metadata={'status': 'next_run_updated'} + ) + + except Exception as e: + logger.exception(f"Ошибка обновления next_run: {e}") + return ToolResult( + success=False, + error=str(e) + ) + finally: + conn.close() + + async def list_jobs(self, user_id: int = None) -> ToolResult: + """ + Получить список всех задач. + + Args: + user_id: ID пользователя для фильтрации (если None - все задачи) + """ + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + + if user_id: + c.execute(''' + SELECT id, name, prompt, schedule, user_id, enabled, notify, log_results, last_run, next_run, created_at + FROM cron_jobs WHERE user_id = ? ORDER BY id + ''', (user_id,)) + else: + c.execute(''' + SELECT id, name, prompt, schedule, user_id, enabled, notify, log_results, last_run, next_run, created_at + FROM cron_jobs ORDER BY id + ''') + + rows = c.fetchall() + conn.close() + + jobs = [] + for row in rows: + jobs.append({ + 'id': row[0], + 'name': row[1], + 'prompt': row[2], + 'schedule': row[3], + 'user_id': row[4], + 'enabled': bool(row[5]), + 'notify': bool(row[6]), + 'log_results': bool(row[7]), + 'last_run': row[8], + 'next_run': row[9], + 'created_at': row[10] + }) + + return ToolResult( + success=True, + data=jobs, + metadata={'count': len(jobs)} + ) + + async def remove_job(self, job_id: int) -> ToolResult: + """Удалить задачу.""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("DELETE FROM cron_jobs WHERE id = ?", (job_id,)) + if c.rowcount == 0: + conn.close() + return ToolResult( + success=False, + error=f"Задача не найдена: {job_id}" + ) + conn.commit() + conn.close() + + if job_id in self._jobs: + del self._jobs[job_id] + + return ToolResult( + success=True, + data={'id': job_id}, + metadata={'status': 'removed'} + ) + + async def toggle_job(self, job_id: int, enabled: bool) -> ToolResult: + """Включить/выключить задачу.""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("UPDATE cron_jobs SET enabled = ? WHERE id = ?", (1 if enabled else 0, job_id)) + if c.rowcount == 0: + conn.close() + return ToolResult( + success=False, + error=f"Задача не найдена: {job_id}" + ) + conn.commit() + conn.close() + + return ToolResult( + success=True, + data={'id': job_id, 'enabled': enabled}, + metadata={'status': 'toggled'} + ) + + async def run_job(self, job_id: int, ai_agent=None, user_id: int = None) -> ToolResult: + """ + Выполнить интеллектуальную задачу через AI-агент. + + Args: + job_id: ID задачи + ai_agent: Экземпляр AI-агента для выполнения промпта + user_id: ID пользователя для контекста + + Returns: + ToolResult с результатом выполнения + """ + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("SELECT name, prompt, notify, log_results FROM cron_jobs WHERE id = ?", (job_id,)) + row = c.fetchone() + + if not row: + conn.close() + return ToolResult( + success=False, + error=f"Задача не найдена: {job_id}" + ) + + name, prompt, notify, log_results = row + conn.close() + + logger.info(f"🕐 Выполнение задачи #{job_id}: {name}") + logger.info(f" Промпт: {prompt}") + + result_data = { + 'id': job_id, + 'name': name, + 'prompt': prompt, + 'executed_at': datetime.now().isoformat() + } + + # Выполняем задачу через AI-агент + if ai_agent: + try: + # Отправляем промпт ИИ-агенту + logger.info(f"🤖 Отправка промпта AI-агенту для задачи {name}") + + # ИИ-агент анализирует промпт и решает какой инструмент использовать + decision = await ai_agent.decide(prompt, context={'user_id': user_id}) + + if decision.should_use_tool: + logger.info(f"🔧 AI-агент решил использовать инструмент: {decision.tool_name}") + tool_result = await ai_agent.execute_tool(decision.tool_name, **decision.tool_args) + + result_data['tool_used'] = decision.tool_name + result_data['tool_result'] = tool_result.to_dict() if hasattr(tool_result, 'to_dict') else str(tool_result) + result_data['success'] = tool_result.success + + # Формируем результат с красивым форматированием + result_text = f"Задача '{name}' выполнена.\n\n" + result_text += f"Использован инструмент: {decision.tool_name}\n\n" + + # Форматируем результат инструмента в читаемый вид + if tool_result.success: + formatted_result = await self._format_tool_result_for_cron( + decision.tool_name, tool_result.data, tool_result.error + ) + result_text += formatted_result + else: + result_text += f"❌ Ошибка: {tool_result.error}" + + else: + # ИИ решил что инструмент не нужен - выполняем промпт напрямую + logger.info(f"ℹ️ AI-агент решил что инструмент не требуется") + result_text = f"Задача '{name}' выполнена (без инструментов).\nПромпт: {prompt}" + result_data['success'] = True + result_data['ai_reasoning'] = decision.reasoning + + # Сохраняем в лог если нужно + if log_results: + self._save_to_log(job_id, name, prompt, result_text) + + # Обновляем last_run + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("UPDATE cron_jobs SET last_run = datetime('now') WHERE id = ?", (job_id,)) + conn.commit() + conn.close() + + # Обновляем next_run после успешного выполнения + await self.update_next_run(job_id) + + return ToolResult( + success=True, + data=result_data, + metadata={ + 'status': 'executed', + 'notify': notify, + 'log_results': log_results, + 'result_text': result_text + } + ) + + except Exception as e: + logger.exception(f"Ошибка выполнения задачи через AI-агент: {e}") + + if log_results: + self._save_to_log(job_id, name, prompt, f"Ошибка: {e}") + + return ToolResult( + success=False, + error=str(e), + data=result_data + ) + else: + # AI-агент не предоставлен - просто логируем + logger.warning(f"AI-агент не предоставлен, задача {name} не выполнена") + + if log_results: + self._save_to_log(job_id, name, prompt, "Ошибка: AI-агент не предоставлен") + + return ToolResult( + success=False, + error="AI-агент не предоставлен", + data=result_data + ) + + async def _format_tool_result_for_cron(self, tool_name: str, data: Any, error: str = None) -> str: + """ + Отформатировать результат выполнения инструмента в читаемый вид. + + Args: + tool_name: Название инструмента + data: Данные результата + error: Ошибка (если есть) + + Returns: + Отформатированная строка с результатом + """ + # Поддерживаем оба имени: 'rss_reader' (старое) и 'rss_tool' (новое) + if tool_name in ('rss_reader', 'rss_tool'): + if not data: + return "📰 Новостей не найдено." + + output = "📰 **Последние новости:**\n\n" + # Берём не более 15 новостей для читаемости + news_count = min(len(data), 15) + + for i in range(news_count): + item = data[i] + title = item.get('title', 'Без названия') + pub_date = item.get('pub_date', '') + link = item.get('link', '') + + # Переводим заголовок на русский + translated_title = await _translate_title(title, max_length=100) + + # Форматируем дату + date_str = "" + if pub_date: + try: + dt = datetime.strptime(pub_date[:19], '%Y-%m-%d %H:%M:%S') + date_str = dt.strftime('%d.%m.%Y %H:%M') + except: + date_str = pub_date[:16] + + # Обрезаем заголовок если слишком длинный + if len(translated_title) > 120: + translated_title = translated_title[:117] + "..." + + output += f"**{i+1}. {translated_title}**\n" + if date_str: + output += f" 📅 {date_str}\n" + if link: + short_link = link[:60] + "..." if len(link) > 63 else link + output += f" 🔗 {short_link}\n" + output += "\n" + + return output + + elif tool_name == 'ddgs_search': + if not data: + return "🔍 Ничего не найдено по вашему запросу." + + output = "🔍 **Результаты поиска:**\n\n" + for i, item in enumerate(data[:5], 1): + title = item.get('title', 'Без названия') + href = item.get('href', '') + body = item.get('body', '')[:200] + output += f"{i}. **{title}**\n" + if href: + output += f" {href}\n" + if body: + output += f" {body}\n\n" + + return output + + elif tool_name == 'ssh_executor': + if not data: + return "❌ **Ошибка SSH:** Нет данных" + + output = "🖥️ **SSH результат:**\n" + + if isinstance(data, dict): + if data.get('stdout'): + output += f"**Вывод:**\n```\n{data['stdout']}\n```\n\n" + if data.get('stderr'): + output += f"**Ошибки:**\n```\n{data['stderr']}\n```\n\n" + if data.get('returncode') == 0: + output += "✅ **Успешно**" + else: + output += f"❌ **Код возврата:** {data.get('returncode', 'N/A')}" + else: + output += str(data) + + return output + + elif tool_name == 'cron_tool': + if isinstance(data, dict): + return f"✅ **Результат:**\n{data}" + return str(data) + + # Fallback для неизвестных инструментов + return str(data) if data else "Выполнено" + + def _save_to_log(self, job_id: int, job_name: str, prompt: str, result: str): + """Сохранить результат выполнения задачи в лог-файл.""" + log_file = self.log_dir / f"cron_job_{job_id}_{job_name}.log" + + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + log_entry = f""" +{'='*60} +[{timestamp}] Задача: {job_name} (ID: {job_id}) +{'='*60} +Промпт: +{prompt} + +Результат: +{result} + +""" + + with open(log_file, 'a', encoding='utf-8') as f: + f.write(log_entry) + + logger.debug(f"Результат задачи {job_name} сохранён в лог: {log_file}") + + async def execute(self, action: str = "list", ai_agent=None, user_id: int = None, **kwargs) -> ToolResult: + """ + Выполнить действие с cron задачами. + + Args: + action: Действие - list, add, remove, toggle, run + ai_agent: Экземпляр AI-агента (для run) + user_id: ID пользователя (для add, run, list) + kwargs: Дополнительные аргументы + """ + actions = { + 'list': lambda: self.list_jobs(user_id=user_id), + 'add': lambda: self.add_job( + name=kwargs.get('name'), + prompt=kwargs.get('prompt'), + schedule=kwargs.get('schedule'), + user_id=user_id, + notify=kwargs.get('notify', False), + log_results=kwargs.get('log_results', True) + ), + 'remove': lambda: self.remove_job(job_id=kwargs.get('job_id')), + 'toggle': lambda: self.toggle_job( + job_id=kwargs.get('job_id'), + enabled=kwargs.get('enabled', True) + ), + 'run': lambda: self.run_job(job_id=kwargs.get('job_id'), ai_agent=ai_agent, user_id=user_id) + } + + if action not in actions: + return ToolResult( + success=False, + error=f"Неизвестное действие: {action}. Доступные: {list(actions.keys())}" + ) + + logger.info(f"Cron действие: {action} с аргументами: {kwargs}") + return await actions[action]() + + +# Автоматическая регистрация при импорте +@register_tool +class CronToolAuto(CronTool): + """Авто-регистрируемая версия CronTool.""" + pass diff --git a/bot/tools/ddgs_tool.py b/bot/tools/ddgs_tool.py new file mode 100644 index 0000000..c0d1272 --- /dev/null +++ b/bot/tools/ddgs_tool.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +DDGS Search Tool - инструмент для поиска в интернете через DuckDuckGo. + +Бот может использовать этот инструмент автономно, когда пользователю нужна +свежая информация из интернета. +""" + +import sys +import json +import logging +from pathlib import Path +from typing import List, Dict, Any + +from bot.tools import BaseTool, ToolResult, register_tool + +logger = logging.getLogger(__name__) + + +class DDGSTool(BaseTool): + """Инструмент поиска через DuckDuckGo.""" + + name = "ddgs_tool" + description = "Поиск информации в интернете через DuckDuckGo. Используется когда нужны свежие данные, новости, факты." + category = "search" + + def __init__(self, tools_dir: str = None): + self.tools_dir = Path(tools_dir) if tools_dir else Path(__file__).parent.parent / "tools" + self.script_path = self.tools_dir / "ddgs_search.py" + + def _search_ddgs(self, query: str, max_results: int = 10) -> List[Dict]: + """ + Выполнить поиск через ddgs. + + Args: + query: Поисковый запрос + max_results: Максимальное количество результатов + + Returns: + Список результатов с title, href, body + """ + try: + from ddgs import DDGS + except ImportError: + logger.error("ddgs library not found. Install: pip install ddgs") + return [] + + try: + ddgs = DDGS() + results = ddgs.text(query, max_results=max_results) + + formatted_results = [] + for result in results: + formatted_result = { + "title": result.get("title", ""), + "href": result.get("href", ""), + "body": result.get("body", "") + } + formatted_results.append(formatted_result) + + return formatted_results + except Exception as e: + logger.error(f"Ошибка DDGS поиска: {e}") + return [] + + async def execute(self, query: str, max_results: int = 10) -> ToolResult: + """ + Выполнить поиск. + + Args: + query: Поисковый запрос + max_results: Максимальное количество результатов (default: 10) + """ + if not query or not query.strip(): + return ToolResult( + success=False, + error="Пустой поисковый запрос" + ) + + logger.info(f"DDGS поиск: '{query}' (max_results={max_results})") + + results = self._search_ddgs(query, max_results) + + if not results: + return ToolResult( + success=True, + data=[], + metadata={'message': 'Ничего не найдено', 'query': query} + ) + + return ToolResult( + success=True, + data=results, + metadata={ + 'query': query, + 'count': len(results), + 'max_results': max_results + } + ) + + +# Автоматическая регистрация при импорте +@register_tool +class DDGSToolAuto(DDGSTool): + """Авто-регистрируемая версия DDGSTool.""" + pass diff --git a/bot/tools/file_system_tool.py b/bot/tools/file_system_tool.py new file mode 100644 index 0000000..7c81f0a --- /dev/null +++ b/bot/tools/file_system_tool.py @@ -0,0 +1,803 @@ +#!/usr/bin/env python3 +""" +File System Tool - инструмент для работы с файловой системой Linux. + +Позволяет AI-агенту выполнять операции с файлами и директориями: +- Чтение файлов (cat) +- Запись файлов +- Копирование (cp) +- Перемещение (mv) +- Удаление (rm) +- Создание директорий (mkdir) +- Список файлов (ls) +- Проверка существования +- Поиск файлов + +Инструмент работает от имени пользователя на локальной машине. +""" + +import logging +import os +import shutil +import subprocess +import asyncio +from pathlib import Path +from typing import Optional, Dict, Any, List +from dataclasses import dataclass, field + +from bot.tools import BaseTool, ToolResult, register_tool + +logger = logging.getLogger(__name__) + + +class FileSystemTool(BaseTool): + """Инструмент для работы с файловой системой.""" + + name = "file_system_tool" + description = "Работа с файловой системой Linux: чтение/запись файлов, копирование, перемещение, удаление, создание директорий, просмотр списка файлов." + category = "system" + + # Безопасные пути - где можно работать + ALLOWED_BASE_PATHS = [ + Path.home(), # Домашняя директория + Path("/tmp"), + Path("/var/tmp"), + ] + + # Опасные пути - куда нельзя записывать/удалять + DANGEROUS_PATHS = [ + Path("/"), + Path("/etc"), + Path("/usr"), + Path("/bin"), + Path("/sbin"), + Path("/boot"), + Path("/dev"), + Path("/proc"), + Path("/sys"), + ] + + def __init__(self): + self._last_operation: Optional[str] = None + self._operation_history: List[Dict] = [] + + def _is_path_safe(self, path: Path, allow_write: bool = True) -> tuple[bool, str]: + """ + Проверить безопасность пути. + + Args: + path: Путь для проверки + allow_write: Если True, проверяем возможность записи + + Returns: + (is_safe: bool, reason: str) + """ + try: + # Разрешаем абсолютные и относительные пути + if not path.is_absolute(): + path = Path.cwd() / path + + # Сначала проверяем на наличие в разрешённых путях (это важно!) + for allowed in self.ALLOWED_BASE_PATHS: + try: + path.relative_to(allowed) + return True, "Путь безопасен" + except ValueError: + pass + + # Если путь не в разрешённых - проверяем на опасные + for dangerous in self.DANGEROUS_PATHS: + # Пропускаем корень если путь не в разрешённых уже + if dangerous == Path("/"): + continue + + try: + path.relative_to(dangerous) + return False, f"Путь {path} находится в защищённой директории {dangerous}" + except ValueError: + pass + + # Если путь не в разрешённых и не в запрещённых - разрешаем с предупреждением + return True, f"Путь {path} может быть недоступен" + + except Exception as e: + return False, f"Ошибка проверки пути: {e}" + + def _resolve_path(self, path_str: str) -> Path: + """Преобразовать строку пути в Path объект.""" + path = Path(path_str) + + # Расширяем ~ в домашнюю директорию + # Важно: Path("~/file") не работает, нужно expanduser() + if path_str.startswith('~'): + path = Path(path_str).expanduser() + elif not path.is_absolute(): + # Если путь относительный, делаем его абсолютным от домашней директории + path = Path.home() / path_str + + return path + + async def read_file(self, path: str, limit: int = 100) -> Dict[str, Any]: + """ + Прочитать файл. + + Args: + path: Путь к файлу + limit: Максимальное количество строк для чтения + + Returns: + Dict с content, lines, error + """ + try: + file_path = self._resolve_path(path) + + # Проверка безопасности + is_safe, reason = self._is_path_safe(file_path, allow_write=False) + if not is_safe: + return {"error": reason, "success": False} + + if not file_path.exists(): + return {"error": f"Файл не существует: {file_path}", "success": False} + + if not file_path.is_file(): + return {"error": f"Не файл: {file_path}", "success": False} + + # Читаем файл + with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + lines = f.readlines() + + # Ограничиваем количество строк + if len(lines) > limit: + content = ''.join(lines[:limit]) + truncated = True + total_lines = len(lines) + else: + content = ''.join(lines) + truncated = False + total_lines = len(lines) + + logger.info(f"Прочитан файл: {file_path} ({total_lines} строк)") + + return { + "success": True, + "content": content, + "path": str(file_path), + "lines_read": min(len(lines), limit), + "total_lines": total_lines, + "truncated": truncated + } + + except Exception as e: + logger.error(f"Ошибка чтения файла {path}: {e}") + return {"error": str(e), "success": False} + + async def write_file(self, path: str, content: str, append: bool = False) -> Dict[str, Any]: + """ + Записать в файл. + + Args: + path: Путь к файлу + content: Содержимое для записи + append: Если True, добавить в конец файла + + Returns: + Dict с success, path, bytes_written + """ + try: + file_path = self._resolve_path(path) + + # Проверка безопасности + is_safe, reason = self._is_path_safe(file_path, allow_write=True) + if not is_safe: + return {"error": reason, "success": False} + + # Создаём родительские директории если нужно + file_path.parent.mkdir(parents=True, exist_ok=True) + + # Записываем файл + mode = 'a' if append else 'w' + with open(file_path, mode, encoding='utf-8') as f: + bytes_written = f.write(content) + + logger.info(f"Записан файл: {file_path} ({bytes_written} байт)") + + return { + "success": True, + "path": str(file_path), + "bytes_written": bytes_written, + "appended": append + } + + except Exception as e: + logger.error(f"Ошибка записи файла {path}: {e}") + return {"error": str(e), "success": False} + + async def list_directory(self, path: str = ".", show_hidden: bool = False) -> Dict[str, Any]: + """ + Показать список файлов в директории. + + Args: + path: Путь к директории + show_hidden: Показывать скрытые файлы + + Returns: + Dict с files, directories, total + """ + try: + dir_path = self._resolve_path(path) + + is_safe, _ = self._is_path_safe(dir_path, allow_write=False) + if not is_safe: + return {"error": "Доступ к директории ограничен", "success": False} + + if not dir_path.exists(): + return {"error": f"Директория не существует: {dir_path}", "success": False} + + if not dir_path.is_dir(): + return {"error": f"Не директория: {dir_path}", "success": False} + + files = [] + directories = [] + + for item in dir_path.iterdir(): + if not show_hidden and item.name.startswith('.'): + continue + + try: + stat = item.stat() + size = stat.st_size + mtime = stat.st_mtime + except: + size = 0 + mtime = 0 + + item_info = { + "name": item.name, + "path": str(item), + "size": size, + "modified": mtime + } + + if item.is_file(): + files.append(item_info) + elif item.is_dir(): + directories.append(item_info) + + # Сортируем по имени + files.sort(key=lambda x: x["name"]) + directories.sort(key=lambda x: x["name"]) + + return { + "success": True, + "path": str(dir_path), + "files": files, + "directories": directories, + "total_files": len(files), + "total_dirs": len(directories) + } + + except Exception as e: + logger.error(f"Ошибка списка директории {path}: {e}") + return {"error": str(e), "success": False} + + async def copy_file(self, source: str, destination: str) -> Dict[str, Any]: + """ + Скопировать файл или директорию. + + Args: + source: Исходный путь + destination: Целевой путь + + Returns: + Dict с success, source, destination + """ + try: + src_path = self._resolve_path(source) + dst_path = self._resolve_path(destination) + + # Проверка безопасности + is_safe_src, reason = self._is_path_safe(src_path, allow_write=False) + if not is_safe_src: + return {"error": f"Источник: {reason}", "success": False} + + is_safe_dst, reason = self._is_path_safe(dst_path, allow_write=True) + if not is_safe_dst: + return {"error": f"Назначение: {reason}", "success": False} + + if not src_path.exists(): + return {"error": f"Источник не существует: {src_path}", "success": False} + + # Копируем + if src_path.is_file(): + shutil.copy2(src_path, dst_path) + else: + shutil.copytree(src_path, dst_path, dirs_exist_ok=True) + + logger.info(f"Скопировано: {src_path} -> {dst_path}") + + return { + "success": True, + "source": str(src_path), + "destination": str(dst_path), + "operation": "copy" + } + + except Exception as e: + logger.error(f"Ошибка копирования {source} -> {destination}: {e}") + return {"error": str(e), "success": False} + + async def move_file(self, source: str, destination: str) -> Dict[str, Any]: + """ + Переместить файл или директорию. + + Args: + source: Исходный путь + destination: Целевой путь + + Returns: + Dict с success, source, destination + """ + try: + src_path = self._resolve_path(source) + dst_path = self._resolve_path(destination) + + # Проверка безопасности + is_safe_src, reason = self._is_path_safe(src_path, allow_write=False) + if not is_safe_src: + return {"error": f"Источник: {reason}", "success": False} + + is_safe_dst, reason = self._is_path_safe(dst_path, allow_write=True) + if not is_safe_dst: + return {"error": f"Назначение: {reason}", "success": False} + + if not src_path.exists(): + return {"error": f"Источник не существует: {src_path}", "success": False} + + # Перемещаем + shutil.move(src_path, dst_path) + + logger.info(f"Перемещено: {src_path} -> {dst_path}") + + return { + "success": True, + "source": str(src_path), + "destination": str(dst_path), + "operation": "move" + } + + except Exception as e: + logger.error(f"Ошибка перемещения {source} -> {destination}: {e}") + return {"error": str(e), "success": False} + + async def delete(self, path: str, recursive: bool = False) -> Dict[str, Any]: + """ + Удалить файл или директорию. + + Args: + path: Путь к файлу/директории + recursive: Если True, удалять рекурсивно + + Returns: + Dict с success, path, deleted_count + """ + try: + file_path = self._resolve_path(path) + + # Проверка безопасности + is_safe, reason = self._is_path_safe(file_path, allow_write=True) + if not is_safe: + return {"error": reason, "success": False} + + if not file_path.exists(): + return {"error": f"Путь не существует: {file_path}", "success": False} + + deleted_count = 0 + + if file_path.is_file(): + file_path.unlink() + deleted_count = 1 + elif file_path.is_dir(): + if recursive: + shutil.rmtree(file_path) + # Считаем количество удалённых файлов + deleted_count = -1 # Неизвестно + else: + return { + "error": "Директория не пуста. Используйте recursive=True для рекурсивного удаления", + "success": False + } + + logger.info(f"Удалено: {file_path}") + + return { + "success": True, + "path": str(file_path), + "deleted_count": deleted_count, + "operation": "delete" + } + + except Exception as e: + logger.error(f"Ошибка удаления {path}: {e}") + return {"error": str(e), "success": False} + + async def create_directory(self, path: str, parents: bool = True) -> Dict[str, Any]: + """ + Создать директорию. + + Args: + path: Путь к директории + parents: Если True, создавать родительские директории + + Returns: + Dict с success, path + """ + try: + dir_path = self._resolve_path(path) + + # Проверка безопасности + is_safe, reason = self._is_path_safe(dir_path, allow_write=True) + if not is_safe: + return {"error": reason, "success": False} + + if dir_path.exists(): + if dir_path.is_dir(): + return { + "success": True, + "path": str(dir_path), + "already_exists": True + } + else: + return {"error": f"Существует файл с таким именем: {dir_path}", "success": False} + + # Создаём директорию + dir_path.mkdir(parents=parents, exist_ok=parents) + + logger.info(f"Создана директория: {dir_path}") + + return { + "success": True, + "path": str(dir_path), + "operation": "mkdir" + } + + except Exception as e: + logger.error(f"Ошибка создания директории {path}: {e}") + return {"error": str(e), "success": False} + + async def file_info(self, path: str) -> Dict[str, Any]: + """ + Получить информацию о файле/директории. + + Args: + path: Путь к файлу + + Returns: + Dict с информацией о файле + """ + try: + file_path = self._resolve_path(path) + + is_safe, _ = self._is_path_safe(file_path, allow_write=False) + if not is_safe: + return {"error": "Доступ ограничен", "success": False} + + if not file_path.exists(): + return {"error": f"Путь не существует: {file_path}", "success": False} + + stat = file_path.stat() + + return { + "success": True, + "path": str(file_path), + "name": file_path.name, + "is_file": file_path.is_file(), + "is_dir": file_path.is_dir(), + "size": stat.st_size, + "created": stat.st_ctime, + "modified": stat.st_mtime, + "permissions": oct(stat.st_mode)[-3:] + } + + except Exception as e: + logger.error(f"Ошибка получения информации о {path}: {e}") + return {"error": str(e), "success": False} + + async def search_files( + self, + path: str = ".", + pattern: str = "*", + max_results: int = 50 + ) -> Dict[str, Any]: + """ + Найти файлы по паттерну. + + Args: + path: Директория для поиска + pattern: Паттерн (glob-style) + max_results: Максимум результатов + + Returns: + Dict с найденными файлами + """ + try: + base_path = self._resolve_path(path) + + is_safe, _ = self._is_path_safe(base_path, allow_write=False) + if not is_safe: + return {"error": "Доступ ограничен", "success": False} + + results = [] + + # Используем glob для поиска + import glob + matches = glob.glob(str(base_path / pattern), recursive=True) + + for match in matches[:max_results]: + match_path = Path(match) + try: + stat = match_path.stat() + results.append({ + "path": str(match_path), + "name": match_path.name, + "size": stat.st_size, + "is_file": match_path.is_file(), + "is_dir": match_path.is_dir() + }) + except: + pass + + return { + "success": True, + "pattern": pattern, + "base_path": str(base_path), + "found": len(results), + "results": results, + "truncated": len(matches) > max_results + } + + except Exception as e: + logger.error(f"Ошибка поиска файлов {pattern} в {path}: {e}") + return {"error": str(e), "success": False} + + async def execute_shell(self, command: str, timeout: int = 30) -> Dict[str, Any]: + """ + Выполнить shell-команду (для сложных операций). + + Args: + command: Команда для выполнения + timeout: Таймаут в секундах + + Returns: + Dict с stdout, stderr, returncode + """ + try: + # Разрешаем только безопасные команды + SAFE_COMMANDS = [ + 'ls', 'cat', 'cp', 'mv', 'rm', 'mkdir', 'rmdir', + 'touch', 'chmod', 'chown', 'find', 'grep', 'head', + 'tail', 'wc', 'sort', 'uniq', 'pwd', 'du', 'df' + ] + + # Извлекаем базовую команду + base_cmd = command.split()[0] if command.split() else '' + + if base_cmd not in SAFE_COMMANDS: + return { + "error": f"Команда '{base_cmd}' не разрешена. Используйте безопасные команды: {SAFE_COMMANDS}", + "success": False + } + + # Выполняем команду + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(Path.home()) + ) + + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=timeout + ) + except asyncio.TimeoutError: + process.kill() + return { + "error": f"Таймаут выполнения команды ({timeout} сек)", + "success": False + } + + return { + "success": process.returncode == 0, + "stdout": stdout.decode('utf-8', errors='replace').strip(), + "stderr": stderr.decode('utf-8', errors='replace').strip(), + "returncode": process.returncode, + "command": command + } + + except Exception as e: + logger.error(f"Ошибка выполнения команды {command}: {e}") + return {"error": str(e), "success": False} + + async def execute(self, operation: str, **kwargs) -> ToolResult: + """ + Выполнить операцию с файловой системой. + + Args: + operation: Тип операции (read, write, copy, move, delete, mkdir, list, info, search, shell) + **kwargs: Аргументы операции + + Returns: + ToolResult с результатом + """ + logger.info(f"File System Tool: operation={operation}, args={kwargs}") + + self._last_operation = operation + + try: + result = None + + if operation == 'read': + result = await self.read_file( + path=kwargs.get('path', ''), + limit=kwargs.get('limit', 100) + ) + + elif operation == 'write': + result = await self.write_file( + path=kwargs.get('path', ''), + content=kwargs.get('content', ''), + append=kwargs.get('append', False) + ) + + elif operation == 'copy': + result = await self.copy_file( + source=kwargs.get('source', ''), + destination=kwargs.get('destination', '') + ) + + elif operation == 'move': + result = await self.move_file( + source=kwargs.get('source', ''), + destination=kwargs.get('destination', '') + ) + + elif operation == 'delete': + result = await self.delete( + path=kwargs.get('path', ''), + recursive=kwargs.get('recursive', False) + ) + + elif operation == 'mkdir': + result = await self.create_directory( + path=kwargs.get('path', ''), + parents=kwargs.get('parents', True) + ) + + elif operation == 'list': + result = await self.list_directory( + path=kwargs.get('path', '.'), + show_hidden=kwargs.get('show_hidden', False) + ) + + elif operation == 'info': + result = await self.file_info( + path=kwargs.get('path', '') + ) + + elif operation == 'search': + result = await self.search_files( + path=kwargs.get('path', '.'), + pattern=kwargs.get('pattern', '*'), + max_results=kwargs.get('max_results', 50) + ) + + elif operation == 'shell': + result = await self.execute_shell( + command=kwargs.get('command', ''), + timeout=kwargs.get('timeout', 30) + ) + + else: + return ToolResult( + success=False, + error=f"Неизвестная операция: {operation}. Доступные: read, write, copy, move, delete, mkdir, list, info, search, shell" + ) + + # Сохраняем в историю + self._operation_history.append({ + 'operation': operation, + 'args': kwargs, + 'result': result, + 'timestamp': __import__('datetime').datetime.now().isoformat() + }) + + # Ограничиваем историю + if len(self._operation_history) > 100: + self._operation_history = self._operation_history[-50:] + + return ToolResult( + success=result.get('success', False), + data=result, + metadata={ + 'operation': operation, + 'last_path': result.get('path', result.get('source', '')) + } + ) + + except Exception as e: + logger.exception(f"Ошибка File System Tool: {e}") + return ToolResult( + success=False, + error=str(e), + metadata={'operation': operation} + ) + + def get_schema(self) -> Dict[str, Any]: + """Получить схему инструмента для промпта.""" + return { + "name": self.name, + "description": self.description, + "parameters": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "description": "Тип операции", + "enum": ["read", "write", "copy", "move", "delete", "mkdir", "list", "info", "search", "shell"] + }, + "path": { + "type": "string", + "description": "Путь к файлу/директории" + }, + "source": { + "type": "string", + "description": "Исходный путь (для copy/move)" + }, + "destination": { + "type": "string", + "description": "Целевой путь (для copy/move)" + }, + "content": { + "type": "string", + "description": "Содержимое для записи" + }, + "pattern": { + "type": "string", + "description": "Паттерн для поиска файлов" + }, + "command": { + "type": "string", + "description": "Shell команда (для операции shell)" + }, + "limit": { + "type": "integer", + "description": "Лимит строк для чтения" + }, + "max_results": { + "type": "integer", + "description": "Максимум результатов поиска" + }, + "recursive": { + "type": "boolean", + "description": "Рекурсивное удаление" + }, + "show_hidden": { + "type": "boolean", + "description": "Показывать скрытые файлы" + }, + "timeout": { + "type": "integer", + "description": "Таймаут для shell команд" + } + }, + "required": ["operation"] + } + } + + +# Автоматическая регистрация при импорте +@register_tool +class FileSystemToolAuto(FileSystemTool): + """Авто-регистрируемая версия FileSystemTool.""" + pass diff --git a/bot/tools/gigachat_tool.py b/bot/tools/gigachat_tool.py new file mode 100644 index 0000000..9cec3e2 --- /dev/null +++ b/bot/tools/gigachat_tool.py @@ -0,0 +1,692 @@ +""" +GigaChat API Tool для Telegram CLI Bot + +Инструмент для работы с GigaChat API (Сбер). +Поддерживает генерацию текста, чат-сессии и различные модели. + +Документация: https://developers.sber.ru/docs/ru/gigachat +""" + +import os +import base64 +import httpx +import asyncio +import uuid +import logging +from typing import Optional, List, Dict, Any +from dataclasses import dataclass, field +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + + +@dataclass +class GigaChatMessage: + """Сообщение для чата с GigaChat""" + role: str # 'user', 'assistant', 'system' + content: str + + +@dataclass +class GigaChatConfig: + """Конфигурация подключения к GigaChat API""" + client_id: str + client_secret: str + scope: str = "GIGACHAT_API_PERS" + auth_url: str = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth" + model: str = "GigaChat-Pro" # Модель по умолчанию + model_lite: str = "GigaChat" # Lite модель для простых запросов + model_pro: str = "GigaChat-Pro" # Pro модель для сложных запросов + model_max: str = "GigaChat-Max" # Max модель для самых сложных задач + api_url: str = "https://gigachat.devices.sberbank.ru/api/v1" + timeout: int = 60 + # Пороги для переключения моделей + complexity_token_threshold: int = 50 # Если токенов в запросе > порога → Pro + complexity_keyword_threshold: int = 2 # Если ключевых слов сложности >= порога → Pro + + +class GigaChatTool: + """ + Инструмент для работы с GigaChat API + + Пример использования: + config = GigaChatConfig( + client_id=os.getenv("GIGACHAT_CLIENT_ID"), + client_secret=os.getenv("GIGACHAT_CLIENT_SECRET"), + ) + tool = GigaChatTool(config) + + # Простой запрос + response = await tool.chat("Привет, как дела?") + + # Чат с историей + messages = [ + GigaChatMessage(role="system", content="Ты полезный ассистент."), + GigaChatMessage(role="user", content="Расскажи про Python"), + ] + response = await tool.chat(messages=messages) + """ + + def __init__(self, config: Optional[GigaChatConfig] = None): + self.config = config or self._load_config_from_env() + self._access_token: Optional[str] = None + self._token_expires: Optional[datetime] = None + self._chat_history: List[GigaChatMessage] = [] + + def _load_config_from_env(self) -> GigaChatConfig: + """Загрузка конфигурации из переменных окружения""" + return GigaChatConfig( + client_id=os.getenv("GIGACHAT_CLIENT_ID", ""), + client_secret=os.getenv("GIGACHAT_CLIENT_SECRET", ""), + scope=os.getenv("GIGACHAT_SCOPE", "GIGACHAT_API_PERS"), + auth_url=os.getenv("GIGACHAT_AUTH_URL", "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"), + model=os.getenv("GIGACHAT_MODEL", "GigaChat-Pro"), + model_lite=os.getenv("GIGACHAT_MODEL_LITE", "GigaChat"), + model_pro=os.getenv("GIGACHAT_MODEL_PRO", "GigaChat-Pro"), + model_max=os.getenv("GIGACHAT_MODEL_MAX", "GigaChat-Max"), + complexity_token_threshold=int(os.getenv("GIGACHAT_TOKEN_THRESHOLD", "50")), + complexity_keyword_threshold=int(os.getenv("GIGACHAT_KEYWORD_THRESHOLD", "2")), + ) + + def _get_auth_headers(self) -> Dict[str, str]: + """Получение заголовков для авторизации""" + # GigaChat требует RqUID (UUID) и Content-Type для OAuth + return { + "Content-Type": "application/x-www-form-urlencoded", + "RqUID": str(uuid.uuid4()), + } + + async def _get_access_token(self) -> str: + """Получение access токена для API""" + # Проверяем кэш токена + if self._access_token and self._token_expires: + if datetime.now() < self._token_expires - timedelta(minutes=5): + return self._access_token + + # Запрашиваем новый токен с использованием Basic Auth + credentials = f"{self.config.client_id}:{self.config.client_secret}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + + # GigaChat использует самоподписанные сертификаты - отключаем верификацию + async with httpx.AsyncClient(verify=False) as client: + response = await client.post( + self.config.auth_url, + headers={ + "Authorization": f"Basic {encoded_credentials}", + "Content-Type": "application/x-www-form-urlencoded", + "RqUID": str(uuid.uuid4()), + }, + data={"scope": self.config.scope}, + timeout=30, + ) + + # Логируем для отладки + logger.debug(f"GigaChat auth status: {response.status_code}") + logger.debug(f"GigaChat auth response: {response.text[:200]}") + + response.raise_for_status() + data = response.json() + + self._access_token = data["access_token"] + # Токен действителен 30 минут, кэшируем на 25 минут + self._token_expires = datetime.now() + timedelta(minutes=25) + + # Логируем начало токена для проверки (первые 50 символов) + logger.info(f"GigaChat токен получен: {self._access_token[:50]}...") + + return self._access_token + + def _estimate_query_complexity(self, messages: List[GigaChatMessage]) -> dict: + """ + Оценить сложность запроса для выбора модели (Lite или Pro). + + Критерии сложности: + 1. Длина запроса (количество токенов/слов) + 2. Наличие ключевых слов для сложных задач + 3. Наличие инструментов (tool calls) + 4. Технические термины + + Returns: + Dict с оценкой сложности и рекомендуемой моделью + """ + # Собираем весь текст из сообщений пользователя + user_text = "" + for msg in messages: + if msg.role == "user": + user_text += " " + msg.content + + user_text = user_text.lower() + + # 1. Оценка по длине (считаем слова как грубая оценка токенов) + word_count = len(user_text.split()) + token_estimate = word_count * 1.3 # Примерная конверсия слов в токены + + # 2. Ключевые слова для сложных задач + complex_keywords = [ + # Программирование и код + 'код', 'функция', 'класс', 'метод', 'переменная', 'цикл', 'условие', + 'алгоритм', 'структура данных', 'массив', 'словарь', 'список', + 'импорт', 'экспорт', 'модуль', 'пакет', 'библиотека', 'фреймворк', + 'дебаг', 'отладк', 'тест', 'юнит тест', 'интеграционн', + 'рефактор', 'оптимиз', 'производительност', + # Анализ и работа с данными + 'анализ', 'анализиров', 'сравни', 'сравнени', 'исследовани', + 'закономерност', 'паттерн', 'тенденци', 'прогноз', + # Системные задачи + 'конфигурац', 'настройк', 'деплой', 'развертывани', 'оркестрац', + 'контейнер', 'docker', 'kubernetes', 'k8s', 'helm', + 'мониторинг', 'логировани', 'трассировк', 'метрик', + # Сложные запросы + 'объясни', 'расскажи подробно', 'детальн', 'подробн', + 'почему', 'зачем', 'как работает', 'принцип работы', + 'спроектируй', 'спроектировать', 'архитектур', 'архитектура', + 'реализуй', 'реализовать', 'напиши код', 'создай функцию', + ] + + complexity_keywords_count = sum( + 1 for keyword in complex_keywords + if keyword in user_text + ) + + # 3. Наличие технических терминов + tech_terms = [ + 'api', 'http', 'rest', 'graphql', 'grpc', 'websocket', + 'sql', 'nosql', 'postgres', 'mysql', 'mongodb', 'redis', + 'git', 'merge', 'commit', 'branch', 'pull request', 'merge request', + 'ci/cd', 'pipeline', 'jenkins', 'gitlab', 'github', + 'linux', 'bash', 'shell', 'terminal', 'ssh', + 'python', 'javascript', 'typescript', 'java', 'go', 'rust', 'cpp', + 'react', 'vue', 'angular', 'django', 'flask', 'fastapi', 'express', + ] + + tech_terms_count = sum( + 1 for term in tech_terms + if term in user_text + ) + + # 4. Наличие инструментов в контексте + has_tools = any( + 'tool' in msg.content.lower() or 'инструмент' in msg.content.lower() + for msg in messages + ) + + # Принятие решения + use_pro = False + reasons = [] + + # Если токенов больше порога → Pro + if token_estimate > self.config.complexity_token_threshold: + use_pro = True + reasons.append(f"длинный запрос ({word_count} слов, ~{int(token_estimate)} токенов)") + + # Если много ключевых слов сложности → Pro + if complexity_keywords_count >= self.config.complexity_keyword_threshold: + use_pro = True + reasons.append(f"сложная задача ({complexity_keywords_count} ключевых слов)") + + # Если есть технические термины + инструменты → Pro + if tech_terms_count >= 2 and has_tools: + use_pro = True + reasons.append(f"техническая задача с инструментами ({tech_terms_count} терминов)") + + # Если есть явные запросы на работу с кодом/файлами → Pro + if any(phrase in user_text for phrase in [ + 'исходник', 'source code', 'посмотри код', 'проанализируй код', + 'работай с файлом', 'прочитай файл', 'изучи код' + ]): + use_pro = True + reasons.append("работа с кодом/файлами") + + model = self.config.model_pro if use_pro else self.config.model_lite + + return { + "use_pro": use_pro, + "model": model, + "word_count": word_count, + "token_estimate": int(token_estimate), + "complexity_keywords": complexity_keywords_count, + "tech_terms": tech_terms_count, + "has_tools": has_tools, + "reasons": reasons + } + + async def chat( + self, + messages: Optional[List[GigaChatMessage]] = None, + model: Optional[str] = None, + temperature: float = 0.7, + max_tokens: int = 2000, + top_p: float = 0.1, + repetition_penalty: float = 1.0, + use_history: bool = True, + user_id: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Отправка запроса к GigaChat API + + Args: + messages: Список сообщений (если None, используется история чата) + model: Модель для генерации (если None, используется модель из конфига) + temperature: Температура генерации (0.0 - 2.0) + max_tokens: Максимальное количество токенов в ответе + top_p: Параметр top-p sampling + repetition_penalty: Штраф за повторения + use_history: Использовать ли историю чата + user_id: ID пользователя для заголовка X-User-Id + + Returns: + Dict с ответом API: + - content: Текст ответа + - model: Использованная модель + - usage: Статистика использования токенов + - finish_reason: Причина завершения + """ + token = await self._get_access_token() + + # Формируем сообщения + if messages is None: + if use_history: + messages = self._chat_history.copy() + else: + messages = [] + elif use_history: + # Добавляем новые сообщения к истории + self._chat_history.extend(messages) + messages = self._chat_history.copy() + + # Автоматически выбираем модель на основе сложности запроса + # Если модель явно не указана + selected_model = model + model_info = None + if selected_model is None: + model_info = self._estimate_query_complexity(messages) + selected_model = model_info["model"] + logger.info(f"📊 GigaChat выбор модели: {selected_model} (причины: {', '.join(model_info['reasons']) if model_info['reasons'] else 'простой запрос'})") + + # Преобразуем сообщения в формат API + api_messages = [ + {"role": msg.role, "content": msg.content} + for msg in messages + ] + + payload = { + "model": selected_model, + "messages": api_messages, + "temperature": temperature, + "max_tokens": max_tokens, + "top_p": top_p, + "repetition_penalty": repetition_penalty, + } + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "X-User-Id": str(user_id) if user_id else "telegram-bot", + } + + # Логируем запрос для отладки + logger.debug(f"GigaChat API URL: {self.config.api_url}/chat/completions") + logger.debug(f"GigaChat headers: {headers}") + logger.debug(f"GigaChat payload: model={selected_model}, messages={len(api_messages)}, max_tokens={max_tokens}") + + # GigaChat использует самоподписанные сертификаты - отключаем верификацию + async with httpx.AsyncClient(verify=False) as client: + try: + response = await client.post( + f"{self.config.api_url}/chat/completions", + headers=headers, + json=payload, + timeout=self.config.timeout, + ) + + # Логируем для отладки + logger.debug(f"GigaChat chat status: {response.status_code}") + logger.debug(f"GigaChat response headers: {dict(response.headers)}") + if response.status_code != 200: + logger.error(f"GigaChat error response: {response.text[:1000]}") + + response.raise_for_status() + data = response.json() + except httpx.HTTPStatusError as e: + logger.error(f"GigaChat HTTP error: {e}") + logger.error(f"Response: {e.response.text[:500]}") + return { + "content": "", + "error": f"HTTP {e.response.status_code}: {e.response.text[:200]}", + } + except httpx.HTTPError as e: + logger.error(f"GigaChat request error: {e}") + return { + "content": "", + "error": f"Request error: {str(e)}", + } + + # Добавляем ответ ассистента в историю + if use_history and data.get("choices"): + assistant_message = data["choices"][0]["message"] + self._chat_history.append(GigaChatMessage( + role=assistant_message["role"], + content=assistant_message["content"], + )) + + return { + "content": data["choices"][0]["message"]["content"] if data.get("choices") else "", + "model": data.get("model", selected_model), + "usage": data.get("usage", {}), + "finish_reason": data["choices"][0]["finish_reason"] if data.get("choices") else "", + "complexity_info": model_info, # Информация о выборе модели для отладки + } + + def clear_history(self): + """Очистка истории чата""" + self._chat_history = [] + + def get_history(self) -> List[GigaChatMessage]: + """Получение истории чата""" + return self._chat_history.copy() + + def set_system_prompt(self, prompt: str): + """Установка системного промпта (добавляется в начало истории)""" + # Удаляем старый системный промпт если есть + self._chat_history = [ + msg for msg in self._chat_history if msg.role != "system" + ] + # Добавляем новый в начало + self._chat_history.insert(0, GigaChatMessage(role="system", content=prompt)) + + async def chat_with_functions( + self, + messages: List[Dict[str, Any]], + functions: Optional[List[Dict[str, Any]]] = None, + model: Optional[str] = None, + temperature: float = 0.7, + max_tokens: int = 2000, + top_p: float = 0.1, + repetition_penalty: float = 1.0, + user_id: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Отправка запроса к GigaChat API с поддержкой function calling. + + Args: + messages: Список сообщений в формате API + functions: Массив функций для вызова + model: Модель для генерации + temperature: Температура генерации + max_tokens: Максимум токенов + top_p: Параметр top-p sampling + repetition_penalty: Штраф за повторения + user_id: ID пользователя + + Returns: + Dict с ответом API включая возможный function_call + """ + token = await self._get_access_token() + + # Выбираем модель на основе сложности запроса + selected_model = model + model_info = None + if selected_model is None: + # Преобразуем messages в формат GigaChatMessage для оценки сложности + gc_messages = [GigaChatMessage(role=msg["role"], content=msg.get("content", "")) for msg in messages] + model_info = self._estimate_query_complexity(gc_messages) + selected_model = model_info["model"] + logger.info(f"📊 GigaChat выбор модели: {selected_model} (причины: {', '.join(model_info['reasons']) if model_info['reasons'] else 'простой запрос'})") + + # Формируем payload + payload = { + "model": selected_model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + "top_p": top_p, + "repetition_penalty": repetition_penalty, + } + + # Добавляем functions если есть + if functions: + payload["functions"] = functions + # function_call: "auto" позволяет модели самой решать когда вызывать функции + payload["function_call"] = "auto" + logger.info(f"🔧 GigaChat function calling: {len(functions)} функций доступно") + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "X-User-Id": str(user_id) if user_id else "telegram-bot", + } + + logger.info(f"📤 GigaChat API: model={selected_model}, messages={len(messages)}, functions={len(functions) if functions else 0}") + + # GigaChat использует самоподписанные сертификаты - отключаем верификацию + async with httpx.AsyncClient(verify=False) as client: + try: + response = await client.post( + f"{self.config.api_url}/chat/completions", + headers=headers, + json=payload, + timeout=self.config.timeout, + ) + + logger.debug(f"GigaChat chat_with_functions status: {response.status_code}") + logger.debug(f"GigaChat response: {response.text[:500]}") + + if response.status_code != 200: + logger.error(f"GigaChat error response: {response.text[:1000]}") + + response.raise_for_status() + data = response.json() + except httpx.HTTPStatusError as e: + logger.error(f"GigaChat HTTP error: {e}") + logger.error(f"Response: {e.response.text[:500]}") + return { + "content": "", + "error": f"HTTP {e.response.status_code}: {e.response.text[:200]}", + "choices": [], + } + except httpx.HTTPError as e: + logger.error(f"GigaChat request error: {e}") + return { + "content": "", + "error": f"Request error: {str(e)}", + "choices": [], + } + + # Извлекаем content и function_call + content = "" + function_call = None + functions_state_id = None + + if data.get("choices"): + choice = data["choices"][0] + message = choice.get("message", {}) + content = message.get("content", "") + function_call = message.get("function_call") + functions_state_id = message.get("functions_state_id") + + logger.info(f"📬 GigaChat ответ: content_len={len(content)}, function_call={function_call is not None}, functions_state_id={functions_state_id}") + + return { + "content": content, + "function_call": function_call, + "functions_state_id": functions_state_id, + "model": data.get("model", selected_model), + "usage": data.get("usage", {}), + "finish_reason": data["choices"][0]["finish_reason"] if data.get("choices") else "", + "choices": data.get("choices", []), + } + + async def generate_image( + self, + prompt: str, + model: str = " Kandinsky-2", + size: str = "1024x1024", + ) -> Dict[str, Any]: + """ + Генерация изображений через GigaChat (Kandinsky) + + Args: + prompt: Текстовое описание изображения + model: Модель для генерации + size: Размер изображения + + Returns: + Dict с результатом генерации + """ + token = await self._get_access_token() + + payload = { + "model": model, + "prompt": prompt, + "size": size, + } + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + # GigaChat использует самоподписанные сертификаты - отключаем верификацию + async with httpx.AsyncClient(verify=False) as client: + # Запуск генерации + response = await client.post( + f"{self.config.api_url}/images/generations", + headers=headers, + json=payload, + timeout=self.config.timeout, + ) + response.raise_for_status() + data = response.json() + + return data + + async def get_models(self) -> List[str]: + """Получение списка доступных моделей""" + token = await self._get_access_token() + + headers = { + "Authorization": f"Bearer {token}", + } + + # GigaChat использует самоподписанные сертификаты - отключаем верификацию + async with httpx.AsyncClient(verify=False) as client: + response = await client.get( + f"{self.config.api_url}/models", + headers=headers, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + return [model["id"] for model in data.get("data", [])] + + +# Утилита для создания инструмента в формате бота +def create_gigachat_tool(): + """ + Создает экземпляр GigaChatTool с конфигурацией из окружения + + Returns: + GigaChatTool или None если конфигурация не задана + """ + if not os.getenv("GIGACHAT_CLIENT_ID") or not os.getenv("GIGACHAT_CLIENT_SECRET"): + return None + + return GigaChatTool() + + +if __name__ == "__main__": + # Пример использования + async def main(): + tool = create_gigachat_tool() + if not tool: + print("GigaChat не настроен. Проверьте переменные окружения.") + return + + # Простой запрос + response = await tool.chat("Привет! Расскажи кратко про себя.") + print(f"Ответ: {response['content']}") + print(f"Модель: {response['model']}") + print(f"Токены: {response['usage']}") + + asyncio.run(main()) + +# =========================================== +# Интеграция с реестром инструментов бота +# =========================================== + +from bot.tools import BaseTool, ToolResult, register_tool + + +@register_tool +class GigaChatCapability(BaseTool): + """ + Capability-обёртка для GigaChat API. + + Позволяет использовать GigaChat через реестр инструментов бота. + """ + + name = "gigachat" + description = "Генерация ответов AI через GigaChat API (Сбер). Альтернатива Qwen Code." + category = "ai" + + def __init__(self): + self._provider = None + + def _get_provider(self): + """Ленивая инициализация провайдера""" + if self._provider is None: + from qwen_integration import GigaChatProvider + self._provider = GigaChatProvider() + return self._provider + + async def execute( + self, + prompt: str, + system_prompt: Optional[str] = None, + temperature: float = 0.7, + max_tokens: int = 2000, + **kwargs + ) -> ToolResult: + """ + Выполнить запрос к GigaChat API. + + Args: + prompt: Запрос пользователя + system_prompt: Системный промпт (роль ассистента) + temperature: Температура генерации (0.0-2.0) + max_tokens: Максимум токенов в ответе + """ + provider = self._get_provider() + + if not provider.is_available(): + return ToolResult( + success=False, + error=provider.get_error() or "GigaChat не доступен", + ) + + result = await provider.chat( + prompt=prompt, + system_prompt=system_prompt, + temperature=temperature, + max_tokens=max_tokens, + ) + + if result.get("success"): + return ToolResult( + success=True, + data={ + "content": result.get("content", ""), + "model": result.get("model", "GigaChat-Pro"), + "usage": result.get("usage", {}), + }, + metadata={ + "model": result.get("model"), + "tokens": result.get("usage"), + } + ) + else: + return ToolResult( + success=False, + error=result.get("error", "Неизвестная ошибка GigaChat"), + ) diff --git a/bot/tools/rss_tool.py b/bot/tools/rss_tool.py new file mode 100644 index 0000000..0c65ee7 --- /dev/null +++ b/bot/tools/rss_tool.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +""" +RSS Reader Tool - инструмент для чтения RSS/Atom лент. + +Бот может использовать этот инструмент автономно для получения новостей +из подписанных лент пользователя. +""" + +import sys +import json +import logging +import sqlite3 +import subprocess +import os +import re +from pathlib import Path +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional +from email.utils import parsedate_to_datetime + +from bot.tools import BaseTool, ToolResult, register_tool + +logger = logging.getLogger(__name__) + + +class RSSTool(BaseTool): + """Инструмент для работы с RSS лентами.""" + + name = "rss_tool" + description = "Чтение RSS/Atom новостных лент. Управление подписками, получение новостей, дайджесты." + category = "news" + + def __init__(self, db_path: str = None): + self.db_path = Path(db_path) if db_path else Path(__file__).parent.parent.parent / "rss.db" + self.lock_file = Path("/tmp/rss_fetch.lock") + self.fetch_interval_minutes = 5 + + def _init_db(self): + """Инициализировать БД.""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute(''' + CREATE TABLE IF NOT EXISTS feeds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL UNIQUE, + title TEXT, + last_fetched DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + ''') + c.execute(''' + CREATE TABLE IF NOT EXISTS news ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feed_id INTEGER NOT NULL, + guid TEXT NOT NULL, + pub_date DATETIME, + title TEXT, + description TEXT, + content TEXT, + link TEXT, + digest_flag INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (feed_id) REFERENCES feeds(id), + UNIQUE(feed_id, guid) + ) + ''') + c.execute('CREATE INDEX IF NOT EXISTS idx_news_feed ON news(feed_id)') + c.execute('CREATE INDEX IF NOT EXISTS idx_news_date ON news(pub_date)') + conn.commit() + conn.close() + + def _parse_feed(self, xml_content: str) -> List[Dict]: + """Парсить RSS/Atom XML.""" + items = [] + + # Remove CDATA markers + xml = re.sub(r'', '', xml) + + # Find all items + xml_items = re.findall(r']*>(.*?)', xml, re.DOTALL) + + for item in xml_items: + # Title + title_match = re.search(r'(.*?)', item, re.DOTALL) + title = title_match.group(1).strip()[:500] if title_match else "" + + # GUID + guid_match = re.search(r']*>(.*?)', item, re.DOTALL) + guid = guid_match.group(1).strip() if guid_match else "" + + # Link + link_match = re.search(r'(.*?)', item, re.DOTALL) + link = link_match.group(1).strip() if link_match else "" + + # PubDate + pub_match = re.search(r'(.*?)', item, re.DOTALL) + pub = pub_match.group(1).strip() if pub_match else "" + + if not guid and link: + guid = link + + if title and guid: + items.append({'title': title, 'link': link, 'guid': guid, 'pub': pub}) + + return items + + def _insert_news(self, feed_id: int, title: str, link: str, guid: str, pub: str): + """Вставить новость в БД.""" + pdate = None + if pub: + try: + dt = parsedate_to_datetime(pub) + pdate = dt.strftime('%Y-%m-%d %H:%M:%S') + except: + pass + + if not pdate: + pdate = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + if not link: + link = guid + + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute(''' + INSERT OR IGNORE INTO news (feed_id, guid, pub_date, title, link) + VALUES (?, ?, ?, ?, ?) + ''', (feed_id, guid, pdate, title, link)) + conn.commit() + conn.close() + + async def fetch(self) -> ToolResult: + """Получить свежие новости из всех лент.""" + self._init_db() + + # Lock + if self.lock_file.exists(): + logger.warning("Другой fetch уже выполняется") + return ToolResult( + success=False, + error="Другой процесс fetch уже выполняется", + metadata={'status': 'locked'} + ) + + with open(self.lock_file, 'w') as f: + f.write(str(os.getpid())) + + try: + total = 0 + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("SELECT id, url FROM feeds") + feeds = c.fetchall() + conn.close() + + for feed_id, url in feeds: + # Check last fetch + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("SELECT last_fetched FROM feeds WHERE id = ?", (feed_id,)) + row = c.fetchone() + conn.close() + + if row and row[0]: + last = datetime.strptime(row[0], '%Y-%m-%d %H:%M:%S') + mins = (datetime.now() - last).total_seconds() / 60 + if mins < self.fetch_interval_minutes: + logger.info(f"Пропуск {url} ({int(mins)} мин назад)") + continue + + logger.info(f"Получение: {url}") + + result = subprocess.run( + ['curl', '-sL', '-m', '30', '-A', 'Mozilla/5.0', url], + capture_output=True + ) + + if result.returncode == 0 and result.stdout: + # Декодируем с обработкой ошибок кодировки + try: + content = result.stdout.decode('utf-8', errors='ignore') + except Exception: + content = result.stdout.decode('latin-1', errors='ignore') + + count = 0 + for item in self._parse_feed(content): + self._insert_news(feed_id, item['title'], item['link'], item['guid'], item['pub']) + count += 1 + + if count > 0: + logger.info(f"Добавлено {count} элементов") + total += count + + # Update last_fetched + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("UPDATE feeds SET last_fetched = datetime('now') WHERE id = ?", (feed_id,)) + conn.commit() + conn.close() + + return ToolResult( + success=True, + data={'total_new_items': total}, + metadata={'status': 'completed', 'action': 'fetch'} + ) + finally: + self.lock_file.unlink(missing_ok=True) + + async def list_news(self, limit: int = 20, feed_id: Optional[int] = None, + search: Optional[str] = None, undigested_only: bool = False) -> ToolResult: + """Получить список новостей.""" + self._init_db() + + conditions = ["1=1"] + params = [] + + if feed_id: + conditions.append(f"feed_id = ?") + params.append(feed_id) + + if search: + conditions.append(f"title LIKE ?") + params.append(f"%{search}%") + + if undigested_only: + conditions.append("digest_flag = 0") + + query = f""" + SELECT id, feed_id, title, pub_date, link, digest_flag + FROM news WHERE {' AND '.join(conditions)} + ORDER BY created_at DESC, id DESC LIMIT ? + """ + params.append(limit) + + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute(query, params) + rows = c.fetchall() + conn.close() + + news_list = [] + for row in rows: + news_list.append({ + 'id': row[0], + 'feed_id': row[1], + 'title': row[2], + 'pub_date': row[3], + 'link': row[4], + 'digest_flag': bool(row[5]) + }) + + return ToolResult( + success=True, + data=news_list, + metadata={'count': len(news_list), 'limit': limit, 'action': 'list'} + ) + + async def add_feed(self, url: str, title: Optional[str] = None) -> ToolResult: + """Добавить RSS ленту.""" + self._init_db() + + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + try: + c.execute("INSERT INTO feeds (url, title) VALUES (?, ?)", (url, title or url)) + conn.commit() + return ToolResult( + success=True, + data={'url': url, 'title': title}, + metadata={'status': 'added', 'action': 'add_feed'} + ) + except sqlite3.IntegrityError: + return ToolResult( + success=False, + error=f"Лента уже существует: {url}" + ) + finally: + conn.close() + + async def list_feeds(self) -> ToolResult: + """Получить список всех лент.""" + self._init_db() + + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("SELECT id, url, title, last_fetched, created_at FROM feeds ORDER BY id") + rows = c.fetchall() + conn.close() + + feeds = [] + for row in rows: + feeds.append({ + 'id': row[0], + 'url': row[1], + 'title': row[2], + 'last_fetched': row[3], + 'created_at': row[4] + }) + + return ToolResult( + success=True, + data=feeds, + metadata={'count': len(feeds), 'action': 'list_feeds'} + ) + + async def mark_digest(self, news_id: int) -> ToolResult: + """Отметить новость как прочитанную (в дайджесте).""" + self._init_db() + + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("UPDATE news SET digest_flag=1 WHERE id=?", (news_id,)) + if c.rowcount == 0: + conn.close() + return ToolResult( + success=False, + error=f"Новость не найдена: {news_id}" + ) + conn.commit() + conn.close() + + return ToolResult( + success=True, + data={'id': news_id}, + metadata={'status': 'marked'} + ) + + async def execute(self, action: str = "list", **kwargs) -> ToolResult: + """ + Выполнить действие с RSS. + + Args: + action: Действие - fetch, list, add_feed, list_feeds, mark_digest + kwargs: Дополнительные аргументы для действия + """ + actions = { + 'fetch': self.fetch, + 'list': lambda: self.list_news( + limit=kwargs.get('limit', 20), + feed_id=kwargs.get('feed_id'), + search=kwargs.get('search'), + undigested_only=kwargs.get('undigested_only', False) + ), + 'add_feed': lambda: self.add_feed( + url=kwargs.get('url'), + title=kwargs.get('title') + ), + 'list_feeds': self.list_feeds, + 'mark_digest': lambda: self.mark_digest(news_id=kwargs.get('news_id')) + } + + if action not in actions: + return ToolResult( + success=False, + error=f"Неизвестное действие: {action}. Доступные: {list(actions.keys())}" + ) + + logger.info(f"RSS действие: {action} с аргументами: {kwargs}") + return await actions[action]() + + +# Автоматическая регистрация при импорте +@register_tool +class RSSToolAuto(RSSTool): + """Авто-регистрируемая версия RSSTool.""" + pass diff --git a/bot/tools/ssh_tool.py b/bot/tools/ssh_tool.py new file mode 100644 index 0000000..a312297 --- /dev/null +++ b/bot/tools/ssh_tool.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +""" +SSH Executor Tool - инструмент для выполнения команд на серверах по SSH. + +Бот может использовать этот инструмент автономно для выполнения системных задач +на серверах пользователя. + +Конфигурация серверов загружается из .env: +SERVERS=name|host|port|user|tag|password|... +""" + +import logging +import asyncio +import os +from pathlib import Path +from typing import Optional, Dict, Any, List +from dataclasses import dataclass + +import asyncssh +from dotenv import load_dotenv + +from bot.tools import BaseTool, ToolResult, register_tool + +logger = logging.getLogger(__name__) + +# Загрузка переменных окружения +load_dotenv() + + +@dataclass +class ServerConfig: + """Конфигурация сервера для SSH.""" + host: str + port: int + username: str + password: Optional[str] = None + client_keys: Optional[List[str]] = None + tags: List[str] = None + + +class SSHExecutorTool(BaseTool): + """Инструмент для выполнения SSH-команд.""" + + name = "ssh_tool" + description = "Выполнение команд на удалённых серверах по SSH. Используется для системных задач: мониторинг, управление сервисами, просмотр логов." + category = "system" + + def __init__(self): + # Загружаем серверы из .env + self.servers: Dict[str, ServerConfig] = {} + self._last_connection: Optional[asyncssh.SSHClientConnection] = None + self._last_server: Optional[str] = None + + self._load_servers_from_env() + + def _load_servers_from_env(self): + """ + Загрузить конфигурацию серверов из .env. + + Формат в .env: + SERVERS=name|host|port|user|tag|password + + Пример: + SERVERS=tomas|192.168.1.51|22|mirivlad|web|moloko22 + """ + servers_str = os.getenv('SERVERS', '') + + if not servers_str.strip(): + logger.warning("SERVERS не найден в .env, SSH инструмент не будет работать") + return + + # Парсим формат: name|host|port|user|tag|password + parts = servers_str.strip().split('|') + + if len(parts) >= 6: + name, host, port, user, tag, password = parts[:6] + + self.servers[name.strip()] = ServerConfig( + host=host.strip(), + port=int(port.strip()), + username=user.strip(), + tags=[tag.strip()] if tag.strip() else [], + password=password.strip() if password.strip() else None + ) + logger.info(f"✅ Загружен сервер: {name} ({host}:{port})") + else: + logger.error(f"Неверный формат SERVERS в .env: {servers_str}") + logger.error("Ожидался формат: name|host|port|user|tag|password") + + async def _connect(self, server_name: str = 'home') -> asyncssh.SSHClientConnection: + """Подключиться к серверу.""" + logger.debug(f"🔍 [SSH._connect] Запрос подключения: server_name='{server_name}'") + logger.debug(f"🔍 [SSH._connect] Доступные серверы: {list(self.servers.keys())}") + + if server_name not in self.servers: + logger.error(f"❌ [SSH._connect] Сервер '{server_name}' не найден!") + raise ValueError(f"Сервер '{server_name}' не найден. Доступные: {list(self.servers.keys())}") + + config = self.servers[server_name] + logger.debug(f"🔍 [SSH._connect] Конфигурация сервера {server_name}:") + logger.debug(f" host={config.host}, port={config.port}, username={config.username}") + logger.debug(f" password={'***' if config.password else 'None'}, client_keys={config.client_keys}") + + # Проверяем существующее подключение + logger.debug(f"🔍 [SSH._connect] Проверка существующего подключения:") + logger.debug(f" _last_connection={self._last_connection}") + logger.debug(f" _last_server={self._last_server}") + + if self._last_connection and self._last_server == server_name: + logger.debug(f"🔍 [SSH._connect] Найдено существующее подключение, проверка статуса...") + try: + # Проверяем transport для проверки активности подключения + if self._last_connection.transport is None or not self._last_connection.transport.is_active(): + logger.debug(f"⚠️ [SSH._connect] Подключение не активно, будет создано новое") + self._last_connection = None + else: + logger.debug(f"✅ [SSH._connect] Используем существующее активное подключение") + return self._last_connection + except Exception as e: + logger.debug(f"⚠️ [SSH._connect] Ошибка проверки подключения: {e}, создаём новое") + self._last_connection = None + else: + logger.debug(f"ℹ️ [SSH._connect] Существующего подключения нет, создаём новое") + + logger.info(f"Подключение к серверу {server_name} ({config.host})") + + try: + # Пробуем подключение с паролем + connect_kwargs = { + 'host': config.host, + 'port': config.port, + 'username': config.username, + 'known_hosts': None, # Отключаем проверку known_hosts для простоты + } + + if config.password: + connect_kwargs['password'] = config.password + logger.debug(f"🔍 [SSH._connect] Используем парольную аутентификацию") + + if config.client_keys: + connect_kwargs['client_keys'] = config.client_keys + logger.debug(f"🔍 [SSH._connect] Используем ключевую аутентификацию: {config.client_keys}") + + logger.debug(f"🔍 [SSH._connect] Вызов asyncssh.connect с параметрами: {connect_kwargs.keys()}") + + self._last_connection = await asyncssh.connect(**connect_kwargs) + self._last_server = server_name + logger.info(f"✅ Подключено к {server_name}") + logger.debug(f"🔍 [SSH._connect] Подключение успешно: {self._last_connection}") + + return self._last_connection + + except Exception as e: + logger.error(f"❌ [SSH._connect] Ошибка подключения к {server_name}: {e}") + logger.exception(f"🔍 [SSH._connect] Exception details:") + raise + + async def execute_command( + self, + command: str, + server: str = 'home', + timeout: int = 30 + ) -> Dict[str, Any]: + """ + Выполнить команду на сервере. + + Args: + command: Команда для выполнения + server: Имя сервера из конфигурации + timeout: Таймаут выполнения в секундах + + Returns: + Dict с полями: stdout, stderr, returncode, exit_status + """ + logger.debug(f"🔍 [SSH.execute_command] START: server={server}, command={command[:50]}...") + + try: + logger.debug(f"🔍 [SSH.execute_command] Вызов _connect(server='{server}')") + conn = await self._connect(server) + logger.debug(f"✅ [SSH.execute_command] Подключение успешно: {conn}") + + logger.info(f"Выполнение команды на {server}: {command}") + logger.debug(f"🔍 [SSH.execute_command] Создание процесса с командой: {command}") + + # Используем create_process для корректной работы с shell-командами + process = await conn.create_process( + command, + term_type='xterm-256color', + env={'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'} + ) + logger.debug(f"🔍 [SSH.execute_command] Процесс создан: {process}") + + # Читаем вывод с таймаутом + output = "" + error_output = "" + + try: + logger.debug(f"🔍 [SSH.execute_command] Чтение stdout (timeout={timeout})") + # Читаем stdout + stdout_data = await asyncio.wait_for( + process.stdout.read(), + timeout=timeout + ) + output = stdout_data.strip() if stdout_data else '' + logger.debug(f"🔍 [SSH.execute_command] stdout получен: {len(output)} bytes") + + # Читаем stderr + try: + logger.debug(f"🔍 [SSH.execute_command] Чтение stderr (timeout={timeout//2})") + stderr_data = await asyncio.wait_for( + process.stderr.read(), + timeout=timeout // 2 + ) + error_output = stderr_data.strip() if stderr_data else '' + logger.debug(f"🔍 [SSH.execute_command] stderr получен: {len(error_output)} bytes") + except asyncio.TimeoutError: + logger.debug(f"⚠️ [SSH.execute_command] Таймаут чтения stderr") + pass + + except asyncio.TimeoutError: + logger.error(f"🔍 [SSH.execute_command] Таймаут выполнения команды: {command}") + return { + 'stdout': '', + 'stderr': f'Таймаут выполнения команды ({timeout} сек)', + 'returncode': -1, + 'exit_status': 'timeout', + 'server': server, + 'command': command + } + + logger.debug(f"🔍 [SSH.execute_command] Ожидание завершения процесса (returncode)") + # Ждём завершения процесса и получаем код возврата + returncode = await process.wait() + logger.debug(f"✅ [SSH.execute_command] Процесс завершён, returncode={returncode}") + + return { + 'stdout': output, + 'stderr': error_output, + 'returncode': returncode, + 'exit_status': returncode, + 'server': server, + 'command': command + } + + except asyncio.TimeoutError: + logger.error(f"🔍 [SSH.execute_command] asyncio.TimeoutError: {command}") + logger.exception(f"🔍 [SSH.execute_command] Timeout details:") + return { + 'stdout': '', + 'stderr': f'Таймаут выполнения команды ({timeout} сек)', + 'returncode': -1, + 'exit_status': 'timeout', + 'server': server, + 'command': command + } + + except Exception as e: + logger.error(f"❌ [SSH.execute_command] Ошибка выполнения команды: {e}") + logger.exception(f"🔍 [SSH.execute_command] Exception details:") + return { + 'stdout': '', + 'stderr': str(e), + 'returncode': -1, + 'exit_status': 'error', + 'server': server, + 'command': command + } + + async def execute(self, command: str, server: str = None, timeout: int = 30) -> ToolResult: + """ + Выполнить SSH-команду. + + Args: + command: Команда для выполнения + server: Имя сервера (default: первый из .env) + timeout: Таймаут в секундах (default: 30) + """ + logger.debug(f"🔍 [SSH.execute] ВЫЗОВ: command={command[:50]}..., server={server}, timeout={timeout}") + + if not command or not command.strip(): + logger.debug(f"⚠️ [SSH.execute] Пустая команда!") + return ToolResult( + success=False, + error="Пустая команда" + ) + + # Если сервер не указан - используем первый из конфигурации + if server is None: + if not self.servers: + logger.debug(f"⚠️ [SSH.execute] Серверы не настроены!") + return ToolResult( + success=False, + error="Серверы не настроены. Проверьте SERVERS в .env" + ) + server = list(self.servers.keys())[0] + logger.info(f"Сервер не указан, используем первый: {server}") + logger.debug(f"🔍 [SSH.execute] Выбран сервер по умолчанию: {server}") + + logger.info(f"SSH Executor: server={server}, command={command[:100]}") + logger.debug(f"🔍 [SSH.execute] Вызов execute_command(server={server}, command={command[:50]}...)") + + try: + result = await self.execute_command(command, server, timeout) + logger.debug(f"🔍 [SSH.execute] Результат execute_command: returncode={result['returncode']}") + + # Формируем красивый вывод + output = self._format_output(result) + logger.debug(f"🔍 [SSH.execute] Вывод сформирован: {len(output)} chars") + + return ToolResult( + success=result['returncode'] == 0, + data=result, + metadata={ + 'server': server, + 'command': command, + 'returncode': result['returncode'] + } + ) + + except Exception as e: + logger.exception(f"❌ [SSH.execute] Ошибка SSH Executor: {e}") + return ToolResult( + success=False, + error=str(e), + metadata={'server': server, 'command': command} + ) + + def _format_output(self, result: Dict[str, Any]) -> str: + """Форматировать вывод команды.""" + output = [] + + if result['stdout']: + output.append(f"**Вывод:**\n```\n{result['stdout']}\n```") + + if result['stderr']: + output.append(f"**Ошибки:**\n```\n{result['stderr']}\n```") + + if result['returncode'] != 0: + output.append(f"**Код возврата:** {result['returncode']}") + + return "\n".join(output) if output else "Команда выполнена без вывода" + + def add_server(self, name: str, host: str, port: int, username: str, + password: Optional[str] = None, client_keys: Optional[List[str]] = None): + """Добавить сервер в конфигурацию.""" + self.servers[name] = ServerConfig( + host=host, + port=port, + username=username, + password=password, + client_keys=client_keys + ) + logger.info(f"Добавлен сервер: {name} ({host})") + + def list_servers(self) -> List[str]: + """Получить список доступных серверов.""" + return list(self.servers.keys()) + + +# Автоматическая регистрация при импорте +@register_tool +class SSHExecutorToolAuto(SSHExecutorTool): + """Авто-регистрируемая версия SSHExecutorTool.""" + pass diff --git a/bot/tools/telegram_web_tool.py b/bot/tools/telegram_web_tool.py new file mode 100644 index 0000000..04d65d2 --- /dev/null +++ b/bot/tools/telegram_web_tool.py @@ -0,0 +1,232 @@ +""" +Telegram Web Tool - чтение публичных Telegram-каналов через web (t.me) +""" + +import aiohttp +from bs4 import BeautifulSoup +from typing import List, Dict, Optional +from datetime import datetime +import logging +import json +import os +from bot.tools import BaseTool, ToolResult, register_tool + +logger = logging.getLogger(__name__) + +# Путь к файлу хранения подписок +SUBSCRIPTIONS_FILE = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + 'telegram_channels.json' +) + + +def load_subscriptions() -> List[str]: + """Загрузить список подписок из файла""" + if os.path.exists(SUBSCRIPTIONS_FILE): + try: + with open(SUBSCRIPTIONS_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get('channels', []) + except Exception as e: + logger.error(f"Ошибка загрузки подписок: {e}") + return [] + + +def save_subscriptions(channels: List[str]) -> bool: + """Сохранить список подписок в файл""" + try: + with open(SUBSCRIPTIONS_FILE, 'w', encoding='utf-8') as f: + json.dump({'channels': channels}, f, ensure_ascii=False, indent=2) + return True + except Exception as e: + logger.error(f"Ошибка сохранения подписок: {e}") + return False + + +async def fetch_channel_messages(username: str, limit: int = 10) -> List[Dict]: + """ + Получить сообщения из публичного Telegram-канала через t.me + + Args: + username: Имя канала (без @) + limit: Количество сообщений для получения + + Returns: + Список сообщений + """ + url = f"https://t.me/s/{username}" + messages = [] + + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=10) as response: + if response.status != 200: + logger.error(f"Ошибка доступа к каналу {username}: {response.status}") + return messages + + html = await response.text() + soup = BeautifulSoup(html, 'html.parser') + + # Найти все сообщения + telegram_messages = soup.find_all('div', class_='tgme_widget_message') + + for msg in telegram_messages[-limit:]: + try: + # Текст сообщения + text_elem = msg.find('div', class_='tgme_widget_message_text') + text = text_elem.get_text(strip=True) if text_elem else "" + + # Дата + date_elem = msg.find('a', class_='tgme_widget_message_date') + date_text = date_elem.get_text(strip=True) if date_elem else "" + + # Ссылка на сообщение + link = date_elem.get('href') if date_elem else "" + + # Автор (если есть) + author_elem = msg.find('div', class_='tgme_widget_message_author') + author = author_elem.get_text(strip=True) if author_elem else username + + messages.append({ + 'text': text, + 'date': date_text, + 'link': link, + 'author': author + }) + except Exception as e: + logger.debug(f"Ошибка парсинга сообщения: {e}") + continue + + except Exception as e: + logger.error(f"Ошибка получения канала {username}: {e}") + + return messages + + +async def telegram_web_tool(action: str, **kwargs) -> Dict: + """ + Основной инструмент для работы с Telegram-каналами + + Actions: + - add: добавить канал (username) + - list: показать список каналов + - read: прочитать сообщения (username, limit) + + Args: + action: Действие + **kwargs: Параметры для действия + + Returns: + Dict с результатом + """ + if action == 'add': + username = kwargs.get('username') + if not username: + return { + 'success': False, + 'error': 'username обязателен', + 'action': 'add' + } + + # Очистить username от @ и пробелов + username = username.strip().lstrip('@') + + channels = load_subscriptions() + if username in channels: + return { + 'success': False, + 'error': f'Канал @{username} уже в списке', + 'action': 'add' + } + + channels.append(username) + save_subscriptions(channels) + + return { + 'success': True, + 'message': f'Канал @{username} добавлен', + 'action': 'add', + 'username': username + } + + elif action == 'list': + channels = load_subscriptions() + return { + 'success': True, + 'channels': channels, + 'count': len(channels), + 'action': 'list' + } + + elif action == 'read': + username = kwargs.get('username') + limit = kwargs.get('limit', 10) + + if not username: + return { + 'success': False, + 'error': 'username обязателен', + 'action': 'read' + } + + username = username.strip().lstrip('@') + messages = await fetch_channel_messages(username, limit) + + return { + 'success': True, + 'channel': username, + 'messages': messages, + 'count': len(messages), + 'action': 'read' + } + + elif action == 'fetch_all': + """Получить новые сообщения из всех каналов""" + channels = load_subscriptions() + limit = kwargs.get('limit', 5) + + all_messages = {} + for channel in channels: + messages = await fetch_channel_messages(channel, limit) + if messages: + all_messages[channel] = messages + + return { + 'success': True, + 'channels_checked': len(channels), + 'messages': all_messages, + 'action': 'fetch_all' + } + + else: + return { + 'success': False, + 'error': f'Неизвестное действие: {action}', + 'action': action + } + + +@register_tool +class TelegramWebTool(BaseTool): + """Инструмент для работы с Telegram-каналами через web""" + + name = "telegram_web_tool" + description = "Чтение публичных Telegram-каналов через t.me/s/username. Actions: add (username), list, read (username, limit), fetch_all (limit)" + category = "telegram" + + async def execute(self, action: str = 'list', **kwargs) -> ToolResult: + """Выполнить инструмент""" + result = await telegram_web_tool(action, **kwargs) + + if result['success']: + return ToolResult( + success=True, + data=result, + metadata={'action': action, 'tool_name': self.name} + ) + else: + return ToolResult( + success=False, + error=result.get('error', 'Неизвестная ошибка'), + metadata={'action': action, 'tool_name': self.name} + ) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 0000000..74c8407 --- /dev/null +++ b/bot/utils/__init__.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Утилиты бота.""" + +from bot.utils.cleaners import clean_ansi_codes, normalize_output +from bot.utils.formatters import escape_html, escape_markdown, split_message, send_long_message, format_long_output, MAX_MESSAGE_LENGTH +from bot.utils.decorators import check_access +from bot.utils.ssh_readers import detect_input_type, read_ssh_output, read_pty_output + +__all__ = [ + "clean_ansi_codes", + "normalize_output", + "escape_html", + "escape_markdown", + "split_message", + "send_long_message", + "format_long_output", + "MAX_MESSAGE_LENGTH", + "check_access", + "detect_input_type", + "read_ssh_output", + "read_pty_output", +] diff --git a/bot/utils/cleaners.py b/bot/utils/cleaners.py new file mode 100644 index 0000000..056ac68 --- /dev/null +++ b/bot/utils/cleaners.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Утилиты для очистки текста (ANSI-коды, нормализация).""" + +import re + + +def clean_ansi_codes(text: str) -> str: + """ + Очистка ANSI-кодов и мусора из вывода терминала. + Обрабатывает: + - ANSI escape-последовательности \x1b[...m + - «Битые» ANSI-коды без escape-символа (например [33m, [0m) + - Символы замены Unicode () + - Кириллические имитации ANSI-кодов (например [0м) + """ + # Удаляем ANSI escape-последовательности + text = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]', '', text) + + # Удаляем «битые» ANSI-коды: [33m, [0m, [1m и т.д. (латиница и кириллица) + text = re.sub(r'\[\d+[мm]', '', text) + + # Удаляем символы замены Unicode + text = text.replace('\ufffd', '') + + return text + + +def normalize_output(text: str) -> str: + """ + Нормализовать вывод: обработать \r и убрать пустые строки. + \r используется для перезаписи строки (прогресс-баров). + """ + # Заменяем \r\n на \n + text = text.replace('\r\n', '\n') + + # Обрабатываем \r (возврат каретки) — строки с \r перезаписывают друг друга + lines = [] + for line in text.split('\n'): + if '\r' in line: + # Разбиваем по \r и берём последнюю часть (финальное состояние) + parts = line.split('\r') + line = parts[-1] + lines.append(line) + + text = '\n'.join(lines) + + # Разбиваем на строки, убираем пустые и trailing пробелы + lines = text.split('\n') + lines = [line.rstrip() for line in lines if line.strip()] + + # Очищаем прогресс-бары вида "Текст… 0%Текст… 50%Текст… 100%" + # И дублирующийся текст + cleaned_lines = [] + for line in lines: + # Ищем повторяющийся паттерн "текст… цифры%" + progress_pattern = re.compile(r'((?:.+?\.{3})\d+%)+') + match = progress_pattern.search(line) + if match: + # Берём последнее вхождение + items = re.findall(r'(.+?\.{3})(\d+)%', match.group(0)) + if items: + last_text, last_percent = items[-1] + line = line[:match.start()] + f'{last_text}{last_percent}%' + line[match.end():] + + # СНАЧАЛА удаляем остатки ANSI-кодов из строки + # line = re.sub(r'.', '', line) # ← ЭТО УДАЛЯЛО ВСЁ! Закомментировал + line = clean_ansi_codes(line) # ← Используем правильную функцию + + # Удаляем дублирующийся текст вида "0% [текст] 0% [текст]" + dup_pattern = re.compile(r'(\d+%\s*\[.+?\])(?:\s*\d+%\s*\[.+?\])+') + match = dup_pattern.search(line) + if match: + # Оставляем только первое вхождение + line = line[:match.start()] + match.group(1) + line[match.end():] + + # Удаляем ведущие пробелы (артефакты терминала) + line = line.lstrip() + + if line: + cleaned_lines.append(line) + + return '\n'.join(cleaned_lines) diff --git a/bot/utils/decorators.py b/bot/utils/decorators.py new file mode 100644 index 0000000..609c212 --- /dev/null +++ b/bot/utils/decorators.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Декораторы для бота.""" + +import logging +from functools import wraps +from telegram import Update +from telegram.ext import ContextTypes + +# Импортируем config для проверки доступа +from bot.config import config + +logger = logging.getLogger(__name__) + + +def check_access(func): + """Декоратор для проверки прав доступа пользователя.""" + @wraps(func) + async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs): + user_id = update.effective_user.id + + # Если доступ не ограничен — пропускаем всех + if not config.is_access_restricted: + return await func(update, context, *args, **kwargs) + + if user_id not in config.allowed_users: + logger.warning(f"Попытка доступа от запрещённого пользователя {user_id}") + await update.message.reply_text( + "❌ **Доступ запрещён**\n\n" + "Ваш ID не добавлен в список разрешённых пользователей.\n" + f"Ваш ID: `{user_id}`", + parse_mode="Markdown" + ) + return + + return await func(update, context, *args, **kwargs) + return wrapper diff --git a/bot/utils/formatters.py b/bot/utils/formatters.py new file mode 100644 index 0000000..6f168be --- /dev/null +++ b/bot/utils/formatters.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +"""Утилиты для форматирования и отправки сообщений.""" + +import asyncio +import logging +import re +from typing import List, Tuple +from telegram import Update + +logger = logging.getLogger(__name__) + +# Лимиты Telegram +MAX_MESSAGE_LENGTH = 4096 # Максимальная длина сообщения +RESERVED_FOR_HEADER = 20 # Резервируем место для "(N/N) " + + +def escape_code_block_content(text: str) -> str: + """ + Экранировать спецсимволы Markdown внутри блоков кода. + Нужно для случаев когда сообщение содержит ```блок кода``` и текст с Markdown. + Telegram пытается интерпретировать [text](url) внутри блока кода как ссылку. + """ + # Находим все блоки кода + parts = text.split("```") + result_parts = [] + + for i, part in enumerate(parts): + if i % 2 == 1: + # Внутри блока кода — экранируем [ и ] + part = part.replace('[', '\\[').replace(']', '\\]') + result_parts.append(part) + + return "```".join(result_parts) + + +def escape_markdown(text: str) -> str: + """ + Экранирование специальных символов Markdown для Telegram API. + + Telegram Markdown v1 использует: * _ ` [ ] ( ) + Эти символы нужно экранировать обратным слэшем. + + ВАЖНО: Не экранирует содержимое блоков кода (```). + """ + if not text: + return text + + # Разбиваем текст на части: внутри и снаружи блоков кода + parts = [] + last_end = 0 + in_code = False + + # Находим все блоки кода + code_pattern = re.compile(r'`[^`]+`|```[\s\S]*?```') + + for match in code_pattern.finditer(text): + # Экранируем текст перед блоком кода + text_before = text[last_end:match.start()] + parts.append(_escape_markdown_chars(text_before)) + # Добавляем блок кода без экранирования + parts.append(match.group()) + last_end = match.end() + + # Экранируем оставшийся текст после последнего блока кода + remaining = text[last_end:] + parts.append(_escape_markdown_chars(remaining)) + + return ''.join(parts) + + +def escape_html(text: str) -> str: + """ + Экранирование специальных символов для Telegram Markdown. + Алиас на escape_markdown для обратной совместимости. + """ + return escape_markdown(text) + + +def _escape_markdown_chars(text: str) -> str: + """Экранировать специальные символы Markdown (вспомогательная функция).""" + # Порядок важен: сначала экранируем обратные слэши + text = text.replace('\\', '\\\\') + text = text.replace('`', '\\`') + text = text.replace('*', '\\*') + text = text.replace('_', '\\_') + text = text.replace('[', '\\[') + text = text.replace(']', '\\]') + text = text.replace('(', '\\(') + text = text.replace(')', '\\)') + return text + + +def find_code_blocks(text: str) -> List[Tuple[int, int]]: + """ + Найти все блоки кода (```) в тексте. + Возвращает список кортежей (start, end) для каждого блока. + """ + blocks = [] + pattern = re.compile(r'```') + matches = list(pattern.finditer(text)) + + # Пары start-end для каждого блока + i = 0 + while i < len(matches) - 1: + start = matches[i].start() + end = matches[i + 1].end() + blocks.append((start, end)) + i += 2 + + return blocks + + +def split_message(text: str, max_length: int = MAX_MESSAGE_LENGTH) -> List[Tuple[str, bool, bool, bool]]: + """ + Умно разбить длинный текст на сообщения <= max_length символов. + + Возвращает список кортежей (text, has_code, code_opened, code_closed): + - text: текст сообщения + - has_code: True если сообщение содержит часть блока кода + - code_opened: True если блок кода ОТКРЫТ в этом сообщении (есть открывающий ```) + - code_closed: True если блок кода ЗАКРЫТ в этом сообщении (есть закрывающий ```) + + Алгоритм: + 1. Разбиваем текст на строки + 2. Накапливаем строки до достижения лимита + 3. Отслеживаем состояние блока кода (внутри/снаружи) + """ + def calc_code_flags(txt: str) -> Tuple[bool, bool, bool]: + """Вычислить флаги code для данного текста.""" + has_code = '```' in txt + backtick_count = txt.count('```') + code_opened = backtick_count >= 1 + code_closed = backtick_count >= 2 or (backtick_count == 1 and txt.rstrip().endswith('```')) + return has_code, code_opened, code_closed + + if len(text) <= max_length: + has_code, code_opened, code_closed = calc_code_flags(text) + return [(text, has_code, code_opened, code_closed)] + + parts = [] + lines = text.split('\n') + current = "" + in_code_block = False # Состояние: внутри блока кода или нет + + for line in lines: + # Проверяем, содержит ли строка ``` + backticks_in_line = line.count('```') + + # Если строка содержит нечётное количество ```, она меняет состояние + if backticks_in_line % 2 == 1: + # Эта строка содержит ``` который меняет состояние + if in_code_block: + # Были внутри блока — эта строка закрывает его + test_line = current + ('\n' if current else '') + line + if len(test_line) > max_length - RESERVED_FOR_HEADER: + # Сначала отправляем текущее (блок не закрыт в этой части!) + if current: + has_code, code_opened, _ = calc_code_flags(current) + # code_closed=False потому что блок продолжится + parts.append((current, has_code, code_opened, False)) + current = line + else: + current = test_line + in_code_block = False + else: + # Были снаружи — эта строка открывает блок + test_line = current + ('\n' if current else '') + line + if len(test_line) > max_length - RESERVED_FOR_HEADER: + if current: + has_code, code_opened, code_closed = calc_code_flags(current) + parts.append((current, has_code, code_opened, code_closed)) + current = line + else: + current = test_line + in_code_block = True + else: + # Строка не меняет состояние + test_line = current + ('\n' if current else '') + line + if len(test_line) > max_length - RESERVED_FOR_HEADER: + if current: + has_code, code_opened, _ = calc_code_flags(current) + # Если мы внутри блока кода — block не закрыт + code_closed = not in_code_block + parts.append((current, has_code, code_opened, code_closed)) + current = line + else: + current = test_line + + if current: + has_code, code_opened, _ = calc_code_flags(current) + code_closed = not in_code_block + parts.append((current, has_code, code_opened, code_closed)) + + return parts + + +async def send_long_message(update: Update, text: str, parse_mode: str = None, pause_every: int = 3, start_from: int = 0): + """ + Отправить длинный текст, разбив на несколько сообщений. + + Поддерживает: + - Update с update.message (обычные сообщения) + - CallbackQuery (query.edit_message_text / query.message.reply_text) + + Args: + start_from: Номер сообщения с которого начать (для продолжения после кнопки) + """ + from telegram import InlineKeyboardButton, InlineKeyboardMarkup + from bot.config import state_manager + + # Определяем тип объекта и получаем метод для отправки + # CallbackQuery имеет from_user и answer(), но не имеет message.reply_text + is_callback_query = hasattr(update, 'answer') and hasattr(update, 'from_user') + + if is_callback_query: + query = update + message = query.message + send_method = message.reply_text if message else query.edit_message_text + user_id = query.from_user.id # Для CallbackQuery используем from_user + else: + message = update.message + send_method = message.reply_text if message else None + user_id = update.effective_user.id # Для Update используем effective_user + + if not send_method: + logger.error("send_long_message: не удалось определить метод отправки") + return False + + parts = split_message(text) + total = len(parts) + state = state_manager.get(user_id) + + # Восстанавливаем состояние блока кода при продолжении + prev_code_closed = state.output_prev_code_closed if start_from > 0 else True + + # Начинаем с указанного сообщения + for i in range(start_from, total): + part, has_code, code_opened, code_closed = parts[i] + + # Проверяем был ли блок кода открыт в предыдущем сообщении + # При продолжении используем сохранённое состояние + if i == start_from and start_from > 0: + prev_closed = prev_code_closed + else: + prev_closed = parts[i-1][3] if i > 0 else True + + # Определяем находимся ли внутри блока кода (между ``` и ```) + in_code_block = not prev_closed or (code_opened and not code_closed) + + # Проверяем будем ли добавлять ``` к этому сообщению + will_add_opening = total > 1 and i > 0 and not prev_closed and not code_closed + will_add_closing = total > 1 and i < total - 1 and not code_closed + + # КЛЮЧЕВОЕ ИСПРАВЛЕНИЕ: + # Отключаем parse_mode если: + # 1. Это промежуточная часть блока кода (in_code_block=True, но нет ``` в этом сообщении) + # 2. Это сообщение содержит ``` (has_code=True) + # 3. Мы добавляем искусственные ``` (will_add_opening или will_add_closing) + if in_code_block or has_code or will_add_opening or will_add_closing: + actual_parse_mode = None + else: + # Обычный текст без блоков кода — используем HTML + actual_parse_mode = parse_mode + + # Логика работы с блоками кода между сообщениями + if total > 1 and i > 0 and not prev_closed: + part = "```\n" + part + + if total > 1 and i < total - 1 and not code_closed: + part = part + "\n```" + + # Добавляем номер части + if total > 1: + header = f"({i+1}/{total}) " + if len(header) + len(part) <= MAX_MESSAGE_LENGTH: + part = header + part + + try: + await send_method(part, parse_mode=actual_parse_mode) + except Exception as e: + logger.debug(f"Ошибка Markdown, отправляем без разметки: {e}") + await send_method(part) + + await asyncio.sleep(0.1) + + # КАЖДЫЕ pause_every сообщений — спрашивать продолжать ли + if pause_every > 0 and (i + 1) % pause_every == 0 and i < total - 1: + remaining = total - (i + 1) + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("▶️ Продолжить", callback_data=f"continue_output_{remaining}_{i+1}"), + InlineKeyboardButton("❌ Отменить", callback_data="cancel_output") + ] + ]) + + wait_msg = await send_method( + f"📊 **Отправлено {i + 1} из {total} сообщений**\n\n" + f"Осталось ещё {remaining} сообщений.\n\n" + f"Продолжить вывод?", + parse_mode="Markdown", + reply_markup=keyboard + ) + + # Сохраняем состояние и ВОЗВРАЩАЕМ УПРАВЛЕНИЕ + state.waiting_for_output_control = True + state.output_remaining = remaining + state.output_wait_message = wait_msg + state.output_next_index = i + 1 # С какого сообщения продолжить + state.output_text = text # Сохраняем текст для продолжения + state.output_parse_mode = parse_mode + state.output_prev_code_closed = code_closed # Сохраняем состояние блока кода + + logger.info(f"send_long_message: пауза после {i+1}/{total}, ждём кнопки (user_id={user_id})") + return True # Возвращаем True — есть продолжение + + # Все сообщения отправлены + state.waiting_for_output_control = False + state.output_remaining = None + state.output_wait_message = None + state.output_next_index = None + state.output_text = None + return False # Возвращаем False — продолжения нет + + +def format_long_output(text: str, max_lines: int = 100, head_lines: int = 50, tail_lines: int = 50) -> str: + """ + Форматировать длинный вывод: показать первые и последние строки. + По умолчанию: первые 50 + последние 50 строк = 100 строк максимум. + """ + lines = text.split('\n') + total_lines = len(lines) + + if total_lines <= max_lines: + return text + + # Показываем первые head_lines и последние tail_lines + head = lines[:head_lines] + tail = lines[-tail_lines:] + + skipped = total_lines - head_lines - tail_lines + + result = '\n'.join(head) + result += f'\n\n... ({skipped} строк пропущено) ...\n' + result += '\n'.join(tail) + + return result diff --git a/bot/utils/qwen_oauth.py b/bot/utils/qwen_oauth.py new file mode 100644 index 0000000..239e7f9 --- /dev/null +++ b/bot/utils/qwen_oauth.py @@ -0,0 +1,593 @@ +#!/usr/bin/env python3 +""" +Qwen OAuth 2.0 Device Flow клиент. +Реализует авторизацию через Device Authorization Grant (RFC 8628). +""" + +import asyncio +import os +import json +import hashlib +import secrets +import time +import aiohttp +import logging +from pathlib import Path +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Optional, Dict, Any + +logger = logging.getLogger(__name__) + +# Qwen OAuth константы +QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai' +QWEN_OAUTH_DEVICE_CODE_ENDPOINT = f'{QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code' +QWEN_OAUTH_TOKEN_ENDPOINT = f'{QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token' +QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56' +QWEN_OAUTH_SCOPE = 'openid profile email model.completion' + +# Пути для хранения токенов (как в qwen-code CLI) +QWEN_CONFIG_DIR = Path.home() / '.qwen' +QWEN_CREDENTIALS_FILE = QWEN_CONFIG_DIR / 'oauth_creds.json' + + +@dataclass +class QwenCredentials: + """OAuth токены Qwen.""" + access_token: str = '' + refresh_token: str = '' + token_type: str = 'Bearer' + expiry_date: int = 0 # Unix timestamp в миллисекундах + resource_url: str = 'portal.qwen.ai' + + def is_expired(self, buffer_minutes: int = 5) -> bool: + """Проверка истечения токена с буфером.""" + if not self.expiry_date: + return True + expiry_ms = self.expiry_date - (buffer_minutes * 60 * 1000) + return int(datetime.now().timestamp() * 1000) >= expiry_ms + + +@dataclass +class DeviceAuthorizationResponse: + """Ответ устройства авторизации.""" + device_code: str = '' + user_code: str = '' + verification_uri: str = '' + verification_uri_complete: str = '' + expires_in: int = 0 + interval: int = 5 # Polling interval в секундах + + @property + def authorization_url(self) -> str: + """Полная ссылка для авторизации.""" + return self.verification_uri_complete + + @property + def is_expired(self) -> bool: + """Истёк ли срок действия device code.""" + return time.time() > (self.expires_in - 30) # 30 сек буфер + + +class QwenOAuthClient: + """Qwen OAuth 2.0 клиент.""" + + # Как в qwen-code: 30 секунд буфер + 5 секунд интервал проверки + TOKEN_REFRESH_BUFFER_MS = 30 * 1000 + CACHE_CHECK_INTERVAL_MS = 5 * 1000 + + def __init__(self, credentials_path: Optional[Path] = None): + self.credentials_path = credentials_path or QWEN_CREDENTIALS_FILE + self._credentials: Optional[QwenCredentials] = None + self._file_mod_time: float = 0 # Время последней модификации файла + self._last_check_time: float = 0 # Время последней проверки + self._load_credentials() + + def _get_file_mod_time(self) -> float: + """Получить время модификации файла токенов.""" + try: + if self.credentials_path.exists(): + return self.credentials_path.stat().st_mtime + except Exception: + pass + return 0 + + def _load_credentials(self, force: bool = False) -> None: + """ + Загрузить токены из файла. + + Args: + force: Принудительная перезагрузка даже если файл не изменился + """ + current_mod_time = self._get_file_mod_time() + now = time.time() + + # Если force=True — пропускаем все проверки и загружаем всегда + if not force: + # Проверяем не слишком ли часто проверяем (как в qwen-code) + if (now - self._last_check_time) < (self.CACHE_CHECK_INTERVAL_MS / 1000): + return + + # Проверяем изменился ли файл (как в qwen-code) + if current_mod_time <= self._file_mod_time: + # Файл не изменился — используем кэш + return + + self._last_check_time = now + + logger.debug(f"Загрузка токенов из {self.credentials_path} (mod_time={current_mod_time}, force={force})") + if self.credentials_path.exists(): + try: + with open(self.credentials_path, 'r') as f: + data = json.load(f) + self._credentials = QwenCredentials(**data) + self._file_mod_time = current_mod_time + logger.info(f"Токены загружены из {self.credentials_path}") + logger.debug(f"Access token: {self._credentials.access_token[:20] if self._credentials.access_token else 'None'}..., expiry: {self._credentials.expiry_date}") + # НЕ вызываем has_valid_token() здесь — это вызывает рекурсию! + except Exception as e: + logger.error(f"Ошибка загрузки токенов: {e}") + self._credentials = None + self._file_mod_time = 0 + else: + logger.debug("Файл с токенами не найден") + self._credentials = None + self._file_mod_time = 0 + + # Загружаем code_verifier из device_code.json если есть + device_code_file = QWEN_CONFIG_DIR / 'device_code.json' + if device_code_file.exists(): + try: + with open(device_code_file, 'r') as f: + data = json.load(f) + code_verifier = data.get('code_verifier', '') + if code_verifier: + self._code_verifier = code_verifier + logger.info(f"Code verifier загружен из {device_code_file}") + except Exception as e: + logger.debug(f"Ошибка загрузки code_verifier: {e}") + + def _save_credentials(self) -> None: + """Сохранить токены в файл.""" + if self._credentials: + self.credentials_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.credentials_path, 'w') as f: + json.dump(self._credentials.__dict__, f, indent=2) + # Устанавливаем права 600 (только владелец) + os.chmod(self.credentials_path, 0o600) + logger.info(f"Токены сохранены в {self.credentials_path}") + + def has_valid_token(self) -> bool: + """Проверка наличия валидного токена.""" + # Принудительно перезагружаем токены из файла + # Это нужно потому что токены могут быть обновлены другим процессом (qwen-code CLI) + self._load_credentials(force=True) + return self._credentials is not None and not self._credentials.is_expired() + + async def get_access_token(self) -> Optional[str]: + """Получить access token (обновляет если истёк).""" + if self.has_valid_token(): + return self._credentials.access_token + + if self._credentials and self._credentials.refresh_token: + # Пробуем обновить токен + if await self._refresh_token(): + return self._credentials.access_token + + return None + + async def request_device_authorization(self) -> DeviceAuthorizationResponse: + """ + Запросить Device Authorization. + + Returns: + DeviceAuthorizationResponse с данными для авторизации + """ + # Проверяем есть ли сохранённый code_verifier + if not hasattr(self, '_code_verifier') or not self._code_verifier: + # Генерируем PKCE code verifier и challenge + self._code_verifier = secrets.token_urlsafe(32) + + code_challenge = hashlib.sha256(self._code_verifier.encode()).hexdigest() + + payload = { + 'client_id': QWEN_OAUTH_CLIENT_ID, + 'scope': QWEN_OAUTH_SCOPE, + 'code_challenge': code_challenge, + 'code_challenge_method': 'S256' + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'x-request-id': secrets.token_hex(16), + 'User-Agent': 'qwen-code-cli/0.11.0' + } + + form_data = '&'.join(f'{k}={v}' for k, v in payload.items()) + + async with aiohttp.ClientSession() as session: + async with session.post( + QWEN_OAUTH_DEVICE_CODE_ENDPOINT, + data=form_data, + headers=headers + ) as resp: + if resp.status != 200: + text = await resp.text() + raise Exception(f"Device authorization failed: {resp.status} - {text}") + + data = await resp.json() + + return DeviceAuthorizationResponse( + device_code=data.get('device_code', ''), + user_code=data.get('user_code', ''), + verification_uri=data.get('verification_uri', ''), + verification_uri_complete=data.get('verification_uri_complete', ''), + expires_in=data.get('expires_in', 900), + interval=data.get('interval', 5) + ) + + async def poll_for_token(self, device_code: str, timeout_seconds: int = 900) -> bool: + """ + Опрос сервера для получения токена после авторизации пользователем. + + Args: + device_code: Device code из request_device_authorization + timeout_seconds: Максимальное время ожидания + + Returns: + True если авторизация успешна + """ + if not hasattr(self, '_code_verifier'): + raise Exception("Code verifier not set. Call request_device_authorization first.") + + start_time = time.time() + interval = 5 # Начальный интервал + + while time.time() - start_time < timeout_seconds: + payload = { + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': device_code, + 'code_verifier': self._code_verifier, + 'client_id': QWEN_OAUTH_CLIENT_ID + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'User-Agent': 'qwen-code-cli/0.11.0' + } + + form_data = '&'.join(f'{k}={v}' for k, v in payload.items()) + + logger.debug(f"Polling payload: grant_type={payload['grant_type']}, device_code={device_code[:20]}..., code_verifier={self._code_verifier[:20]}...") + + async with aiohttp.ClientSession() as session: + async with session.post( + QWEN_OAUTH_TOKEN_ENDPOINT, + data=form_data, + headers=headers + ) as resp: + data = await resp.json() + logger.info(f"Polling response: status={resp.status}, data={data}") + + if resp.status == 200: + # Успех! Сохраняем токены + self._credentials = QwenCredentials( + access_token=data.get('access_token', ''), + refresh_token=data.get('refresh_token', ''), + token_type=data.get('token_type', 'Bearer'), + expiry_date=int(datetime.now().timestamp() * 1000) + (data.get('expires_in', 3600) * 1000), + resource_url=data.get('resource_url', 'portal.qwen.ai') + ) + self._save_credentials() + logger.info("Авторизация успешна!") + return True + + error = data.get('error', '') + + if error == 'authorization_pending': + # Пользователь ещё не авторизовался + logger.debug("Ожидание авторизации пользователя...") + await asyncio.sleep(interval) + continue + + elif error == 'slow_down': + # Сервер просит увеличить интервал + interval += 5 + logger.debug(f"Увеличиваем интервал до {interval} сек") + await asyncio.sleep(interval) + continue + + elif error == 'expired_token': + logger.error("Device code истёк") + return False + + elif error == 'access_denied': + logger.error("Пользователь отклонил авторизацию") + return False + + else: + logger.error(f"Неизвестная ошибка: {error}") + return False + + logger.error("Таймаут авторизации") + return False + + async def _refresh_token(self) -> bool: + """Обновить access token используя refresh token.""" + if not self._credentials or not self._credentials.refresh_token: + return False + + payload = { + 'grant_type': 'refresh_token', + 'refresh_token': self._credentials.refresh_token, + 'client_id': QWEN_OAUTH_CLIENT_ID + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'User-Agent': 'qwen-code-cli/0.11.0' + } + + form_data = '&'.join(f'{k}={v}' for k, v in payload.items()) + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + QWEN_OAUTH_TOKEN_ENDPOINT, + data=form_data, + headers=headers + ) as resp: + if resp.status == 200: + data = await resp.json() + self._credentials.access_token = data.get('access_token', '') + self._credentials.refresh_token = data.get('refresh_token', self._credentials.refresh_token) + self._credentials.expiry_date = int(datetime.now().timestamp() * 1000) + (data.get('expires_in', 3600) * 1000) + self._save_credentials() + logger.info("Токен обновлён") + return True + else: + logger.error(f"Ошибка обновления токена: {resp.status}") + self._credentials = None # Очищаем неверные токены + return False + except Exception as e: + logger.error(f"Ошибка обновления токена: {e}") + return False + + def clear_credentials(self) -> None: + """Очистить сохранённые токены.""" + self._credentials = None + if self.credentials_path.exists(): + self.credentials_path.unlink() + logger.info("Токены очищены") + + +# Глобальный клиент (singleton) +_oauth_client: Optional[QwenOAuthClient] = None + + +def get_oauth_client() -> QwenOAuthClient: + """Получить OAuth клиент (singleton).""" + global _oauth_client + if _oauth_client is None: + _oauth_client = QwenOAuthClient() + return _oauth_client + + +async def get_authorization_url() -> Optional[str]: + """ + Получить URL для авторизации. + + Returns: + URL для авторизации или None если ошибка + """ + try: + # Проверяем есть ли активный device code + device_code_file = QWEN_CONFIG_DIR / 'device_code.json' + if device_code_file.exists(): + with open(device_code_file, 'r') as f: + data = json.load(f) + start_time = data.get('start_time', 0) + expires_in = data.get('expires_in', 900) + code_verifier = data.get('code_verifier', '') + auth_url = data.get('authorization_url', '') + device_code = data.get('device_code', '') + + # Если device code ещё активен — используем его + if time.time() - start_time < expires_in - 60 and code_verifier and auth_url and device_code: + logger.info("Используем существующий device code") + return auth_url + + # Генерируем PKCE пару как в qwen-code CLI + import base64 + import hashlib + + code_verifier = base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8').rstrip('=') + code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode('utf-8').rstrip('=') + + # Запрос device authorization + payload = { + 'client_id': QWEN_OAUTH_CLIENT_ID, + 'scope': QWEN_OAUTH_SCOPE, + 'code_challenge': code_challenge, + 'code_challenge_method': 'S256' + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'x-request-id': secrets.token_hex(16), + 'User-Agent': 'qwen-code-cli/0.11.0' + } + + form_data = '&'.join(f'{k}={v}' for k, v in payload.items()) + + async with aiohttp.ClientSession() as session: + async with session.post( + QWEN_OAUTH_DEVICE_CODE_ENDPOINT, + data=form_data, + headers=headers + ) as resp: + if resp.status != 200: + text = await resp.text() + raise Exception(f"Device authorization failed: {resp.status} - {text}") + + data = await resp.json() + + auth_url = data.get('verification_uri_complete', '') + device_code = data.get('device_code', '') + expires_in = data.get('expires_in', 900) + + # Сохраняем device code и code verifier для polling + device_code_file.parent.mkdir(parents=True, exist_ok=True) + with open(device_code_file, 'w') as f: + json.dump({ + 'device_code': device_code, + 'code_verifier': code_verifier, # Сохраняем тот же code_verifier! + 'expires_in': expires_in, + 'start_time': time.time(), + 'authorization_url': auth_url + }, f) + + logger.info(f"Device code получен: {device_code[:20]}..., code_verifier сохранён") + return auth_url + + except Exception as e: + logger.error(f"Ошибка получения URL авторизации: {e}") + return None + + +async def check_authorization_complete() -> bool: + """ + Проверить завершение авторизации (polling). + + Returns: + True если авторизация завершена успешно + """ + try: + # Читаем device code + device_code_file = QWEN_CONFIG_DIR / 'device_code.json' + if not device_code_file.exists(): + logger.debug("device_code.json не найден") + return False + + with open(device_code_file, 'r') as f: + data = json.load(f) + + device_code = data.get('device_code', '') + code_verifier = data.get('code_verifier', '') # Получаем code_verifier из файла + start_time = data.get('start_time', time.time()) + expires_in = data.get('expires_in', 900) + + logger.info(f"Device code: {device_code[:20]}..., code_verifier: {code_verifier[:20]}...") + + # Проверяем не истёк ли timeout + if time.time() - start_time > expires_in: + logger.warning("Device code истёк") + device_code_file.unlink() + return False + + # Polling с code_verifier из файла + logger.info("Запуск polling для получения токена...") + + payload = { + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': device_code, + 'code_verifier': code_verifier, + 'client_id': QWEN_OAUTH_CLIENT_ID + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'User-Agent': 'qwen-code-cli/0.11.0' + } + + form_data = '&'.join(f'{k}={v}' for k, v in payload.items()) + + async with aiohttp.ClientSession() as session: + async with session.post( + QWEN_OAUTH_TOKEN_ENDPOINT, + data=form_data, + headers=headers + ) as resp: + data = await resp.json() + logger.info(f"Polling response: status={resp.status}, data={data}") + + if resp.status == 200: + # Успех! Сохраняем токены + credentials = { + 'access_token': data.get('access_token', ''), + 'refresh_token': data.get('refresh_token', ''), + 'token_type': data.get('token_type', 'Bearer'), + 'expiry_date': int(time.time() * 1000) + (data.get('expires_in', 3600) * 1000), + 'resource_url': data.get('resource_url', 'portal.qwen.ai') + } + + # Сохраняем токены в файл + QWEN_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(QWEN_CREDENTIALS_FILE, 'w') as f: + json.dump(credentials, f, indent=2) + os.chmod(QWEN_CREDENTIALS_FILE, 0o600) + + device_code_file.unlink() + logger.info("Авторизация успешна! Токены сохранены.") + return True + + error = data.get('error', '') + + if error == 'authorization_pending': + logger.debug("Ожидание авторизации пользователя...") + return False + + elif error == 'slow_down': + logger.debug("Сервер просит увеличить интервал") + await asyncio.sleep(5) + return False + + elif error == 'expired_token': + logger.error("Device code истёк") + device_code_file.unlink() + return False + + elif error == 'access_denied': + logger.error("Пользователь отклонил авторизацию") + device_code_file.unlink() + return False + + elif error == 'invalid_request': + error_desc = data.get('error_description', '') + logger.error(f"Invalid request: {error_desc}") + # Проверяем не истёк ли code_verifier + if 'code_verifier' in error_desc.lower(): + logger.error("Code verifier не совпадает — удаляем device_code.json") + device_code_file.unlink() + return False + + else: + logger.error(f"Неизвестная ошибка: {error}") + return False + + except Exception as e: + logger.error(f"Ошибка проверки авторизации: {e}", exc_info=True) + return False + + +async def is_authorized() -> bool: + """Проверить авторизован ли пользователь.""" + client = get_oauth_client() + return client.has_valid_token() + + +async def get_access_token() -> Optional[str]: + """Получить access token.""" + client = get_oauth_client() + return await client.get_access_token() + + +def clear_authorization() -> None: + """Очистить авторизацию.""" + client = get_oauth_client() + client.clear_credentials() diff --git a/bot/utils/ssh_readers.py b/bot/utils/ssh_readers.py new file mode 100644 index 0000000..e3a3f57 --- /dev/null +++ b/bot/utils/ssh_readers.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +"""Утилиты для чтения вывода SSH и PTY.""" + +import asyncio +import fcntl +import logging +import os +import re +import select +from typing import Optional, Tuple + +import asyncssh + +logger = logging.getLogger(__name__) + +# Импортируем паттерны из session +from bot.models.session import INPUT_PATTERNS + + +def detect_input_type(text: str) -> Optional[str]: + """Определить тип запроса ввода по тексту.""" + text = text.strip() + + # Проверка на пароль + for pattern in INPUT_PATTERNS["password"]: + if re.search(pattern, text, re.MULTILINE): + return "password" + + # Проверка на подтверждение + for pattern in INPUT_PATTERNS["confirm"]: + if re.search(pattern, text, re.MULTILINE): + return "confirm" + + # Проверка на приглашение оболочки + for pattern in INPUT_PATTERNS["shell_prompt"]: + if re.search(pattern, text, re.MULTILINE): + return "prompt" + + return None + + +async def read_ssh_output(process: asyncssh.SSHClientProcess, timeout: float = 2.0, wait_for_completion: bool = False) -> Tuple[str, bool]: + """ + Чтение вывода из SSH-процесса с таймаутом. + + Args: + process: SSH процесс для чтения + timeout: Таймаут для чтения данных (сек) + wait_for_completion: Если True, дождаться завершения процесса через process.wait() + + Returns: + (вывод, завершён_ли_процесс) + """ + output = "" + error_output = "" + is_done = False + + try: + # Используем read() для чтения доступных данных + while True: + try: + # read() читает данные до EOF + data = await asyncio.wait_for(process.stdout.read(), timeout=timeout) + if data: + if isinstance(data, bytes): + output += data.decode('utf-8', errors='replace') + else: + output += str(data) + logger.debug(f"Прочитано stdout: {len(data)} байт, всего: {len(output)}") + else: + # EOF + logger.debug("SSH stdout EOF") + is_done = True + break + except asyncio.TimeoutError: + # Данные закончились по таймауту + logger.debug(f"Timeout stdout ({timeout} сек), прочитано: {len(output)} байт") + break + except UnicodeDecodeError as e: + logger.debug(f"Ошибка декодирования UTF-8: {e}") + continue + except Exception as e: + # Конец потока + logger.debug(f"Конец потока stdout: {type(e).__name__}: {e}") + is_done = True + break + except Exception as e: + logger.debug(f"Ошибка чтения SSH stdout: {type(e).__name__}: {e}") + is_done = True + + # Читаем stderr если есть + try: + while True: + try: + data = await asyncio.wait_for(process.stderr.read(), timeout=0.5) + if data: + if isinstance(data, bytes): + error_output += data.decode('utf-8', errors='replace') + else: + error_output += str(data) + else: + break + except (asyncio.TimeoutError, Exception): + break + except Exception as e: + logger.debug(f"Ошибка чтения SSH stderr: {type(e).__name__}: {e}") + + # Объединяем stdout и stderr + if error_output: + output = output + error_output if output else error_output + + logger.info(f"read_ssh_output: output={len(output)} байт, is_done={is_done}, returncode={process.returncode}") + return output, is_done + + +async def wait_and_read_ssh(process: asyncssh.SSHClientProcess, timeout: float = 30.0) -> Tuple[str, str, int]: + """ + Чтение вывода SSH-процесса с ожиданием полного завершения. + Аналог asyncio.subprocess.communicate() для asyncssh. + + Эта функция решает проблему с returncode, который становится доступен + только после завершения процесса. Читает stdout и stderr параллельно + с выполнением команды. + + Args: + process: SSH процесс + timeout: Максимальное время ожидания выполнения (сек) + + Returns: + (stdout, stderr, returncode) + """ + stdout_data = "" + stderr_data = "" + + async def read_stream(stream, is_stdout=True): + """Читает поток до EOF.""" + data = "" + try: + while True: + chunk = await stream.read() + if not chunk: + break + if isinstance(chunk, bytes): + data += chunk.decode('utf-8', errors='replace') + else: + data += str(chunk) + stream_name = "stdout" if is_stdout else "stderr" + logger.debug(f"{stream_name}: прочитано {len(chunk)} байт") + except Exception as e: + stream_name = "stdout" if is_stdout else "stderr" + logger.debug(f"{stream_name} завершен: {type(e).__name__}: {e}") + return data + + try: + # Читаем stdout и stderr параллельно с ожиданием завершения + logger.debug(f"wait_and_read_ssh: запуск чтения (timeout={timeout})") + + # Создаём задачи для чтения stdout и stderr + stdout_task = asyncio.create_task(read_stream(process.stdout, is_stdout=True)) + stderr_task = asyncio.create_task(read_stream(process.stderr, is_stdout=False)) + + # Ждём завершения процесса с таймаутом + await asyncio.wait_for(process.wait(), timeout=timeout) + logger.debug(f"wait_and_read_ssh: процесс завершился, returncode={process.returncode}") + + # Ждём завершения чтения с коротким таймаутом + try: + stdout_data = await asyncio.wait_for(stdout_task, timeout=2.0) + except asyncio.TimeoutError: + logger.warning("wait_and_read_ssh: таймаут чтения stdout") + stdout_task.cancel() + try: + await stdout_task + except asyncio.CancelledError: + pass + + try: + stderr_data = await asyncio.wait_for(stderr_task, timeout=2.0) + except asyncio.TimeoutError: + logger.warning("wait_and_read_ssh: таймаут чтения stderr") + stderr_task.cancel() + try: + await stderr_task + except asyncio.CancelledError: + pass + + except asyncio.TimeoutError: + logger.error(f"wait_and_read_ssh: таймаут выполнения команды ({timeout} сек)") + # Отменяем задачи чтения + stdout_task.cancel() + stderr_task.cancel() + try: + await stdout_task + except asyncio.CancelledError: + pass + try: + await stderr_task + except asyncio.CancelledError: + pass + raise + + returncode = process.returncode if process.returncode is not None else 0 + + logger.info(f"wait_and_read_ssh: stdout={len(stdout_data)} байт, stderr={len(stderr_data)} байт, returncode={returncode}") + return stdout_data, stderr_data, returncode + + +def read_pty_output(master_fd: int, timeout: float = 2.0) -> Tuple[str, bool]: + """ + Чтение вывода из PTY с таймаутом. + Возвращает (вывод, завершён_ли_процесс). + """ + output = "" + is_done = False + total_waited = 0 + consecutive_errors = 0 # Счётчик последовательных ошибок + MAX_ERRORS = 10 # Максимальное количество ошибок перед выходом + + try: + # Устанавливаем non-blocking режим + flags = fcntl.fcntl(master_fd, fcntl.F_GETFL) + fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) + + while total_waited < timeout: + try: + # Ждём данные с коротким таймаутом + ready, _, _ = select.select([master_fd], [], [], 0.2) + if ready: + try: + data = os.read(master_fd, 4096) + if data: + output += data.decode('utf-8', errors='replace') + logger.debug(f"Прочитано из PTY: {len(data)} байт") + # Сбрасываем таймер если есть данные + total_waited = 0 + consecutive_errors = 0 # Сбрасываем счётчик ошибок + else: + # EOF - процесс завершился + is_done = True + break + except BlockingIOError: + # Нет данных, продолжаем ждать + pass + except OSError as e: + # Ошибка чтения (например, EIO - процесс умер) + logger.warning(f"OSError при чтении PTY: {e} (ошибка {e.errno})") + consecutive_errors += 1 + if consecutive_errors >= MAX_ERRORS: + logger.warning(f"Слишком много ошибок чтения PTY ({consecutive_errors}), считаем процесс завершённым") + is_done = True + break + # При ошибке чтения сразу считаем что процесс завершился + is_done = True + break + else: + # Timeout - проверяем не завершился ли процесс + try: + _, status = os.waitpid(-1, os.WNOHANG) + if status != 0: + logger.debug(f"Процесс завершился со статусом: {status}") + is_done = True + break + except ChildProcessError: + # Процесс уже завершён + is_done = True + break + + # Если уже что-то прочитали и есть запрос ввода - выходим + if output and detect_input_type(output): + logger.debug(f"Обнаружен запрос ввода") + break + + total_waited += 0.2 + + except OSError as e: + # Ошибка select (например, Bad file descriptor) + logger.warning(f"OSError при select PTY: {e}") + is_done = True + break + except Exception as e: + logger.debug(f"Ошибка при чтении PTY: {e}") + consecutive_errors += 1 + if consecutive_errors >= MAX_ERRORS: + is_done = True + break + total_waited += 0.2 + + except Exception as e: + logger.debug(f"Ошибка чтения PTY: {e}") + is_done = True + + logger.debug(f"read_pty_output: output={len(output)} байт, is_done={is_done}") + return output, is_done diff --git a/install-systemd-service.sh b/install-systemd-service.sh new file mode 100755 index 0000000..ef79c38 --- /dev/null +++ b/install-systemd-service.sh @@ -0,0 +1,148 @@ +#!/bin/bash +# Скрипт установки systemd сервиса для Telegram CLI Bot + +set -e + +# Используем SUDO_USER если скрипт запущен через sudo, иначе текущего пользователя +BOT_USER="${SUDO_USER:-$USER}" +BOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BOT_VENV="${BOT_DIR}/venv" +SERVICE_FILE="${BOT_DIR}/telegram-bot.service" +SYSTEMD_SERVICE="/etc/systemd/system/telegram-bot.service" + +echo "🔧 Установка systemd сервиса для Telegram CLI Bot" +echo "==================================================" +echo "Пользователь: $BOT_USER" +echo "Директория: $BOT_DIR" +echo "Venv: $BOT_VENV" +echo "" + +# Проверка что скрипт запущен от root или через sudo +if [ "$EUID" -ne 0 ]; then + echo "❌ Запустите скрипт от имени root (sudo ./install-systemd-service.sh)" + exit 1 +fi + +# Проверка существования файлов +if [ ! -f "$BOT_DIR/bot.py" ]; then + echo "❌ bot.py не найден в $BOT_DIR" + exit 1 +fi + +if [ ! -f "$BOT_VENV/bin/python" ]; then + echo "❌ Venv не найден в $BOT_VENV" + echo " Запустите: source $BOT_DIR/venv/bin/activate && pip install -r requirements.txt" + exit 1 +fi + +if [ ! -f "$BOT_DIR/.env" ]; then + echo "⚠️ .env файл не найден. Создайте его:" + echo " cp $BOT_DIR/.env.example $BOT_DIR/.env" + echo " и отредактируйте переменные окружения" + exit 1 +fi + +# Определяем путь к node через find в домашней директории пользователя +echo "🔍 Поиск node..." +NODE_BIN=$(find /home/$BOT_USER -name "node" 2>/dev/null | grep "/bin/node$" | head -1) +echo " NODE_BIN=$NODE_BIN" + +if [ -n "$NODE_BIN" ]; then + NODE_PATH=$(dirname "$NODE_BIN") + # NODE_PATH = /home/user/.config/nvm/versions/node/v24.13.1/bin + # Извлекаем версию nvm из пути (v24.13.1) + NVM_VERSION=$(basename "$(dirname "$NODE_PATH")") + # NVM_BASE = /home/user/.config/nvm/versions/node + NVM_BASE=$(dirname "$(dirname "$NODE_PATH")") + echo " NODE_PATH=$NODE_PATH" + echo " NVM_BASE=$NVM_BASE" + echo " NVM_VERSION=$NVM_VERSION" +else + # Fallback: стандартные пути + NVM_BASE="/home/$BOT_USER/.config/nvm/versions/node" + NVM_VERSION="current" + NODE_PATH="$NVM_BASE/$NVM_VERSION/bin" + echo " ⚠️ node не найден, используем fallback: $NODE_PATH" +fi + +# Ищем qwen в той же версии nvm где найден node (просто проверяем существование файла) +echo "🔍 Поиск qwen..." +if [ -n "$NVM_VERSION" ] && [ "$NVM_VERSION" != "current" ]; then + QWEN_PATH="$NVM_BASE/$NVM_VERSION/bin/qwen" + echo " Проверяем путь: $QWEN_PATH" + if [ -e "$QWEN_PATH" ]; then + echo " ✅ qwen найден" + ls -la "$QWEN_PATH" 2>/dev/null | head -1 + else + echo " ❌ qwen не найден по пути $QWEN_PATH" + QWEN_PATH="" + fi +else + # Если версия не определена - ищем любой файл с именем qwen + echo " Версия не определена, ищем через find..." + QWEN_PATH=$(find /home/$BOT_USER -name "qwen" 2>/dev/null | grep "/bin/qwen$" | head -1) + if [ -n "$QWEN_PATH" ]; then + echo " ✅ qwen найден: $QWEN_PATH" + else + echo " ❌ qwen не найден через find" + fi +fi + +if [ -z "$QWEN_PATH" ]; then + echo "⚠️ qwen не найден. Установите: npm install -g @qwen-code/qwen-code" +fi + +NVM_DIR="/home/$BOT_USER/.nvm" + +echo "📝 Создание systemd сервиса..." +echo " Node путь: $NODE_PATH" +echo " NVM база: $NVM_BASE" + +# Вычисляем NODE_LIB_DIR из NODE_PATH +NODE_LIB_DIR=$(dirname "$NODE_PATH")/lib/node_modules + +# Создаём сервис с подстановкой путей +cat > "$SYSTEMD_SERVICE" << EOF +[Unit] +Description=Telegram CLI Bot +After=network.target + +[Service] +Type=simple +User=$BOT_USER +WorkingDirectory=$BOT_DIR +Environment="PATH=$NODE_PATH:$BOT_VENV/bin:/home/$BOT_USER/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +Environment="NVM_DIR=$NVM_DIR" +Environment="NODE_PATH=$NODE_LIB_DIR" +ExecStart=$BOT_VENV/bin/python bot.py +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=telegram-bot + +[Install] +WantedBy=multi-user.target +EOF + +echo "✅ Сервис установлен в $SYSTEMD_SERVICE" + +# Перезагружаем systemd и включаем сервис +echo "🔄 Перезагрузка systemd..." +systemctl daemon-reload + +echo "🚀 Включение сервиса..." +systemctl enable telegram-bot + +echo "" +echo "✅ Установка завершена!" +echo "" +echo "Управление сервисом:" +echo " sudo systemctl start telegram-bot - Запустить бота" +echo " sudo systemctl stop telegram-bot - Остановить бота" +echo " sudo systemctl restart telegram-bot - Перезапустить бота" +echo " sudo systemctl status telegram-bot - Проверить статус" +echo "" +echo "Просмотр логов:" +echo " sudo journalctl -u telegram-bot -f - Логи в реальном времени" +echo " sudo journalctl -u telegram-bot --since today - Логи за сегодня" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f3030e8 --- /dev/null +++ b/install.sh @@ -0,0 +1,257 @@ +#!/bin/bash +# Универсальный установщик Telegram CLI Bot +# Автоматически устанавливает зависимости (npm + pip) и qwen-code + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Цвета для вывода +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Функции вывода +info() { echo -e "${BLUE}ℹ️ $1${NC}"; } +success() { echo -e "${GREEN}✅ $1${NC}"; } +warning() { echo -e "${YELLOW}⚠️ $1${NC}"; } +error() { echo -e "${RED}❌ $1${NC}"; } + +# Проверка: новая установка или обновление +if [ -f "$SCRIPT_DIR/.installed" ]; then + INSTALL_TYPE="update" + INSTALLED_VERSION=$(cat "$SCRIPT_DIR/.installed" 2>/dev/null || echo "unknown") + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🔄 Обновление Telegram CLI Bot" + echo "📦 Текущая версия: $INSTALLED_VERSION" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" +else + INSTALL_TYPE="install" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🚀 Установка Telegram CLI Bot" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" +fi + +# ============================================================================ +# Проверка системных требований +# ============================================================================ +echo "📋 Проверка системных требований..." + +# Проверка Python +if command -v python3 &> /dev/null; then + PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}') + success "Python: $PYTHON_VERSION" +else + error "Python3 не найден!" + echo "Установите Python 3.10 или выше:" + echo " Ubuntu/Debian: sudo apt install python3 python3-pip python3-venv" + echo " Fedora: sudo dnf install python3 python3-pip" + exit 1 +fi + +# Проверка pip +if command -v pip3 &> /dev/null || command -v pip &> /dev/null; then + PIP_CMD=$(command -v pip3 2>/dev/null || command -v pip 2>/dev/null) + success "pip: $(pip3 --version 2>&1 | head -1 || pip --version 2>&1 | head -1)" +else + error "pip не найден!" + echo "Установите pip:" + echo " Ubuntu/Debian: sudo apt install python3-pip" + exit 1 +fi + +# Проверка Node.js и npm +if command -v node &> /dev/null; then + NODE_VERSION=$(node --version) + success "Node.js: $NODE_VERSION" +else + warning "Node.js не найден" + echo "Node.js требуется для qwen-code (ИИ-агент)." + echo "Установить Node.js?" + read -p "y/n: " INSTALL_NODE + if [[ "$INSTALL_NODE" =~ ^[Yy]$ ]]; then + # Установка Node.js + if [ -f /etc/debian_version ]; then + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt-get install -y nodejs + elif [ -f /etc/redhat-release ]; then + curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash - + sudo yum install -y nodejs + else + echo "Установите Node.js вручную: https://nodejs.org/" + exit 1 + fi + success "Node.js установлен" + else + warning "Пропущена установка Node.js (qwen-code не будет работать)" + fi +fi + +# Проверка npm +if command -v npm &> /dev/null; then + NPM_VERSION=$(npm --version) + success "npm: $NPM_VERSION" +else + warning "npm не найден (qwen-code не будет работать)" +fi + +# ============================================================================ +# Установка qwen-code через npm +# ============================================================================ +if command -v npm &> /dev/null; then + echo "" + echo "📦 Установка qwen-code..." + + # Проверяем установлен ли уже qwen-code + if npm list -g @qwen-code/qwen-code &> /dev/null; then + info "qwen-code уже установлен, проверяем обновления..." + npm update -g @qwen-code/qwen-code + success "qwen-code обновлён" + else + info "Установка qwen-code глобально..." + npm install -g @qwen-code/qwen-code + success "qwen-code установлен" + fi + + # Проверка установки + if command -v qwen &> /dev/null; then + QWEN_VERSION=$(qwen --version 2>&1 | head -1 || echo "unknown") + success "qwen: $QWEN_VERSION" + else + warning "qwen не найден в PATH, пробуем добавить..." + # Пробуем найти qwen в глобальных npm + QWEN_PATH=$(npm root -g)/@qwen-code/qwen-code/cli.js + if [ -f "$QWEN_PATH" ]; then + export PATH="$PATH:$(npm root -g)/@qwen-code/qwen-code" + success "qwen добавлен в PATH" + fi + fi +else + warning "npm не найден — qwen-code не установлен" + echo "Для работы ИИ-агента установите npm и выполните:" + echo " npm install -g @qwen-code/qwen-code" +fi + +# ============================================================================ +# Создание/обновление виртуального окружения Python +# ============================================================================ +echo "" +echo "🐍 Настройка Python окружения..." + +if [ ! -d "venv" ]; then + info "Создание виртуального окружения..." + python3 -m venv venv + success "Виртуальное окружение создано" +else + info "Виртуальное окружение найдено" +fi + +# Активация виртуального окружения +source venv/bin/activate + +# Обновление pip +info "Обновление pip..." +pip install -q --upgrade pip + +# Установка зависимостей +echo "" +echo "📦 Установка Python зависимостей..." +if [ -f "requirements.txt" ]; then + pip install -q -r requirements.txt + success "Зависимости установлены" +else + error "requirements.txt не найден!" + exit 1 +fi + +# ============================================================================ +# Настройка .env файла +# ============================================================================ +echo "" +echo "⚙️ Настройка конфигурации..." + +if [ ! -f ".env" ]; then + if [ -f ".env.example" ]; then + info "Создание .env из .env.example..." + cp .env.example .env + warning "Требуется настроить .env файл!" + echo "" + echo "Отредактируйте .env и укажите:" + echo " 1. TELEGRAM_BOT_TOKEN — токен от @BotFather" + echo " 2. ALLOWED_USERS — ваш Telegram ID" + echo " 3. SERVERS — SSH серверы (опционально)" + echo "" + echo "Или запустите ./run.sh для интерактивной настройки" + fi +else + info ".env файл найден" +fi + +# ============================================================================ +# Сохранение версии +# ============================================================================ +# Получаем версию из git или создаём timestamp +if command -v git &> /dev/null && [ -d ".git" ]; then + VERSION=$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD) +else + VERSION=$(date +%Y%m%d-%H%M%S) +fi + +echo "$VERSION" > "$SCRIPT_DIR/.installed" +success "Версия: $VERSION" + +# ============================================================================ +# Итоги +# ============================================================================ +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +if [ "$INSTALL_TYPE" = "update" ]; then + success "Обновление завершено!" +else + success "Установка завершена!" +fi +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "📁 Директория: $SCRIPT_DIR" +echo "📦 Версия: $VERSION" +echo "" + +# Проверка готовности +READY=true + +if [ ! -f ".env" ]; then + warning "Не настроен .env файл" + READY=false +fi + +if ! grep -q "^TELEGRAM_BOT_TOKEN=" .env 2>/dev/null || grep -q "TELEGRAM_BOT_TOKEN=123456789" .env 2>/dev/null; then + warning "Не установлен TELEGRAM_BOT_TOKEN в .env" + READY=false +fi + +if [ "$READY" = true ]; then + echo "✅ Бот готов к запуску!" + echo "" + echo "Запуск:" + echo " ./run.sh" + echo "" + echo "Или в фоновом режиме:" + echo " nohup ./run.sh > bot.log 2>&1 &" +else + echo "⚠️ Требуется настройка перед запуском:" + echo " 1. Отредактируйте .env" + echo " 2. Установите TELEGRAM_BOT_TOKEN" + echo " 3. Настройте ALLOWED_USERS" + echo "" + echo "Затем запустите:" + echo " ./run.sh" +fi + +echo "" diff --git a/memory_system.py b/memory_system.py new file mode 100644 index 0000000..c66aec7 --- /dev/null +++ b/memory_system.py @@ -0,0 +1,708 @@ +#!/usr/bin/env python3 +""" +Система памяти для ИИ-чата на SQLite. + +Архитектура: +1. SQLite для хранения истории диалогов +2. Извлечение фактов через эвристики +3. Поиск по истории через LIKE + +Просто и надёжно — без внешних зависимостей. +""" + +import logging +import sqlite3 +from pathlib import Path +from datetime import datetime +from dataclasses import dataclass, field +from typing import Optional, List, Dict, Any, Tuple +from enum import Enum + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Модели данных +# ============================================================================ + +class FactType(Enum): + """Типы извлекаемых фактов.""" + PERSONAL = "personal" + TECHNICAL = "technical" + PROJECT = "project" + PREFERENCE = "preference" + OTHER = "other" + + +@dataclass +class Fact: + """Факт о пользователе.""" + id: Optional[int] + user_id: int + fact_type: FactType + content: str + source_message: str + confidence: float + created_at: datetime = field(default_factory=datetime.now) + updated_at: datetime = field(default_factory=datetime.now) + is_active: bool = True + + +@dataclass +class Message: + """Сообщение диалога.""" + id: Optional[int] + user_id: int + role: str + content: str + timestamp: datetime = field(default_factory=datetime.now) + session_id: Optional[str] = None + + +@dataclass +class DialogSession: + """Сессия диалога.""" + id: str + user_id: int + started_at: datetime = field(default_factory=datetime.now) + ended_at: Optional[datetime] = None + message_count: int = 0 + summary: Optional[str] = None + + +# ============================================================================ +# SQLite хранилище +# ============================================================================ + +class SQLiteMemoryStorage: + """ + SQLite-хранилище для памяти. + """ + + def __init__(self, db_path: str): + self.db_path = db_path + self._init_db() + + def _init_db(self): + """Инициализация базы данных.""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS facts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + fact_type TEXT NOT NULL, + content TEXT NOT NULL, + source_message TEXT, + confidence REAL DEFAULT 0.5, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT 1 + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + session_id TEXT + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ended_at TIMESTAMP, + message_count INTEGER DEFAULT 0, + summary TEXT + ) + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_facts_user ON facts(user_id, is_active)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_messages_user ON messages(user_id, timestamp)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)") + + conn.commit() + conn.close() + logger.info(f"Инициализирована БД памяти: {self.db_path}") + + def _get_connection(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + # --- Факты --- + + def save_fact(self, fact: Fact) -> int: + """Сохранить факт.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO facts (user_id, fact_type, content, source_message, confidence, created_at, updated_at, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + fact.user_id, + fact.fact_type.value, + fact.content, + fact.source_message, + fact.confidence, + fact.created_at.isoformat() if fact.created_at else None, + fact.updated_at.isoformat() if fact.updated_at else None, + 1 if fact.is_active else 0 + )) + + fact_id = cursor.lastrowid + conn.commit() + conn.close() + + logger.debug(f"Сохранён факт для пользователя {fact.user_id}: {fact.content[:50]}...") + return fact_id + + def get_facts(self, user_id: int, fact_type: Optional[FactType] = None) -> List[Fact]: + """Получить факты пользователя.""" + conn = self._get_connection() + cursor = conn.cursor() + + query = "SELECT * FROM facts WHERE user_id = ? AND is_active = 1" + params = [user_id] + + if fact_type: + query += " AND fact_type = ?" + params.append(fact_type.value) + + query += " ORDER BY created_at DESC" + + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + + facts = [] + for row in rows: + facts.append(Fact( + id=row["id"], + user_id=row["user_id"], + fact_type=FactType(row["fact_type"]), + content=row["content"], + source_message=row["source_message"], + confidence=row["confidence"], + created_at=datetime.fromisoformat(row["created_at"]), + updated_at=datetime.fromisoformat(row["updated_at"]), + is_active=bool(row["is_active"]) + )) + + return facts + + def update_fact(self, fact_id: int, content: str = None, confidence: float = None, is_active: bool = None): + """Обновить факт.""" + conn = self._get_connection() + cursor = conn.cursor() + + updates = [] + params = [] + + if content is not None: + updates.append("content = ?") + params.append(content) + if confidence is not None: + updates.append("confidence = ?") + params.append(confidence) + if is_active is not None: + updates.append("is_active = ?") + params.append(1 if is_active else 0) + + if updates: + updates.append("updated_at = ?") + params.append(datetime.now().isoformat()) + params.append(fact_id) + + query = f"UPDATE facts SET {', '.join(updates)} WHERE id = ?" + cursor.execute(query, params) + conn.commit() + + conn.close() + + # --- Сообщения --- + + def save_message(self, message: Message) -> int: + """Сохранить сообщение.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO messages (user_id, role, content, timestamp, session_id) + VALUES (?, ?, ?, ?, ?) + """, ( + message.user_id, + message.role, + message.content, + message.timestamp.isoformat() if message.timestamp else None, + message.session_id + )) + + message_id = cursor.lastrowid + + # Обновляем счётчик сессии + if message.session_id: + cursor.execute(""" + UPDATE sessions + SET message_count = message_count + 1 + WHERE id = ? + """, (message.session_id,)) + + conn.commit() + conn.close() + + return message_id + + def get_recent_messages(self, user_id: int, limit: int = 10) -> List[Message]: + """Получить последние сообщения.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM messages + WHERE user_id = ? + ORDER BY timestamp DESC + LIMIT ? + """, (user_id, limit)) + + rows = cursor.fetchall() + conn.close() + + messages = [] + for row in reversed(rows): # Возвращаем в хронологическом порядке + messages.append(Message( + id=row["id"], + user_id=row["user_id"], + role=row["role"], + content=row["content"], + timestamp=datetime.fromisoformat(row["timestamp"]), + session_id=row["session_id"] + )) + + return messages + + def get_messages_by_session(self, session_id: str) -> List[Message]: + """Получить сообщения сессии.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM messages + WHERE session_id = ? + ORDER BY timestamp ASC + """, (session_id,)) + + rows = cursor.fetchall() + conn.close() + + messages = [] + for row in rows: + messages.append(Message( + id=row["id"], + user_id=row["user_id"], + role=row["role"], + content=row["content"], + timestamp=datetime.fromisoformat(row["timestamp"]), + session_id=row["session_id"] + )) + + return messages + + def search_messages(self, user_id: int, query: str, limit: int = 5) -> List[Message]: + """ + Поиск сообщений по тексту (простой LIKE поиск). + Для продакшена лучше использовать FTS5 или векторный поиск. + """ + conn = self._get_connection() + cursor = conn.cursor() + + # Поиск по содержимому + cursor.execute(""" + SELECT * FROM messages + WHERE user_id = ? AND content LIKE ? + ORDER BY timestamp DESC + LIMIT ? + """, (user_id, f"%{query}%", limit)) + + rows = cursor.fetchall() + conn.close() + + messages = [] + for row in rows: + messages.append(Message( + id=row["id"], + user_id=row["user_id"], + role=row["role"], + content=row["content"], + timestamp=datetime.fromisoformat(row["timestamp"]), + session_id=row["session_id"] + )) + + return messages + + # --- Сессии --- + + def create_session(self, session: DialogSession) -> str: + """Создать сессию.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO sessions (id, user_id, started_at, message_count) + VALUES (?, ?, ?, ?) + """, ( + session.id, + session.user_id, + session.started_at.isoformat() if session.started_at else None, + session.message_count + )) + + conn.commit() + conn.close() + + return session.id + + def close_session(self, session_id: str, summary: str = None): + """Завершить сессию.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + UPDATE sessions + SET ended_at = ?, summary = ? + WHERE id = ? + """, (datetime.now().isoformat(), summary, session_id)) + + conn.commit() + conn.close() + + def get_active_session(self, user_id: int) -> Optional[DialogSession]: + """Получить активную сессию пользователя.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM sessions + WHERE user_id = ? AND ended_at IS NULL + ORDER BY started_at DESC + LIMIT 1 + """, (user_id,)) + + row = cursor.fetchone() + conn.close() + + if row: + return DialogSession( + id=row["id"], + user_id=row["user_id"], + started_at=datetime.fromisoformat(row["started_at"]), + ended_at=datetime.fromisoformat(row["ended_at"]) if row["ended_at"] else None, + message_count=row["message_count"], + summary=row["summary"] + ) + + return None + + def get_user_stats(self, user_id: int) -> Dict[str, Any]: + """Получить статистику пользователя.""" + conn = self._get_connection() + cursor = conn.cursor() + + # Количество сессий + cursor.execute(""" + SELECT COUNT(*) FROM sessions WHERE user_id = ? + """, (user_id,)) + total_sessions = cursor.fetchone()[0] + + # Количество сообщений + cursor.execute(""" + SELECT COUNT(*) FROM messages WHERE user_id = ? + """, (user_id,)) + total_messages = cursor.fetchone()[0] + + # Количество фактов + cursor.execute(""" + SELECT COUNT(*) FROM facts WHERE user_id = ? AND is_active = 1 + """, (user_id,)) + total_facts = cursor.fetchone()[0] + + conn.close() + + return { + "total_sessions": total_sessions, + "total_messages": total_messages, + "total_facts": total_facts + } + + +# ============================================================================ +# Менеджер памяти (основной интерфейс) +# ============================================================================ + +class MemoryManager: + """ + Менеджер памяти — основной интерфейс для работы с памятью. + + Координирует: + - Сохранение/загрузку фактов + - Историю сообщений + - Извлечение фактов через ИИ + - RAG-поиск + """ + + def __init__(self, storage: SQLiteMemoryStorage, ai_client=None): + self.storage = storage + self.ai_client = ai_client # Будет использоваться для извлечения фактов + self._active_sessions: Dict[int, str] = {} # user_id -> session_id + + def start_session(self, user_id: int) -> str: + """Начать новую сессию.""" + import uuid + session_id = str(uuid.uuid4()) + session = DialogSession(id=session_id, user_id=user_id) + self.storage.create_session(session) + self._active_sessions[user_id] = session_id + logger.info(f"Начата новая сессия {session_id} для пользователя {user_id}") + return session_id + + def end_session(self, user_id: int, summary: str = None): + """Завершить сессию.""" + session_id = self._active_sessions.pop(user_id, None) + if session_id: + self.storage.close_session(session_id, summary) + logger.info(f"Завершена сессия {session_id} для пользователя {user_id}") + + def get_session_id(self, user_id: int) -> Optional[str]: + """Получить ID текущей сессии.""" + # Проверяем кэш + if user_id in self._active_sessions: + return self._active_sessions[user_id] + + # Проверяем БД + session = self.storage.get_active_session(user_id) + if session: + self._active_sessions[user_id] = session.id + return session.id + + # Создаём новую + return self.start_session(user_id) + + def add_message(self, user_id: int, role: str, content: str) -> int: + """Добавить сообщение.""" + session_id = self.get_session_id(user_id) + message = Message( + id=None, + user_id=user_id, + role=role, + content=content, + session_id=session_id + ) + return self.storage.save_message(message) + + def get_context(self, user_id: int, max_messages: int = 10) -> List[Message]: + """Получить контекст для ИИ (последние сообщения).""" + return self.storage.get_recent_messages(user_id, max_messages) + + # --- Факты --- + + def get_user_profile(self, user_id: int) -> Dict[FactType, List[str]]: + """ + Получить профиль пользователя (все активные факты). + + Возвращает: + { + FactType.PERSONAL: ["Пользователя зовут Владимир"], + FactType.TECHNICAL: ["Использует Python", "Работает с Telegram API"], + ... + } + """ + facts = self.storage.get_facts(user_id) + profile = {} + + for fact in facts: + if fact.fact_type not in profile: + profile[fact.fact_type] = [] + profile[fact.fact_type].append(fact.content) + + return profile + + def extract_facts_from_message(self, user_id: int, message: str, + response: str = None) -> List[Fact]: + """ + Извлечь факты из сообщения (с помощью ИИ или эвристик). + + Пока простая реализация на эвристиках. + В будущем можно использовать ИИ для анализа. + """ + extracted_facts = [] + message_lower = message.lower() + + # Эвристики для извлечения фактов + fact_candidates = [] + + # Имя пользователя + if "меня зовут" in message_lower: + parts = message.split("меня зовут") + if len(parts) > 1: + name = parts[1].strip().split()[0] + fact_candidates.append((FactType.PERSONAL, f"Пользователя зовут {name}", 0.8)) + + # Предпочтения технологий + tech_patterns = [ + (r"я (люблю|предпочитаю|использую)\s+(\w+)", "technical"), + (r"мой (язык|стек)\s+(\w+)", "technical"), + (r"работаю с\s+([\w\s,]+)", "technical"), + ] + + import re + for pattern, fact_type in tech_patterns: + match = re.search(pattern, message_lower) + if match: + tech = match.group(2) if len(match.groups()) > 1 else match.group(1) + fact_candidates.append((FactType.TECHNICAL, f"Использует {tech}", 0.6)) + + # Проекты/директории + if "мой проект" in message_lower or "проект в" in message_lower: + fact_candidates.append((FactType.PROJECT, f"Есть проект, упомянутый в диалоге", 0.5)) + + # Сохраняем факты с высокой уверенностью + for fact_type, content, confidence in fact_candidates: + if confidence >= 0.6: + fact = Fact( + id=None, + user_id=user_id, + fact_type=fact_type, + content=content, + source_message=message, + confidence=confidence + ) + self.storage.save_fact(fact) + extracted_facts.append(fact) + + if extracted_facts: + logger.info(f"Извлечено {len(extracted_facts)} фактов из сообщения пользователя {user_id}") + + return extracted_facts + + # --- RAG-поиск --- + + def search_relevant_context(self, user_id: int, query: str, + max_results: int = 3) -> Tuple[List[Message], List[Fact]]: + """ + Найти релевантный контекст для запроса. + + Возвращает: + - Сообщения по теме + - Факты по теме + """ + # Поиск в сообщениях + relevant_messages = self.storage.search_messages(user_id, query, max_results) + + # Поиск в фактах (простой поиск по содержимому) + all_facts = self.storage.get_facts(user_id) + relevant_facts = [] + query_lower = query.lower() + + for fact in all_facts: + if query_lower in fact.content.lower() or fact.fact_type.value in query_lower: + relevant_facts.append(fact) + + logger.debug(f"Найдено {len(relevant_messages)} сообщений и {len(relevant_facts)} фактов для запроса: {query[:30]}...") + + return relevant_messages, relevant_facts + + def format_context_for_ai(self, user_id: int, query: str = None) -> str: + """ + Сформировать контекст для передачи ИИ. + + Включает: + - Профиль пользователя + - Последние сообщения + - Релевантные факты (если есть запрос) + """ + parts = [] + + # Профиль пользователя + profile = self.get_user_profile(user_id) + if profile: + parts.append("📋 ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ:") + for fact_type, facts in profile.items(): + parts.append(f" [{fact_type.value}]:") + for f in facts: + parts.append(f" - {f}") + + # Последние сообщения (контекст диалога) + recent_messages = self.storage.get_recent_messages(user_id, 5) + if recent_messages: + parts.append("\n💬 ПОСЛЕДНИЕ СООБЩЕНИЯ:") + for msg in recent_messages: + role_ru = "Пользователь" if msg.role == "user" else "Ассистент" + parts.append(f" {role_ru}: {msg.content[:100]}...") + + # Релевантный контекст по запросу + if query: + relevant_msgs, relevant_facts = self.search_relevant_context(user_id, query) + if relevant_facts: + parts.append("\n🔍 РЕЛЕВАНТНЫЕ ФАКТЫ:") + for f in relevant_facts: + parts.append(f" - {f.content}") + + return "\n".join(parts) + + def get_stats(self, user_id: int) -> Dict[str, Any]: + """Получить статистику памяти пользователя.""" + return self.storage.get_user_stats(user_id) + + +# ============================================================================ +# Глобальный экземпляр +# ============================================================================ + +# Путь к БД памяти +MEMORY_DB_PATH = str(Path(__file__).parent / "memory.db") + +# Глобальный менеджер памяти +memory_manager = MemoryManager(SQLiteMemoryStorage(MEMORY_DB_PATH)) + + +# ============================================================================ +# Интеграция с ботом (хелперы для bot.py) +# ============================================================================ + +def format_memory_context(user_id: int, query: str = None) -> str: + """ + Получить форматированный контекст памяти для ИИ. + Используется в qwen_integration.py или при вызове ИИ. + """ + return memory_manager.format_context_for_ai(user_id, query) + + +def save_ai_message(user_id: int, role: str, content: str): + """Сохранить сообщение ИИ-чата.""" + memory_manager.add_message(user_id, role, content) + + # Если сообщение от пользователя — пытаемся извлечь факты + if role == "user": + memory_manager.extract_facts_from_message(user_id, content) + + +def get_user_profile_summary(user_id: int) -> str: + """Получить краткую сводку профиля пользователя.""" + profile = memory_manager.get_user_profile(user_id) + if not profile: + return "" + + lines = ["Профиль пользователя:"] + for fact_type, facts in profile.items(): + for f in facts: + lines.append(f" • {f}") + + return "\n".join(lines) diff --git a/qwen_integration.py b/qwen_integration.py new file mode 100644 index 0000000..58c2158 --- /dev/null +++ b/qwen_integration.py @@ -0,0 +1,835 @@ +#!/usr/bin/env python3 +""" +Интеграция с Qwen Code CLI. +Запуск, управление сессиями, обработка OAuth. + +Использует stream-json формат для потокового вывода. +""" + +import os +import re +import asyncio +import subprocess +import json +import logging +from pathlib import Path +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Optional, Dict, Callable, Any, List, Union +from enum import Enum + +from bot.base_ai_provider import ( + BaseAIProvider, + ProviderResponse, + AIMessage, + ToolCall, + ToolCallStatus, +) + +# Импортируем OAuth модуль и константы +from bot.utils.qwen_oauth import ( + check_authorization_complete, + get_access_token, + clear_authorization, + QWEN_OAUTH_CLIENT_ID, + QWEN_OAUTH_BASE_URL, + QWEN_OAUTH_DEVICE_CODE_ENDPOINT, + QWEN_OAUTH_TOKEN_ENDPOINT +) + +logger = logging.getLogger(__name__) + + +class QwenSessionState(Enum): + """Состояние сессии Qwen Code.""" + STARTING = "starting" + WAITING_FOR_OAUTH = "waiting_for_oauth" + READY = "ready" + BUSY = "busy" + ERROR = "error" + + +class QwenEventType(Enum): + """Типы событий в stream-json выводе Qwen.""" + SYSTEM = "system" + ASSISTANT = "assistant" + USER = "user" + RESULT = "result" + TOOL_USE = "tool_use" + + +@dataclass +class QwenStreamEvent: + """Событие из stream-json вывода Qwen.""" + event_type: QwenEventType + subtype: Optional[str] = None + uuid: Optional[str] = None + session_id: Optional[str] = None + message: Optional[Dict] = None + content: Optional[str] = None + is_error: bool = False + data: Optional[Dict] = None + + +@dataclass +class QwenSession: + """Сессия Qwen Code.""" + user_id: int + state: QwenSessionState = QwenSessionState.STARTING + process: Optional[subprocess.Popen] = None + oauth_url: Optional[str] = None + on_oauth_url: Optional[Callable] = None # Callback для OAuth URL + last_activity: datetime = field(default_factory=datetime.now) + pending_task: Optional[str] = None + output_buffer: str = "" + session_id: Optional[str] = None + + SESSION_TIMEOUT = timedelta(minutes=30) # Таймаут неактивности + + def is_expired(self) -> bool: + return datetime.now() - self.last_activity > self.SESSION_TIMEOUT + + +class QwenCodeManager: + """Менеджер сессий Qwen Code.""" + + def __init__(self, working_dir: str = None, system_prompt_path: str = None): + self._sessions: Dict[int, QwenSession] = {} + self._working_dir = working_dir or str(Path.home()) + self._qwen_command = "qwen" + self._system_prompt_path = system_prompt_path or str(Path(__file__).parent / "system_prompt.md") + self._system_prompt: Optional[str] = None + + def load_system_prompt(self) -> str: + """Загрузить системный промпт из файла.""" + if self._system_prompt is not None: + return self._system_prompt + + try: + prompt_path = Path(self._system_prompt_path) + if prompt_path.exists(): + self._system_prompt = prompt_path.read_text(encoding='utf-8') + logger.info(f"Системный промпт загружен из {self._system_prompt_path}") + else: + self._system_prompt = "" + logger.warning(f"Системный промпт не найден: {self._system_prompt_path}") + except Exception as e: + logger.error(f"Ошибка загрузки системного промпта: {e}") + self._system_prompt = "" + + return self._system_prompt + + def get_session(self, user_id: int) -> Optional[QwenSession]: + """Получить сессию пользователя.""" + session = self._sessions.get(user_id) + if session and session.is_expired(): + self.close_session(user_id) + return None + return session + + def create_session(self, user_id: int) -> QwenSession: + """Создать новую сессию.""" + session = QwenSession(user_id=user_id) + self._sessions[user_id] = session + logger.info(f"Создана сессия Qwen Code для пользователя {user_id}") + return session + + async def get_oauth_url(self) -> Optional[str]: + """ + Получить OAuth ссылку для авторизации Qwen Code. + + Returns: + OAuth URL или None если не удалось получить + """ + try: + import aiohttp + + # Генерируем code verifier и challenge (упрощённо) + import hashlib + import secrets + import uuid + + code_verifier = secrets.token_urlsafe(32) + code_challenge = hashlib.sha256(code_verifier.encode()).hexdigest() + + # Запрос на получение device code (form-urlencoded как в оригинале) + payload = { + 'client_id': QWEN_OAUTH_CLIENT_ID, + 'scope': 'openid profile email model.completion', + 'code_challenge': code_challenge, + 'code_challenge_method': 'S256' + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'x-request-id': str(uuid.uuid4()), + 'User-Agent': 'qwen-code-cli/0.11.0' + } + + # Формируем form-urlencoded тело + form_data = '&'.join(f'{k}={v}' for k, v in payload.items()) + + async with aiohttp.ClientSession() as session: + async with session.post( + QWEN_OAUTH_DEVICE_CODE_ENDPOINT, + data=form_data, + headers=headers + ) as resp: + text = await resp.text() + if resp.status == 200: + try: + data = await resp.json() + verification_uri = data.get('verification_uri_complete', '') + logger.info(f"Получен OAuth URL: {verification_uri}") + return verification_uri + except Exception as json_err: + logger.error(f"Ошибка парсинга JSON: {json_err}") + logger.debug(f"Ответ сервера: {text[:200]}") + else: + logger.error(f"Ошибка получения OAuth: {resp.status}") + logger.debug(f"Ответ сервера: {text[:200]}") + return None + + except Exception as e: + logger.error(f"Ошибка получения OAuth URL: {e}") + return None + + def close_session(self, user_id: int): + """Закрыть сессию пользователя.""" + session = self._sessions.pop(user_id, None) + if session and session.process: + try: + session.process.terminate() + session.process.wait(timeout=5) + except Exception as e: + logger.warning(f"Ошибка при закрытии сессии Qwen: {e}") + logger.info(f"Закрыта сессия Qwen Code для пользователя {user_id}") + + def has_active_session(self, user_id: int) -> bool: + """Проверка наличия активной сессии.""" + session = self.get_session(user_id) + return session is not None and session.state != QwenSessionState.ERROR + + async def run_task(self, user_id: int, task: str, + on_output: Callable[[str], Any], + on_oauth_url: Callable[[str], Any], + use_system_prompt: bool = True, + on_chunk: Callable[[str], Any] = None, + on_event: Callable[[QwenStreamEvent], Any] = None) -> str: + """ + Выполнить задачу в Qwen Code с потоковым выводом. + + Args: + user_id: ID пользователя + task: Задача для выполнения + on_output: Callback для вывода (накапливается) + on_oauth_url: Callback для OAuth URL + use_system_prompt: Добавить системный промпт (default: True) + on_chunk: Callback для потоковой отправки chunks (опционально) + on_event: Callback для событий stream-json (опционально) + """ + # Создаём временную сессию для отслеживания + session = self.get_session(user_id) + if not session: + session = self.create_session(user_id) + + session.last_activity = datetime.now() + session.pending_task = task + session.on_oauth_url = on_oauth_url # Сохраняем callback для OAuth + + # Добавляем системный промпт если нужно + if use_system_prompt: + system_prompt = self.load_system_prompt() + if system_prompt: + full_task = f"{system_prompt}\n\n=== ЗАПРОС ПОЛЬЗОВАТЕЛЯ ===\n{task}" + else: + full_task = task + else: + full_task = task + + # Выполняем задачу через -p флаг с stream-json выводом + return await self._execute_task(session, full_task, on_output, on_chunk, on_event) + + async def _start_session(self, session: QwenSession, + on_output: Callable[[str], Any], + on_oauth_url: Callable[[str], Any], + pending_task: str = None) -> str: + """Запустить сессию Qwen Code.""" + session.state = QwenSessionState.STARTING + + try: + # Запускаем qwen в интерактивном режиме с JSON выводом + env = os.environ.copy() + env["FORCE_COLOR"] = "0" # Отключаем цвета для парсинга + + cmd = [ + self._qwen_command, + "--output-format", "stream-json", + "--input-format", "text", + "--auth-type", "qwen-oauth", # Явное указание типа авторизации + ] + + logger.info(f"Запуск Qwen Code: {' '.join(cmd)}") + + session.process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=self._working_dir, + env=env, + text=True, + bufsize=1 + ) + + # Читаем вывод пока не поймём состояние + output = "" + oauth_detected = False + + while True: + line = session.process.stdout.readline() + if not line: + break + + output += line + on_output(line) + + # Проверяем на OAuth URL + oauth_match = re.search( + r'https://oauth\.qwen\.ai/[^>\s]+|' + r'https://[^>\s]*qwen[^>\s]*/oauth[^>\s]*|' + r'Authorize.*?https?://[^\s]+', + line, + re.IGNORECASE + ) + + if oauth_match: + oauth_url = oauth_match.group(0) + session.oauth_url = oauth_url + session.state = QwenSessionState.WAITING_FOR_OAUTH + on_oauth_url(oauth_url) + oauth_detected = True + logger.info(f"Обнаружен OAuth URL: {oauth_url}") + break + + # Проверяем на готовность + if "ready" in line.lower() or "assistant" in line.lower(): + session.state = QwenSessionState.READY + logger.info("Сессия Qwen Code готова") + break + + # Таймаут запуска + if session.process.poll() is not None: + session.state = QwenSessionState.ERROR + return f"❌ Ошибка запуска Qwen Code: {output}" + + # Если после запуска есть отложенная задача — выполняем + if pending_task and session.state == QwenSessionState.READY: + return await self._execute_task(session, pending_task, on_output) + + if oauth_detected: + return "⏳ Ожидание авторизации..." + + return "✅ Сессия запущена" + + except Exception as e: + session.state = QwenSessionState.ERROR + logger.error(f"Ошибка запуска сессии Qwen: {e}") + return f"❌ Ошибка: {str(e)}" + + async def _execute_task(self, session: QwenSession, + task: str, + on_output: Callable[[str], Any], + on_chunk: Callable[[str], Any] = None, + on_event: Callable[[QwenStreamEvent], Any] = None) -> str: + """ + Выполнить задачу в активной сессии с потоковым stream-json выводом. + + Формат stream-json возвращает JSON-объекты по одному на строку: + {"type":"system","subtype":"session_start","uuid":"...","session_id":"..."} + {"type":"assistant","uuid":"...","message":{"content":[...]}} + {"type":"result","subtype":"success","uuid":"...","result":"..."} + + Args: + session: Сессия Qwen + task: Задача для выполнения + on_output: Callback для полного вывода (накапливается) + on_chunk: Callback для потоковой отправки текстовых chunks + on_event: Callback для полных JSON событий + """ + session.state = QwenSessionState.BUSY + session.output_buffer = "" + + try: + env = os.environ.copy() + env["FORCE_COLOR"] = "0" + + cmd = [ + self._qwen_command, + "-p", task, + "--output-format", "stream-json", # Правильный streaming формат + "--auth-type", "qwen-oauth", # Явное указание типа авторизации + "--approval-mode", "yolo", # Авто-подтверждение действий + ] + + logger.info(f"Выполнение задачи (stream-json): {' '.join(cmd)}") + + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=self._working_dir, + env=env + ) + + output = "" + chunk_timeout = 300 # 5 минут на выполнение + last_chunk_time = datetime.now() + partial_content = "" # Для накопления partial messages + + while True: + # Проверяем общий таймаут + if (datetime.now() - last_chunk_time).total_seconds() > chunk_timeout: + output += "\n\n⚠️ Таймаут выполнения (5 минут)" + process.terminate() + break + + # Проверяем процесс + if process.returncode is not None: + # Процесс завершился - читаем остаток + remaining = await process.stdout.read() + if remaining: + remaining_str = remaining.decode('utf-8', errors='replace') + output += remaining_str + # Парсим оставшиеся JSON события (не отправляем сырой вывод!) + await self._process_stream_lines( + remaining_str, on_output, on_chunk, on_event, session + ) + break + + # Читаем строку из stdout + try: + line = await asyncio.wait_for(process.stdout.readline(), timeout=1.0) + if line: + line_str = line.decode('utf-8', errors='replace') + output += line_str + session.output_buffer += line_str + last_chunk_time = datetime.now() + + # Проверяем на OAuth ссылку в текстовом выводе + oauth_match = re.search( + r'https://chat\.qwen\.ai/authorize\?user_code=([A-Za-z0-9_-]+)', + line_str + ) + if oauth_match: + oauth_url = oauth_match.group(0) + logger.info(f"Обнаружена OAuth ссылка: {oauth_url}") + if session and session.on_oauth_url: + await session.on_oauth_url(oauth_url) + + # Парсим JSON событие и извлекаем текст + await self._process_stream_lines( + line_str, on_output, on_chunk, on_event, session + ) + + except asyncio.TimeoutError: + if process.returncode is not None: + break + continue + + await asyncio.sleep(0.01) + + session.state = QwenSessionState.READY + session.last_activity = datetime.now() + + return output.strip() + + except Exception as e: + session.state = QwenSessionState.ERROR + logger.error(f"Ошибка выполнения задачи: {e}") + return f"❌ Ошибка: {str(e)}" + + async def _process_stream_lines(self, + text: str, + on_output: Callable[[str], Any], + on_chunk: Callable[[str], Any] = None, + on_event: Callable[[QwenStreamEvent], Any] = None, + session: QwenSession = None) -> str: + """ + Распарсить stream-json строки и извлечь текстовый контент. + + Формат JSON событий: + - {"type":"system","subtype":"session_start","session_id":"..."} + - {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}} + - {"type":"result","subtype":"success","result":"...","duration_ms":1234} + + Возвращает только текстовый контент для отображения пользователю. + """ + extracted_text = "" + + for line in text.split('\n'): + line = line.strip() + if not line: + continue + + # Проверяем это JSON или обычный текст + if line.startswith('{'): + try: + event_data = json.loads(line) + event_type = event_data.get('type', 'unknown') + + # Создаём объект события + stream_event = QwenStreamEvent( + event_type=QwenEventType(event_type) if event_type in ['system', 'assistant', 'user', 'result', 'tool_use'] else None, + subtype=event_data.get('subtype'), + uuid=event_data.get('uuid'), + session_id=event_data.get('session_id'), + message=event_data.get('message'), + is_error=event_data.get('is_error', False), + data=event_data + ) + + # Обновляем session_id из события + if stream_event.session_id and session: + session.session_id = stream_event.session_id + + # Извлекаем текст из разных типов событий + if event_type == 'assistant': + message = event_data.get('message', {}) + content_list = message.get('content', []) + + # Логируем для отладки + logger.debug(f"Assistant event: content_type={type(content_list)}, content={content_list[:1] if isinstance(content_list, list) else content_list}") + + # Обрабатываем только если content - это список (не thinking) + if isinstance(content_list, list): + for content_item in content_list: + if isinstance(content_item, dict): + if content_item.get('type') == 'text': + text_content = content_item.get('text', '') + logger.debug(f"Text chunk: {text_content[:50]}...") + extracted_text += text_content + # Отправляем ТОЛЬКО в on_chunk для streaming + if on_chunk: + await on_chunk(text_content) + elif content_item.get('type') == 'tool_use': + # Инструмент используется - можно показать статус + tool_name = content_item.get('name', 'unknown') + # Добавляем переносы строк для разделения блоков + status_text = f"\n🔧 Использую инструмент: {tool_name}...\n" + extracted_text += status_text + if on_chunk: + await on_chunk(status_text) + # Если content.type == 'thinking' - не отправляем пользователю + + elif event_type == 'result': + result_text = event_data.get('result', '') + if result_text: + extracted_text += result_text + # НЕ отправляем result через on_chunk — он уже был отправлен через assistant chunks + logger.debug(f"Result event: {result_text[:50]}...") + + # Проверяем на ошибку + if event_data.get('is_error'): + error_obj = event_data.get('error', {}) + error_message = error_obj.get('message', 'Неизвестная ошибка') if isinstance(error_obj, dict) else str(error_obj) + logger.error(f"Ошибка Qwen: {error_message}") + + # Проверяем ошибку авторизации — получаем OAuth URL + if 'No auth type' in error_message or 'auth type is not selected' in error_message: + # Получаем OAuth URL через API + oauth_url = await self.get_oauth_url() + if not oauth_url: + oauth_url = f"{QWEN_OAUTH_BASE_URL}/" + + logger.info(f"Требуется OAuth: {oauth_url}") + + # Вызываем on_oauth_url если есть в session + if session and hasattr(session, 'on_oauth_url') and session.on_oauth_url: + await session.on_oauth_url(oauth_url) + + elif event_type == 'system': + subtype = event_data.get('subtype', '') + if subtype == 'session_start': + logger.info(f"Сессия Qwen запущена: {stream_event.session_id}") + elif subtype == 'init': + # Игнорируем init событие + pass + + # Вызываем callback события если есть + if on_event: + on_event(stream_event) + + except json.JSONDecodeError as e: + # Не JSON строка - возвращаем как текст + logger.debug(f"Не JSON строка: {line[:100]}...") + extracted_text += line + "\n" + if on_chunk: + await on_chunk(line + "\n") + else: + # Обычный текст (не JSON) - например, приветственное сообщение + extracted_text += line + "\n" + if on_chunk: + await on_chunk(line + "\n") + + return extracted_text + + def _parse_output(self, output: str) -> str: + """ + Распарсить JSON вывод qwen-code. + Если вывод не JSON — вернуть как есть. + """ + # Пока просто возвращаем очищенный вывод + # В будущем можно парсить JSON stream-format + lines = output.split('\n') + cleaned = [] + + for line in lines: + # Убираем служебные сообщения + if line.strip() and not line.startswith('{'): + cleaned.append(line) + + return '\n'.join(cleaned) if cleaned else output + + +class GigaChatProvider(BaseAIProvider): + """ + AI-провайдер для работы с GigaChat API. + + Альтернатива Qwen Code для генерации ответов. + Использует GigaChatTool для взаимодействия с API Сбера. + """ + + def __init__(self): + super().__init__() + self._tool = None + self._initialized = False + self._config_error: Optional[str] = None + + @property + def provider_name(self) -> str: + return "GigaChat" + + @property + def supports_tools(self) -> bool: + return False + + @property + def supports_streaming(self) -> bool: + return False + + def _ensure_initialized(self): + """Ленивая инициализация инструмента""" + if self._initialized: + return + + try: + from bot.tools.gigachat_tool import create_gigachat_tool + self._tool = create_gigachat_tool() + + if not self._tool: + self._config_error = "GigaChat не настроен. Проверьте GIGACHAT_CLIENT_ID и GIGACHAT_CLIENT_SECRET в .env" + logger.warning(self._config_error) + else: + logger.info("GigaChatProvider инициализирован") + except ImportError as e: + self._config_error = f"Ошибка импорта GigaChat: {e}" + logger.error(self._config_error) + except Exception as e: + self._config_error = f"Ошибка инициализации GigaChat: {e}" + logger.error(self._config_error) + + self._initialized = True + + async def chat( + self, + prompt: str, + system_prompt: Optional[str] = None, + temperature: float = 0.7, + max_tokens: int = 2000, + on_chunk: Optional[Callable[[str], Any]] = None, + ) -> Dict[str, Any]: + """ + Отправка запроса к GigaChat API + + Args: + prompt: Запрос пользователя + system_prompt: Системный промпт (роль ассистента) + temperature: Температура генерации + max_tokens: Максимум токенов в ответе + on_chunk: Callback для потоковой отправки (не используется, GigaChat отдаёт целиком) + + Returns: + Dict с полями: + - success: bool + - content: str - текст ответа + - error: str - ошибка если есть + - model: str - использованная модель + - usage: dict - статистика токенов + """ + self._ensure_initialized() + + if not self._tool: + return { + "success": False, + "error": self._config_error or "GigaChat не инициализирован", + "content": "", + } + + try: + from bot.tools.gigachat_tool import GigaChatMessage + + # Формируем сообщения + # ВАЖНО: prompt уже содержит весь контекст (system_prompt + summary + memory + history + запрос) + # Поэтому system_prompt отдельно НЕ добавляем + messages = [ + GigaChatMessage(role="user", content=prompt), + ] + + # Вызываем GigaChat API + response = await self._tool.chat( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + use_history=False, # Не используем встроенную историю — у нас своя + user_id="telegram-bot", + ) + + # Проверяем наличие ошибки в ответе + if response.get("error"): + logger.error(f"GigaChat API error: {response['error']}") + return { + "success": False, + "error": response["error"], + "content": "", + } + + # Потоковая отправка если есть callback + if on_chunk and response.get("content"): + await on_chunk(response["content"]) + + return { + "success": True, + "content": response.get("content", ""), + "model": response.get("model", "GigaChat-Pro"), + "usage": response.get("usage", {}), + } + + except Exception as e: + logger.error(f"Ошибка GigaChat API: {e}") + return { + "success": False, + "error": str(e), + "content": "", + } + + def clear_session(self): + """Очистка сессии (истории чата)""" + if self._tool: + self._tool.clear_history() + + def is_available(self) -> bool: + """Проверка доступности провайдера""" + self._ensure_initialized() + return self._tool is not None + + def get_error(self) -> Optional[str]: + """Получение ошибки инициализации""" + self._ensure_initialized() + return self._config_error + + async def chat( + self, + prompt: str, + system_prompt: Optional[str] = None, + context: Optional[List[Dict[str, str]]] = None, + tools: Optional[List[Dict[str, Any]]] = None, + on_chunk: Optional[Callable[[str], Any]] = None, + user_id: Optional[int] = None, + **kwargs + ) -> ProviderResponse: + """Реализация метода chat для интерфейса BaseAIProvider.""" + result = await self.chat( + prompt=prompt, + system_prompt=system_prompt, + on_chunk=on_chunk, + ) + + if result.get("success"): + return ProviderResponse( + success=True, + message=AIMessage( + content=result.get("content", ""), + metadata={"model": result.get("model")} + ), + provider_name=self.provider_name, + usage=result.get("usage") + ) + else: + return ProviderResponse( + success=False, + error=result.get("error", "Unknown error"), + provider_name=self.provider_name + ) + + async def execute_tool( + self, + tool_name: str, + tool_args: Dict[str, Any], + tool_call_id: Optional[str] = None, + **kwargs + ) -> ToolCall: + """GigaChat не поддерживает инструменты нативно.""" + return ToolCall( + tool_name=tool_name, + tool_args=tool_args, + tool_call_id=tool_call_id, + status=ToolCallStatus.PENDING + ) + + async def process_with_tools( + self, + prompt: str, + system_prompt: Optional[str] = None, + context: Optional[List[Dict[str, str]]] = None, + tools_registry: Optional[Dict[str, Any]] = None, + on_chunk: Optional[Callable[[str], Any]] = None, + max_iterations: int = 5, + **kwargs + ) -> ProviderResponse: + """Обработка запроса с инструментами для GigaChat. + + GigaChat не поддерживает инструменты нативно, поэтому просто + выполняем запрос без инструментов. + """ + # GigaChat не поддерживает инструменты - выполняем обычный запрос + result = await self.chat( + prompt=prompt, + system_prompt=system_prompt, + on_chunk=on_chunk, + ) + + if result.get("success"): + return ProviderResponse( + success=True, + message=AIMessage( + content=result.get("content", ""), + metadata={"model": result.get("model")} + ), + provider_name=self.provider_name, + usage=result.get("usage") + ) + else: + return ProviderResponse( + success=False, + error=result.get("error", "Unknown error"), + provider_name=self.provider_name + ) + + +# Глобальный менеджер +qwen_manager = QwenCodeManager() + +# Глобальный GigaChat провайдер +gigachat_provider = GigaChatProvider() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ff4e81c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +python-telegram-bot==21.0 +pyyaml==6.0.1 +python-dotenv==1.0.1 +asyncssh==2.16.0 +pexpect==4.9.0 +chromadb>=0.4.0 +sentence-transformers>=2.2.0 +httpx[socks]>=0.27.0 +ddgs>=0.3.0 +croniter>=2.0.0 +aiohttp>=3.9.0 +beautifulsoup4>=4.12.0 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..d8ab2cf --- /dev/null +++ b/run.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Скрипт запуска Telegram CLI Bot +# Предполагается что зависимости уже установлены через install.sh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +ENV_FILE="$SCRIPT_DIR/.env" + +# Цвета для вывода +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${GREEN}🤖 Запуск Telegram CLI Bot...${NC}" + +# Проверка виртуального окружения +if [ ! -d "venv" ]; then + echo -e "${YELLOW}⚠️ Виртуальное окружение не найдено${NC}" + echo "Запустите установку: ./install.sh" + exit 1 +fi + +# Активация виртуального окружения +if [ -f "venv/bin/activate" ]; then + source venv/bin/activate +else + echo -e "${YELLOW}❌ Ошибка: venv/bin/activate не найден${NC}" + echo "Запустите установку: ./install.sh" + exit 1 +fi + +# Проверка .env файла +if [ ! -f "$ENV_FILE" ]; then + echo -e "${YELLOW}⚠️ Файл .env не найден${NC}" + echo "Скопируйте .env.example в .env и настройте его" + exit 1 +fi + +# Проверка токена +TOKEN=$(grep "^TELEGRAM_BOT_TOKEN=" "$ENV_FILE" | cut -d'=' -f2) +if [ -z "$TOKEN" ] || [ "$TOKEN" = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz" ]; then + echo -e "${YELLOW}⚠️ TELEGRAM_BOT_TOKEN не установлен в .env${NC}" + echo "Отредактируйте .env и укажите токен от @BotFather" + exit 1 +fi + +# Экспорт токена +export TELEGRAM_BOT_TOKEN="$TOKEN" + +# Проверка qwen-code (опционально) +if command -v qwen &> /dev/null; then + echo -e "${GREEN}✅ qwen-code: $(qwen --version 2>&1 | head -1)${NC}" +else + echo -e "${YELLOW}⚠️ qwen-code не найден (ИИ-чат не будет работать)${NC}" + echo "Установите: npm install -g @qwen-code/qwen-code" +fi + +# Запуск бота +echo "" +python bot.py diff --git a/system_prompt.md b/system_prompt.md new file mode 100644 index 0000000..bd80a02 --- /dev/null +++ b/system_prompt.md @@ -0,0 +1,374 @@ +# СИСТЕМНЫЙ ПРОМПТ TELEGRAM CLI BOT +## Персональный AI-ассистент Владимира + +--- + +## 👤 ИМЯ АССИСТЕНТА + +**Ты - Telegram-бот "Рик"**. Ты работаешь внутри Telegram CLI Bot (программа на Python). Ты - это и есть бот, а не отдельный ИИ. + +**Твоё имя: Рик** (как Рик Санчез из "Рик и Морти") + +Когда пользователь говорит "бот" или "телеграм-бот" - он говорит о тебе! + +Пользователь обращается к тебе по имени **Рик**. Отвечай естественно, как персональный ассистент. + +**ВАЖНО: Не здоровайся при каждом ответе, если в контексте уже есть история вашего разговора. Начинай сразу с ответа на вопрос.** + +--- + +## 🎯 РОЛЬ И ЗАДАЧИ + +Ты — персональный AI-ассистент системного администратора Владимира. Твоя задача — помогать в повседневной работе: поиск информации, чтение новостей, управление серверами, автоматизация задач. + +**Важно:** Бот персональный (для одного пользователя), поэтому приоритет на удобстве и функциональности, а не на безопасности и ролевой модели. + +--- + +## 🛠️ ДОСТУПНЫЕ ИНСТРУМЕНТЫ (CAPABILITIES) + +У тебя есть следующие инструменты. **Используй их АВТОНОМНО** когда понимаешь что они нужны — не жди прямых команд! + +### 1. 🔍 DDGS Search (`ddgs_search`) + +**Назначение:** Поиск информации в интернете через DuckDuckGo. + +**Когда использовать:** +- Пользователь спрашивает про факты, события, новости +- Запросы типа "найди...", "погугли...", "узнай...", "что такое..." +- Вопросы про текущие события, свежие данные +- Запросы с триггерами: "найди", "поиск", "погугли", "узнай", "проверь в интернете", "что нового", "последние новости", "свежая информация", "как сделать", "найди информацию", "посмотри в сети" + +**Параметры:** +- `query` (str): Поисковый запрос +- `max_results` (int, default=10): Количество результатов + +**Пример вызова:** +```python +ddgs_search(query="Python asyncio tutorial", max_results=5) +``` + +--- + +### 2. 📰 RSS Reader (`rss_reader`) + +**Назначение:** Чтение RSS/Atom новостных лент. + +**Когда использовать:** +- Пользователь просит "почитать новости", "что нового в IT" +- Запросы про технологии, Linux, opensource +- Слова: "новости", "лента", "дайджест", "rss", "feed" +- Запросы с триггерами: "новости", "rss", "лента", "feed", "дайджест", "что нового в linux", "новости it", "tech news" + +**Действия:** +- `list` — показать последние новости (параметр `limit`, `undigested_only=True`) +- `fetch` — обновить ленты +- `list_feeds` — показать список подписок +- `add_feed` — добавить новую ленту (параметр `url`) + +**Пример вызова:** +```python +rss_reader(action="list", limit=10, undigested_only=True) +``` + +--- + +### 3. 🖥️ SSH Executor (`ssh_executor`) + +**Назначение:** Выполнение команд на серверах по SSH. + +**Когда использовать:** +- Пользователь просит выполнить команду на сервере +- Запросы про мониторинг, логи, процессы, диски +- Управление сервисами (systemctl, service) +- Слова: "выполни команду", "ssh", "запусти на сервере", "проверь сервер", "посмотри логи", "покажи процесс", "сколько места" +- Упоминания утилит: systemctl, journalctl, top, htop, df, du, free, ps, netstat + +**Доступные серверы:** +- `home` — 192.168.1.51 (пользователь: mirivlad) + +**Параметры:** +- `command` (str): Команда для выполнения +- `server` (str, default='home'): Имя сервера +- `timeout` (int, default=30): Таймаут выполнения + +**Пример вызова:** +```python +ssh_executor(command="df -h", server="home", timeout=30) +``` + +--- + +### 4. ⏰ Cron Manager (`cron_manager`) + +**Назначение:** Управление периодическими задачами (cron). + +**Когда использовать:** +- Пользователь просит "напомни", "запланируй" +- Запросы с расписанием: "каждый день", "каждый час", "периодически" +- Слова: "напомни", "запланируй", "каждый", "ежедневно", "ежечасно", "периодически", "по расписанию", "автоматически" + +**Действия:** +- `list` — показать задачи +- `add` — добавить задачу (параметры `name`, `command`, `schedule`) +- `remove` — удалить задачу (параметр `id`) +- `run` — выполнить задачу вручную (параметр `id`) + +**Пример вызова:** +```python +cron_manager(action="list") +``` + +--- + +### 5. 📁 File System Tool (`file_system_tool`) + +**Назначение:** Работа с файловой системой Linux. + +**Когда использовать:** +- Пользователь просит прочитать, создать, скопировать, переместить, удалить файл +- Запросы про просмотр содержимого директории +- Слова: "прочитай", "покажи файл", "создай", "скопируй", "перемести", "удали", "ls", "cat", "cp", "mv", "rm", "mkdir" +- Команды Unix: `cat `, `ls `, `mkdir `, `cp `, `mv `, `rm `, `touch ` + +**Действия:** +- `read` — прочитать файл (параметры `path`, `limit`) +- `write` — записать в файл (параметры `path`, `content`, `append`) +- `copy` — копировать файл (параметры `source`, `destination`) +- `move` — переместить файл (параметры `source`, `destination`) +- `delete` — удалить файл (параметры `path`, `recursive`) +- `mkdir` — создать директорию (параметры `path`, `parents`) +- `list` — список файлов (параметры `path`, `show_hidden`) +- `info` — информация о файле (параметр `path`) +- `search` — поиск файлов (параметры `path`, `pattern`, `max_results`) +- `shell` — выполнить shell-команду (параметры `command`, `timeout`) + +**Примеры вызова:** +```python +file_system_tool(operation='read', path='/home/mirivlad/test.txt') +file_system_tool(operation='write', path='/tmp/note.txt', content='Текст заметки') +file_system_tool(operation='list', path='/home/mirivlad/git') +file_system_tool(operation='copy', source='file.txt', destination='backup/file.txt') +``` + +**Безопасность:** +- Разрешена работа в домашней директории, `/tmp`, `/var/tmp` +- Запрещена запись в системные директории (`/etc`, `/usr`, `/bin`, etc.) + +--- + +## 🧠 ПРИНЦИПЫ РАБОТЫ + +### 1. **Автономность (Agentic AI)** +- Сам решай когда использовать инструменты — не жди прямых команд +- Если видишь триггер инструмента — сразу предлагай его использовать +- Пример: "найди свежие новости про Python" → сам вызываешь `ddgs_search` или `rss_reader` + +### 2. **Контекст и память** +- У тебя есть доступ к памяти (ChromaDB RAG) — используй для контекста +- Помни предыдущие сообщения в диалоге +- Извлекай факты из диалогов для долгосрочной памяти + +### 3. **Прозрачность** +- Объясняй что делаешь: "Сейчас поищу информацию..." +- Показывай результаты инструментов в понятном формате +- Если инструмент не сработал — пробуй альтернативы + +### 4. **Приоритеты инструментов** +При принятии решения следуй приоритету: +1. **File System** — если операция с файлами/директориями +2. **SSH** — если явная системная задача на сервере +3. **Cron** — если планирование/напоминание +4. **Поиск (DDGS)** — если нужны свежие данные из интернета +5. **RSS** — если новости из подписанных лент + +### 5. **⚠️ БЛОКИРОВКА: Реакция на действия бота** + +**ГЛАВНОЕ ПРАВИЛО:** Не активируй инструменты если пользователь говорит о **прошлых действиях бота**, а не просит сделать что-то новое. + +**❌ НЕ активируй инструменты если пользователь:** + +| Тип сообщения | Примеры | Реакция | +|--------------|---------|---------| +| **Комментирует прошлые действия** | "ты опять ddgs запустил", "зачем ты rss включил", "ну и снова ты cron включил" | ❌ Не запускать инструмент | +| **Критикует срабатывание** | "перестань", "хватит", "не надо", "отстань" | ❌ Не запускать инструмент | +| **Указывает на ошибку** | "баг", "ошибка", "неправильно", "глюк", "нерелевантно", "не то" | ❌ Не запускать инструмент | +| **Говорит о прошлом** | "я не просил", "я не говорил", "я не это имел в виду" | ❌ Не запускать инструмент | +| **Реагирует на результат** | "это не то", "зачем мне это", "я вижу что ты..." | ❌ Не запускать инструмент | +| **Описывает проблему срабатывания** | "срабатывает нерелевантно", "ложное срабатывание", "неправильно понимаешь" | ❌ Не запускать инструмент | + +**✅ Активируй инструменты только если:** +- Пользователь явно просит сделать что-то **новое** ("найди...", "проверь...", "запусти...") +- В запросе есть **триггерные слова** из раздела инструментов +- Пользователь продолжает тему и нужен **новый запрос** к инструменту + +**Примеры правильной реакции:** + +| Сообщение пользователя | Действие | +|----------------------|----------| +| "ты опять ddgs запустил" | ❌ Извиниться, не запускать | +| "найди информацию про Python" | ✅ Запустить ddgs_search | +| "перестань запускать cron" | ❌ Извиниться, не запускать | +| "запланируй напоминание на завтра" | ✅ Запустить cron_manager | +| "баг в твоей логике" | ❌ Извиниться, спросить что исправить | +| "покажи последние новости" | ✅ Запустить rss_reader | +| "я не просил искать новости" | ❌ Извиниться, не запускать rss_reader | +| "что нового в Linux?" | ✅ Запустить rss_reader | + +**Правильное поведение при ошибке:** Извиниться кратко, объяснить что исправишь логику, но **не запускать инструмент повторно**. + +--- + +## 📋 ФОРМАТ ОТВЕТОВ + +### При использовании инструментов: + +``` +🔍 **Результаты поиска:** + +1. **Название результата** + https://ссылка + Краткое описание... + +2. **Следующий результат** + ... + +--- +📊 Контекст: X% +``` + +### При SSH-командах: + +``` +🖥️ **SSH: home (192.168.1.51)** +**Команда:** `df -h` + +**Вывод:** +``` +Filesystem Size Used Avail Use% Mounted on +... +``` + +✅ **Успешно** +``` + +### При RSS-новостях: + +``` +📰 **Последние новости:** + +1. Заголовок новости + 📅 2026-02-25 10:30:00 + 🔗 https://ссылка + +2. ... + +--- +📊 Контекст: X% +``` + +--- + +## ⚠️ ВАЖНЫЕ ПРАВИЛА + +1. **Не переводи технические термины** — оставляй на английском +2. **Код и команды в code blocks** — используй \`\\\`\` для форматирования +3. **Сокращай длинные выводы** — первые 5 и последние 10 строк +4. **Проверяй контекст** — не превышай лимит токенов (200K max) +5. **Сохраняй историю** — добавляй ответы в память для будущего контекста + +--- + +## 🧠 АНАЛИЗ РЕЗУЛЬТАТОВ ИНСТРУМЕНТОВ + +**ВАЖНОЕ ПРАВИЛО:** Когда ты получаешь результаты от инструментов (ddgs_search, rss_reader, ssh_executor и др.) — **не просто пересказывай их**, а: + +1. **Проанализируй** данные и выдели главное +2. **Сделай выводы** на основе полученной информации +3. **Предоставь пользователю полезную информацию** в сжатом, понятном виде +4. **Упомяни ключевые источники** если есть ссылки + +**Пример правильного ответа на результаты ddgs_search:** + +❌ **Плохо:** +``` +🔍 Результаты поиска: +1. Статья 1 + https://example.com + Описание... +2. Статья 2 + ... +``` + +✅ **Хорошо:** +``` +Нашёл несколько полезных ресурсов по вашему запросу: + +**Основное:** +- [Название статьи](https://ссылка) — краткое описание почему это важно + +**Дополнительно:** +- [Ещё один источник](https://ссылка) — альтернативный взгляд + +**Вывод:** Основная информация по теме находится здесь [ссылка]. +Ключевые моменты: пункт 1, пункт 2, пункт 3. +``` + +**Пример правильного ответа на результаты rss_reader:** + +❌ **Плохо:** +``` +📰 Последние новости: +1. Заголовок 1 + 📅 2026-02-25 + 🔗 https://ссылка +``` + +✅ **Хорошо:** +``` +Вот главное из IT-новостей за сегодня: + +**Важное:** +• **Заголовок новости** — краткая суть (источник: Habr) + +**Интересное:** +• **Другая новость** — почему это важно (источник: OpenNET) + +Хотите подробнее про что-то конкретное? +``` + +--- + +## 🔄 ПРИМЕРЫ ДИАЛОГОВ + +### Пример 1: Поиск информации +**Пользователь:** "найди информацию про asyncio в Python" +**Твои действия:** Вызвать `ddgs_search(query="Python asyncio tutorial", max_results=5)` + +### Пример 2: Новости +**Пользователь:** "что нового в Linux?" +**Твои действия:** Вызвать `rss_reader(action="list", limit=10, undigested_only=True)` + +### Пример 3: SSH-команда +**Пользователь:** "проверь нагрузку на сервере" +**Твои действия:** Вызвать `ssh_executor(command="uptime && top -bn1 | head -20", server="home")` + +### Пример 4: Комбинированный запрос +**Пользователь:** "найди свежие новости про Python и покажи мне" +**Твои действия:** +1. Сначала `ddgs_search(query="Python news 2026", max_results=5)` +2. Потом `rss_reader(action="list", limit=5, undigested_only=True)` +3. Объединить результаты в ответе + +--- + +## 🎯 ТЕКУЩАЯ ВЕРСИЯ + +**Bot Version:** 0.8.0 +**AI Provider Manager:** Поддержка multiple AI providers (Qwen Code, GigaChat) +**Memory:** ChromaDB RAG + Vector Memory +**Tools:** ddgs_tool, rss_tool, ssh_tool, cron_tool, file_system_tool + +--- + +*Этот системный промпт загружается при запуске чата и определяет поведение AI-агента.* diff --git a/telegram-bot.service b/telegram-bot.service new file mode 100644 index 0000000..8d516f4 --- /dev/null +++ b/telegram-bot.service @@ -0,0 +1,20 @@ +[Unit] +Description=Telegram CLI Bot +After=network.target + +[Service] +Type=simple +User=%USER% +WorkingDirectory=%WORKDIR% +Environment="PATH=%NODE_BIN_DIR%:%VENV_PATH%:/home/%USER%/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +Environment="NVM_DIR=/home/%USER%/.nvm" +Environment="NODE_PATH=%NODE_LIB_DIR%" +ExecStart=%VENV_PATH%/python bot.py +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=telegram-bot + +[Install] +WantedBy=multi-user.target diff --git a/telegram_channels.json b/telegram_channels.json new file mode 100644 index 0000000..880105b --- /dev/null +++ b/telegram_channels.json @@ -0,0 +1,5 @@ +{ + "channels": [ + "it_mirv" + ] +} \ No newline at end of file diff --git a/test_memory.py b/test_memory.py new file mode 100644 index 0000000..2f427ee --- /dev/null +++ b/test_memory.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +Тест системы памяти Telegram бота. + +Проверяет: +1. Сохранение сообщений в SQLite +2. Сохранение сообщений в ChromaDB +3. Загрузку истории из БД +4. RAG-поиск по векторной базе +5. Извлечение фактов +""" + +import os +import sys +import asyncio +from pathlib import Path + +# Добавляем путь к боту +BOT_DIR = Path(__file__).parent +sys.path.insert(0, str(BOT_DIR)) + +# Импортируем компоненты памяти +from vector_memory import ( + hybrid_memory_manager, + save_message, + get_context, + load_history_to_state, + search_memory, + get_profile +) + +from bot.models.user_state import UserState, StateManager + + +# Тестовый пользователь +TEST_USER_ID = 999999 + + +def test_sqlite_save(): + """Тест 1: Сохранение сообщений в SQLite.""" + print("\n" + "="*60) + print("ТЕСТ 1: Сохранение сообщений в SQLite") + print("="*60) + + # Сохраняем тестовые сообщения + save_message(TEST_USER_ID, "user", "Привет! Меня зовут Владимир.") + save_message(TEST_USER_ID, "assistant", "Привет, Владимир! Чем могу помочь?") + save_message(TEST_USER_ID, "user", "Я работаю системным администратором.") + save_message(TEST_USER_ID, "assistant", "Отлично! Какие технологии вы используете?") + + # Проверяем сохранение — используем гибридный менеджер напрямую + context = hybrid_memory_manager.get_context(TEST_USER_ID, max_messages=10) + print(f"✅ Сохранено сообщений в SQLite: {len(context)}") + + for msg in context: + print(f" - [{msg.role}]: {msg.content[:50]}...") + + assert len(context) >= 4, "Сообщения не сохранились в SQLite!" + print("\n✅ ТЕСТ 1: УСПЕШНО\n") + return True + + +def test_vector_save(): + """Тест 2: Сохранение сообщений в векторную базу.""" + print("\n" + "="*60) + print("ТЕСТ 2: Сохранение сообщений в ChromaDB") + print("="*60) + + # Проверяем векторную базу + if hybrid_memory_manager.vector: + stats = hybrid_memory_manager.vector.get_stats() + print(f"✅ Векторная база активна") + print(f" - Документы: {stats.get('total_documents', 0)}") + print(f" - Модель: {stats.get('model', 'unknown')}") + + assert stats.get('total_documents', 0) >= 4, "Сообщения не сохранились в ChromaDB!" + print("\n✅ ТЕСТ 2: УСПЕШНО\n") + return True + else: + print("⚠️ Векторная база не активна") + return False + + +def test_history_loading(): + """Тест 3: Загрузка истории из БД в состояние.""" + print("\n" + "="*60) + print("ТЕСТ 3: Загрузка истории из БД") + print("="*60) + + # Создаём тестовое состояние + state = UserState() + state_manager = StateManager() + + # Загружаем историю + history = load_history_to_state(TEST_USER_ID, state, state_manager) + + print(f"✅ Загружено сообщений из БД: {len(history)}") + print(f" - История в state.ai_chat_history: {len(state.ai_chat_history)}") + print(f" - Флаг загрузки: {state_manager.is_history_loaded(TEST_USER_ID)}") + + if history: + print("\n Последние сообщения:") + for msg in history[-3:]: + print(f" {msg[:80]}...") + + assert len(state.ai_chat_history) >= 4, "История не загрузилась из БД!" + assert state_manager.is_history_loaded(TEST_USER_ID), "Флаг загрузки не установлен!" + print("\n✅ ТЕСТ 3: УСПЕШНО\n") + return True + + +async def test_rag_search(): + """Тест 4: RAG-поиск по векторной базе.""" + print("\n" + "="*60) + print("ТЕСТ 4: RAG-поиск по векторной базе") + print("="*60) + + # Ищем по запросу + query = "Владимир работа" + results = search_memory(TEST_USER_ID, query, limit=5) + + print(f"✅ Найдено результатов по запросу '{query}': {len(results)}") + + for msg, score in results: + print(f" - [{score:.2f}] [{msg.role}]: {msg.content[:60]}...") + + assert len(results) >= 1, "RAG-поиск не нашёл результатов!" + print("\n✅ ТЕСТ 4: УСПЕШНО\n") + return True + + +def test_fact_extraction(): + """Тест 5: Извлечение фактов.""" + print("\n" + "="*60) + print("ТЕСТ 5: Извлечение фактов") + print("="*60) + + # Сохраняем сообщение с фактом + save_message(TEST_USER_ID, "user", "Меня зовут Владимир, я живу в городе Ангарск.") + + # Получаем профиль + profile = get_profile(TEST_USER_ID) + + print(f"✅ Факты в профиле:") + total_facts = 0 + for fact_type, facts in profile.items(): + print(f" [{fact_type}]:") + for fact in facts: + print(f" - {fact}") + total_facts += 1 + + print(f"\n Всего фактов: {total_facts}") + + # Факты могут не извлечься эвристиками, это нормально + print("\n✅ ТЕСТ 5: ЗАВЕРШЁН\n") + return True + + +async def test_memory_gradient(): + """Тест 6: Градиентная память (STM → LTM → RAG).""" + print("\n" + "="*60) + print("ТЕСТ 6: Градиентная память (STM → LTM → RAG)") + print("="*60) + + # Получаем контекст с градиентной памятью + from vector_memory import get_context as get_formatted_context + + query = "Владимир" + context = get_formatted_context(TEST_USER_ID, query=query, stm_size=3, ltm_size=5) + + print(f"✅ Сформирован контекст для ИИ:") + print(f" - Длина: {len(context)} символов") + print(f" - STM размер: 3 сообщения") + print(f" - LTM размер: 5 сообщений") + print(f" - RAG поиск по запросу: {query}") + + # Проверяем наличие секций + has_stm = "💬 STM" in context + has_ltm = "🕰️ LTM" in context + has_rag = "🔍 RAG" in context + has_profile = "📋 ПРОФИЛЬ" in context + + print(f"\n Секции:") + print(f" - Профиль: {'✅' if has_profile else '❌'}") + print(f" - STM: {'✅' if has_stm else '❌'}") + print(f" - LTM: {'✅' if has_ltm else '❌'}") + print(f" - RAG: {'✅' if has_rag else '❌'}") + + assert has_stm or has_ltm, "Градиентная память не работает!" + print("\n✅ ТЕСТ 6: УСПЕШНО\n") + return True + + +async def main(): + """Запуск всех тестов.""" + print("\n" + "="*60) + print("🧠 ТЕСТИРОВАНИЕ СИСТЕМЫ ПАМЯТИ TELEGRAM БОТА") + print("="*60) + print(f"Тестовый пользователь ID: {TEST_USER_ID}") + print(f"Дата: {__import__('datetime').datetime.now()}") + + results = { + "SQLite Save": False, + "Vector Save": False, + "History Loading": False, + "RAG Search": False, + "Fact Extraction": False, + "Memory Gradient": False + } + + try: + # Тест 1: SQLite + results["SQLite Save"] = test_sqlite_save() + + # Тест 2: Vector + results["Vector Save"] = test_vector_save() + + # Тест 3: Загрузка истории + results["History Loading"] = test_history_loading() + + # Тест 4: RAG-поиск + results["RAG Search"] = await test_rag_search() + + # Тест 5: Извлечение фактов + results["Fact Extraction"] = test_fact_extraction() + + # Тест 6: Градиентная память + results["Memory Gradient"] = await test_memory_gradient() + + except Exception as e: + print(f"\n❌ ОШИБКА ПРИ ТЕСТИРОВАНИИ: {e}") + import traceback + traceback.print_exc() + + # Итоговый отчёт + print("\n" + "="*60) + print("📊 ИТОГОВЫЙ ОТЧЁТ") + print("="*60) + + for test_name, result in results.items(): + icon = "✅" if result else "❌" + print(f" {icon} {test_name}: {'УСПЕШНО' if result else 'ПРОВАЛ'}") + + total_passed = sum(results.values()) + total_tests = len(results) + + print(f"\n Всего пройдено: {total_passed}/{total_tests}") + + if total_passed == total_tests: + print("\n🎉 ВСЕ ТЕСТЫ ПРОЙДЕНЫ!") + else: + print(f"\n⚠️ {total_tests - total_passed} тест(а) не пройдено") + + print("\n" + "="*60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/vector_memory.py b/vector_memory.py new file mode 100644 index 0000000..6c0cf40 --- /dev/null +++ b/vector_memory.py @@ -0,0 +1,742 @@ +#!/usr/bin/env python3 +""" +Векторная память для ИИ-чата на основе ChromaDB + sentence-transformers. + +Обеспечивает семантический поиск по истории диалогов. +Используется вместе с SQLiteMemoryStorage из memory_system.py + +Модель: all-MiniLM-L6-v2 (90MB, 384 измерения) — быстрая и лёгкая. +""" + +import logging +from pathlib import Path +from datetime import datetime +from typing import Optional, List, Dict, Any, Tuple +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +# Импортируем модели из memory_system.py +from memory_system import Message, Fact, FactType, SQLiteMemoryStorage, MEMORY_DB_PATH + + +# ============================================================================ +# ChromaDB хранилище +# ============================================================================ + +class VectorMemoryStorage: + """ + Векторное хранилище на основе ChromaDB. + + Модель: all-MiniLM-L6-v2 + - Размер: 90MB + - Измерения: 384 + - Скорость: ~1000 эмбеддингов/сек на CPU + """ + + def __init__(self, persist_directory: str = None, model_name: str = "all-MiniLM-L6-v2"): + """ + Инициализация ChromaDB и модели эмбеддингов. + """ + self.persist_directory = persist_directory + self.model_name = model_name + self._client = None + self._collection = None + self._embedding_model = None + + self._init_db() + + def _init_db(self): + """Инициализация клиента ChromaDB и модели.""" + import chromadb + from chromadb.config import Settings + + # Инициализация клиента + if self.persist_directory: + self._client = chromadb.PersistentClient( + path=self.persist_directory, + settings=Settings( + anonymized_telemetry=False, + allow_reset=True + ) + ) + logger.info(f"ChromaDB инициализирован (persistent): {self.persist_directory}") + else: + self._client = chromadb.EphemeralClient() + logger.info("ChromaDB инициализирован (in-memory)") + + # Создаём коллекцию + self._collection = self._client.get_or_create_collection( + name="telegram_messages", + metadata={"description": "История диалогов Telegram бота"} + ) + logger.info(f"Коллекция готова: {self._collection.name}") + + def _get_embedding_model(self): + """Ленивая загрузка модели эмбеддингов.""" + if self._embedding_model is None: + import os + # Отключаем проверку обновлений на HuggingFace + os.environ["TRANSFORMERS_OFFLINE"] = "1" + os.environ["HF_HUB_OFFLINE"] = "1" + + from sentence_transformers import SentenceTransformer + # local_files_only=True — загружать только из кэша + self._embedding_model = SentenceTransformer( + self.model_name, + local_files_only=True + ) + logger.info(f"Модель эмбеддингов загружена: {self.model_name}") + return self._embedding_model + + def _compute_embedding(self, text: str) -> List[float]: + """Вычислить эмбеддинг текста.""" + model = self._get_embedding_model() + embedding = model.encode(text, convert_to_numpy=True) + return embedding.tolist() + + def add_message(self, message: Message) -> str: + """Добавить сообщение в векторное хранилище.""" + import uuid + + doc_id = str(uuid.uuid4()) + embedding = self._compute_embedding(message.content) + + metadata = { + "user_id": str(message.user_id), + "role": message.role, + "timestamp": message.timestamp.isoformat() if message.timestamp else datetime.now().isoformat(), + "session_id": message.session_id or "unknown" + } + + self._collection.add( + ids=[doc_id], + embeddings=[embedding], + documents=[message.content], + metadatas=[metadata] + ) + + logger.debug(f"Добавлено сообщение в векторную БД: user={message.user_id}, len={len(message.content)}") + return doc_id + + def add_messages_batch(self, messages: List[Message]) -> List[str]: + """Добавить пакет сообщений.""" + import uuid + + if not messages: + return [] + + ids = [str(uuid.uuid4()) for _ in messages] + documents = [msg.content for msg in messages] + + # Вычисляем эмбеддинги батчем (быстрее) + model = self._get_embedding_model() + embeddings = model.encode(documents, convert_to_numpy=True).tolist() + + metadatas = [ + { + "user_id": str(msg.user_id), + "role": msg.role, + "timestamp": msg.timestamp.isoformat() if msg.timestamp else datetime.now().isoformat(), + "session_id": msg.session_id or "unknown" + } + for msg in messages + ] + + self._collection.add( + ids=ids, + embeddings=embeddings, + documents=documents, + metadatas=metadatas + ) + + logger.info(f"Добавлено {len(messages)} сообщений в векторную БД") + return ids + + def search_similar( + self, + user_id: int, + query: str, + limit: int = 5, + role_filter: Optional[str] = None + ) -> List[Tuple[Message, float]]: + """Семантический поиск похожих сообщений.""" + # Вычисляем эмбеддинг запроса + query_embedding = self._compute_embedding(query) + + # Фильтр по пользователю + where_filter = {"user_id": str(user_id)} + if role_filter: + where_filter = {"$and": [{"user_id": str(user_id)}, {"role": role_filter}]} + + # Поиск + results = self._collection.query( + query_embeddings=[query_embedding], + n_results=limit, + where=where_filter, + include=["documents", "metadatas", "distances"] + ) + + # Преобразуем результаты + found_messages = [] + + if results and results.get('ids') and results['ids']: + docs = results.get('documents', [[]])[0] + metas = results.get('metadatas', [[]])[0] + dists = results.get('distances', [[]])[0] if results.get('distances') else [] + + for i, doc_id in enumerate(results['ids'][0]): + doc_text = docs[i] if i < len(docs) else "" + metadata = metas[i] if i < len(metas) else {} + distance = dists[i] if i < len(dists) else 0.0 + + message = Message( + id=None, + user_id=int(metadata.get('user_id', 0)), + role=metadata.get('role', 'user'), + content=doc_text, + timestamp=datetime.fromisoformat(metadata.get('timestamp', datetime.now().isoformat())), + session_id=metadata.get('session_id') + ) + + found_messages.append((message, distance)) + + logger.debug(f"Векторный поиск: query='{query[:30]}...', found={len(found_messages)}") + return found_messages + + def search_by_session( + self, + session_id: str, + query: str = None, + limit: int = 20 + ) -> List[Message]: + """Получить сообщения из сессии.""" + where_filter = {"session_id": session_id} + + if query: + query_embedding = self._compute_embedding(query) + results = self._collection.query( + query_embeddings=[query_embedding], + n_results=limit, + where=where_filter, + include=["documents", "metadatas"] + ) + else: + # Получаем все сообщения сессии + results = self._collection.get( + where=where_filter, + include=["documents", "metadatas"], + limit=limit + ) + + messages = [] + if results and results.get('ids') and results['ids']: + docs = results.get('documents', [[]])[0] if results.get('documents') else [] + metas = results.get('metadatas', [[]])[0] if results.get('metadatas') else [] + + for i, doc_id in enumerate(results['ids'][0]): + doc_text = docs[i] if i < len(docs) else "" + metadata = metas[i] if i < len(metas) else {} + + message = Message( + id=None, + user_id=int(metadata.get('user_id', 0)), + role=metadata.get('role', 'user'), + content=doc_text, + timestamp=datetime.fromisoformat(metadata.get('timestamp', datetime.now().isoformat())), + session_id=metadata.get('session_id') + ) + messages.append(message) + + return messages + + def get_stats(self) -> Dict[str, Any]: + """Получить статистику коллекции.""" + count = self._collection.count() + return { + "total_documents": count, + "collection_name": self._collection.name, + "model": self.model_name + } + + def delete_user_data(self, user_id: int) -> int: + """Удалить все данные пользователя.""" + results = self._collection.get( + where={"user_id": str(user_id)}, + include=[] + ) + + if results and results.get('ids'): + count = len(results['ids']) + self._collection.delete(ids=results['ids']) + logger.info(f"Удалено {count} документов пользователя {user_id}") + return count + return 0 + + +# ============================================================================ +# Гибридный менеджер памяти (SQLite + Vector) +# ============================================================================ + +class HybridMemoryManager: + """ + Гибридный менеджер памяти. + + Объединяет: + - SQLiteMemoryStorage для хранения фактов и истории + - VectorMemoryStorage для семантического поиска + """ + + def __init__( + self, + sqlite_storage: SQLiteMemoryStorage, + vector_storage: VectorMemoryStorage = None, + ai_client=None + ): + self.sqlite = sqlite_storage + self.vector = vector_storage + self.ai_client = ai_client + self._active_sessions: Dict[int, str] = {} + + def start_session(self, user_id: int) -> str: + """Начать новую сессию.""" + import uuid + session_id = str(uuid.uuid4()) + + from memory_system import DialogSession + session = DialogSession(id=session_id, user_id=user_id) + self.sqlite.create_session(session) + self._active_sessions[user_id] = session_id + + logger.info(f"Начата новая сессия {session_id} для пользователя {user_id}") + return session_id + + def end_session(self, user_id: int, summary: str = None): + """Завершить сессию.""" + session_id = self._active_sessions.pop(user_id, None) + if session_id: + self.sqlite.close_session(session_id, summary) + logger.info(f"Завершена сессия {session_id} для пользователя {user_id}") + + def get_session_id(self, user_id: int) -> Optional[str]: + """Получить ID текущей сессии.""" + if user_id in self._active_sessions: + return self._active_sessions[user_id] + + session = self.sqlite.get_active_session(user_id) + if session: + self._active_sessions[user_id] = session.id + return session.id + + return self.start_session(user_id) + + def add_message(self, user_id: int, role: str, content: str) -> int: + """Добавить сообщение в оба хранилища.""" + from memory_system import Message + + session_id = self.get_session_id(user_id) + message = Message( + id=None, + user_id=user_id, + role=role, + content=content, + session_id=session_id + ) + + # Сохраняем в SQLite + sqlite_id = self.sqlite.save_message(message) + + # Сохраняем в векторную БД + if self.vector: + try: + self.vector.add_message(message) + except Exception as e: + logger.error(f"Ошибка сохранения в векторную БД: {e}") + + return sqlite_id + + def get_context(self, user_id: int, max_messages: int = 10) -> List[Message]: + """Получить контекст для ИИ (последние сообщения).""" + return self.sqlite.get_recent_messages(user_id, max_messages) + + def search_relevant( + self, + user_id: int, + query: str, + max_results: int = 5, + use_vector: bool = True + ) -> List[Tuple[Message, float]]: + """Найти релевантные сообщения.""" + # Приоритет векторному поиску + if use_vector and self.vector: + try: + results = self.vector.search_similar( + user_id=user_id, + query=query, + limit=max_results + ) + logger.info(f"Векторный поиск: найдено {len(results)} результатов") + return results + except Exception as e: + logger.error(f"Ошибка векторного поиска, используем SQLite: {e}") + + # Фоллбэк на SQLite LIKE поиск + messages = self.sqlite.search_messages(user_id, query, max_results) + return [(msg, 0.5) for msg in messages] + + def get_user_profile(self, user_id: int) -> Dict[str, List[str]]: + """Получить профиль пользователя (факты).""" + facts = self.sqlite.get_facts(user_id) + profile = {} + + for fact in facts: + type_name = fact.fact_type.value + if type_name not in profile: + profile[type_name] = [] + profile[type_name].append(fact.content) + + return profile + + def extract_and_save_facts(self, user_id: int, message: str, response: str = None): + """Извлечь факты из сообщения (эвристики + ИИ).""" + import re + from memory_system import Fact, FactType + + extracted = [] + message_lower = message.lower() + + # Эвристики для быстрых простых фактов + if "меня зовут" in message_lower: + parts = message.split("меня зовут") + if len(parts) > 1: + name = parts[1].strip().split()[0] + fact = Fact( + id=None, + user_id=user_id, + fact_type=FactType.PERSONAL, + content=f"Пользователя зовут {name}", + source_message=message, + confidence=0.8 + ) + self.sqlite.save_fact(fact) + extracted.append(fact) + + # Технологии + tech_patterns = [ + (r"я (люблю|предпочитаю|использую)\s+(\w+)", FactType.TECHNICAL), + (r"мой (язык|стек)\s+(\w+)", FactType.TECHNICAL), + ] + + for pattern, fact_type in tech_patterns: + match = re.search(pattern, message_lower) + if match: + tech = match.group(2) if len(match.groups()) > 1 else match.group(1) + fact = Fact( + id=None, + user_id=user_id, + fact_type=fact_type, + content=f"Использует {tech}", + source_message=message, + confidence=0.6 + ) + self.sqlite.save_fact(fact) + extracted.append(fact) + + if extracted: + logger.info(f"Извлечено {len(extracted)} фактов (эвристики) для пользователя {user_id}") + + async def extract_facts_with_ai(self, user_id: int, dialog_context: str) -> List: + """ + Извлечь факты из диалога с помощью ИИ. + + Args: + user_id: ID пользователя + dialog_context: Текст диалога для анализа + + Returns: + Список сохранённых фактов + """ + from memory_system import Fact, FactType + import json + import re + + # Промпт для извлечения фактов + prompt = f""" +Проанализируй диалог и извлеки факты о пользователе. + +Диалог: +{dialog_context} + +Извлеки факты по категориям: +1. **PERSONAL** — имя, возраст, город, профессия, предпочтения +2. **TECHNICAL** — технологии, языки программирования, инструменты, стек +3. **PROJECT** — проекты, репозитории, директории, домены +4. **PREFERENCE** — предпочтения в коде, стиле, инструментах + +Верни ответ ТОЛЬКО в формате JSON: +{{ + "facts": [ + {{"type": "personal", "content": "Пользователя зовут Владимир", "confidence": 0.9}}, + {{"type": "technical", "content": "Использует Python и Docker", "confidence": 0.8}}, + {{"type": "project", "content": "Проект telegram-cli-bot в ~/git/", "confidence": 0.7}} + ] +}} + +Если фактов нет — верни {{"facts": []}} +Не выдумывай факты. Только то, что явно указано в диалоге. +""" + + try: + # Импортируем qwen_manager для выполнения задачи + from qwen_integration import qwen_manager + + output_buffer = [] + def on_output(text): + output_buffer.append(text) + + # Выполняем задачу + await qwen_manager.run_task(user_id, prompt, on_output, lambda x: None) + + result = "".join(output_buffer).strip() + + # Парсим JSON из ответа + json_match = re.search(r'\{.*\}', result, re.DOTALL) + if not json_match: + logger.warning(f"Не найден JSON в ответе ИИ: {result[:200]}") + return [] + + data = json.loads(json_match.group(0)) + facts_data = data.get("facts", []) + + if not facts_data: + logger.info(f"ИИ не нашёл фактов для пользователя {user_id}") + return [] + + # Сохраняем факты + extracted = [] + for fact_item in facts_data: + fact_type_str = fact_item.get("type", "other").lower() + + # Маппинг типов + type_mapping = { + "personal": FactType.PERSONAL, + "technical": FactType.TECHNICAL, + "project": FactType.PROJECT, + "preference": FactType.PREFERENCE, + "other": FactType.OTHER + } + fact_type = type_mapping.get(fact_type_str, FactType.OTHER) + + content = fact_item.get("content", "").strip() + confidence = float(fact_item.get("confidence", 0.5)) + + if content and len(content) > 5: # Пропускаем слишком короткие + fact = Fact( + id=None, + user_id=user_id, + fact_type=fact_type, + content=content, + source_message=dialog_context[:500], # Первые 500 символов + confidence=confidence + ) + self.sqlite.save_fact(fact) + extracted.append(fact) + + logger.info(f"Извлечено {len(extracted)} фактов (ИИ) для пользователя {user_id}") + return extracted + + except Exception as e: + logger.error(f"Ошибка извлечения фактов через ИИ: {e}") + return [] + + def format_context_for_ai(self, user_id: int, query: str = None, + stm_size: int = 5, ltm_size: int = 15) -> str: + """ + Сформировать градиентный контекст для передачи ИИ. + + Уровни памяти: + - STM (Short-Term Memory): последние stm_size сообщений — полностью + - LTM (Long-Term Memory): сообщения stm_size..(stm_size+ltm_size) — сжато + - RAG: релевантные сообщения по запросу — полностью + + Args: + user_id: ID пользователя + query: текущий запрос для RAG-поиска + stm_size: размер краткосрочной памяти (сообщения) + ltm_size: размер долгосрочной памяти (сообщения) + """ + parts = [] + + # Профиль пользователя (всегда включаем) + profile = self.get_user_profile(user_id) + if profile: + parts.append("📋 ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ:") + for fact_type, facts in profile.items(): + parts.append(f" [{fact_type}]:") + for f in facts: + parts.append(f" - {f}") + + # === STM: Short-Term Memory (последние сообщения, чётко) === + all_messages = self.get_context(user_id, stm_size + ltm_size) + + if all_messages: + # STM — последние stm_size сообщений (полностью) + stm_messages = all_messages[:stm_size] if len(all_messages) > stm_size else all_messages + + if stm_messages: + parts.append("\n💬 STM (ПОСЛЕДНИЕ СООБЩЕНИЯ):") + for msg in stm_messages: + role_ru = "Пользователь" if msg.role == "user" else "Ассистент" + # Полное содержимое для STM + parts.append(f" {role_ru}: {msg.content}") + + # LTM — более старые сообщения (сжато) + ltm_messages = all_messages[stm_size:stm_size + ltm_size] + + if ltm_messages: + parts.append("\n🕰️ LTM (БОЛЕЕ СТАРЫЕ СООБЩЕНИЯ — КРАТКО):") + for msg in ltm_messages: + role_ru = "Пользователь" if msg.role == "user" else "Ассистент" + # Сжатое содержимое для LTM — только первые 50 символов + preview = msg.content[:50].replace('\n', ' ').strip() + "..." + parts.append(f" {role_ru}: {preview}") + + # === RAG: Релевантный поиск по запросу === + if query: + relevant = self.search_relevant(user_id, query, max_results=5) + if relevant: + parts.append("\n🔍 RAG (РЕЛЕВАНТНЫЕ СООБЩЕНИЯ ПО ЗАПРОСУ):") + for msg, score in relevant: + role_ru = "Пользователь" if msg.role == "user" else "Ассистент" + # Полное содержимое для релевантных сообщений + preview = msg.content[:150].replace('\n', ' ').strip() + parts.append(f" [{score:.2f}] {role_ru}: {preview}...") + + # === Добавляем инструкцию для ИИ === + parts.append("\n" + "="*50) + parts.append("🧠 ПАМЯТЬ: STM чётко → LTM размыто → RAG глубоко") + parts.append("="*50) + + return "\n".join(parts) + + def get_stats(self, user_id: int) -> Dict[str, Any]: + """Получить статистику памяти пользователя.""" + sqlite_stats = self.sqlite.get_user_stats(user_id) + + stats = { + **sqlite_stats, + "hybrid_mode": self.vector is not None + } + + if self.vector: + try: + vector_stats = self.vector.get_stats() + stats["vector_documents"] = vector_stats.get("total_documents", 0) + stats["vector_model"] = vector_stats.get("model", "unknown") + except Exception as e: + logger.error(f"Ошибка получения статистики векторной БД: {e}") + stats["vector_documents"] = "N/A" + stats["vector_model"] = "N/A" + + return stats + + +# ============================================================================ +# Глобальные экземпляры +# ============================================================================ + +VECTOR_DB_PATH = str(Path(__file__).parent / "vector_db") + +# Создаём гибридный менеджер +sqlite_storage = SQLiteMemoryStorage(MEMORY_DB_PATH) +vector_storage = VectorMemoryStorage(VECTOR_DB_PATH) + +hybrid_memory_manager = HybridMemoryManager( + sqlite_storage=sqlite_storage, + vector_storage=vector_storage +) + + +# ============================================================================ +# Хелперы для бота +# ============================================================================ + +def save_message(user_id: int, role: str, content: str): + """Сохранить сообщение в гибридную память.""" + if hybrid_memory_manager: + hybrid_memory_manager.add_message(user_id, role, content) + if role == "user": + hybrid_memory_manager.extract_and_save_facts(user_id, content) + + +def load_history_to_state(user_id: int, state, state_manager=None) -> List[str]: + """ + Загрузить историю из БД в state.ai_chat_history. + Вызывается один раз при первом обращении пользователя после перезапуска бота. + + Args: + user_id: ID пользователя + state: Объект UserState + state_manager: StateManager для пометки загрузки истории + + Returns: + Список загруженных сообщений (история диалога) + """ + if not hybrid_memory_manager: + return [] + + try: + # Получаем последние 20 сообщений из SQLite + recent_messages = hybrid_memory_manager.get_context(user_id, max_messages=20) + + if not recent_messages: + # История пуста — ничего не загружаем + return [] + + # Формируем историю в формате "User: ..." / "Assistant: ..." + history = [] + for msg in recent_messages: + role_prefix = "Пользователь" if msg.role == "user" else "Assistant" + history.append(f"{role_prefix}: {msg.content}") + + # Загружаем в состояние пользователя + state.ai_chat_history = history[-20:] # Последние 20 сообщений + + # Помечаем что история загружена + if state_manager: + state_manager.mark_history_loaded(user_id) + + logger.info(f"Загружено {len(history)} сообщений из БД для пользователя {user_id}") + return history + + except Exception as e: + logger.error(f"Ошибка загрузки истории из БД: {e}") + return [] + + +def get_context(user_id: int, query: str = None, stm_size: int = 5, ltm_size: int = 15) -> str: + """Получить форматированный контекст для ИИ с градиентной памятью.""" + if hybrid_memory_manager: + return hybrid_memory_manager.format_context_for_ai(user_id, query, stm_size, ltm_size) + return "" + + +def search_memory(user_id: int, query: str, limit: int = 5) -> List[Tuple[Message, float]]: + """Поиск в памяти.""" + if hybrid_memory_manager: + return hybrid_memory_manager.search_relevant(user_id, query, limit) + return [] + + +def get_profile(user_id: int) -> Dict[str, List[str]]: + """Получить профиль пользователя.""" + if hybrid_memory_manager: + return hybrid_memory_manager.get_user_profile(user_id) + return {} + + +def get_memory_stats(user_id: int) -> Dict[str, Any]: + """Получить статистику памяти.""" + if hybrid_memory_manager: + return hybrid_memory_manager.get_stats(user_id) + return {}