Initial commit: Telegram CLI Bot with AI memory, tools and multi-provider support
This commit is contained in:
commit
6b8bd8d7a6
|
|
@ -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=
|
||||
|
|
@ -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/
|
||||
|
|
@ -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`
|
||||
|
||||
**Приятного использования! 🚀**
|
||||
|
|
@ -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
|
||||
- [ ] Статистика использования провайдеров
|
||||
- [ ] Настройка провайдера по умолчанию
|
||||
|
|
@ -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*
|
||||
|
|
@ -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-агентом*
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
- [ ] Эмоциональная память (важные события помним лучше)
|
||||
|
||||
---
|
||||
|
||||
*Градиентная память — как у людей: чёткое недавнее, размытое прошлое, глубокий поиск по запросу.*
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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*
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
# 🧠 Система памяти для ИИ-чата
|
||||
|
||||
Простая и надёжная система памяти на **SQLite** для Telegram CLI бота с ИИ-агентом.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Обзор
|
||||
|
||||
Система памяти позволяет ИИ-агенту:
|
||||
- Помнить контекст между сессиями
|
||||
- Запоминать факты о пользователе (имя, предпочтения, проекты)
|
||||
- Искать в истории диалогов по запросу
|
||||
- Предоставлять персонализированные ответы
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Архитектура
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Telegram Bot (bot.py) │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌───────────┐ │
|
||||
│ │ handle_ai_ │───▶│ memory_ │───▶│ qwen_ │ │
|
||||
│ │ task() │ │ system │ │ integration│ │
|
||||
│ └─────────────┘ └───────┬──────┘ └───────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ SQLiteStorage │ │
|
||||
│ │ (facts, │ │
|
||||
│ │ messages, │ │
|
||||
│ │ sessions) │ │
|
||||
│ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Структура базы данных
|
||||
|
||||
### Таблицы:
|
||||
|
||||
**facts** — факты о пользователе:
|
||||
- `user_id` — ID пользователя
|
||||
- `fact_type` — тип факта (personal, technical, project, preference)
|
||||
- `content` — текст факта
|
||||
- `confidence` — уверенность (0.0–1.0)
|
||||
|
||||
**messages** — история сообщений:
|
||||
- `user_id` — ID пользователя
|
||||
- `role` — "user" или "assistant"
|
||||
- `content` — текст сообщения
|
||||
- `session_id` — ID сессии
|
||||
|
||||
**sessions** — сессии диалогов:
|
||||
- `user_id` — ID пользователя
|
||||
- `started_at` / `ended_at` — время сессии
|
||||
- `message_count` — количество сообщений
|
||||
- `summary` — краткое резюме (опционально)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Использование
|
||||
|
||||
### Сохранение сообщения:
|
||||
|
||||
```python
|
||||
from memory_system import save_ai_message
|
||||
|
||||
# Сохранить сообщение пользователя
|
||||
save_ai_message(user_id=123456, role="user", content="Меня зовут Владимир")
|
||||
|
||||
# Сохранить ответ ИИ
|
||||
save_ai_message(user_id=123456, role="assistant", content="Приятно познакомиться!")
|
||||
```
|
||||
|
||||
### Получение контекста:
|
||||
|
||||
```python
|
||||
from memory_system import format_memory_context
|
||||
|
||||
# Получить профиль + последние сообщения + релевантные факты
|
||||
context = format_memory_context(user_id=123456, query="Где мои файлы?")
|
||||
```
|
||||
|
||||
### Профиль пользователя:
|
||||
|
||||
```python
|
||||
from memory_system import get_user_profile_summary
|
||||
|
||||
profile = get_user_profile_summary(user_id=123456)
|
||||
# Профиль пользователя:
|
||||
# • Пользователя зовут Владимир
|
||||
# • Использует Python
|
||||
# • Проект в ~/git/telegram-cli-bot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Извлечение фактов
|
||||
|
||||
Система автоматически извлекает факты из сообщений:
|
||||
|
||||
| Паттерн | Пример | Извлекаемый факт |
|
||||
|---------|--------|------------------|
|
||||
| `меня зовут ...` | "Меня зовут Владимир" | `PERSONAL: Пользователя зовут Владимир` |
|
||||
| `я использую ...` | "Я использую Python" | `TECHNICAL: Использует Python` |
|
||||
| `мой проект ...` | "Мой проект в ~/git/foo" | `PROJECT: Есть проект foo` |
|
||||
|
||||
---
|
||||
|
||||
## 📁 Файлы
|
||||
|
||||
- `memory_system.py` — основная система памяти
|
||||
- `memory.db` — SQLite база данных (создаётся автоматически)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Настройка
|
||||
|
||||
Никакой дополнительной настройки не требуется! Система работает из коробки.
|
||||
|
||||
При первом запуске автоматически создаётся `memory.db` с нужными таблицами.
|
||||
|
|
@ -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
|
||||
|
|
@ -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*
|
||||
|
|
@ -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 версиях
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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/`
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 "❌ Файл с токеном не найден"
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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-пресетов зарегистрированы")
|
||||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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("Обработчики файлов зарегистрированы")
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Клавиатуры бота."""
|
||||
|
||||
from bot.keyboards.menus import MenuItem, MenuBuilder, CommandRegistry, init_menus
|
||||
|
||||
__all__ = [
|
||||
"MenuItem",
|
||||
"MenuBuilder",
|
||||
"CommandRegistry",
|
||||
"init_menus",
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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 - Логи за сегодня"
|
||||
|
|
@ -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 ""
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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-агента.*
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"channels": [
|
||||
"it_mirv"
|
||||
]
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
@ -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 {}
|
||||
Loading…
Reference in New Issue