Initial commit: Telegram CLI Bot with AI memory, tools and multi-provider support

This commit is contained in:
mirivlad 2026-03-16 03:47:15 +08:00
commit 6b8bd8d7a6
69 changed files with 20857 additions and 0 deletions

81
.env.example Normal file
View File

@ -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=

42
.gitignore vendored Normal file
View File

@ -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/

261
AI_AGENT_TOOLS.md Normal file
View File

@ -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`
**Приятного использования! 🚀**

102
AI_PROVIDERS.md Normal file
View File

@ -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
- [ ] Статистика использования провайдеров
- [ ] Настройка провайдера по умолчанию

233
AI_PROVIDER_ARCHITECTURE.md Normal file
View File

@ -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*

219
CRON_SYSTEM.md Normal file
View File

@ -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> <prompt>
```
**Параметры:**
- `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 <id>
```
**Пример:**
```
/cron run 1
```
### `/cron remove` - Удалить задачу
```
/cron remove <id>
```
### `/cron toggle` - Включить/выключить задачу
```
/cron toggle <id>
```
## 🛠️ Инструменты ИИ-агента
При выполнении задачи ИИ-агент может использовать:
| Инструмент | Назначение | Триггеры |
|------------|------------|----------|
| `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-агентом*

140
FILE_SYSTEM_TOOL.md Normal file
View File

@ -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

225
GRADIENT_MEMORY.md Normal file
View File

@ -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
- [ ] Эмоциональная память (важные события помним лучше)
---
*Градиентная память — как у людей: чёткое недавнее, размытое прошлое, глубокий поиск по запросу.*

94
HTML_ERROR_FIX.md Normal file
View File

@ -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-теги разрывались (`<b>` в одной части, `</b>` в другой)
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

129
INSTRUMENTS_FIX.md Normal file
View File

@ -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

264
MEMORY_CONTEXT.md Normal file
View File

@ -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*

124
MEMORY_SYSTEM.md Normal file
View File

@ -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.01.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` с нужными таблицами.

409
README.md Normal file
View File

@ -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 <repository_url> 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

183
SYSTEM_PROMPT.md Normal file
View File

@ -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*

97
TODO.md Normal file
View File

@ -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 версиях

342
TOOLS.md Normal file
View File

@ -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`.

244
VECTOR_RAG_MEMORY.md Normal file
View File

@ -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/`

55
add_channels.py Normal file
View File

@ -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)

20
authorize_qwen.sh Normal file
View File

@ -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 "❌ Файл с токеном не найден"

2654
bot.py Normal file

File diff suppressed because it is too large Load Diff

22
bot/__init__.py Normal file
View File

@ -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",
]

786
bot/ai_agent.py Normal file
View File

@ -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()

294
bot/ai_provider_manager.py Normal file
View File

@ -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

347
bot/base_ai_provider.py Normal file
View File

@ -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
)

520
bot/compaction.py Normal file
View File

@ -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

54
bot/config.py Normal file
View File

@ -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()

18
bot/handlers/__init__.py Normal file
View File

@ -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",
]

298
bot/handlers/ai_presets.py Normal file
View File

@ -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-пресетов зарегистрированы")

878
bot/handlers/callbacks.py Normal file
View File

@ -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)
)

528
bot/handlers/commands.py Normal file
View File

@ -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 <name> <schedule> <prompt> - добавить задачу
/cron run <id> - выполнить задачу немедленно
/cron remove <id> - удалить задачу
/cron toggle <id> - включить/выключить задачу
"""
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 <name> <schedule> <prompt>`"
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 <name> <schedule> <prompt> [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 <id>`")
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 <id>`")
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 <id>`")
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 <name> <schedule> <prompt>` - добавить задачу\n"
"• `/cron run <id>` - выполнить задачу\n"
"• `/cron remove <id>` - удалить задачу\n"
"• `/cron toggle <id>` - включить/выключить задачу",
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 ленту <url>\"_")
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)

507
bot/handlers/files.py Normal file
View File

@ -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("Обработчики файлов зарегистрированы")

11
bot/keyboards/__init__.py Normal file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env python3
"""Клавиатуры бота."""
from bot.keyboards.menus import MenuItem, MenuBuilder, CommandRegistry, init_menus
__all__ = [
"MenuItem",
"MenuBuilder",
"CommandRegistry",
"init_menus",
]

242
bot/keyboards/menus.py Normal file
View File

@ -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)

24
bot/models/__init__.py Normal file
View File

@ -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",
]

264
bot/models/server.py Normal file
View File

@ -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}")

189
bot/models/session.py Normal file
View File

@ -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()

89
bot/models/user_state.py Normal file
View File

@ -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

14
bot/providers/__init__.py Normal file
View File

@ -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",
]

View File

@ -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
)

View File

@ -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()

View File

@ -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
)

16
bot/services/__init__.py Normal file
View File

@ -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",
]

View File

@ -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")

View File

@ -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

127
bot/tools/__init__.py Normal file
View File

@ -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

704
bot/tools/cron_tool.py Normal file
View File

@ -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

106
bot/tools/ddgs_tool.py Normal file
View File

@ -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

View File

@ -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

692
bot/tools/gigachat_tool.py Normal file
View File

@ -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"),
)

367
bot/tools/rss_tool.py Normal file
View File

@ -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'<!\[CDATA\[', '', xml_content)
xml = re.sub(r'\]\]>', '', xml)
# Find all items
xml_items = re.findall(r'<item[^>]*>(.*?)</item>', xml, re.DOTALL)
for item in xml_items:
# Title
title_match = re.search(r'<title>(.*?)</title>', item, re.DOTALL)
title = title_match.group(1).strip()[:500] if title_match else ""
# GUID
guid_match = re.search(r'<guid[^>]*>(.*?)</guid>', item, re.DOTALL)
guid = guid_match.group(1).strip() if guid_match else ""
# Link
link_match = re.search(r'<link>(.*?)</link>', item, re.DOTALL)
link = link_match.group(1).strip() if link_match else ""
# PubDate
pub_match = re.search(r'<pubDate>(.*?)</pubDate>', 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

364
bot/tools/ssh_tool.py Normal file
View File

@ -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

View File

@ -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}
)

22
bot/utils/__init__.py Normal file
View File

@ -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",
]

82
bot/utils/cleaners.py Normal file
View File

@ -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)

36
bot/utils/decorators.py Normal file
View File

@ -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

346
bot/utils/formatters.py Normal file
View File

@ -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

593
bot/utils/qwen_oauth.py Normal file
View File

@ -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()

293
bot/utils/ssh_readers.py Normal file
View File

@ -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

148
install-systemd-service.sh Executable file
View File

@ -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 - Логи за сегодня"

257
install.sh Executable file
View File

@ -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 ""

708
memory_system.py Normal file
View File

@ -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)

835
qwen_integration.py Normal file
View File

@ -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()

12
requirements.txt Normal file
View File

@ -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

63
run.sh Executable file
View File

@ -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

374
system_prompt.md Normal file
View File

@ -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-агента.*

20
telegram-bot.service Normal file
View File

@ -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

5
telegram_channels.json Normal file
View File

@ -0,0 +1,5 @@
{
"channels": [
"it_mirv"
]
}

258
test_memory.py Normal file
View File

@ -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())

742
vector_memory.py Normal file
View File

@ -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 {}