feat: add AI agent tools, system prompt, and compaction module

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mirivlad 2026-02-25 09:52:10 +08:00
parent 89b071b42a
commit 2773680da1
16 changed files with 3036 additions and 14 deletions

5
.gitignore vendored
View File

@ -30,3 +30,8 @@ huggingface_cache/
# OS
.DS_Store
Thumbs.db
# Database (personal data - do not commit)
*.db
*.sqlite3
vector_db/

260
AI_AGENT_TOOLS.md Normal file
View File

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

159
SYSTEM_PROMPT.md Normal file
View File

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

342
TOOLS.md Normal file
View File

@ -0,0 +1,342 @@
# 🛠️ Инструменты Telegram CLI Bot
Инструменты — это capabilities, которые бот может использовать **автономно** для выполнения задач пользователя (Agentic AI подход).
## 📋 Архитектура
```
bot/
├── tools/
│ ├── __init__.py # Реестр инструментов (ToolsRegistry)
│ ├── ddgs_tool.py # Поиск в интернете через DuckDuckGo
│ ├── rss_tool.py # Чтение RSS/Atom лент
│ ├── ssh_tool.py # Выполнение команд по SSH
│ └── cron_tool.py # Управление задачами по расписанию
└── ai_agent.py # AI агент для принятия решений
```
## 🏗️ Как это работает
### 1. Реестр инструментов
`ToolsRegistry` — синглтон, который хранит все доступные инструменты:
```python
from bot.tools import tools_registry
# Получить инструмент
tool = tools_registry.get('ddgs_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`.

157
bot.py
View File

@ -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):
@ -161,15 +197,21 @@ async def handle_ai_task(update: Update, text: str):
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

319
bot/ai_agent.py Normal file
View File

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

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

@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Реестр инструментов для Telegram CLI Bot.
Инструменты - это capabilities, которые бот может использовать автономно
для выполнения задач пользователя (Agentic AI подход).
"""
import logging
from abc import ABC, abstractmethod
from typing import Dict, List, Any, Optional
from dataclasses import dataclass, field
from datetime import datetime
logger = logging.getLogger(__name__)
@dataclass
class ToolResult:
"""Результат выполнения инструмента."""
success: bool
data: Any = None
error: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict:
return {
'success': self.success,
'data': self.data,
'error': self.error,
'metadata': self.metadata
}
class BaseTool(ABC):
"""Базовый класс для всех инструментов."""
name: str = "base_tool"
description: str = "Базовый инструмент"
category: str = "general"
@abstractmethod
async def execute(self, **kwargs) -> ToolResult:
"""Выполнить инструмент."""
pass
def get_capabilities(self) -> Dict:
"""Вернуть описание возможностей инструмента."""
return {
'name': self.name,
'description': self.description,
'category': self.category
}
class ToolsRegistry:
"""Реестр всех доступных инструментов."""
_instance: Optional['ToolsRegistry'] = None
_tools: Dict[str, BaseTool] = {}
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def register(self, tool: BaseTool):
"""Зарегистрировать инструмент."""
self._tools[tool.name] = tool
logger.info(f"Зарегистрирован инструмент: {tool.name}")
def unregister(self, tool_name: str):
"""От-register инструмент."""
if tool_name in self._tools:
del self._tools[tool_name]
logger.info(f"Удален инструмент: {tool_name}")
def get(self, tool_name: str) -> Optional[BaseTool]:
"""Получить инструмент по имени."""
return self._tools.get(tool_name)
def get_all(self) -> Dict[str, BaseTool]:
"""Получить все инструменты."""
return self._tools.copy()
def get_capabilities_list(self) -> List[Dict]:
"""Получить список всех возможностей для ИИ."""
return [tool.get_capabilities() for tool in self._tools.values()]
async def execute_tool(self, tool_name: str, **kwargs) -> ToolResult:
"""Выполнить инструмент по имени."""
tool = self.get(tool_name)
if not tool:
return ToolResult(
success=False,
error=f"Инструмент '{tool_name}' не найден"
)
logger.info(f"Выполнение инструмента: {tool_name} с аргументами: {kwargs}")
try:
result = await tool.execute(**kwargs)
result.metadata['tool_name'] = tool_name
result.metadata['timestamp'] = datetime.now().isoformat()
return result
except Exception as e:
logger.exception(f"Ошибка выполнения инструмента {tool_name}: {e}")
return ToolResult(
success=False,
error=str(e),
metadata={'tool_name': tool_name}
)
# Глобальный экземпляр реестра
tools_registry = ToolsRegistry()
def register_tool(tool_class: type) -> type:
"""Декоратор для автоматической регистрации инструмента."""
tool_instance = tool_class()
tools_registry.register(tool_instance)
return tool_class
# Авто-импорт инструментов для регистрации
# Импортируем после определения register_tool чтобы декоратор сработал
from bot.tools import ddgs_tool, rss_tool, ssh_tool, cron_tool

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

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

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

@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""
DDGS Search Tool - инструмент для поиска в интернете через DuckDuckGo.
Бот может использовать этот инструмент автономно, когда пользователю нужна
свежая информация из интернета.
"""
import sys
import json
import logging
from pathlib import Path
from typing import List, Dict, Any
from bot.tools import BaseTool, ToolResult, register_tool
logger = logging.getLogger(__name__)
class DDGSTool(BaseTool):
"""Инструмент поиска через DuckDuckGo."""
name = "ddgs_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

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

@ -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'<!\[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, 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

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

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

View File

@ -47,10 +47,31 @@ 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]:
"""Получить сессию пользователя."""
@ -85,10 +106,18 @@ class QwenCodeManager:
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)
@ -98,8 +127,18 @@ class QwenCodeManager:
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],

View File

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

237
system_prompt.md Normal file
View File

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

58
tools/ddgs_search.py Executable file
View File

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

355
tools/rss_reader.py Executable file
View File

@ -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'<!\[CDATA\[', '', xml_content)
xml = re.sub(r'\]\]>', '', xml)
# Find all items
items = re.findall(r'<item[^>]*>(.*?)</item>', xml, re.DOTALL)
for item in 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:
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()