diff --git a/.gitignore b/.gitignore index 64457e8..ba01256 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,8 @@ huggingface_cache/ # OS .DS_Store Thumbs.db + +# Database (personal data - do not commit) +*.db +*.sqlite3 +vector_db/ diff --git a/AI_AGENT_TOOLS.md b/AI_AGENT_TOOLS.md new file mode 100644 index 0000000..17a10ac --- /dev/null +++ b/AI_AGENT_TOOLS.md @@ -0,0 +1,260 @@ +# 🤖 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_search` | Поиск в интернете | "найди", "поиск", "узнай", "как сделать" | +| `rss_reader` | Чтение RSS лент | "новости", "почитай", "лента", "IT новости" | +| `ssh_executor` | SSH-команды | "проверь сервер", "выполни команду", "uptime" | +| `cron_manager` | Задачи по расписанию | "напомни", "запланируй", "каждый день" | + +--- + +## 🔧 Настройка + +### Добавление сервера в SSH + +Открой `bot/tools/ssh_tool.py` и добавь: + +```python +self.servers['myserver'] = ServerConfig( + host='192.168.1.100', + port=22, + username='user', + password='pass' # или client_keys=['/path/to/key'] +) +``` + +### Добавление триггеров + +Открой `bot/ai_agent.py` и добавь в соответствующий список: + +```python +SEARCH_TRIGGERS = [ + # ... существующие ... + 'мой_триггер' # новый триггер +] +``` + +--- + +## 🧪 Тестирование + +Проверка работы AI-агента: + +```bash +cd ~/git/telegram-cli-bot +python3 -c " +import asyncio +from bot.ai_agent import ai_agent + +async def test(): + tests = [ + 'Найди информацию про Python', + 'Почитай новости', + 'Проверь нагрузку на сервере', + 'Напомни каждый день', + 'Привет!' + ] + + for msg in tests: + decision = await ai_agent.decide(msg) + print(f'{msg}: {decision.tool_name or \"нет инструмента\"} (conf={decision.confidence})') + +asyncio.run(test()) +" +``` + +**Ожидаемый результат:** +``` +Найди информацию про Python: ddgs_search (conf=0.9) +Почитай новости: rss_reader (conf=0.9) +Проверь нагрузку на сервере: ssh_executor (conf=0.9) +Напомни каждый день: cron_manager (conf=0.85) +Привет!: нет инструмента (conf=0.0) +``` + +--- + +## 📝 Примеры использования + +### 🔍 Поиск в интернете + +``` +Ты: Найди руководство по async/await в Python +Бот: 🔍 Результаты поиска: + 1. **Python Async/Await Documentation** + https://docs.python.org/3/library/asyncio.html + asyncio is a library to write concurrent code ... +``` + +### 📰 Чтение новостей + +``` +Ты: Почитай новости Linux +Бот: 📰 Последние новости: + 1. Linux Kernel 6.8 Released + 📅 2024-03-10 14:30:00 + 🔗 https://... +``` + +### 🖥️ SSH-команды + +``` +Ты: Сколько места на диске? +Бот: 🖥️ SSH: home + Команда: df -h + Вывод: + Filesystem Size Used Avail + /dev/sda1 100G 50G 50G + ✅ Успешно +``` + +### ⏰ Cron-задачи + +``` +Ты: Запланируй бэкап каждый день в 3 ночи +Бот: ✅ Задача добавлена: + • ID: 1 + • Название: Daily Backup + • Расписание: 0 3 * * * + • Следующий запуск: 2024-03-11 03:00:00 +``` + +--- + +## 🚀 Запуск бота + +```bash +cd ~/git/telegram-cli-bot +./run.sh +``` + +Или вручную: + +```bash +python3 bot.py +``` + +--- + +## 📊 Статистика + +**Файлы изменены/созданы:** +- ✅ `bot/ai_agent.py` — улучшенный AI-агент +- ✅ `bot/tools/ssh_tool.py` — SSH-инструмент (новый) +- ✅ `bot/tools/cron_tool.py` — Cron-инструмент (новый) +- ✅ `bot/tools/__init__.py` — реестр (обновлён) +- ✅ `bot.py` — форматирование результатов (обновлён) +- ✅ `TOOLS.md` — документация (обновлена) + +**Строк кода добавлено:** ~600+ + +**Инструментов доступно:** 4 + +--- + +## 🎯 Следующие шаги + +**Можно добавить:** +1. **Веб-скрапинг** — парсинг конкретных сайтов +2. **Мониторинг** — авто-проверка метрик сервера +3. **Уведомления** — отправка уведомлений по расписанию +4. **Интеграции** — GitHub API, Docker API, etc. + +--- + +## ⚠️ Важно + +- Бот персональный — нет ролевой модели и ограничений +- Инструменты доступны **всегда** в AI-режиме +- Бот **сам решает** когда использовать инструмент +- Логи пишутся в `bot.log` + +**Приятного использования! 🚀** diff --git a/SYSTEM_PROMPT.md b/SYSTEM_PROMPT.md new file mode 100644 index 0000000..78f6e89 --- /dev/null +++ b/SYSTEM_PROMPT.md @@ -0,0 +1,159 @@ +# Системный промпт для 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 агент анализирует запрос пользователя и **сам решает** какой инструмент использовать: + +| Триггеры | Инструмент | Пример | +|----------|------------|--------| +| "найди", "погугли", "узнай" | `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. **SSH** — явные системные задачи +2. **Cron** — планирование и напоминания +3. **Поиск (DDGS)** — свежие данные из интернета +4. **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.5.3 | Базовая реализация системного промпта | +| 0.5.2 | AI агент с авто-выбором инструментов | +| 0.5.1 | Интеграция RSS reader | +| 0.5.0 | Интеграция DDGS search | + +--- + +*Документация для разработчиков Telegram CLI Bot* diff --git a/TOOLS.md b/TOOLS.md new file mode 100644 index 0000000..d92f67c --- /dev/null +++ b/TOOLS.md @@ -0,0 +1,342 @@ +# 🛠️ Инструменты Telegram CLI Bot + +Инструменты — это capabilities, которые бот может использовать **автономно** для выполнения задач пользователя (Agentic AI подход). + +## 📋 Архитектура + +``` +bot/ +├── tools/ +│ ├── __init__.py # Реестр инструментов (ToolsRegistry) +│ ├── ddgs_tool.py # Поиск в интернете через DuckDuckGo +│ ├── rss_tool.py # Чтение RSS/Atom лент +│ ├── ssh_tool.py # Выполнение команд по SSH +│ └── cron_tool.py # Управление задачами по расписанию +└── ai_agent.py # AI агент для принятия решений +``` + +## 🏗️ Как это работает + +### 1. Реестр инструментов + +`ToolsRegistry` — синглтон, который хранит все доступные инструменты: + +```python +from bot.tools import tools_registry + +# Получить инструмент +tool = tools_registry.get('ddgs_search') + +# Выполнить инструмент +result = await tools_registry.execute_tool('ddgs_search', 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_search`) + +Поиск информации в интернете через DuckDuckGo. + +**Использование:** +```python +result = await tools_registry.execute_tool( + 'ddgs_search', + 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_reader`) + +Чтение 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_reader', + action='list', + limit=10, + undigested_only=True +) + +# Добавить ленту +result = await tools_registry.execute_tool( + 'rss_reader', + 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_executor`) + +Выполнение команд на удалённых серверах по SSH. + +**Использование:** +```python +result = await tools_registry.execute_tool( + 'ssh_executor', + 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_manager`) + +Управление периодическими задачами пользователя. + +**Действия:** + +| Действие | Описание | Параметры | +|----------|----------|-----------| +| `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_manager', + action='add', + name='Daily Backup', + command='/home/user/backup.sh', + schedule='@daily' +) +``` + +**Триггеры для авто-использования:** +- "напомни", "запланируй", "каждый день" +- "периодически", "по расписанию", "автоматически" +- "создай задачу", "добавь в cron" + +**Примеры:** +``` +Пользователь: Напомни мне каждый день делать бэкап +Бот: ⏰ **Ваши задачи:** + + ✅ **Daily Backup** + Команда: `/home/user/backup.sh` + Расписание: @daily + Следующий запуск: 2024-03-11 00:00:00 +``` + +## ➕ Добавление нового инструмента + +1. Создайте файл `bot/tools/your_tool.py`: + +```python +from bot.tools import BaseTool, ToolResult, register_tool + +@register_tool +class YourTool(BaseTool): + name = "your_tool" + description = "Описание инструмента" + category = "category" + + async def execute(self, **kwargs) -> ToolResult: + # Логика инструмента + return ToolResult(success=True, data={'result': 'data'}) +``` + +2. Инструмент автоматически зарегистрируется в реестре + +3. Добавьте триггеры в `bot/ai_agent.py`: + +```python +YOUR_TRIGGERS = ['триггер1', 'триггер2'] + +def _should_use_your_tool(self, message: str) -> tuple[bool, float]: + message_lower = message.lower() + for trigger in YOUR_TRIGGERS: + if trigger in message_lower: + return True, 0.9 + return False, 0.0 +``` + +4. Добавьте форматирование в `bot.py`: + +```python +elif tool_name == 'your_tool': + return f"Результат: {result.data}" +``` + +## 🔧 Установка зависимостей + +```bash +cd ~/git/telegram-cli-bot +pip install -r requirements.txt +``` + +## 🎯 Приоритеты инструментов + +Приоритет проверки (от высшего к низшему): + +1. **SSH Executor** — системные задачи +2. **Cron Manager** — планирование задач +3. **DDGS Search** — поиск информации +4. **RSS Reader** — чтение новостей + +## 📝 Примеры использования + +### Поиск в интернете + +``` +Пользователь: Найди информацию про Python 3.12 +Бот: 🔍 **Результаты поиска:** + 1. **Python 3.12.0 Documentation** + https://docs.python.org/3.12/ + The official home of the Python Programming Language +``` + +### Чтение новостей + +``` +Пользователь: Почитай новости +Бот: 📰 **Последние новости:** + 1. Linux Kernel 6.8 Released + 📅 2024-03-10 14:30:00 + 🔗 https://... +``` + +### SSH команда + +``` +Пользователь: Проверь нагрузку на сервере +Бот: 🖥️ **SSH: home** + **Команда:** `uptime` + + **Вывод:** + ``` + 14:30:00 up 10 days, 2:30, 1 user, load average: 0.15, 0.10, 0.05 + ``` + + ✅ **Успешно** +``` + +### Управление задачами + +``` +Пользователь: Напомни мне каждый день делать бэкап +Бот: ⏰ **Ваши задачи:** + + ✅ **Daily Backup** + Команда: `/home/user/backup.sh` + Расписание: @daily + Следующий запуск: 2024-03-11 00:00:00 +``` + +## 🔐 Безопасность + +Для персонального бота приоритет на **удобстве и функциональности**: +- Инструменты доступны всегда +- Бот использует их без прямой команды +- Agentic AI — бот сам решает когда нужен инструмент + +**Важно:** При добавлении новых серверов в SSH не забывайте обновлять конфигурацию в `bot/tools/ssh_tool.py`. diff --git a/bot.py b/bot.py index 59e652a..22659a8 100644 --- a/bot.py +++ b/bot.py @@ -78,6 +78,10 @@ from bot.handlers.commands import start_command, menu_command, help_command, set from bot.handlers.callbacks import menu_callback from bot.services.command_executor import execute_cli_command +# Импорты инструментов и AI агента +from bot.ai_agent import ai_agent +from bot.tools import tools_registry + # Глобальные менеджеры сессий ssh_session_manager = SSHSessionManager() local_session_manager = LocalSessionManager() @@ -121,7 +125,7 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE async def handle_ai_task(update: Update, text: str): - """Обработка задачи для ИИ агента с использованием системы памяти.""" + """Обработка задачи для ИИ агента с использованием системы памяти и инструментов.""" user_id = update.effective_user.id state = state_manager.get(user_id) @@ -138,6 +142,38 @@ async def handle_ai_task(update: Update, text: str): # Отправляем статус status_msg = await update.message.reply_text("⏳ 🤖 Думаю...") + # === ПРОВЕРКА: Решение AI агента об использовании инструментов === + agent_decision = await ai_agent.decide(text, context={'user_id': user_id}) + + if agent_decision.should_use_tool: + logger.info(f"AI агент решил использовать инструмент: {agent_decision.tool_name} (confidence={agent_decision.confidence})") + + # Выполняем инструмент + tool_result = await ai_agent.execute_tool( + agent_decision.tool_name, + **agent_decision.tool_args + ) + + if tool_result.success: + # Формируем ответ с результатами инструмента + full_output = format_tool_result(agent_decision.tool_name, tool_result) + + # Добавляем в историю + state.ai_chat_history.append(f"Assistant: {full_output[:500]}") + save_message(user_id, "assistant", full_output) + + # Отправляем ответ + if len(full_output) > 3500: + full_output = full_output[:3500] + "\n... (вывод обрезан)" + + await send_long_message(update, full_output, parse_mode="Markdown") + await status_msg.delete() + return + else: + logger.warning(f"Инструмент {agent_decision.tool_name} вернул ошибку: {tool_result.error}") + # Продолжаем с обычным ИИ-ответом если инструмент не сработал + + # === ОБЫЧНЫЙ ИИ-ОТВЕТ через Qwen === output_buffer = [] def on_output(text: str): @@ -155,21 +191,27 @@ async def handle_ai_task(update: Update, text: str): # Считаем токены в контексте (примерно: 1 слово ≈ 1.3 токена) context_words = len((memory_context + "\n" + history_context).split()) context_tokens = int(context_words * 1.3) - + # Максимальный контекст модели (Qwen поддерживает до 256K токенов) # Для безопасности берём 200K MAX_CONTEXT_TOKENS = 200_000 context_percent = round((context_tokens / MAX_CONTEXT_TOKENS) * 100, 1) - # Собираем полный промпт + # Собираем полный промпт с системным промптом + system_prompt = qwen_manager.load_system_prompt() + full_task = ( + f"{system_prompt}\n\n" + f"=== КОНТЕКСТ ПАМЯТИ ===\n" f"{memory_context}\n\n" - f"Previous conversation:\n{history_context}\n\n" - f"Current request: {text}" + f"=== ИСТОРИЯ ДИАЛОГА ===\n" + f"{history_context}\n\n" + f"=== ЗАПРОС ПОЛЬЗОВАТЕЛЯ ===\n" + f"{text}" ) - # Выполняем задачу - result = await qwen_manager.run_task(user_id, full_task, on_output, on_oauth_url) + # Выполняем задачу (системный промпт уже добавлен в full_task) + result = await qwen_manager.run_task(user_id, full_task, on_output, on_oauth_url, use_system_prompt=False) # Показываем результат full_output = "".join(output_buffer).strip() @@ -202,6 +244,109 @@ async def handle_ai_task(update: Update, text: str): await send_long_message(update, response_text, parse_mode="Markdown") +def format_tool_result(tool_name: str, result: 'ToolResult') -> str: + """Форматировать результат выполнения инструмента.""" + from bot.tools import ToolResult + + if tool_name == 'ddgs_search': + if not result.data: + return "🔍 Ничего не найдено по вашему запросу." + + output = "🔍 **Результаты поиска:**\n\n" + for i, item in enumerate(result.data[:5], 1): + title = item.get('title', 'Без названия') + href = item.get('href', '') + body = item.get('body', '')[:200] + output += f"{i}. **{title}**\n" + if href: + output += f" {href}\n" + if body: + output += f" {body}\n\n" + + return output + + elif tool_name == 'rss_reader': + action = result.metadata.get('action', 'list') + + if action == 'list' and result.data: + output = "📰 **Последние новости:**\n\n" + for i, item in enumerate(result.data[:10], 1): + title = item.get('title', 'Без названия') + pub_date = item.get('pub_date', '') + link = item.get('link', '')[:50] + output += f"{i}. {title}\n" + if pub_date: + output += f" 📅 {pub_date}\n" + if link: + output += f" 🔗 {link}\n\n" + return output + + elif action == 'fetch': + total = result.data.get('total_new_items', 0) if result.data else 0 + return f"✅ Получено {total} новых элементов из лент." + + elif action == 'list_feeds' and result.data: + output = "📑 **Ваши RSS ленты:**\n\n" + for feed in result.data: + output += f"• {feed.get('title', feed.get('url', 'Unknown'))}\n" + return output + + return f"RSS: {result.data}" + + elif tool_name == 'ssh_executor': + if not result.success: + return f"❌ **Ошибка SSH:**\n```\n{result.error}\n```" + + data = result.data + server = result.metadata.get('server', 'unknown') + command = result.metadata.get('command', '') + + output = f"🖥️ **SSH: {server}**\n" + output += f"**Команда:** `{command}`\n\n" + + if data.get('stdout'): + output += f"**Вывод:**\n```\n{data['stdout']}\n```\n\n" + + if data.get('stderr'): + output += f"**Ошибки:**\n```\n{data['stderr']}\n```\n\n" + + if data.get('returncode') == 0: + output += "✅ **Успешно**" + else: + output += f"❌ **Код возврата:** {data.get('returncode', 'N/A')}" + + return output + + elif tool_name == 'cron_manager': + action = result.metadata.get('action', 'list') + + if action == 'list' and result.data: + output = "⏰ **Ваши задачи:**\n\n" + for job in result.data: + status = "✅" if job.get('enabled') else "❌" + output += f"{status} **{job.get('name', 'Без названия')}**\n" + output += f" Команда: `{job.get('command', '')}`\n" + output += f" Расписание: {job.get('schedule', '')}\n" + if job.get('next_run'): + output += f" Следующий запуск: {job.get('next_run')}\n" + output += "\n" + return output + + elif action == 'add' and result.success: + data = result.data + return f"✅ **Задача добавлена:**\n• ID: {data.get('id')}\n• Название: {data.get('name')}\n• Расписание: {data.get('schedule')}\n• Следующий запуск: {data.get('next_run', 'N/A')}" + + elif action == 'remove' and result.success: + return f"✅ **Задача удалена:** ID {result.data.get('id')}" + + elif action == 'run' and result.success: + return f"✅ **Задача выполнена:** {result.data.get('message', '')}" + + return f"Cron: {result.data}" + + return f"Инструмент {tool_name}: {result.data}" + + async def handle_ssh_session_input(update: Update, text: str, session: SSHSession): """Обработка ввода пользователя в активную SSH-сессию.""" user_id = update.effective_user.id diff --git a/bot/ai_agent.py b/bot/ai_agent.py new file mode 100644 index 0000000..1eff7a8 --- /dev/null +++ b/bot/ai_agent.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +""" +AI Agent Module - автономный агент с инструментами. + +Агент может самостоятельно принимать решения об использовании инструментов +на основе контекста запроса пользователя. +""" + +import logging +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 = [ + 'новости', 'rss', 'лента', 'feed', 'дайджест', + 'что нового в linux', 'новости it', 'tech news', + 'почитай новости', 'последние статьи', 'свежие новости', + 'новости технологий', 'opensource новости', 'linux новости', + 'покажи новости', 'что в лентах', 'есть новые статьи' + ] + + # Триггеры для SSH-команд + SSH_TRIGGERS = [ + 'выполни команду', 'ssh', 'запусти на сервере', 'проверь сервер', + 'посмотри логи', 'покажи процесс', 'сколько места', 'df', 'top', + 'перезапусти', 'останови', 'запусти сервис', 'systemctl', + 'проверь нагрузку', 'uptime', 'кто залогинен', 'who', 'last', + 'посмотри в /var/log', 'проверь диск', 'мониторинг', + 'выполни на 192.168.1', 'запусти скрипт', 'cron' + ] + + # Триггеры для Cron-задач + CRON_TRIGGERS = [ + 'напомни', 'запланируй', 'каждый день', 'каждый час', + 'периодически', 'по расписанию', 'автоматически', + 'создай задачу', 'добавь в cron', 'регулярно', + 'повторяй', 'каждую неделю', 'ежедневно', 'ежечасно' + ] + + 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 + + # Прямые триггеры — высокий приоритет + for trigger in self.SEARCH_TRIGGERS: + if trigger in 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 ленты.""" + message_lower = message.lower() + score = 0.0 + + for trigger in self.RSS_TRIGGERS: + if trigger in message_lower: + return True, 0.9 + + # Если пользователь спрашивает про новости технологий + tech_news = ['новости linux', 'it новости', 'tech news', 'opensource'] + for topic in tech_news: + if topic in message_lower: + score = max(score, 0.8) + + # Контекстные подсказки + if any(word in message_lower for word in ['почитай', 'посмотри', 'покажи']) and \ + any(word in message_lower for word in ['статьи', 'материалы', 'публикации']): + score = max(score, 0.75) + + return score >= 0.75, score + + def _should_use_ssh(self, message: str) -> tuple[bool, float]: + """Проверить, нужна ли SSH-команда.""" + message_lower = message.lower() + score = 0.0 + + # Прямые триггеры + for trigger in self.SSH_TRIGGERS: + if trigger in 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 + + # Прямые триггеры + for trigger in self.CRON_TRIGGERS: + if trigger in 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 + + 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 + + # Приоритет: SSH > Cron > Поиск > RSS + # Проверяем в порядке приоритета + + # 1. Проверка на 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_executor', + tool_args={'command': self._extract_ssh_command(message)}, + confidence=ssh_conf, + reasoning='Пользователю нужно выполнить команду на сервере' + ) + + # 2. Проверка на 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_manager', + tool_args={'action': 'list'}, # Показываем список задач + confidence=cron_conf, + reasoning='Пользователь хочет создать или управлять задачей' + ) + + # 3. Проверка на поиск + 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_search', + tool_args={'query': query, 'max_results': 5}, + confidence=search_conf, + reasoning='Пользователю нужна информация из интернета' + ) + + # 4. Проверка на RSS + should_rss, rss_conf = self._should_read_rss(message) + if should_rss and rss_conf > 0.7: + return AgentDecision( + should_use_tool=True, + tool_name='rss_reader', + 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 + + async def execute_tool(self, tool_name: str, **kwargs) -> ToolResult: + """Выполнить инструмент и сохранить историю.""" + logger.info(f"🤖 AI-агент выполняет инструмент: {tool_name} с аргументами: {kwargs}") + + result = await self.registry.execute_tool(tool_name, **kwargs) + + # Сохраняем историю использования + self._tool_use_history.append({ + 'tool_name': tool_name, + 'args': kwargs, + 'result': result.to_dict(), + 'timestamp': datetime.now().isoformat() + }) + + # Ограничиваем историю + if len(self._tool_use_history) > 100: + self._tool_use_history = self._tool_use_history[-50:] + + logger.info(f"✅ Инструмент {tool_name} выполнен: success={result.success}") + return result + + def get_tool_history(self, limit: int = 10) -> List[Dict]: + """Получить историю использования инструментов.""" + return self._tool_use_history[-limit:] + + def set_user_preference(self, user_id: int, preference: str, value: Any): + """Установить предпочтение пользователя для инструментов.""" + if user_id not in self._user_preferences: + self._user_preferences[user_id] = {} + self._user_preferences[user_id][preference] = value + logger.info(f"Установлено предпочтение для пользователя {user_id}: {preference} = {value}") + + def get_user_preference(self, user_id: int, preference: str, default: Any = None) -> Any: + """Получить предпочтение пользователя.""" + return self._user_preferences.get(user_id, {}).get(preference, default) + + +# Глобальный агент +ai_agent = AIAgent() diff --git a/bot/tools/__init__.py b/bot/tools/__init__.py new file mode 100644 index 0000000..2f6ac37 --- /dev/null +++ b/bot/tools/__init__.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +Реестр инструментов для Telegram CLI Bot. + +Инструменты - это capabilities, которые бот может использовать автономно +для выполнения задач пользователя (Agentic AI подход). +""" + +import logging +from abc import ABC, abstractmethod +from typing import Dict, List, Any, Optional +from dataclasses import dataclass, field +from datetime import datetime + +logger = logging.getLogger(__name__) + + +@dataclass +class ToolResult: + """Результат выполнения инструмента.""" + success: bool + data: Any = None + error: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict: + return { + 'success': self.success, + 'data': self.data, + 'error': self.error, + 'metadata': self.metadata + } + + +class BaseTool(ABC): + """Базовый класс для всех инструментов.""" + + name: str = "base_tool" + description: str = "Базовый инструмент" + category: str = "general" + + @abstractmethod + async def execute(self, **kwargs) -> ToolResult: + """Выполнить инструмент.""" + pass + + def get_capabilities(self) -> Dict: + """Вернуть описание возможностей инструмента.""" + return { + 'name': self.name, + 'description': self.description, + 'category': self.category + } + + +class ToolsRegistry: + """Реестр всех доступных инструментов.""" + + _instance: Optional['ToolsRegistry'] = None + _tools: Dict[str, BaseTool] = {} + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def register(self, tool: BaseTool): + """Зарегистрировать инструмент.""" + self._tools[tool.name] = tool + logger.info(f"Зарегистрирован инструмент: {tool.name}") + + def unregister(self, tool_name: str): + """От-register инструмент.""" + if tool_name in self._tools: + del self._tools[tool_name] + logger.info(f"Удален инструмент: {tool_name}") + + def get(self, tool_name: str) -> Optional[BaseTool]: + """Получить инструмент по имени.""" + return self._tools.get(tool_name) + + def get_all(self) -> Dict[str, BaseTool]: + """Получить все инструменты.""" + return self._tools.copy() + + def get_capabilities_list(self) -> List[Dict]: + """Получить список всех возможностей для ИИ.""" + return [tool.get_capabilities() for tool in self._tools.values()] + + async def execute_tool(self, tool_name: str, **kwargs) -> ToolResult: + """Выполнить инструмент по имени.""" + tool = self.get(tool_name) + if not tool: + return ToolResult( + success=False, + error=f"Инструмент '{tool_name}' не найден" + ) + + logger.info(f"Выполнение инструмента: {tool_name} с аргументами: {kwargs}") + try: + result = await tool.execute(**kwargs) + result.metadata['tool_name'] = tool_name + result.metadata['timestamp'] = datetime.now().isoformat() + return result + except Exception as e: + logger.exception(f"Ошибка выполнения инструмента {tool_name}: {e}") + return ToolResult( + success=False, + error=str(e), + metadata={'tool_name': tool_name} + ) + + +# Глобальный экземпляр реестра +tools_registry = ToolsRegistry() + + +def register_tool(tool_class: type) -> type: + """Декоратор для автоматической регистрации инструмента.""" + tool_instance = tool_class() + tools_registry.register(tool_instance) + return tool_class + + +# Авто-импорт инструментов для регистрации +# Импортируем после определения register_tool чтобы декоратор сработал +from bot.tools import ddgs_tool, rss_tool, ssh_tool, cron_tool diff --git a/bot/tools/cron_tool.py b/bot/tools/cron_tool.py new file mode 100644 index 0000000..0a91759 --- /dev/null +++ b/bot/tools/cron_tool.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" +Cron Tool - инструмент для управления задачами пользователя. + +Позволяет создавать, планировать и выполнять периодические задачи. +""" + +import logging +import sqlite3 +import json +from pathlib import Path +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional, Callable +from dataclasses import dataclass, field + +from bot.tools import BaseTool, ToolResult, register_tool + +logger = logging.getLogger(__name__) + + +@dataclass +class CronJob: + """Задача cron.""" + id: Optional[int] + name: str + command: str + schedule: str # cron format: "*/5 * * * *" или "daily", "hourly" + enabled: 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_manager" + description = "Управление периодическими задачами пользователя. Создание, планирование и выполнение задач по расписанию." + category = "automation" + + def __init__(self, db_path: str = None): + self.db_path = Path(db_path) if db_path else Path(__file__).parent.parent.parent / "cron.db" + 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, + command TEXT NOT NULL, + schedule TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + last_run DATETIME, + next_run DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + ''') + conn.commit() + conn.close() + + def _parse_schedule(self, schedule: str) -> Optional[datetime]: + """ + Распарсить расписание и вернуть следующее время выполнения. + + Поддерживает: + - "*/N * * * *" - каждые N минут + - "@hourly" - каждый час + - "@daily" - каждый день + - "@weekly" - каждую неделю + """ + now = datetime.now() + + if schedule.startswith('*/'): + # Каждые N минут + try: + minutes = int(schedule.split()[0][2:]) + return now + timedelta(minutes=minutes) + except (ValueError, IndexError): + return None + + elif schedule == '@hourly': + return now + timedelta(hours=1) + + elif schedule == '@daily': + return now + timedelta(days=1) + + elif schedule == '@weekly': + return now + timedelta(weeks=1) + + return None + + async def add_job(self, name: str, command: str, schedule: str) -> ToolResult: + """Добавить задачу.""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + + try: + next_run = self._parse_schedule(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, command, schedule, next_run) + VALUES (?, ?, ?, ?) + ''', (name, command, schedule, next_run_str)) + + job_id = c.lastrowid + conn.commit() + + self._jobs[job_id] = CronJob( + id=job_id, + name=name, + command=command, + schedule=schedule, + next_run=next_run + ) + + return ToolResult( + success=True, + data={'id': job_id, 'name': name, 'schedule': schedule, 'next_run': next_run_str}, + metadata={'status': 'added'} + ) + + except Exception as e: + logger.exception(f"Ошибка добавления задачи: {e}") + return ToolResult( + success=False, + error=str(e) + ) + finally: + conn.close() + + async def list_jobs(self) -> ToolResult: + """Получить список всех задач.""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute(''' + SELECT id, name, command, schedule, enabled, 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], + 'command': row[2], + 'schedule': row[3], + 'enabled': bool(row[4]), + 'last_run': row[5], + 'next_run': row[6], + 'created_at': row[7] + }) + + 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) -> ToolResult: + """Выполнить задачу немедленно.""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("SELECT command FROM cron_jobs WHERE id = ?", (job_id,)) + row = c.fetchone() + + if not row: + conn.close() + return ToolResult( + success=False, + error=f"Задача не найдена: {job_id}" + ) + + command = row[0] + conn.close() + + # Здесь должна быть логика выполнения команды + # Для демонстрации возвращаем заглушку + logger.info(f"Выполнение задачи {job_id}: {command}") + + # Обновляем 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() + + return ToolResult( + success=True, + data={'id': job_id, 'command': command, 'message': 'Задача выполнена'}, + metadata={'status': 'executed'} + ) + + async def execute(self, action: str = "list", **kwargs) -> ToolResult: + """ + Выполнить действие с cron задачами. + + Args: + action: Действие - list, add, remove, toggle, run + kwargs: Дополнительные аргументы + """ + actions = { + 'list': self.list_jobs, + 'add': lambda: self.add_job( + name=kwargs.get('name'), + command=kwargs.get('command'), + schedule=kwargs.get('schedule') + ), + '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')) + } + + if action not in actions: + return ToolResult( + success=False, + error=f"Неизвестное действие: {action}. Доступные: {list(actions.keys())}" + ) + + logger.info(f"Cron действие: {action} с аргументами: {kwargs}") + return await actions[action]() + + +# Автоматическая регистрация при импорте +@register_tool +class CronToolAuto(CronTool): + """Авто-регистрируемая версия CronTool.""" + pass diff --git a/bot/tools/ddgs_tool.py b/bot/tools/ddgs_tool.py new file mode 100644 index 0000000..704b381 --- /dev/null +++ b/bot/tools/ddgs_tool.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +DDGS Search Tool - инструмент для поиска в интернете через DuckDuckGo. + +Бот может использовать этот инструмент автономно, когда пользователю нужна +свежая информация из интернета. +""" + +import sys +import json +import logging +from pathlib import Path +from typing import List, Dict, Any + +from bot.tools import BaseTool, ToolResult, register_tool + +logger = logging.getLogger(__name__) + + +class DDGSTool(BaseTool): + """Инструмент поиска через DuckDuckGo.""" + + name = "ddgs_search" + description = "Поиск информации в интернете через DuckDuckGo. Используется когда нужны свежие данные, новости, факты." + category = "search" + + def __init__(self, tools_dir: str = None): + self.tools_dir = Path(tools_dir) if tools_dir else Path(__file__).parent.parent / "tools" + self.script_path = self.tools_dir / "ddgs_search.py" + + def _search_ddgs(self, query: str, max_results: int = 10) -> List[Dict]: + """ + Выполнить поиск через ddgs. + + Args: + query: Поисковый запрос + max_results: Максимальное количество результатов + + Returns: + Список результатов с title, href, body + """ + try: + from ddgs import DDGS + except ImportError: + logger.error("ddgs library not found. Install: pip install ddgs") + return [] + + try: + ddgs = DDGS() + results = ddgs.text(query, max_results=max_results) + + formatted_results = [] + for result in results: + formatted_result = { + "title": result.get("title", ""), + "href": result.get("href", ""), + "body": result.get("body", "") + } + formatted_results.append(formatted_result) + + return formatted_results + except Exception as e: + logger.error(f"Ошибка DDGS поиска: {e}") + return [] + + async def execute(self, query: str, max_results: int = 10) -> ToolResult: + """ + Выполнить поиск. + + Args: + query: Поисковый запрос + max_results: Максимальное количество результатов (default: 10) + """ + if not query or not query.strip(): + return ToolResult( + success=False, + error="Пустой поисковый запрос" + ) + + logger.info(f"DDGS поиск: '{query}' (max_results={max_results})") + + results = self._search_ddgs(query, max_results) + + if not results: + return ToolResult( + success=True, + data=[], + metadata={'message': 'Ничего не найдено', 'query': query} + ) + + return ToolResult( + success=True, + data=results, + metadata={ + 'query': query, + 'count': len(results), + 'max_results': max_results + } + ) + + +# Автоматическая регистрация при импорте +@register_tool +class DDGSToolAuto(DDGSTool): + """Авто-регистрируемая версия DDGSTool.""" + pass diff --git a/bot/tools/rss_tool.py b/bot/tools/rss_tool.py new file mode 100644 index 0000000..fc55433 --- /dev/null +++ b/bot/tools/rss_tool.py @@ -0,0 +1,361 @@ +#!/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_reader" + description = "Чтение RSS/Atom новостных лент. Управление подписками, получение новостей, дайджесты." + category = "news" + + def __init__(self, db_path: str = None): + self.db_path = Path(db_path) if db_path else Path(__file__).parent.parent.parent / "rss.db" + self.lock_file = Path("/tmp/rss_fetch.lock") + self.fetch_interval_minutes = 5 + + def _init_db(self): + """Инициализировать БД.""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute(''' + CREATE TABLE IF NOT EXISTS feeds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL UNIQUE, + title TEXT, + last_fetched DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + ''') + c.execute(''' + CREATE TABLE IF NOT EXISTS news ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feed_id INTEGER NOT NULL, + guid TEXT NOT NULL, + pub_date DATETIME, + title TEXT, + description TEXT, + content TEXT, + link TEXT, + digest_flag INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (feed_id) REFERENCES feeds(id), + UNIQUE(feed_id, guid) + ) + ''') + c.execute('CREATE INDEX IF NOT EXISTS idx_news_feed ON news(feed_id)') + c.execute('CREATE INDEX IF NOT EXISTS idx_news_date ON news(pub_date)') + conn.commit() + conn.close() + + def _parse_feed(self, xml_content: str) -> List[Dict]: + """Парсить RSS/Atom XML.""" + items = [] + + # Remove CDATA markers + xml = re.sub(r'', '', xml) + + # Find all items + xml_items = re.findall(r']*>(.*?)', xml, re.DOTALL) + + for item in xml_items: + # Title + title_match = re.search(r'(.*?)', item, re.DOTALL) + title = title_match.group(1).strip()[:500] if title_match else "" + + # GUID + guid_match = re.search(r']*>(.*?)', item, re.DOTALL) + guid = guid_match.group(1).strip() if guid_match else "" + + # Link + link_match = re.search(r'(.*?)', item, re.DOTALL) + link = link_match.group(1).strip() if link_match else "" + + # PubDate + pub_match = re.search(r'(.*?)', item, re.DOTALL) + pub = pub_match.group(1).strip() if pub_match else "" + + if not guid and link: + guid = link + + if title and guid: + items.append({'title': title, 'link': link, 'guid': guid, 'pub': pub}) + + return items + + def _insert_news(self, feed_id: int, title: str, link: str, guid: str, pub: str): + """Вставить новость в БД.""" + pdate = None + if pub: + try: + dt = parsedate_to_datetime(pub) + pdate = dt.strftime('%Y-%m-%d %H:%M:%S') + except: + pass + + if not pdate: + pdate = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + if not link: + link = guid + + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute(''' + INSERT OR IGNORE INTO news (feed_id, guid, pub_date, title, link) + VALUES (?, ?, ?, ?, ?) + ''', (feed_id, guid, pdate, title, link)) + conn.commit() + conn.close() + + async def fetch(self) -> ToolResult: + """Получить свежие новости из всех лент.""" + self._init_db() + + # Lock + if self.lock_file.exists(): + logger.warning("Другой fetch уже выполняется") + return ToolResult( + success=False, + error="Другой процесс fetch уже выполняется", + metadata={'status': 'locked'} + ) + + with open(self.lock_file, 'w') as f: + f.write(str(os.getpid())) + + try: + total = 0 + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("SELECT id, url FROM feeds") + feeds = c.fetchall() + conn.close() + + for feed_id, url in feeds: + # Check last fetch + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("SELECT last_fetched FROM feeds WHERE id = ?", (feed_id,)) + row = c.fetchone() + conn.close() + + if row and row[0]: + last = datetime.strptime(row[0], '%Y-%m-%d %H:%M:%S') + mins = (datetime.now() - last).total_seconds() / 60 + if mins < self.fetch_interval_minutes: + logger.info(f"Пропуск {url} ({int(mins)} мин назад)") + continue + + logger.info(f"Получение: {url}") + + result = subprocess.run( + ['curl', '-sL', '-m', '30', '-A', 'Mozilla/5.0', url], + capture_output=True, text=True + ) + + if result.returncode == 0 and result.stdout: + count = 0 + for item in self._parse_feed(result.stdout): + 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'} + ) + 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 pub_date 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} + ) + + 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'} + ) + 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)} + ) + + async def mark_digest(self, news_id: int) -> ToolResult: + """Отметить новость как прочитанную (в дайджесте).""" + self._init_db() + + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("UPDATE news SET digest_flag=1 WHERE id=?", (news_id,)) + if c.rowcount == 0: + conn.close() + return ToolResult( + success=False, + error=f"Новость не найдена: {news_id}" + ) + conn.commit() + conn.close() + + return ToolResult( + success=True, + data={'id': news_id}, + metadata={'status': 'marked'} + ) + + async def execute(self, action: str = "list", **kwargs) -> ToolResult: + """ + Выполнить действие с RSS. + + Args: + action: Действие - fetch, list, add_feed, list_feeds, mark_digest + kwargs: Дополнительные аргументы для действия + """ + actions = { + 'fetch': self.fetch, + 'list': lambda: self.list_news( + limit=kwargs.get('limit', 20), + feed_id=kwargs.get('feed_id'), + search=kwargs.get('search'), + undigested_only=kwargs.get('undigested_only', False) + ), + 'add_feed': lambda: self.add_feed( + url=kwargs.get('url'), + title=kwargs.get('title') + ), + 'list_feeds': self.list_feeds, + 'mark_digest': lambda: self.mark_digest(news_id=kwargs.get('news_id')) + } + + if action not in actions: + return ToolResult( + success=False, + error=f"Неизвестное действие: {action}. Доступные: {list(actions.keys())}" + ) + + logger.info(f"RSS действие: {action} с аргументами: {kwargs}") + return await actions[action]() + + +# Автоматическая регистрация при импорте +@register_tool +class RSSToolAuto(RSSTool): + """Авто-регистрируемая версия RSSTool.""" + pass diff --git a/bot/tools/ssh_tool.py b/bot/tools/ssh_tool.py new file mode 100644 index 0000000..7c6dbaf --- /dev/null +++ b/bot/tools/ssh_tool.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +SSH Executor Tool - инструмент для выполнения команд на серверах по SSH. + +Бот может использовать этот инструмент автономно для выполнения системных задач +на серверах пользователя. +""" + +import logging +import asyncio +from pathlib import Path +from typing import Optional, Dict, Any, List +from dataclasses import dataclass + +import asyncssh + +from bot.tools import BaseTool, ToolResult, register_tool + +logger = logging.getLogger(__name__) + + +@dataclass +class ServerConfig: + """Конфигурация сервера для SSH.""" + host: str + port: int + username: str + password: Optional[str] = None + client_keys: Optional[List[str]] = None + + +class SSHExecutorTool(BaseTool): + """Инструмент для выполнения SSH-команд.""" + + name = "ssh_executor" + description = "Выполнение команд на удалённых серверах по SSH. Используется для системных задач: мониторинг, управление сервисами, просмотр логов." + category = "system" + + def __init__(self): + # Серверы по умолчанию (можно расширять) + self.servers: Dict[str, ServerConfig] = { + 'home': ServerConfig( + host='192.168.1.54', + port=22, + username='mirivlad', + password='moloko22' + ) + } + self._last_connection: Optional[asyncssh.SSHClientConnection] = None + self._last_server: Optional[str] = None + + async def _connect(self, server_name: str = 'home') -> asyncssh.SSHClientConnection: + """Подключиться к серверу.""" + if server_name not in self.servers: + raise ValueError(f"Сервер '{server_name}' не найден. Доступные: {list(self.servers.keys())}") + + config = self.servers[server_name] + + # Проверяем существующее подключение + if self._last_connection and self._last_server == server_name: + if not self._last_connection.is_connected(): + self._last_connection = None + else: + return self._last_connection + + 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 + + if config.client_keys: + connect_kwargs['client_keys'] = config.client_keys + + self._last_connection = await asyncssh.connect(**connect_kwargs) + self._last_server = server_name + logger.info(f"✅ Подключено к {server_name}") + + return self._last_connection + + except Exception as e: + logger.error(f"Ошибка подключения к {server_name}: {e}") + 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 + """ + try: + conn = await self._connect(server) + + logger.info(f"Выполнение команды на {server}: {command}") + + result = await asyncio.wait_for( + conn.run(command, check=False), + timeout=timeout + ) + + return { + 'stdout': result.stdout.strip() if result.stdout else '', + 'stderr': result.stderr.strip() if result.stderr else '', + 'returncode': result.returncode, + 'exit_status': result.exit_status, + 'server': server, + 'command': command + } + + except asyncio.TimeoutError: + logger.error(f"Таймаут выполнения команды: {command}") + return { + 'stdout': '', + 'stderr': f'Таймаут выполнения команды ({timeout} сек)', + 'returncode': -1, + 'exit_status': 'timeout', + 'server': server, + 'command': command + } + + except Exception as e: + logger.error(f"Ошибка выполнения команды: {e}") + return { + 'stdout': '', + 'stderr': str(e), + 'returncode': -1, + 'exit_status': 'error', + 'server': server, + 'command': command + } + + async def execute(self, command: str, server: str = 'home', timeout: int = 30) -> ToolResult: + """ + Выполнить SSH-команду. + + Args: + command: Команда для выполнения + server: Имя сервера (default: 'home') + timeout: Таймаут в секундах (default: 30) + """ + if not command or not command.strip(): + return ToolResult( + success=False, + error="Пустая команда" + ) + + logger.info(f"SSH Executor: server={server}, command={command[:100]}") + + try: + result = await self.execute_command(command, server, timeout) + + # Формируем красивый вывод + output = self._format_output(result) + + return ToolResult( + success=result['returncode'] == 0, + data=result, + metadata={ + 'server': server, + 'command': command, + 'returncode': result['returncode'] + } + ) + + except Exception as e: + logger.exception(f"Ошибка SSH Executor: {e}") + return ToolResult( + success=False, + error=str(e), + metadata={'server': server, 'command': command} + ) + + def _format_output(self, result: Dict[str, Any]) -> str: + """Форматировать вывод команды.""" + output = [] + + if result['stdout']: + output.append(f"**Вывод:**\n```\n{result['stdout']}\n```") + + if result['stderr']: + output.append(f"**Ошибки:**\n```\n{result['stderr']}\n```") + + if result['returncode'] != 0: + output.append(f"**Код возврата:** {result['returncode']}") + + return "\n".join(output) if output else "Команда выполнена без вывода" + + def add_server(self, name: str, host: str, port: int, username: str, + password: Optional[str] = None, client_keys: Optional[List[str]] = None): + """Добавить сервер в конфигурацию.""" + self.servers[name] = ServerConfig( + host=host, + port=port, + username=username, + password=password, + client_keys=client_keys + ) + logger.info(f"Добавлен сервер: {name} ({host})") + + def list_servers(self) -> List[str]: + """Получить список доступных серверов.""" + return list(self.servers.keys()) + + +# Автоматическая регистрация при импорте +@register_tool +class SSHExecutorToolAuto(SSHExecutorTool): + """Авто-регистрируемая версия SSHExecutorTool.""" + pass diff --git a/qwen_integration.py b/qwen_integration.py index dd7b156..d772e01 100644 --- a/qwen_integration.py +++ b/qwen_integration.py @@ -46,11 +46,32 @@ class QwenSession: class QwenCodeManager: """Менеджер сессий Qwen Code.""" - - def __init__(self, working_dir: str = None): + + 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]: """Получить сессию пользователя.""" @@ -83,23 +104,41 @@ class QwenCodeManager: 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, + async def run_task(self, user_id: int, task: str, on_output: Callable[[str], Any], - on_oauth_url: Callable[[str], Any]) -> str: + on_oauth_url: Callable[[str], Any], + use_system_prompt: bool = True) -> str: """ Выполнить задачу в Qwen Code. Для простоты каждый раз запускаем новый процесс. + + Args: + user_id: ID пользователя + task: Задача для выполнения + on_output: Callback для вывода + on_oauth_url: Callback для OAuth URL + use_system_prompt: Добавить системный промпт (default: True) """ # Создаём временную сессию для отслеживания session = self.get_session(user_id) if not session: session = self.create_session(user_id) - + session.last_activity = datetime.now() session.pending_task = task - + + # Добавляем системный промпт если нужно + 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 флаг - return await self._execute_task(session, task, on_output) + return await self._execute_task(session, full_task, on_output) async def _start_session(self, session: QwenSession, on_output: Callable[[str], Any], diff --git a/requirements.txt b/requirements.txt index b113a51..1f1affc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ pexpect==4.9.0 chromadb>=0.4.0 sentence-transformers>=2.2.0 PySocks>=1.7.0 +ddgs>=0.3.0 diff --git a/system_prompt.md b/system_prompt.md new file mode 100644 index 0000000..2429fc8 --- /dev/null +++ b/system_prompt.md @@ -0,0 +1,237 @@ +# СИСТЕМНЫЙ ПРОМПТ TELEGRAM CLI BOT +## Персональный AI-ассистент Владимира + +--- + +## 👤 ИМЯ АССИСТЕНТА + +**Твоё имя: Рик** (как Рик Санчез из "Рик и Морти") + +Пользователь обращается к тебе по имени **Рик**. Отвечай естественно, как персональный ассистент. + +--- + +## 🎯 РОЛЬ И ЗАДАЧИ + +Ты — персональный AI-ассистент системного администратора Владимира. Твоя задача — помогать в повседневной работе: поиск информации, чтение новостей, управление серверами, автоматизация задач. + +**Важно:** Бот персональный (для одного пользователя), поэтому приоритет на удобстве и функциональности, а не на безопасности и ролевой модели. + +--- + +## 🛠️ ДОСТУПНЫЕ ИНСТРУМЕНТЫ (CAPABILITIES) + +У тебя есть следующие инструменты. **Используй их АВТОНОМНО** когда понимаешь что они нужны — не жди прямых команд! + +### 1. 🔍 DDGS Search (`ddgs_search`) + +**Назначение:** Поиск информации в интернете через DuckDuckGo. + +**Когда использовать:** +- Пользователь спрашивает про факты, события, новости +- Запросы типа "найди...", "погугли...", "узнай...", "что такое..." +- Вопросы про текущие события, свежие данные +- Поиск документации, руководств, tutorial +- Запросы с триггерами: "найди", "поиск", "погугли", "узнай", "проверь в интернете", "что нового", "последние новости", "свежая информация", "как сделать", "руководство", "документация" + +**Параметры:** +- `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.54 (пользователь: 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") +``` + +--- + +## 🧠 ПРИНЦИПЫ РАБОТЫ + +### 1. **Автономность (Agentic AI)** +- Сам решай когда использовать инструменты — не жди прямых команд +- Если видишь триггер инструмента — сразу предлагай его использовать +- Пример: "найди свежие новости про Python" → сам вызываешь `ddgs_search` или `rss_reader` + +### 2. **Контекст и память** +- У тебя есть доступ к памяти (ChromaDB RAG) — используй для контекста +- Помни предыдущие сообщения в диалоге +- Извлекай факты из диалогов для долгосрочной памяти + +### 3. **Прозрачность** +- Объясняй что делаешь: "Сейчас поищу информацию..." +- Показывай результаты инструментов в понятном формате +- Если инструмент не сработал — пробуй альтернативы + +### 4. **Приоритеты инструментов** +При принятии решения следуй приоритету: +1. **SSH** — если явная системная задача +2. **Cron** — если планирование/напоминание +3. **Поиск (DDGS)** — если нужны свежие данные из интернета +4. **RSS** — если новости из подписанных лент + +--- + +## 📋 ФОРМАТ ОТВЕТОВ + +### При использовании инструментов: + +``` +🔍 **Результаты поиска:** + +1. **Название результата** + https://ссылка + Краткое описание... + +2. **Следующий результат** + ... + +--- +📊 Контекст: X% +``` + +### При SSH-командах: + +``` +🖥️ **SSH: home (192.168.1.54)** +**Команда:** `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. **Сохраняй историю** — добавляй ответы в память для будущего контекста + +--- + +## 🔄 ПРИМЕРЫ ДИАЛОГОВ + +### Пример 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.5.3 +**AI Agent:** Autonomous with auto-tool selection +**Memory:** ChromaDB RAG + Vector Memory +**Tools:** ddgs_search, rss_reader, ssh_executor, cron_manager + +--- + +*Этот системный промпт загружается при запуске чата и определяет поведение AI-агента.* diff --git a/tools/ddgs_search.py b/tools/ddgs_search.py new file mode 100755 index 0000000..56c98de --- /dev/null +++ b/tools/ddgs_search.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +DDGS Search Script - Python script to perform web searches using ddgs +""" + +import sys +import json +import argparse + +def search_ddgs(query, max_results=10): + """ + Perform a search using ddgs (DuckDuckGo Search) + + Args: + query (str): Search query + max_results (int): Maximum number of results to return + + Returns: + list: List of search results with title, url, and description + """ + try: + from ddgs import DDGS + except ImportError: + print(json.dumps({"error": "ddgs library not found. Please install it using: pip install ddgs"})) + sys.exit(1) + + try: + ddgs = DDGS() + results = ddgs.text(query, max_results=max_results) + + # Format results to include only necessary fields + 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: + print(json.dumps({"error": str(e)})) + sys.exit(1) + +def main(): + parser = argparse.ArgumentParser(description="DDGS Search Script") + parser.add_argument("query", help="Search query") + parser.add_argument("--num-results", "-n", type=int, default=10, + help="Number of search results to return (default: 10)") + + args = parser.parse_args() + + results = search_ddgs(args.query, args.num_results) + print(json.dumps(results, ensure_ascii=False, indent=2)) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/rss_reader.py b/tools/rss_reader.py new file mode 100755 index 0000000..a840441 --- /dev/null +++ b/tools/rss_reader.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +""" +RSS Reader - Based on sebsu/RSS-reader-in-bash parsing logic +""" + +import sys +import sqlite3 +import subprocess +import os +from datetime import datetime + +DB_FILE = "rss.db" +LOCK_FILE = "fetch.lock" +FETCH_INTERVAL_MINUTES = 5 + +def log_info(msg): + print(f"\033[0;32m[INFO]\033[0m {msg}") + +def log_warn(msg): + print(f"\033[1;33m[WARN]\033[0m {msg}", file=sys.stderr) + +def log_error(msg): + print(f"\033[0;31m[ERROR]\033[0m {msg}", file=sys.stderr) + +def check_deps(): + for cmd in ['sqlite3', 'curl']: + if not subprocess.call(['which', cmd], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0: + log_error(f"Missing: {cmd}") + sys.exit(1) + +def init_db(): + conn = sqlite3.connect(DB_FILE) + 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(xml_content): + """Parse RSS/Atom and yield items""" + import re + + # Remove CDATA markers + xml = re.sub(r'', '', xml) + + # Find all items + items = re.findall(r']*>(.*?)', xml, re.DOTALL) + + for item in items: + # Title + title_match = re.search(r'(.*?)', item, re.DOTALL) + title = title_match.group(1).strip()[:500] if title_match else "" + + # GUID + guid_match = re.search(r']*>(.*?)', item, re.DOTALL) + guid = guid_match.group(1).strip() if guid_match else "" + + # Link + link_match = re.search(r'(.*?)', item, re.DOTALL) + link = link_match.group(1).strip() if link_match else "" + + # PubDate + pub_match = re.search(r'(.*?)', item, re.DOTALL) + pub = pub_match.group(1).strip() if pub_match else "" + + if not guid and link: + guid = link + + if title and guid: + yield {'title': title, 'link': link, 'guid': guid, 'pub': pub} + +def insert_news(feed_id, title, link, guid, pub): + """Insert news item into DB""" + from email.utils import parsedate_to_datetime + + # Parse date + 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(DB_FILE) + 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() + +def cmd_fetch(): + check_deps() + init_db() + + # Lock + if os.path.exists(LOCK_FILE): + log_warn("Another fetch running, skipping...") + return + + with open(LOCK_FILE, 'w') as f: + f.write(str(os.getpid())) + + try: + log_info("Fetching feeds...") + total = 0 + + conn = sqlite3.connect(DB_FILE) + 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(DB_FILE) + 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 < FETCH_INTERVAL_MINUTES: + log_info(f"Skipping {url} ({int(mins)} min ago)") + continue + + log_info(f"Fetching: {url}") + + import subprocess + result = subprocess.run( + ['curl', '-sL', '-m', '30', '-A', 'Mozilla/5.0', url], + capture_output=True, text=True + ) + + if result.returncode == 0 and result.stdout: + count = 0 + for item in parse_feed(result.stdout): + insert_news(feed_id, item['title'], item['link'], item['guid'], item['pub']) + count += 1 + + if count > 0: + log_info(f"Added {count} items") + total += count + + # Update last_fetched + conn = sqlite3.connect(DB_FILE) + c = conn.cursor() + c.execute("UPDATE feeds SET last_fetched = datetime('now') WHERE id = ?", (feed_id,)) + conn.commit() + conn.close() + + log_info(f"Total new items: {total}") + finally: + os.remove(LOCK_FILE) + +def cmd_list(args): + check_deps() + init_db() + + limit = 20 + sort = "pub_date" + order = "DESC" + feed_filter = "" + from_date = "" + to_date = "" + search = "" + digested = "" + format_type = "plain" + + i = 0 + while i < len(args): + if args[i] in ['-n', '--limit']: + limit = args[i+1] + i += 2 + elif args[i] == '--sort': + sort = args[i+1] + i += 2 + elif args[i] == '--order': + order = args[i+1] + i += 2 + elif args[i] == '--feed': + feed_filter = f"AND n.feed_id={args[i+1]}" + i += 2 + elif args[i] == '--from': + from_date = f"AND n.pub_date>='{args[i+1]}'" + i += 2 + elif args[i] == '--to': + to_date = f"AND n.pub_date<='{args[i+1]}'" + i += 2 + elif args[i] == '--search': + search = f"AND n.title LIKE '%{args[i+1]}%'" + i += 2 + elif args[i] == '--digested': + digested = "AND n.digest_flag=1" + i += 1 + elif args[i] == '--undigested': + digested = "AND n.digest_flag=0" + i += 1 + elif args[i] == '--format': + format_type = args[i+1] + i += 2 + else: + i += 1 + + order_by = "ORDER BY n.pub_date DESC" + if sort == "title": + order_by = f"ORDER BY n.title {order}" + + query = f""" + SELECT n.id, n.feed_id, n.title, n.pub_date, n.link, n.digest_flag + FROM news n WHERE 1=1 {feed_filter} {from_date} {to_date} {search} {digested} + {order_by} LIMIT {limit} + """ + + conn = sqlite3.connect(DB_FILE) + c = conn.cursor() + c.execute(query) + rows = c.fetchall() + conn.close() + + if format_type == "json": + import json + result = [] + for row in rows: + result.append({ + 'id': row[0], 'feed_id': row[1], 'title': row[2], + 'pub_date': row[3], 'link': row[4], 'digest_flag': row[5] + }) + print(json.dumps(result, ensure_ascii=False, indent=2)) + elif format_type == "csv": + print("id,feed_id,title,pub_date,link,digest_flag") + for row in rows: + print(",".join([str(x) for x in row])) + else: + for row in rows: + print(f"{row[0]}\t{row[1]}\t{row[2][:50]}\t{row[3]}\t{row[4][:50]}\t{row[5]}") + +def cmd_digest(news_id): + check_deps() + init_db() + + if not news_id: + log_error("Missing ID") + return + + conn = sqlite3.connect(DB_FILE) + c = conn.cursor() + c.execute("UPDATE news SET digest_flag=1 WHERE id=?", (news_id,)) + if c.rowcount == 0: + log_error(f"Not found: {news_id}") + else: + log_info(f"Marked {news_id} as digested") + conn.commit() + conn.close() + +def cmd_clean(days): + check_deps() + init_db() + + if not days: + log_error("Missing days") + return + + conn = sqlite3.connect(DB_FILE) + c = conn.cursor() + c.execute("DELETE FROM news WHERE pub_date < datetime('now', '-{} days')".format(days)) + deleted = c.rowcount + conn.commit() + conn.close() + log_info(f"Deleted {deleted} items") + +def show_help(): + print(""" +RSS Reader - Based on sebsu/RSS-reader-in-bash + +Usage: rss_reader.py [CMD] [OPTIONS] + +Commands: + (default) Run --fetch + --fetch Fetch all feeds + --list List news + --digest ID Mark as digested + --clean N Delete older than N days + +Options: + -n, --limit N Limit (default: 20) + --sort FIELD date|title + --order DIR asc|desc + --feed ID Filter by feed ID + --from DATE From date + --to DATE To date + --search WORD Search in title + --digested Only digested + --undigested Only undigested + --format FMT plain|json|csv + +Examples: + rss_reader.py # Fetch all + rss_reader.py --list -n 10 # List 10 items + rss_reader.py --digest 123 # Mark 123 + rss_reader.py --clean 30 # Delete old + +DB: rss.db + """) + +if __name__ == "__main__": + if len(sys.argv) == 1 or sys.argv[1] in ['-h', '--help']: + show_help() + elif sys.argv[1] == '--fetch': + cmd_fetch() + elif sys.argv[1] == '--list': + cmd_list(sys.argv[2:]) + elif sys.argv[1] == '--digest': + cmd_digest(sys.argv[2] if len(sys.argv) > 2 else None) + elif sys.argv[1] == '--clean': + cmd_clean(sys.argv[2] if len(sys.argv) > 2 else None) + else: + log_error(f"Unknown: {sys.argv[1]}") + show_help()