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

This commit is contained in:
mirivlad
2026-03-16 03:47:15 +08:00
commit 6b8bd8d7a6
69 changed files with 20857 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Реестр инструментов для Telegram CLI Bot.
Инструменты - это capabilities, которые бот может использовать автономно
для выполнения задач пользователя (Agentic AI подход).
"""
import logging
from abc import ABC, abstractmethod
from typing import Dict, List, Any, Optional
from dataclasses import dataclass, field
from datetime import datetime
logger = logging.getLogger(__name__)
@dataclass
class ToolResult:
"""Результат выполнения инструмента."""
success: bool
data: Any = None
error: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict:
return {
'success': self.success,
'data': self.data,
'error': self.error,
'metadata': self.metadata
}
class BaseTool(ABC):
"""Базовый класс для всех инструментов."""
name: str = "base_tool"
description: str = "Базовый инструмент"
category: str = "general"
@abstractmethod
async def execute(self, **kwargs) -> ToolResult:
"""Выполнить инструмент."""
pass
def get_capabilities(self) -> Dict:
"""Вернуть описание возможностей инструмента."""
return {
'name': self.name,
'description': self.description,
'category': self.category
}
class ToolsRegistry:
"""Реестр всех доступных инструментов."""
_instance: Optional['ToolsRegistry'] = None
_tools: Dict[str, BaseTool] = {}
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def register(self, tool: BaseTool):
"""Зарегистрировать инструмент."""
self._tools[tool.name] = tool
logger.info(f"Зарегистрирован инструмент: {tool.name}")
def unregister(self, tool_name: str):
"""От-register инструмент."""
if tool_name in self._tools:
del self._tools[tool_name]
logger.info(f"Удален инструмент: {tool_name}")
def get(self, tool_name: str) -> Optional[BaseTool]:
"""Получить инструмент по имени."""
return self._tools.get(tool_name)
def get_all(self) -> Dict[str, BaseTool]:
"""Получить все инструменты."""
return self._tools.copy()
def get_capabilities_list(self) -> List[Dict]:
"""Получить список всех возможностей для ИИ."""
return [tool.get_capabilities() for tool in self._tools.values()]
async def execute_tool(self, tool_name: str, **kwargs) -> ToolResult:
"""Выполнить инструмент по имени."""
tool = self.get(tool_name)
if not tool:
return ToolResult(
success=False,
error=f"Инструмент '{tool_name}' не найден"
)
logger.info(f"Выполнение инструмента: {tool_name} с аргументами: {kwargs}")
try:
result = await tool.execute(**kwargs)
result.metadata['tool_name'] = tool_name
result.metadata['timestamp'] = datetime.now().isoformat()
return result
except Exception as e:
logger.exception(f"Ошибка выполнения инструмента {tool_name}: {e}")
return ToolResult(
success=False,
error=str(e),
metadata={'tool_name': tool_name}
)
# Глобальный экземпляр реестра
tools_registry = ToolsRegistry()
def register_tool(tool_class: type) -> type:
"""Декоратор для автоматической регистрации инструмента."""
tool_instance = tool_class()
tools_registry.register(tool_instance)
return tool_class
# Авто-импорт инструментов для регистрации
# Импортируем после определения register_tool чтобы декоратор сработал
from bot.tools import ddgs_tool, rss_tool, ssh_tool, cron_tool, gigachat_tool, file_system_tool, telegram_web_tool
+704
View File
@@ -0,0 +1,704 @@
#!/usr/bin/env python3
"""
Cron Tool - инструмент для управления интеллектуальными задачами.
Позволяет создавать, планировать и выполнять периодические задачи через AI-агент.
Задачи хранятся как промпты для ИИ, а не как команды.
"""
import logging
import sqlite3
import json
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any, Optional, Callable
from dataclasses import dataclass, field
from croniter import croniter
from bot.tools import BaseTool, ToolResult, register_tool
logger = logging.getLogger(__name__)
async def _translate_title(title: str, max_length: int = 100) -> str:
"""
Перевести заголовок на русский через Qwen.
Args:
title: Заголовок для перевода
max_length: Максимальная длина
Returns:
Переведённый заголовок
"""
try:
import subprocess
import json
# Создаём временный промпт для перевода
translate_prompt = f"Translate this news title to Russian. Keep it concise, natural, and informative. Maximum {max_length} characters. Return ONLY the translation, no quotes or explanations.\n\nTitle: {title[:200]}"
# Используем qwen-cli если доступен
result = subprocess.run(
['qwen', 'chat', '--json', '--prompt', translate_prompt],
capture_output=True,
text=True,
timeout=15
)
if result.returncode == 0:
# Парсим JSON ответ
try:
response = json.loads(result.stdout)
translated = response.get('content', response.get('response', title))
except json.JSONDecodeError:
translated = result.stdout.strip()
# Очищаем от кавычек
translated = translated.strip('"\'')
return translated[:max_length]
except Exception as e:
logger.debug(f"Ошибка перевода заголовка: {e}")
# Fallback - обрезаем оригинал
return title[:max_length]
@dataclass
class CronJob:
"""
Интеллектуальная задача cron.
Attributes:
id: ID задачи
name: Название задачи
prompt: Промпт для ИИ-агента (вместо команды)
schedule: Расписание (cron format: "*/5 * * * *" или "@daily", "@hourly")
enabled: Включена ли задача
user_id: ID пользователя Telegram
notify: Уведомлять ли пользователя в Telegram о результате
log_results: Сохранять ли результат в лог-файл
last_run: Время последнего выполнения
next_run: Время следующего выполнения
created_at: Время создания
"""
id: Optional[int]
name: str
prompt: str # Промпт для ИИ вместо команды
schedule: str
user_id: Optional[int] = None # ID пользователя Telegram
enabled: bool = True
notify: bool = False # Уведомлять пользователя в Telegram
log_results: bool = True # Сохранять в лог
last_run: Optional[datetime] = None
next_run: Optional[datetime] = None
created_at: datetime = field(default_factory=datetime.now)
class CronTool(BaseTool):
"""Инструмент для управления интеллектуальными задачами пользователя."""
name = "cron_tool"
description = "Управление периодическими задачами через AI-агент. Создание, планирование и выполнение задач по расписанию."
category = "automation"
def __init__(self, db_path: str = None, log_dir: str = None):
self.db_path = Path(db_path) if db_path else Path(__file__).parent.parent.parent / "cron.db"
self.log_dir = Path(log_dir) if log_dir else Path(__file__).parent.parent.parent / "cron_logs"
self.log_dir.mkdir(parents=True, exist_ok=True)
self._jobs: Dict[int, CronJob] = {}
self._init_db()
def _init_db(self):
"""Инициализировать БД."""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
# Создаём таблицу со всеми колонками
c.execute('''
CREATE TABLE IF NOT EXISTS cron_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
prompt TEXT NOT NULL,
schedule TEXT NOT NULL,
user_id INTEGER,
enabled INTEGER DEFAULT 1,
notify INTEGER DEFAULT 0,
log_results INTEGER DEFAULT 1,
last_run DATETIME,
next_run DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
# Проверяем наличие всех колонок (для обратной совместимости)
c.execute("PRAGMA table_info(cron_jobs)")
columns = [col[1] for col in c.fetchall()]
# Миграции для старых БД
migrations = {
'prompt': 'ALTER TABLE cron_jobs ADD COLUMN prompt TEXT DEFAULT ""',
'user_id': 'ALTER TABLE cron_jobs ADD COLUMN user_id INTEGER',
'enabled': 'ALTER TABLE cron_jobs ADD COLUMN enabled INTEGER DEFAULT 1',
'notify': 'ALTER TABLE cron_jobs ADD COLUMN notify INTEGER DEFAULT 0',
'log_results': 'ALTER TABLE cron_jobs ADD COLUMN log_results INTEGER DEFAULT 1',
'last_run': 'ALTER TABLE cron_jobs ADD COLUMN last_run DATETIME',
'next_run': 'ALTER TABLE cron_jobs ADD COLUMN next_run DATETIME'
}
for col_name, alter_query in migrations.items():
if col_name not in columns:
logger.info(f"Добавление колонки {col_name} в таблицу cron_jobs")
try:
c.execute(alter_query)
except sqlite3.OperationalError as e:
# Игнорируем ошибку если колонка уже существует (race condition)
if "duplicate column" not in str(e).lower():
raise
conn.commit()
conn.close()
def _parse_schedule(self, schedule: str, base_time: datetime = None) -> Optional[datetime]:
"""
Распарсить расписание и вернуть следующее время выполнения.
Поддерживает полноценный cron-синтаксис через croniter:
- "*/5 * * * *" - каждые 5 минут
- "0 * * * *" - каждый час в 0 минут
- "0 5 * * *" - каждый день в 05:00
- "0 0 1 * *" - каждый месяц 1 числа в 00:00
- "0 0 * * 0" - каждое воскресенье в 00:00
- "@hourly", "@daily", "@weekly", "@monthly", "@yearly"
Args:
schedule: Cron-выражение или special string
base_time: Базовое время для расчёта (по умолчанию сейчас)
Returns:
Следующее время выполнения или None если ошибка парсинга
"""
if base_time is None:
base_time = datetime.now()
# Поддержка special strings
special_schedules = {
'@hourly': '0 * * * *',
'@daily': '0 0 * * *',
'@midnight': '0 0 * * *',
'@weekly': '0 0 * * 0',
'@monthly': '0 0 1 * *',
'@yearly': '0 0 1 1 *',
'@annually': '0 0 1 1 *'
}
cron_expr = special_schedules.get(schedule.lower(), schedule)
try:
# croniter возвращает следующее время выполнения
cron = croniter(cron_expr, base_time)
next_run = cron.get_next(datetime)
return next_run
except Exception as e:
logger.error(f"Ошибка парсинга cron-расписания '{schedule}': {e}")
return None
def _calculate_next_run(self, schedule: str, last_run: datetime = None) -> Optional[datetime]:
"""
Рассчитать следующее время выполнения на основе last_run.
Args:
schedule: Cron-выражение
last_run: Время последнего выполнения (по умолчанию сейчас)
Returns:
Следующее время выполнения
"""
base_time = last_run if last_run else datetime.now()
return self._parse_schedule(schedule, base_time)
async def add_job(self, name: str, prompt: str, schedule: str, user_id: int = None, notify: bool = False, log_results: bool = True) -> ToolResult:
"""
Добавить интеллектуальную задачу.
Args:
name: Название задачи
prompt: Промпт для ИИ-агента
schedule: Расписание (cron format или @daily, @hourly, @weekly)
user_id: ID пользователя Telegram
notify: Уведомлять ли пользователя в Telegram
log_results: Сохранять ли результат в лог
"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
try:
next_run = self._calculate_next_run(schedule)
next_run_str = next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else None
c.execute('''
INSERT INTO cron_jobs (name, prompt, schedule, user_id, notify, log_results, next_run)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (name, prompt, schedule, user_id, 1 if notify else 0, 1 if log_results else 0, next_run_str))
job_id = c.lastrowid
conn.commit()
self._jobs[job_id] = CronJob(
id=job_id,
name=name,
prompt=prompt,
schedule=schedule,
user_id=user_id,
notify=notify,
log_results=log_results,
next_run=next_run
)
return ToolResult(
success=True,
data={'id': job_id, 'name': name, 'prompt': prompt, 'schedule': schedule, 'user_id': user_id, 'next_run': next_run_str},
metadata={'status': 'added', 'notify': notify, 'log_results': log_results}
)
except Exception as e:
logger.exception(f"Ошибка добавления задачи: {e}")
return ToolResult(
success=False,
error=str(e)
)
finally:
conn.close()
async def update_next_run(self, job_id: int) -> ToolResult:
"""
Пересчитать время следующего выполнения после успешного запуска.
Args:
job_id: ID задачи
Returns:
ToolResult с новым next_run
"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
try:
# Получаем текущие данные задачи
c.execute("SELECT schedule, last_run FROM cron_jobs WHERE id = ?", (job_id,))
row = c.fetchone()
if not row:
return ToolResult(
success=False,
error=f"Задача не найдена: {job_id}"
)
schedule, last_run_str = row
# Рассчитываем следующее время выполнения на основе last_run
last_run = datetime.strptime(last_run_str, '%Y-%m-%d %H:%M:%S') if last_run_str else datetime.now()
next_run = self._calculate_next_run(schedule, last_run)
if not next_run:
return ToolResult(
success=False,
error=f"Не удалось рассчитать next_run для расписания '{schedule}'"
)
next_run_str = next_run.strftime('%Y-%m-%d %H:%M:%S')
# Обновляем next_run в БД
c.execute("UPDATE cron_jobs SET next_run = ? WHERE id = ?", (next_run_str, job_id))
conn.commit()
return ToolResult(
success=True,
data={'id': job_id, 'next_run': next_run_str},
metadata={'status': 'next_run_updated'}
)
except Exception as e:
logger.exception(f"Ошибка обновления next_run: {e}")
return ToolResult(
success=False,
error=str(e)
)
finally:
conn.close()
async def list_jobs(self, user_id: int = None) -> ToolResult:
"""
Получить список всех задач.
Args:
user_id: ID пользователя для фильтрации (если None - все задачи)
"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
if user_id:
c.execute('''
SELECT id, name, prompt, schedule, user_id, enabled, notify, log_results, last_run, next_run, created_at
FROM cron_jobs WHERE user_id = ? ORDER BY id
''', (user_id,))
else:
c.execute('''
SELECT id, name, prompt, schedule, user_id, enabled, notify, log_results, last_run, next_run, created_at
FROM cron_jobs ORDER BY id
''')
rows = c.fetchall()
conn.close()
jobs = []
for row in rows:
jobs.append({
'id': row[0],
'name': row[1],
'prompt': row[2],
'schedule': row[3],
'user_id': row[4],
'enabled': bool(row[5]),
'notify': bool(row[6]),
'log_results': bool(row[7]),
'last_run': row[8],
'next_run': row[9],
'created_at': row[10]
})
return ToolResult(
success=True,
data=jobs,
metadata={'count': len(jobs)}
)
async def remove_job(self, job_id: int) -> ToolResult:
"""Удалить задачу."""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("DELETE FROM cron_jobs WHERE id = ?", (job_id,))
if c.rowcount == 0:
conn.close()
return ToolResult(
success=False,
error=f"Задача не найдена: {job_id}"
)
conn.commit()
conn.close()
if job_id in self._jobs:
del self._jobs[job_id]
return ToolResult(
success=True,
data={'id': job_id},
metadata={'status': 'removed'}
)
async def toggle_job(self, job_id: int, enabled: bool) -> ToolResult:
"""Включить/выключить задачу."""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("UPDATE cron_jobs SET enabled = ? WHERE id = ?", (1 if enabled else 0, job_id))
if c.rowcount == 0:
conn.close()
return ToolResult(
success=False,
error=f"Задача не найдена: {job_id}"
)
conn.commit()
conn.close()
return ToolResult(
success=True,
data={'id': job_id, 'enabled': enabled},
metadata={'status': 'toggled'}
)
async def run_job(self, job_id: int, ai_agent=None, user_id: int = None) -> ToolResult:
"""
Выполнить интеллектуальную задачу через AI-агент.
Args:
job_id: ID задачи
ai_agent: Экземпляр AI-агента для выполнения промпта
user_id: ID пользователя для контекста
Returns:
ToolResult с результатом выполнения
"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("SELECT name, prompt, notify, log_results FROM cron_jobs WHERE id = ?", (job_id,))
row = c.fetchone()
if not row:
conn.close()
return ToolResult(
success=False,
error=f"Задача не найдена: {job_id}"
)
name, prompt, notify, log_results = row
conn.close()
logger.info(f"🕐 Выполнение задачи #{job_id}: {name}")
logger.info(f" Промпт: {prompt}")
result_data = {
'id': job_id,
'name': name,
'prompt': prompt,
'executed_at': datetime.now().isoformat()
}
# Выполняем задачу через AI-агент
if ai_agent:
try:
# Отправляем промпт ИИ-агенту
logger.info(f"🤖 Отправка промпта AI-агенту для задачи {name}")
# ИИ-агент анализирует промпт и решает какой инструмент использовать
decision = await ai_agent.decide(prompt, context={'user_id': user_id})
if decision.should_use_tool:
logger.info(f"🔧 AI-агент решил использовать инструмент: {decision.tool_name}")
tool_result = await ai_agent.execute_tool(decision.tool_name, **decision.tool_args)
result_data['tool_used'] = decision.tool_name
result_data['tool_result'] = tool_result.to_dict() if hasattr(tool_result, 'to_dict') else str(tool_result)
result_data['success'] = tool_result.success
# Формируем результат с красивым форматированием
result_text = f"Задача '{name}' выполнена.\n\n"
result_text += f"Использован инструмент: {decision.tool_name}\n\n"
# Форматируем результат инструмента в читаемый вид
if tool_result.success:
formatted_result = await self._format_tool_result_for_cron(
decision.tool_name, tool_result.data, tool_result.error
)
result_text += formatted_result
else:
result_text += f"❌ Ошибка: {tool_result.error}"
else:
# ИИ решил что инструмент не нужен - выполняем промпт напрямую
logger.info(f"ℹ️ AI-агент решил что инструмент не требуется")
result_text = f"Задача '{name}' выполнена (без инструментов).\nПромпт: {prompt}"
result_data['success'] = True
result_data['ai_reasoning'] = decision.reasoning
# Сохраняем в лог если нужно
if log_results:
self._save_to_log(job_id, name, prompt, result_text)
# Обновляем last_run
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("UPDATE cron_jobs SET last_run = datetime('now') WHERE id = ?", (job_id,))
conn.commit()
conn.close()
# Обновляем next_run после успешного выполнения
await self.update_next_run(job_id)
return ToolResult(
success=True,
data=result_data,
metadata={
'status': 'executed',
'notify': notify,
'log_results': log_results,
'result_text': result_text
}
)
except Exception as e:
logger.exception(f"Ошибка выполнения задачи через AI-агент: {e}")
if log_results:
self._save_to_log(job_id, name, prompt, f"Ошибка: {e}")
return ToolResult(
success=False,
error=str(e),
data=result_data
)
else:
# AI-агент не предоставлен - просто логируем
logger.warning(f"AI-агент не предоставлен, задача {name} не выполнена")
if log_results:
self._save_to_log(job_id, name, prompt, "Ошибка: AI-агент не предоставлен")
return ToolResult(
success=False,
error="AI-агент не предоставлен",
data=result_data
)
async def _format_tool_result_for_cron(self, tool_name: str, data: Any, error: str = None) -> str:
"""
Отформатировать результат выполнения инструмента в читаемый вид.
Args:
tool_name: Название инструмента
data: Данные результата
error: Ошибка (если есть)
Returns:
Отформатированная строка с результатом
"""
# Поддерживаем оба имени: 'rss_reader' (старое) и 'rss_tool' (новое)
if tool_name in ('rss_reader', 'rss_tool'):
if not data:
return "📰 Новостей не найдено."
output = "📰 **Последние новости:**\n\n"
# Берём не более 15 новостей для читаемости
news_count = min(len(data), 15)
for i in range(news_count):
item = data[i]
title = item.get('title', 'Без названия')
pub_date = item.get('pub_date', '')
link = item.get('link', '')
# Переводим заголовок на русский
translated_title = await _translate_title(title, max_length=100)
# Форматируем дату
date_str = ""
if pub_date:
try:
dt = datetime.strptime(pub_date[:19], '%Y-%m-%d %H:%M:%S')
date_str = dt.strftime('%d.%m.%Y %H:%M')
except:
date_str = pub_date[:16]
# Обрезаем заголовок если слишком длинный
if len(translated_title) > 120:
translated_title = translated_title[:117] + "..."
output += f"**{i+1}. {translated_title}**\n"
if date_str:
output += f" 📅 {date_str}\n"
if link:
short_link = link[:60] + "..." if len(link) > 63 else link
output += f" 🔗 {short_link}\n"
output += "\n"
return output
elif tool_name == 'ddgs_search':
if not data:
return "🔍 Ничего не найдено по вашему запросу."
output = "🔍 **Результаты поиска:**\n\n"
for i, item in enumerate(data[:5], 1):
title = item.get('title', 'Без названия')
href = item.get('href', '')
body = item.get('body', '')[:200]
output += f"{i}. **{title}**\n"
if href:
output += f" {href}\n"
if body:
output += f" {body}\n\n"
return output
elif tool_name == 'ssh_executor':
if not data:
return "❌ **Ошибка SSH:** Нет данных"
output = "🖥️ **SSH результат:**\n"
if isinstance(data, dict):
if data.get('stdout'):
output += f"**Вывод:**\n```\n{data['stdout']}\n```\n\n"
if data.get('stderr'):
output += f"**Ошибки:**\n```\n{data['stderr']}\n```\n\n"
if data.get('returncode') == 0:
output += "✅ **Успешно**"
else:
output += f"❌ **Код возврата:** {data.get('returncode', 'N/A')}"
else:
output += str(data)
return output
elif tool_name == 'cron_tool':
if isinstance(data, dict):
return f"✅ **Результат:**\n{data}"
return str(data)
# Fallback для неизвестных инструментов
return str(data) if data else "Выполнено"
def _save_to_log(self, job_id: int, job_name: str, prompt: str, result: str):
"""Сохранить результат выполнения задачи в лог-файл."""
log_file = self.log_dir / f"cron_job_{job_id}_{job_name}.log"
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
log_entry = f"""
{'='*60}
[{timestamp}] Задача: {job_name} (ID: {job_id})
{'='*60}
Промпт:
{prompt}
Результат:
{result}
"""
with open(log_file, 'a', encoding='utf-8') as f:
f.write(log_entry)
logger.debug(f"Результат задачи {job_name} сохранён в лог: {log_file}")
async def execute(self, action: str = "list", ai_agent=None, user_id: int = None, **kwargs) -> ToolResult:
"""
Выполнить действие с cron задачами.
Args:
action: Действие - list, add, remove, toggle, run
ai_agent: Экземпляр AI-агента (для run)
user_id: ID пользователя (для add, run, list)
kwargs: Дополнительные аргументы
"""
actions = {
'list': lambda: self.list_jobs(user_id=user_id),
'add': lambda: self.add_job(
name=kwargs.get('name'),
prompt=kwargs.get('prompt'),
schedule=kwargs.get('schedule'),
user_id=user_id,
notify=kwargs.get('notify', False),
log_results=kwargs.get('log_results', True)
),
'remove': lambda: self.remove_job(job_id=kwargs.get('job_id')),
'toggle': lambda: self.toggle_job(
job_id=kwargs.get('job_id'),
enabled=kwargs.get('enabled', True)
),
'run': lambda: self.run_job(job_id=kwargs.get('job_id'), ai_agent=ai_agent, user_id=user_id)
}
if action not in actions:
return ToolResult(
success=False,
error=f"Неизвестное действие: {action}. Доступные: {list(actions.keys())}"
)
logger.info(f"Cron действие: {action} с аргументами: {kwargs}")
return await actions[action]()
# Автоматическая регистрация при импорте
@register_tool
class CronToolAuto(CronTool):
"""Авто-регистрируемая версия CronTool."""
pass
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""
DDGS Search Tool - инструмент для поиска в интернете через DuckDuckGo.
Бот может использовать этот инструмент автономно, когда пользователю нужна
свежая информация из интернета.
"""
import sys
import json
import logging
from pathlib import Path
from typing import List, Dict, Any
from bot.tools import BaseTool, ToolResult, register_tool
logger = logging.getLogger(__name__)
class DDGSTool(BaseTool):
"""Инструмент поиска через DuckDuckGo."""
name = "ddgs_tool"
description = "Поиск информации в интернете через DuckDuckGo. Используется когда нужны свежие данные, новости, факты."
category = "search"
def __init__(self, tools_dir: str = None):
self.tools_dir = Path(tools_dir) if tools_dir else Path(__file__).parent.parent / "tools"
self.script_path = self.tools_dir / "ddgs_search.py"
def _search_ddgs(self, query: str, max_results: int = 10) -> List[Dict]:
"""
Выполнить поиск через ddgs.
Args:
query: Поисковый запрос
max_results: Максимальное количество результатов
Returns:
Список результатов с title, href, body
"""
try:
from ddgs import DDGS
except ImportError:
logger.error("ddgs library not found. Install: pip install ddgs")
return []
try:
ddgs = DDGS()
results = ddgs.text(query, max_results=max_results)
formatted_results = []
for result in results:
formatted_result = {
"title": result.get("title", ""),
"href": result.get("href", ""),
"body": result.get("body", "")
}
formatted_results.append(formatted_result)
return formatted_results
except Exception as e:
logger.error(f"Ошибка DDGS поиска: {e}")
return []
async def execute(self, query: str, max_results: int = 10) -> ToolResult:
"""
Выполнить поиск.
Args:
query: Поисковый запрос
max_results: Максимальное количество результатов (default: 10)
"""
if not query or not query.strip():
return ToolResult(
success=False,
error="Пустой поисковый запрос"
)
logger.info(f"DDGS поиск: '{query}' (max_results={max_results})")
results = self._search_ddgs(query, max_results)
if not results:
return ToolResult(
success=True,
data=[],
metadata={'message': 'Ничего не найдено', 'query': query}
)
return ToolResult(
success=True,
data=results,
metadata={
'query': query,
'count': len(results),
'max_results': max_results
}
)
# Автоматическая регистрация при импорте
@register_tool
class DDGSToolAuto(DDGSTool):
"""Авто-регистрируемая версия DDGSTool."""
pass
+803
View File
@@ -0,0 +1,803 @@
#!/usr/bin/env python3
"""
File System Tool - инструмент для работы с файловой системой Linux.
Позволяет AI-агенту выполнять операции с файлами и директориями:
- Чтение файлов (cat)
- Запись файлов
- Копирование (cp)
- Перемещение (mv)
- Удаление (rm)
- Создание директорий (mkdir)
- Список файлов (ls)
- Проверка существования
- Поиск файлов
Инструмент работает от имени пользователя на локальной машине.
"""
import logging
import os
import shutil
import subprocess
import asyncio
from pathlib import Path
from typing import Optional, Dict, Any, List
from dataclasses import dataclass, field
from bot.tools import BaseTool, ToolResult, register_tool
logger = logging.getLogger(__name__)
class FileSystemTool(BaseTool):
"""Инструмент для работы с файловой системой."""
name = "file_system_tool"
description = "Работа с файловой системой Linux: чтение/запись файлов, копирование, перемещение, удаление, создание директорий, просмотр списка файлов."
category = "system"
# Безопасные пути - где можно работать
ALLOWED_BASE_PATHS = [
Path.home(), # Домашняя директория
Path("/tmp"),
Path("/var/tmp"),
]
# Опасные пути - куда нельзя записывать/удалять
DANGEROUS_PATHS = [
Path("/"),
Path("/etc"),
Path("/usr"),
Path("/bin"),
Path("/sbin"),
Path("/boot"),
Path("/dev"),
Path("/proc"),
Path("/sys"),
]
def __init__(self):
self._last_operation: Optional[str] = None
self._operation_history: List[Dict] = []
def _is_path_safe(self, path: Path, allow_write: bool = True) -> tuple[bool, str]:
"""
Проверить безопасность пути.
Args:
path: Путь для проверки
allow_write: Если True, проверяем возможность записи
Returns:
(is_safe: bool, reason: str)
"""
try:
# Разрешаем абсолютные и относительные пути
if not path.is_absolute():
path = Path.cwd() / path
# Сначала проверяем на наличие в разрешённых путях (это важно!)
for allowed in self.ALLOWED_BASE_PATHS:
try:
path.relative_to(allowed)
return True, "Путь безопасен"
except ValueError:
pass
# Если путь не в разрешённых - проверяем на опасные
for dangerous in self.DANGEROUS_PATHS:
# Пропускаем корень если путь не в разрешённых уже
if dangerous == Path("/"):
continue
try:
path.relative_to(dangerous)
return False, f"Путь {path} находится в защищённой директории {dangerous}"
except ValueError:
pass
# Если путь не в разрешённых и не в запрещённых - разрешаем с предупреждением
return True, f"Путь {path} может быть недоступен"
except Exception as e:
return False, f"Ошибка проверки пути: {e}"
def _resolve_path(self, path_str: str) -> Path:
"""Преобразовать строку пути в Path объект."""
path = Path(path_str)
# Расширяем ~ в домашнюю директорию
# Важно: Path("~/file") не работает, нужно expanduser()
if path_str.startswith('~'):
path = Path(path_str).expanduser()
elif not path.is_absolute():
# Если путь относительный, делаем его абсолютным от домашней директории
path = Path.home() / path_str
return path
async def read_file(self, path: str, limit: int = 100) -> Dict[str, Any]:
"""
Прочитать файл.
Args:
path: Путь к файлу
limit: Максимальное количество строк для чтения
Returns:
Dict с content, lines, error
"""
try:
file_path = self._resolve_path(path)
# Проверка безопасности
is_safe, reason = self._is_path_safe(file_path, allow_write=False)
if not is_safe:
return {"error": reason, "success": False}
if not file_path.exists():
return {"error": f"Файл не существует: {file_path}", "success": False}
if not file_path.is_file():
return {"error": f"Не файл: {file_path}", "success": False}
# Читаем файл
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
lines = f.readlines()
# Ограничиваем количество строк
if len(lines) > limit:
content = ''.join(lines[:limit])
truncated = True
total_lines = len(lines)
else:
content = ''.join(lines)
truncated = False
total_lines = len(lines)
logger.info(f"Прочитан файл: {file_path} ({total_lines} строк)")
return {
"success": True,
"content": content,
"path": str(file_path),
"lines_read": min(len(lines), limit),
"total_lines": total_lines,
"truncated": truncated
}
except Exception as e:
logger.error(f"Ошибка чтения файла {path}: {e}")
return {"error": str(e), "success": False}
async def write_file(self, path: str, content: str, append: bool = False) -> Dict[str, Any]:
"""
Записать в файл.
Args:
path: Путь к файлу
content: Содержимое для записи
append: Если True, добавить в конец файла
Returns:
Dict с success, path, bytes_written
"""
try:
file_path = self._resolve_path(path)
# Проверка безопасности
is_safe, reason = self._is_path_safe(file_path, allow_write=True)
if not is_safe:
return {"error": reason, "success": False}
# Создаём родительские директории если нужно
file_path.parent.mkdir(parents=True, exist_ok=True)
# Записываем файл
mode = 'a' if append else 'w'
with open(file_path, mode, encoding='utf-8') as f:
bytes_written = f.write(content)
logger.info(f"Записан файл: {file_path} ({bytes_written} байт)")
return {
"success": True,
"path": str(file_path),
"bytes_written": bytes_written,
"appended": append
}
except Exception as e:
logger.error(f"Ошибка записи файла {path}: {e}")
return {"error": str(e), "success": False}
async def list_directory(self, path: str = ".", show_hidden: bool = False) -> Dict[str, Any]:
"""
Показать список файлов в директории.
Args:
path: Путь к директории
show_hidden: Показывать скрытые файлы
Returns:
Dict с files, directories, total
"""
try:
dir_path = self._resolve_path(path)
is_safe, _ = self._is_path_safe(dir_path, allow_write=False)
if not is_safe:
return {"error": "Доступ к директории ограничен", "success": False}
if not dir_path.exists():
return {"error": f"Директория не существует: {dir_path}", "success": False}
if not dir_path.is_dir():
return {"error": f"Не директория: {dir_path}", "success": False}
files = []
directories = []
for item in dir_path.iterdir():
if not show_hidden and item.name.startswith('.'):
continue
try:
stat = item.stat()
size = stat.st_size
mtime = stat.st_mtime
except:
size = 0
mtime = 0
item_info = {
"name": item.name,
"path": str(item),
"size": size,
"modified": mtime
}
if item.is_file():
files.append(item_info)
elif item.is_dir():
directories.append(item_info)
# Сортируем по имени
files.sort(key=lambda x: x["name"])
directories.sort(key=lambda x: x["name"])
return {
"success": True,
"path": str(dir_path),
"files": files,
"directories": directories,
"total_files": len(files),
"total_dirs": len(directories)
}
except Exception as e:
logger.error(f"Ошибка списка директории {path}: {e}")
return {"error": str(e), "success": False}
async def copy_file(self, source: str, destination: str) -> Dict[str, Any]:
"""
Скопировать файл или директорию.
Args:
source: Исходный путь
destination: Целевой путь
Returns:
Dict с success, source, destination
"""
try:
src_path = self._resolve_path(source)
dst_path = self._resolve_path(destination)
# Проверка безопасности
is_safe_src, reason = self._is_path_safe(src_path, allow_write=False)
if not is_safe_src:
return {"error": f"Источник: {reason}", "success": False}
is_safe_dst, reason = self._is_path_safe(dst_path, allow_write=True)
if not is_safe_dst:
return {"error": f"Назначение: {reason}", "success": False}
if not src_path.exists():
return {"error": f"Источник не существует: {src_path}", "success": False}
# Копируем
if src_path.is_file():
shutil.copy2(src_path, dst_path)
else:
shutil.copytree(src_path, dst_path, dirs_exist_ok=True)
logger.info(f"Скопировано: {src_path} -> {dst_path}")
return {
"success": True,
"source": str(src_path),
"destination": str(dst_path),
"operation": "copy"
}
except Exception as e:
logger.error(f"Ошибка копирования {source} -> {destination}: {e}")
return {"error": str(e), "success": False}
async def move_file(self, source: str, destination: str) -> Dict[str, Any]:
"""
Переместить файл или директорию.
Args:
source: Исходный путь
destination: Целевой путь
Returns:
Dict с success, source, destination
"""
try:
src_path = self._resolve_path(source)
dst_path = self._resolve_path(destination)
# Проверка безопасности
is_safe_src, reason = self._is_path_safe(src_path, allow_write=False)
if not is_safe_src:
return {"error": f"Источник: {reason}", "success": False}
is_safe_dst, reason = self._is_path_safe(dst_path, allow_write=True)
if not is_safe_dst:
return {"error": f"Назначение: {reason}", "success": False}
if not src_path.exists():
return {"error": f"Источник не существует: {src_path}", "success": False}
# Перемещаем
shutil.move(src_path, dst_path)
logger.info(f"Перемещено: {src_path} -> {dst_path}")
return {
"success": True,
"source": str(src_path),
"destination": str(dst_path),
"operation": "move"
}
except Exception as e:
logger.error(f"Ошибка перемещения {source} -> {destination}: {e}")
return {"error": str(e), "success": False}
async def delete(self, path: str, recursive: bool = False) -> Dict[str, Any]:
"""
Удалить файл или директорию.
Args:
path: Путь к файлу/директории
recursive: Если True, удалять рекурсивно
Returns:
Dict с success, path, deleted_count
"""
try:
file_path = self._resolve_path(path)
# Проверка безопасности
is_safe, reason = self._is_path_safe(file_path, allow_write=True)
if not is_safe:
return {"error": reason, "success": False}
if not file_path.exists():
return {"error": f"Путь не существует: {file_path}", "success": False}
deleted_count = 0
if file_path.is_file():
file_path.unlink()
deleted_count = 1
elif file_path.is_dir():
if recursive:
shutil.rmtree(file_path)
# Считаем количество удалённых файлов
deleted_count = -1 # Неизвестно
else:
return {
"error": "Директория не пуста. Используйте recursive=True для рекурсивного удаления",
"success": False
}
logger.info(f"Удалено: {file_path}")
return {
"success": True,
"path": str(file_path),
"deleted_count": deleted_count,
"operation": "delete"
}
except Exception as e:
logger.error(f"Ошибка удаления {path}: {e}")
return {"error": str(e), "success": False}
async def create_directory(self, path: str, parents: bool = True) -> Dict[str, Any]:
"""
Создать директорию.
Args:
path: Путь к директории
parents: Если True, создавать родительские директории
Returns:
Dict с success, path
"""
try:
dir_path = self._resolve_path(path)
# Проверка безопасности
is_safe, reason = self._is_path_safe(dir_path, allow_write=True)
if not is_safe:
return {"error": reason, "success": False}
if dir_path.exists():
if dir_path.is_dir():
return {
"success": True,
"path": str(dir_path),
"already_exists": True
}
else:
return {"error": f"Существует файл с таким именем: {dir_path}", "success": False}
# Создаём директорию
dir_path.mkdir(parents=parents, exist_ok=parents)
logger.info(f"Создана директория: {dir_path}")
return {
"success": True,
"path": str(dir_path),
"operation": "mkdir"
}
except Exception as e:
logger.error(f"Ошибка создания директории {path}: {e}")
return {"error": str(e), "success": False}
async def file_info(self, path: str) -> Dict[str, Any]:
"""
Получить информацию о файле/директории.
Args:
path: Путь к файлу
Returns:
Dict с информацией о файле
"""
try:
file_path = self._resolve_path(path)
is_safe, _ = self._is_path_safe(file_path, allow_write=False)
if not is_safe:
return {"error": "Доступ ограничен", "success": False}
if not file_path.exists():
return {"error": f"Путь не существует: {file_path}", "success": False}
stat = file_path.stat()
return {
"success": True,
"path": str(file_path),
"name": file_path.name,
"is_file": file_path.is_file(),
"is_dir": file_path.is_dir(),
"size": stat.st_size,
"created": stat.st_ctime,
"modified": stat.st_mtime,
"permissions": oct(stat.st_mode)[-3:]
}
except Exception as e:
logger.error(f"Ошибка получения информации о {path}: {e}")
return {"error": str(e), "success": False}
async def search_files(
self,
path: str = ".",
pattern: str = "*",
max_results: int = 50
) -> Dict[str, Any]:
"""
Найти файлы по паттерну.
Args:
path: Директория для поиска
pattern: Паттерн (glob-style)
max_results: Максимум результатов
Returns:
Dict с найденными файлами
"""
try:
base_path = self._resolve_path(path)
is_safe, _ = self._is_path_safe(base_path, allow_write=False)
if not is_safe:
return {"error": "Доступ ограничен", "success": False}
results = []
# Используем glob для поиска
import glob
matches = glob.glob(str(base_path / pattern), recursive=True)
for match in matches[:max_results]:
match_path = Path(match)
try:
stat = match_path.stat()
results.append({
"path": str(match_path),
"name": match_path.name,
"size": stat.st_size,
"is_file": match_path.is_file(),
"is_dir": match_path.is_dir()
})
except:
pass
return {
"success": True,
"pattern": pattern,
"base_path": str(base_path),
"found": len(results),
"results": results,
"truncated": len(matches) > max_results
}
except Exception as e:
logger.error(f"Ошибка поиска файлов {pattern} в {path}: {e}")
return {"error": str(e), "success": False}
async def execute_shell(self, command: str, timeout: int = 30) -> Dict[str, Any]:
"""
Выполнить shell-команду (для сложных операций).
Args:
command: Команда для выполнения
timeout: Таймаут в секундах
Returns:
Dict с stdout, stderr, returncode
"""
try:
# Разрешаем только безопасные команды
SAFE_COMMANDS = [
'ls', 'cat', 'cp', 'mv', 'rm', 'mkdir', 'rmdir',
'touch', 'chmod', 'chown', 'find', 'grep', 'head',
'tail', 'wc', 'sort', 'uniq', 'pwd', 'du', 'df'
]
# Извлекаем базовую команду
base_cmd = command.split()[0] if command.split() else ''
if base_cmd not in SAFE_COMMANDS:
return {
"error": f"Команда '{base_cmd}' не разрешена. Используйте безопасные команды: {SAFE_COMMANDS}",
"success": False
}
# Выполняем команду
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(Path.home())
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout
)
except asyncio.TimeoutError:
process.kill()
return {
"error": f"Таймаут выполнения команды ({timeout} сек)",
"success": False
}
return {
"success": process.returncode == 0,
"stdout": stdout.decode('utf-8', errors='replace').strip(),
"stderr": stderr.decode('utf-8', errors='replace').strip(),
"returncode": process.returncode,
"command": command
}
except Exception as e:
logger.error(f"Ошибка выполнения команды {command}: {e}")
return {"error": str(e), "success": False}
async def execute(self, operation: str, **kwargs) -> ToolResult:
"""
Выполнить операцию с файловой системой.
Args:
operation: Тип операции (read, write, copy, move, delete, mkdir, list, info, search, shell)
**kwargs: Аргументы операции
Returns:
ToolResult с результатом
"""
logger.info(f"File System Tool: operation={operation}, args={kwargs}")
self._last_operation = operation
try:
result = None
if operation == 'read':
result = await self.read_file(
path=kwargs.get('path', ''),
limit=kwargs.get('limit', 100)
)
elif operation == 'write':
result = await self.write_file(
path=kwargs.get('path', ''),
content=kwargs.get('content', ''),
append=kwargs.get('append', False)
)
elif operation == 'copy':
result = await self.copy_file(
source=kwargs.get('source', ''),
destination=kwargs.get('destination', '')
)
elif operation == 'move':
result = await self.move_file(
source=kwargs.get('source', ''),
destination=kwargs.get('destination', '')
)
elif operation == 'delete':
result = await self.delete(
path=kwargs.get('path', ''),
recursive=kwargs.get('recursive', False)
)
elif operation == 'mkdir':
result = await self.create_directory(
path=kwargs.get('path', ''),
parents=kwargs.get('parents', True)
)
elif operation == 'list':
result = await self.list_directory(
path=kwargs.get('path', '.'),
show_hidden=kwargs.get('show_hidden', False)
)
elif operation == 'info':
result = await self.file_info(
path=kwargs.get('path', '')
)
elif operation == 'search':
result = await self.search_files(
path=kwargs.get('path', '.'),
pattern=kwargs.get('pattern', '*'),
max_results=kwargs.get('max_results', 50)
)
elif operation == 'shell':
result = await self.execute_shell(
command=kwargs.get('command', ''),
timeout=kwargs.get('timeout', 30)
)
else:
return ToolResult(
success=False,
error=f"Неизвестная операция: {operation}. Доступные: read, write, copy, move, delete, mkdir, list, info, search, shell"
)
# Сохраняем в историю
self._operation_history.append({
'operation': operation,
'args': kwargs,
'result': result,
'timestamp': __import__('datetime').datetime.now().isoformat()
})
# Ограничиваем историю
if len(self._operation_history) > 100:
self._operation_history = self._operation_history[-50:]
return ToolResult(
success=result.get('success', False),
data=result,
metadata={
'operation': operation,
'last_path': result.get('path', result.get('source', ''))
}
)
except Exception as e:
logger.exception(f"Ошибка File System Tool: {e}")
return ToolResult(
success=False,
error=str(e),
metadata={'operation': operation}
)
def get_schema(self) -> Dict[str, Any]:
"""Получить схему инструмента для промпта."""
return {
"name": self.name,
"description": self.description,
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"description": "Тип операции",
"enum": ["read", "write", "copy", "move", "delete", "mkdir", "list", "info", "search", "shell"]
},
"path": {
"type": "string",
"description": "Путь к файлу/директории"
},
"source": {
"type": "string",
"description": "Исходный путь (для copy/move)"
},
"destination": {
"type": "string",
"description": "Целевой путь (для copy/move)"
},
"content": {
"type": "string",
"description": "Содержимое для записи"
},
"pattern": {
"type": "string",
"description": "Паттерн для поиска файлов"
},
"command": {
"type": "string",
"description": "Shell команда (для операции shell)"
},
"limit": {
"type": "integer",
"description": "Лимит строк для чтения"
},
"max_results": {
"type": "integer",
"description": "Максимум результатов поиска"
},
"recursive": {
"type": "boolean",
"description": "Рекурсивное удаление"
},
"show_hidden": {
"type": "boolean",
"description": "Показывать скрытые файлы"
},
"timeout": {
"type": "integer",
"description": "Таймаут для shell команд"
}
},
"required": ["operation"]
}
}
# Автоматическая регистрация при импорте
@register_tool
class FileSystemToolAuto(FileSystemTool):
"""Авто-регистрируемая версия FileSystemTool."""
pass
+692
View File
@@ -0,0 +1,692 @@
"""
GigaChat API Tool для Telegram CLI Bot
Инструмент для работы с GigaChat API (Сбер).
Поддерживает генерацию текста, чат-сессии и различные модели.
Документация: https://developers.sber.ru/docs/ru/gigachat
"""
import os
import base64
import httpx
import asyncio
import uuid
import logging
from typing import Optional, List, Dict, Any
from dataclasses import dataclass, field
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
@dataclass
class GigaChatMessage:
"""Сообщение для чата с GigaChat"""
role: str # 'user', 'assistant', 'system'
content: str
@dataclass
class GigaChatConfig:
"""Конфигурация подключения к GigaChat API"""
client_id: str
client_secret: str
scope: str = "GIGACHAT_API_PERS"
auth_url: str = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
model: str = "GigaChat-Pro" # Модель по умолчанию
model_lite: str = "GigaChat" # Lite модель для простых запросов
model_pro: str = "GigaChat-Pro" # Pro модель для сложных запросов
model_max: str = "GigaChat-Max" # Max модель для самых сложных задач
api_url: str = "https://gigachat.devices.sberbank.ru/api/v1"
timeout: int = 60
# Пороги для переключения моделей
complexity_token_threshold: int = 50 # Если токенов в запросе > порога → Pro
complexity_keyword_threshold: int = 2 # Если ключевых слов сложности >= порога → Pro
class GigaChatTool:
"""
Инструмент для работы с GigaChat API
Пример использования:
config = GigaChatConfig(
client_id=os.getenv("GIGACHAT_CLIENT_ID"),
client_secret=os.getenv("GIGACHAT_CLIENT_SECRET"),
)
tool = GigaChatTool(config)
# Простой запрос
response = await tool.chat("Привет, как дела?")
# Чат с историей
messages = [
GigaChatMessage(role="system", content="Ты полезный ассистент."),
GigaChatMessage(role="user", content="Расскажи про Python"),
]
response = await tool.chat(messages=messages)
"""
def __init__(self, config: Optional[GigaChatConfig] = None):
self.config = config or self._load_config_from_env()
self._access_token: Optional[str] = None
self._token_expires: Optional[datetime] = None
self._chat_history: List[GigaChatMessage] = []
def _load_config_from_env(self) -> GigaChatConfig:
"""Загрузка конфигурации из переменных окружения"""
return GigaChatConfig(
client_id=os.getenv("GIGACHAT_CLIENT_ID", ""),
client_secret=os.getenv("GIGACHAT_CLIENT_SECRET", ""),
scope=os.getenv("GIGACHAT_SCOPE", "GIGACHAT_API_PERS"),
auth_url=os.getenv("GIGACHAT_AUTH_URL", "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"),
model=os.getenv("GIGACHAT_MODEL", "GigaChat-Pro"),
model_lite=os.getenv("GIGACHAT_MODEL_LITE", "GigaChat"),
model_pro=os.getenv("GIGACHAT_MODEL_PRO", "GigaChat-Pro"),
model_max=os.getenv("GIGACHAT_MODEL_MAX", "GigaChat-Max"),
complexity_token_threshold=int(os.getenv("GIGACHAT_TOKEN_THRESHOLD", "50")),
complexity_keyword_threshold=int(os.getenv("GIGACHAT_KEYWORD_THRESHOLD", "2")),
)
def _get_auth_headers(self) -> Dict[str, str]:
"""Получение заголовков для авторизации"""
# GigaChat требует RqUID (UUID) и Content-Type для OAuth
return {
"Content-Type": "application/x-www-form-urlencoded",
"RqUID": str(uuid.uuid4()),
}
async def _get_access_token(self) -> str:
"""Получение access токена для API"""
# Проверяем кэш токена
if self._access_token and self._token_expires:
if datetime.now() < self._token_expires - timedelta(minutes=5):
return self._access_token
# Запрашиваем новый токен с использованием Basic Auth
credentials = f"{self.config.client_id}:{self.config.client_secret}"
encoded_credentials = base64.b64encode(credentials.encode()).decode()
# GigaChat использует самоподписанные сертификаты - отключаем верификацию
async with httpx.AsyncClient(verify=False) as client:
response = await client.post(
self.config.auth_url,
headers={
"Authorization": f"Basic {encoded_credentials}",
"Content-Type": "application/x-www-form-urlencoded",
"RqUID": str(uuid.uuid4()),
},
data={"scope": self.config.scope},
timeout=30,
)
# Логируем для отладки
logger.debug(f"GigaChat auth status: {response.status_code}")
logger.debug(f"GigaChat auth response: {response.text[:200]}")
response.raise_for_status()
data = response.json()
self._access_token = data["access_token"]
# Токен действителен 30 минут, кэшируем на 25 минут
self._token_expires = datetime.now() + timedelta(minutes=25)
# Логируем начало токена для проверки (первые 50 символов)
logger.info(f"GigaChat токен получен: {self._access_token[:50]}...")
return self._access_token
def _estimate_query_complexity(self, messages: List[GigaChatMessage]) -> dict:
"""
Оценить сложность запроса для выбора модели (Lite или Pro).
Критерии сложности:
1. Длина запроса (количество токенов/слов)
2. Наличие ключевых слов для сложных задач
3. Наличие инструментов (tool calls)
4. Технические термины
Returns:
Dict с оценкой сложности и рекомендуемой моделью
"""
# Собираем весь текст из сообщений пользователя
user_text = ""
for msg in messages:
if msg.role == "user":
user_text += " " + msg.content
user_text = user_text.lower()
# 1. Оценка по длине (считаем слова как грубая оценка токенов)
word_count = len(user_text.split())
token_estimate = word_count * 1.3 # Примерная конверсия слов в токены
# 2. Ключевые слова для сложных задач
complex_keywords = [
# Программирование и код
'код', 'функция', 'класс', 'метод', 'переменная', 'цикл', 'условие',
'алгоритм', 'структура данных', 'массив', 'словарь', 'список',
'импорт', 'экспорт', 'модуль', 'пакет', 'библиотека', 'фреймворк',
'дебаг', 'отладк', 'тест', 'юнит тест', 'интеграционн',
'рефактор', 'оптимиз', 'производительност',
# Анализ и работа с данными
'анализ', 'анализиров', 'сравни', 'сравнени', 'исследовани',
'закономерност', 'паттерн', 'тенденци', 'прогноз',
# Системные задачи
'конфигурац', 'настройк', 'деплой', 'развертывани', 'оркестрац',
'контейнер', 'docker', 'kubernetes', 'k8s', 'helm',
'мониторинг', 'логировани', 'трассировк', 'метрик',
# Сложные запросы
'объясни', 'расскажи подробно', 'детальн', 'подробн',
'почему', 'зачем', 'как работает', 'принцип работы',
'спроектируй', 'спроектировать', 'архитектур', 'архитектура',
'реализуй', 'реализовать', 'напиши код', 'создай функцию',
]
complexity_keywords_count = sum(
1 for keyword in complex_keywords
if keyword in user_text
)
# 3. Наличие технических терминов
tech_terms = [
'api', 'http', 'rest', 'graphql', 'grpc', 'websocket',
'sql', 'nosql', 'postgres', 'mysql', 'mongodb', 'redis',
'git', 'merge', 'commit', 'branch', 'pull request', 'merge request',
'ci/cd', 'pipeline', 'jenkins', 'gitlab', 'github',
'linux', 'bash', 'shell', 'terminal', 'ssh',
'python', 'javascript', 'typescript', 'java', 'go', 'rust', 'cpp',
'react', 'vue', 'angular', 'django', 'flask', 'fastapi', 'express',
]
tech_terms_count = sum(
1 for term in tech_terms
if term in user_text
)
# 4. Наличие инструментов в контексте
has_tools = any(
'tool' in msg.content.lower() or 'инструмент' in msg.content.lower()
for msg in messages
)
# Принятие решения
use_pro = False
reasons = []
# Если токенов больше порога → Pro
if token_estimate > self.config.complexity_token_threshold:
use_pro = True
reasons.append(f"длинный запрос ({word_count} слов, ~{int(token_estimate)} токенов)")
# Если много ключевых слов сложности → Pro
if complexity_keywords_count >= self.config.complexity_keyword_threshold:
use_pro = True
reasons.append(f"сложная задача ({complexity_keywords_count} ключевых слов)")
# Если есть технические термины + инструменты → Pro
if tech_terms_count >= 2 and has_tools:
use_pro = True
reasons.append(f"техническая задача с инструментами ({tech_terms_count} терминов)")
# Если есть явные запросы на работу с кодом/файлами → Pro
if any(phrase in user_text for phrase in [
'исходник', 'source code', 'посмотри код', 'проанализируй код',
'работай с файлом', 'прочитай файл', 'изучи код'
]):
use_pro = True
reasons.append("работа с кодом/файлами")
model = self.config.model_pro if use_pro else self.config.model_lite
return {
"use_pro": use_pro,
"model": model,
"word_count": word_count,
"token_estimate": int(token_estimate),
"complexity_keywords": complexity_keywords_count,
"tech_terms": tech_terms_count,
"has_tools": has_tools,
"reasons": reasons
}
async def chat(
self,
messages: Optional[List[GigaChatMessage]] = None,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 2000,
top_p: float = 0.1,
repetition_penalty: float = 1.0,
use_history: bool = True,
user_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
Отправка запроса к GigaChat API
Args:
messages: Список сообщений (если None, используется история чата)
model: Модель для генерации (если None, используется модель из конфига)
temperature: Температура генерации (0.0 - 2.0)
max_tokens: Максимальное количество токенов в ответе
top_p: Параметр top-p sampling
repetition_penalty: Штраф за повторения
use_history: Использовать ли историю чата
user_id: ID пользователя для заголовка X-User-Id
Returns:
Dict с ответом API:
- content: Текст ответа
- model: Использованная модель
- usage: Статистика использования токенов
- finish_reason: Причина завершения
"""
token = await self._get_access_token()
# Формируем сообщения
if messages is None:
if use_history:
messages = self._chat_history.copy()
else:
messages = []
elif use_history:
# Добавляем новые сообщения к истории
self._chat_history.extend(messages)
messages = self._chat_history.copy()
# Автоматически выбираем модель на основе сложности запроса
# Если модель явно не указана
selected_model = model
model_info = None
if selected_model is None:
model_info = self._estimate_query_complexity(messages)
selected_model = model_info["model"]
logger.info(f"📊 GigaChat выбор модели: {selected_model} (причины: {', '.join(model_info['reasons']) if model_info['reasons'] else 'простой запрос'})")
# Преобразуем сообщения в формат API
api_messages = [
{"role": msg.role, "content": msg.content}
for msg in messages
]
payload = {
"model": selected_model,
"messages": api_messages,
"temperature": temperature,
"max_tokens": max_tokens,
"top_p": top_p,
"repetition_penalty": repetition_penalty,
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-User-Id": str(user_id) if user_id else "telegram-bot",
}
# Логируем запрос для отладки
logger.debug(f"GigaChat API URL: {self.config.api_url}/chat/completions")
logger.debug(f"GigaChat headers: {headers}")
logger.debug(f"GigaChat payload: model={selected_model}, messages={len(api_messages)}, max_tokens={max_tokens}")
# GigaChat использует самоподписанные сертификаты - отключаем верификацию
async with httpx.AsyncClient(verify=False) as client:
try:
response = await client.post(
f"{self.config.api_url}/chat/completions",
headers=headers,
json=payload,
timeout=self.config.timeout,
)
# Логируем для отладки
logger.debug(f"GigaChat chat status: {response.status_code}")
logger.debug(f"GigaChat response headers: {dict(response.headers)}")
if response.status_code != 200:
logger.error(f"GigaChat error response: {response.text[:1000]}")
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as e:
logger.error(f"GigaChat HTTP error: {e}")
logger.error(f"Response: {e.response.text[:500]}")
return {
"content": "",
"error": f"HTTP {e.response.status_code}: {e.response.text[:200]}",
}
except httpx.HTTPError as e:
logger.error(f"GigaChat request error: {e}")
return {
"content": "",
"error": f"Request error: {str(e)}",
}
# Добавляем ответ ассистента в историю
if use_history and data.get("choices"):
assistant_message = data["choices"][0]["message"]
self._chat_history.append(GigaChatMessage(
role=assistant_message["role"],
content=assistant_message["content"],
))
return {
"content": data["choices"][0]["message"]["content"] if data.get("choices") else "",
"model": data.get("model", selected_model),
"usage": data.get("usage", {}),
"finish_reason": data["choices"][0]["finish_reason"] if data.get("choices") else "",
"complexity_info": model_info, # Информация о выборе модели для отладки
}
def clear_history(self):
"""Очистка истории чата"""
self._chat_history = []
def get_history(self) -> List[GigaChatMessage]:
"""Получение истории чата"""
return self._chat_history.copy()
def set_system_prompt(self, prompt: str):
"""Установка системного промпта (добавляется в начало истории)"""
# Удаляем старый системный промпт если есть
self._chat_history = [
msg for msg in self._chat_history if msg.role != "system"
]
# Добавляем новый в начало
self._chat_history.insert(0, GigaChatMessage(role="system", content=prompt))
async def chat_with_functions(
self,
messages: List[Dict[str, Any]],
functions: Optional[List[Dict[str, Any]]] = None,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 2000,
top_p: float = 0.1,
repetition_penalty: float = 1.0,
user_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
Отправка запроса к GigaChat API с поддержкой function calling.
Args:
messages: Список сообщений в формате API
functions: Массив функций для вызова
model: Модель для генерации
temperature: Температура генерации
max_tokens: Максимум токенов
top_p: Параметр top-p sampling
repetition_penalty: Штраф за повторения
user_id: ID пользователя
Returns:
Dict с ответом API включая возможный function_call
"""
token = await self._get_access_token()
# Выбираем модель на основе сложности запроса
selected_model = model
model_info = None
if selected_model is None:
# Преобразуем messages в формат GigaChatMessage для оценки сложности
gc_messages = [GigaChatMessage(role=msg["role"], content=msg.get("content", "")) for msg in messages]
model_info = self._estimate_query_complexity(gc_messages)
selected_model = model_info["model"]
logger.info(f"📊 GigaChat выбор модели: {selected_model} (причины: {', '.join(model_info['reasons']) if model_info['reasons'] else 'простой запрос'})")
# Формируем payload
payload = {
"model": selected_model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"top_p": top_p,
"repetition_penalty": repetition_penalty,
}
# Добавляем functions если есть
if functions:
payload["functions"] = functions
# function_call: "auto" позволяет модели самой решать когда вызывать функции
payload["function_call"] = "auto"
logger.info(f"🔧 GigaChat function calling: {len(functions)} функций доступно")
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-User-Id": str(user_id) if user_id else "telegram-bot",
}
logger.info(f"📤 GigaChat API: model={selected_model}, messages={len(messages)}, functions={len(functions) if functions else 0}")
# GigaChat использует самоподписанные сертификаты - отключаем верификацию
async with httpx.AsyncClient(verify=False) as client:
try:
response = await client.post(
f"{self.config.api_url}/chat/completions",
headers=headers,
json=payload,
timeout=self.config.timeout,
)
logger.debug(f"GigaChat chat_with_functions status: {response.status_code}")
logger.debug(f"GigaChat response: {response.text[:500]}")
if response.status_code != 200:
logger.error(f"GigaChat error response: {response.text[:1000]}")
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as e:
logger.error(f"GigaChat HTTP error: {e}")
logger.error(f"Response: {e.response.text[:500]}")
return {
"content": "",
"error": f"HTTP {e.response.status_code}: {e.response.text[:200]}",
"choices": [],
}
except httpx.HTTPError as e:
logger.error(f"GigaChat request error: {e}")
return {
"content": "",
"error": f"Request error: {str(e)}",
"choices": [],
}
# Извлекаем content и function_call
content = ""
function_call = None
functions_state_id = None
if data.get("choices"):
choice = data["choices"][0]
message = choice.get("message", {})
content = message.get("content", "")
function_call = message.get("function_call")
functions_state_id = message.get("functions_state_id")
logger.info(f"📬 GigaChat ответ: content_len={len(content)}, function_call={function_call is not None}, functions_state_id={functions_state_id}")
return {
"content": content,
"function_call": function_call,
"functions_state_id": functions_state_id,
"model": data.get("model", selected_model),
"usage": data.get("usage", {}),
"finish_reason": data["choices"][0]["finish_reason"] if data.get("choices") else "",
"choices": data.get("choices", []),
}
async def generate_image(
self,
prompt: str,
model: str = " Kandinsky-2",
size: str = "1024x1024",
) -> Dict[str, Any]:
"""
Генерация изображений через GigaChat (Kandinsky)
Args:
prompt: Текстовое описание изображения
model: Модель для генерации
size: Размер изображения
Returns:
Dict с результатом генерации
"""
token = await self._get_access_token()
payload = {
"model": model,
"prompt": prompt,
"size": size,
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
# GigaChat использует самоподписанные сертификаты - отключаем верификацию
async with httpx.AsyncClient(verify=False) as client:
# Запуск генерации
response = await client.post(
f"{self.config.api_url}/images/generations",
headers=headers,
json=payload,
timeout=self.config.timeout,
)
response.raise_for_status()
data = response.json()
return data
async def get_models(self) -> List[str]:
"""Получение списка доступных моделей"""
token = await self._get_access_token()
headers = {
"Authorization": f"Bearer {token}",
}
# GigaChat использует самоподписанные сертификаты - отключаем верификацию
async with httpx.AsyncClient(verify=False) as client:
response = await client.get(
f"{self.config.api_url}/models",
headers=headers,
timeout=30,
)
response.raise_for_status()
data = response.json()
return [model["id"] for model in data.get("data", [])]
# Утилита для создания инструмента в формате бота
def create_gigachat_tool():
"""
Создает экземпляр GigaChatTool с конфигурацией из окружения
Returns:
GigaChatTool или None если конфигурация не задана
"""
if not os.getenv("GIGACHAT_CLIENT_ID") or not os.getenv("GIGACHAT_CLIENT_SECRET"):
return None
return GigaChatTool()
if __name__ == "__main__":
# Пример использования
async def main():
tool = create_gigachat_tool()
if not tool:
print("GigaChat не настроен. Проверьте переменные окружения.")
return
# Простой запрос
response = await tool.chat("Привет! Расскажи кратко про себя.")
print(f"Ответ: {response['content']}")
print(f"Модель: {response['model']}")
print(f"Токены: {response['usage']}")
asyncio.run(main())
# ===========================================
# Интеграция с реестром инструментов бота
# ===========================================
from bot.tools import BaseTool, ToolResult, register_tool
@register_tool
class GigaChatCapability(BaseTool):
"""
Capability-обёртка для GigaChat API.
Позволяет использовать GigaChat через реестр инструментов бота.
"""
name = "gigachat"
description = "Генерация ответов AI через GigaChat API (Сбер). Альтернатива Qwen Code."
category = "ai"
def __init__(self):
self._provider = None
def _get_provider(self):
"""Ленивая инициализация провайдера"""
if self._provider is None:
from qwen_integration import GigaChatProvider
self._provider = GigaChatProvider()
return self._provider
async def execute(
self,
prompt: str,
system_prompt: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 2000,
**kwargs
) -> ToolResult:
"""
Выполнить запрос к GigaChat API.
Args:
prompt: Запрос пользователя
system_prompt: Системный промпт (роль ассистента)
temperature: Температура генерации (0.0-2.0)
max_tokens: Максимум токенов в ответе
"""
provider = self._get_provider()
if not provider.is_available():
return ToolResult(
success=False,
error=provider.get_error() or "GigaChat не доступен",
)
result = await provider.chat(
prompt=prompt,
system_prompt=system_prompt,
temperature=temperature,
max_tokens=max_tokens,
)
if result.get("success"):
return ToolResult(
success=True,
data={
"content": result.get("content", ""),
"model": result.get("model", "GigaChat-Pro"),
"usage": result.get("usage", {}),
},
metadata={
"model": result.get("model"),
"tokens": result.get("usage"),
}
)
else:
return ToolResult(
success=False,
error=result.get("error", "Неизвестная ошибка GigaChat"),
)
+367
View File
@@ -0,0 +1,367 @@
#!/usr/bin/env python3
"""
RSS Reader Tool - инструмент для чтения RSS/Atom лент.
Бот может использовать этот инструмент автономно для получения новостей
из подписанных лент пользователя.
"""
import sys
import json
import logging
import sqlite3
import subprocess
import os
import re
from pathlib import Path
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from email.utils import parsedate_to_datetime
from bot.tools import BaseTool, ToolResult, register_tool
logger = logging.getLogger(__name__)
class RSSTool(BaseTool):
"""Инструмент для работы с RSS лентами."""
name = "rss_tool"
description = "Чтение RSS/Atom новостных лент. Управление подписками, получение новостей, дайджесты."
category = "news"
def __init__(self, db_path: str = None):
self.db_path = Path(db_path) if db_path else Path(__file__).parent.parent.parent / "rss.db"
self.lock_file = Path("/tmp/rss_fetch.lock")
self.fetch_interval_minutes = 5
def _init_db(self):
"""Инициализировать БД."""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS feeds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
title TEXT,
last_fetched DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
c.execute('''
CREATE TABLE IF NOT EXISTS news (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_id INTEGER NOT NULL,
guid TEXT NOT NULL,
pub_date DATETIME,
title TEXT,
description TEXT,
content TEXT,
link TEXT,
digest_flag INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (feed_id) REFERENCES feeds(id),
UNIQUE(feed_id, guid)
)
''')
c.execute('CREATE INDEX IF NOT EXISTS idx_news_feed ON news(feed_id)')
c.execute('CREATE INDEX IF NOT EXISTS idx_news_date ON news(pub_date)')
conn.commit()
conn.close()
def _parse_feed(self, xml_content: str) -> List[Dict]:
"""Парсить RSS/Atom XML."""
items = []
# Remove CDATA markers
xml = re.sub(r'<!\[CDATA\[', '', xml_content)
xml = re.sub(r'\]\]>', '', xml)
# Find all items
xml_items = re.findall(r'<item[^>]*>(.*?)</item>', xml, re.DOTALL)
for item in xml_items:
# Title
title_match = re.search(r'<title>(.*?)</title>', item, re.DOTALL)
title = title_match.group(1).strip()[:500] if title_match else ""
# GUID
guid_match = re.search(r'<guid[^>]*>(.*?)</guid>', item, re.DOTALL)
guid = guid_match.group(1).strip() if guid_match else ""
# Link
link_match = re.search(r'<link>(.*?)</link>', item, re.DOTALL)
link = link_match.group(1).strip() if link_match else ""
# PubDate
pub_match = re.search(r'<pubDate>(.*?)</pubDate>', item, re.DOTALL)
pub = pub_match.group(1).strip() if pub_match else ""
if not guid and link:
guid = link
if title and guid:
items.append({'title': title, 'link': link, 'guid': guid, 'pub': pub})
return items
def _insert_news(self, feed_id: int, title: str, link: str, guid: str, pub: str):
"""Вставить новость в БД."""
pdate = None
if pub:
try:
dt = parsedate_to_datetime(pub)
pdate = dt.strftime('%Y-%m-%d %H:%M:%S')
except:
pass
if not pdate:
pdate = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if not link:
link = guid
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute('''
INSERT OR IGNORE INTO news (feed_id, guid, pub_date, title, link)
VALUES (?, ?, ?, ?, ?)
''', (feed_id, guid, pdate, title, link))
conn.commit()
conn.close()
async def fetch(self) -> ToolResult:
"""Получить свежие новости из всех лент."""
self._init_db()
# Lock
if self.lock_file.exists():
logger.warning("Другой fetch уже выполняется")
return ToolResult(
success=False,
error="Другой процесс fetch уже выполняется",
metadata={'status': 'locked'}
)
with open(self.lock_file, 'w') as f:
f.write(str(os.getpid()))
try:
total = 0
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("SELECT id, url FROM feeds")
feeds = c.fetchall()
conn.close()
for feed_id, url in feeds:
# Check last fetch
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("SELECT last_fetched FROM feeds WHERE id = ?", (feed_id,))
row = c.fetchone()
conn.close()
if row and row[0]:
last = datetime.strptime(row[0], '%Y-%m-%d %H:%M:%S')
mins = (datetime.now() - last).total_seconds() / 60
if mins < self.fetch_interval_minutes:
logger.info(f"Пропуск {url} ({int(mins)} мин назад)")
continue
logger.info(f"Получение: {url}")
result = subprocess.run(
['curl', '-sL', '-m', '30', '-A', 'Mozilla/5.0', url],
capture_output=True
)
if result.returncode == 0 and result.stdout:
# Декодируем с обработкой ошибок кодировки
try:
content = result.stdout.decode('utf-8', errors='ignore')
except Exception:
content = result.stdout.decode('latin-1', errors='ignore')
count = 0
for item in self._parse_feed(content):
self._insert_news(feed_id, item['title'], item['link'], item['guid'], item['pub'])
count += 1
if count > 0:
logger.info(f"Добавлено {count} элементов")
total += count
# Update last_fetched
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("UPDATE feeds SET last_fetched = datetime('now') WHERE id = ?", (feed_id,))
conn.commit()
conn.close()
return ToolResult(
success=True,
data={'total_new_items': total},
metadata={'status': 'completed', 'action': 'fetch'}
)
finally:
self.lock_file.unlink(missing_ok=True)
async def list_news(self, limit: int = 20, feed_id: Optional[int] = None,
search: Optional[str] = None, undigested_only: bool = False) -> ToolResult:
"""Получить список новостей."""
self._init_db()
conditions = ["1=1"]
params = []
if feed_id:
conditions.append(f"feed_id = ?")
params.append(feed_id)
if search:
conditions.append(f"title LIKE ?")
params.append(f"%{search}%")
if undigested_only:
conditions.append("digest_flag = 0")
query = f"""
SELECT id, feed_id, title, pub_date, link, digest_flag
FROM news WHERE {' AND '.join(conditions)}
ORDER BY created_at DESC, id DESC LIMIT ?
"""
params.append(limit)
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute(query, params)
rows = c.fetchall()
conn.close()
news_list = []
for row in rows:
news_list.append({
'id': row[0],
'feed_id': row[1],
'title': row[2],
'pub_date': row[3],
'link': row[4],
'digest_flag': bool(row[5])
})
return ToolResult(
success=True,
data=news_list,
metadata={'count': len(news_list), 'limit': limit, 'action': 'list'}
)
async def add_feed(self, url: str, title: Optional[str] = None) -> ToolResult:
"""Добавить RSS ленту."""
self._init_db()
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
try:
c.execute("INSERT INTO feeds (url, title) VALUES (?, ?)", (url, title or url))
conn.commit()
return ToolResult(
success=True,
data={'url': url, 'title': title},
metadata={'status': 'added', 'action': 'add_feed'}
)
except sqlite3.IntegrityError:
return ToolResult(
success=False,
error=f"Лента уже существует: {url}"
)
finally:
conn.close()
async def list_feeds(self) -> ToolResult:
"""Получить список всех лент."""
self._init_db()
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("SELECT id, url, title, last_fetched, created_at FROM feeds ORDER BY id")
rows = c.fetchall()
conn.close()
feeds = []
for row in rows:
feeds.append({
'id': row[0],
'url': row[1],
'title': row[2],
'last_fetched': row[3],
'created_at': row[4]
})
return ToolResult(
success=True,
data=feeds,
metadata={'count': len(feeds), 'action': 'list_feeds'}
)
async def mark_digest(self, news_id: int) -> ToolResult:
"""Отметить новость как прочитанную (в дайджесте)."""
self._init_db()
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("UPDATE news SET digest_flag=1 WHERE id=?", (news_id,))
if c.rowcount == 0:
conn.close()
return ToolResult(
success=False,
error=f"Новость не найдена: {news_id}"
)
conn.commit()
conn.close()
return ToolResult(
success=True,
data={'id': news_id},
metadata={'status': 'marked'}
)
async def execute(self, action: str = "list", **kwargs) -> ToolResult:
"""
Выполнить действие с RSS.
Args:
action: Действие - fetch, list, add_feed, list_feeds, mark_digest
kwargs: Дополнительные аргументы для действия
"""
actions = {
'fetch': self.fetch,
'list': lambda: self.list_news(
limit=kwargs.get('limit', 20),
feed_id=kwargs.get('feed_id'),
search=kwargs.get('search'),
undigested_only=kwargs.get('undigested_only', False)
),
'add_feed': lambda: self.add_feed(
url=kwargs.get('url'),
title=kwargs.get('title')
),
'list_feeds': self.list_feeds,
'mark_digest': lambda: self.mark_digest(news_id=kwargs.get('news_id'))
}
if action not in actions:
return ToolResult(
success=False,
error=f"Неизвестное действие: {action}. Доступные: {list(actions.keys())}"
)
logger.info(f"RSS действие: {action} с аргументами: {kwargs}")
return await actions[action]()
# Автоматическая регистрация при импорте
@register_tool
class RSSToolAuto(RSSTool):
"""Авто-регистрируемая версия RSSTool."""
pass
+364
View File
@@ -0,0 +1,364 @@
#!/usr/bin/env python3
"""
SSH Executor Tool - инструмент для выполнения команд на серверах по SSH.
Бот может использовать этот инструмент автономно для выполнения системных задач
на серверах пользователя.
Конфигурация серверов загружается из .env:
SERVERS=name|host|port|user|tag|password|...
"""
import logging
import asyncio
import os
from pathlib import Path
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
import asyncssh
from dotenv import load_dotenv
from bot.tools import BaseTool, ToolResult, register_tool
logger = logging.getLogger(__name__)
# Загрузка переменных окружения
load_dotenv()
@dataclass
class ServerConfig:
"""Конфигурация сервера для SSH."""
host: str
port: int
username: str
password: Optional[str] = None
client_keys: Optional[List[str]] = None
tags: List[str] = None
class SSHExecutorTool(BaseTool):
"""Инструмент для выполнения SSH-команд."""
name = "ssh_tool"
description = "Выполнение команд на удалённых серверах по SSH. Используется для системных задач: мониторинг, управление сервисами, просмотр логов."
category = "system"
def __init__(self):
# Загружаем серверы из .env
self.servers: Dict[str, ServerConfig] = {}
self._last_connection: Optional[asyncssh.SSHClientConnection] = None
self._last_server: Optional[str] = None
self._load_servers_from_env()
def _load_servers_from_env(self):
"""
Загрузить конфигурацию серверов из .env.
Формат в .env:
SERVERS=name|host|port|user|tag|password
Пример:
SERVERS=tomas|192.168.1.51|22|mirivlad|web|moloko22
"""
servers_str = os.getenv('SERVERS', '')
if not servers_str.strip():
logger.warning("SERVERS не найден в .env, SSH инструмент не будет работать")
return
# Парсим формат: name|host|port|user|tag|password
parts = servers_str.strip().split('|')
if len(parts) >= 6:
name, host, port, user, tag, password = parts[:6]
self.servers[name.strip()] = ServerConfig(
host=host.strip(),
port=int(port.strip()),
username=user.strip(),
tags=[tag.strip()] if tag.strip() else [],
password=password.strip() if password.strip() else None
)
logger.info(f"✅ Загружен сервер: {name} ({host}:{port})")
else:
logger.error(f"Неверный формат SERVERS в .env: {servers_str}")
logger.error("Ожидался формат: name|host|port|user|tag|password")
async def _connect(self, server_name: str = 'home') -> asyncssh.SSHClientConnection:
"""Подключиться к серверу."""
logger.debug(f"🔍 [SSH._connect] Запрос подключения: server_name='{server_name}'")
logger.debug(f"🔍 [SSH._connect] Доступные серверы: {list(self.servers.keys())}")
if server_name not in self.servers:
logger.error(f"❌ [SSH._connect] Сервер '{server_name}' не найден!")
raise ValueError(f"Сервер '{server_name}' не найден. Доступные: {list(self.servers.keys())}")
config = self.servers[server_name]
logger.debug(f"🔍 [SSH._connect] Конфигурация сервера {server_name}:")
logger.debug(f" host={config.host}, port={config.port}, username={config.username}")
logger.debug(f" password={'***' if config.password else 'None'}, client_keys={config.client_keys}")
# Проверяем существующее подключение
logger.debug(f"🔍 [SSH._connect] Проверка существующего подключения:")
logger.debug(f" _last_connection={self._last_connection}")
logger.debug(f" _last_server={self._last_server}")
if self._last_connection and self._last_server == server_name:
logger.debug(f"🔍 [SSH._connect] Найдено существующее подключение, проверка статуса...")
try:
# Проверяем transport для проверки активности подключения
if self._last_connection.transport is None or not self._last_connection.transport.is_active():
logger.debug(f"⚠️ [SSH._connect] Подключение не активно, будет создано новое")
self._last_connection = None
else:
logger.debug(f"✅ [SSH._connect] Используем существующее активное подключение")
return self._last_connection
except Exception as e:
logger.debug(f"⚠️ [SSH._connect] Ошибка проверки подключения: {e}, создаём новое")
self._last_connection = None
else:
logger.debug(f"ℹ️ [SSH._connect] Существующего подключения нет, создаём новое")
logger.info(f"Подключение к серверу {server_name} ({config.host})")
try:
# Пробуем подключение с паролем
connect_kwargs = {
'host': config.host,
'port': config.port,
'username': config.username,
'known_hosts': None, # Отключаем проверку known_hosts для простоты
}
if config.password:
connect_kwargs['password'] = config.password
logger.debug(f"🔍 [SSH._connect] Используем парольную аутентификацию")
if config.client_keys:
connect_kwargs['client_keys'] = config.client_keys
logger.debug(f"🔍 [SSH._connect] Используем ключевую аутентификацию: {config.client_keys}")
logger.debug(f"🔍 [SSH._connect] Вызов asyncssh.connect с параметрами: {connect_kwargs.keys()}")
self._last_connection = await asyncssh.connect(**connect_kwargs)
self._last_server = server_name
logger.info(f"✅ Подключено к {server_name}")
logger.debug(f"🔍 [SSH._connect] Подключение успешно: {self._last_connection}")
return self._last_connection
except Exception as e:
logger.error(f"❌ [SSH._connect] Ошибка подключения к {server_name}: {e}")
logger.exception(f"🔍 [SSH._connect] Exception details:")
raise
async def execute_command(
self,
command: str,
server: str = 'home',
timeout: int = 30
) -> Dict[str, Any]:
"""
Выполнить команду на сервере.
Args:
command: Команда для выполнения
server: Имя сервера из конфигурации
timeout: Таймаут выполнения в секундах
Returns:
Dict с полями: stdout, stderr, returncode, exit_status
"""
logger.debug(f"🔍 [SSH.execute_command] START: server={server}, command={command[:50]}...")
try:
logger.debug(f"🔍 [SSH.execute_command] Вызов _connect(server='{server}')")
conn = await self._connect(server)
logger.debug(f"✅ [SSH.execute_command] Подключение успешно: {conn}")
logger.info(f"Выполнение команды на {server}: {command}")
logger.debug(f"🔍 [SSH.execute_command] Создание процесса с командой: {command}")
# Используем create_process для корректной работы с shell-командами
process = await conn.create_process(
command,
term_type='xterm-256color',
env={'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'}
)
logger.debug(f"🔍 [SSH.execute_command] Процесс создан: {process}")
# Читаем вывод с таймаутом
output = ""
error_output = ""
try:
logger.debug(f"🔍 [SSH.execute_command] Чтение stdout (timeout={timeout})")
# Читаем stdout
stdout_data = await asyncio.wait_for(
process.stdout.read(),
timeout=timeout
)
output = stdout_data.strip() if stdout_data else ''
logger.debug(f"🔍 [SSH.execute_command] stdout получен: {len(output)} bytes")
# Читаем stderr
try:
logger.debug(f"🔍 [SSH.execute_command] Чтение stderr (timeout={timeout//2})")
stderr_data = await asyncio.wait_for(
process.stderr.read(),
timeout=timeout // 2
)
error_output = stderr_data.strip() if stderr_data else ''
logger.debug(f"🔍 [SSH.execute_command] stderr получен: {len(error_output)} bytes")
except asyncio.TimeoutError:
logger.debug(f"⚠️ [SSH.execute_command] Таймаут чтения stderr")
pass
except asyncio.TimeoutError:
logger.error(f"🔍 [SSH.execute_command] Таймаут выполнения команды: {command}")
return {
'stdout': '',
'stderr': f'Таймаут выполнения команды ({timeout} сек)',
'returncode': -1,
'exit_status': 'timeout',
'server': server,
'command': command
}
logger.debug(f"🔍 [SSH.execute_command] Ожидание завершения процесса (returncode)")
# Ждём завершения процесса и получаем код возврата
returncode = await process.wait()
logger.debug(f"✅ [SSH.execute_command] Процесс завершён, returncode={returncode}")
return {
'stdout': output,
'stderr': error_output,
'returncode': returncode,
'exit_status': returncode,
'server': server,
'command': command
}
except asyncio.TimeoutError:
logger.error(f"🔍 [SSH.execute_command] asyncio.TimeoutError: {command}")
logger.exception(f"🔍 [SSH.execute_command] Timeout details:")
return {
'stdout': '',
'stderr': f'Таймаут выполнения команды ({timeout} сек)',
'returncode': -1,
'exit_status': 'timeout',
'server': server,
'command': command
}
except Exception as e:
logger.error(f"❌ [SSH.execute_command] Ошибка выполнения команды: {e}")
logger.exception(f"🔍 [SSH.execute_command] Exception details:")
return {
'stdout': '',
'stderr': str(e),
'returncode': -1,
'exit_status': 'error',
'server': server,
'command': command
}
async def execute(self, command: str, server: str = None, timeout: int = 30) -> ToolResult:
"""
Выполнить SSH-команду.
Args:
command: Команда для выполнения
server: Имя сервера (default: первый из .env)
timeout: Таймаут в секундах (default: 30)
"""
logger.debug(f"🔍 [SSH.execute] ВЫЗОВ: command={command[:50]}..., server={server}, timeout={timeout}")
if not command or not command.strip():
logger.debug(f"⚠️ [SSH.execute] Пустая команда!")
return ToolResult(
success=False,
error="Пустая команда"
)
# Если сервер не указан - используем первый из конфигурации
if server is None:
if not self.servers:
logger.debug(f"⚠️ [SSH.execute] Серверы не настроены!")
return ToolResult(
success=False,
error="Серверы не настроены. Проверьте SERVERS в .env"
)
server = list(self.servers.keys())[0]
logger.info(f"Сервер не указан, используем первый: {server}")
logger.debug(f"🔍 [SSH.execute] Выбран сервер по умолчанию: {server}")
logger.info(f"SSH Executor: server={server}, command={command[:100]}")
logger.debug(f"🔍 [SSH.execute] Вызов execute_command(server={server}, command={command[:50]}...)")
try:
result = await self.execute_command(command, server, timeout)
logger.debug(f"🔍 [SSH.execute] Результат execute_command: returncode={result['returncode']}")
# Формируем красивый вывод
output = self._format_output(result)
logger.debug(f"🔍 [SSH.execute] Вывод сформирован: {len(output)} chars")
return ToolResult(
success=result['returncode'] == 0,
data=result,
metadata={
'server': server,
'command': command,
'returncode': result['returncode']
}
)
except Exception as e:
logger.exception(f"❌ [SSH.execute] Ошибка SSH Executor: {e}")
return ToolResult(
success=False,
error=str(e),
metadata={'server': server, 'command': command}
)
def _format_output(self, result: Dict[str, Any]) -> str:
"""Форматировать вывод команды."""
output = []
if result['stdout']:
output.append(f"**Вывод:**\n```\n{result['stdout']}\n```")
if result['stderr']:
output.append(f"**Ошибки:**\n```\n{result['stderr']}\n```")
if result['returncode'] != 0:
output.append(f"**Код возврата:** {result['returncode']}")
return "\n".join(output) if output else "Команда выполнена без вывода"
def add_server(self, name: str, host: str, port: int, username: str,
password: Optional[str] = None, client_keys: Optional[List[str]] = None):
"""Добавить сервер в конфигурацию."""
self.servers[name] = ServerConfig(
host=host,
port=port,
username=username,
password=password,
client_keys=client_keys
)
logger.info(f"Добавлен сервер: {name} ({host})")
def list_servers(self) -> List[str]:
"""Получить список доступных серверов."""
return list(self.servers.keys())
# Автоматическая регистрация при импорте
@register_tool
class SSHExecutorToolAuto(SSHExecutorTool):
"""Авто-регистрируемая версия SSHExecutorTool."""
pass
+232
View File
@@ -0,0 +1,232 @@
"""
Telegram Web Tool - чтение публичных Telegram-каналов через web (t.me)
"""
import aiohttp
from bs4 import BeautifulSoup
from typing import List, Dict, Optional
from datetime import datetime
import logging
import json
import os
from bot.tools import BaseTool, ToolResult, register_tool
logger = logging.getLogger(__name__)
# Путь к файлу хранения подписок
SUBSCRIPTIONS_FILE = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'telegram_channels.json'
)
def load_subscriptions() -> List[str]:
"""Загрузить список подписок из файла"""
if os.path.exists(SUBSCRIPTIONS_FILE):
try:
with open(SUBSCRIPTIONS_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get('channels', [])
except Exception as e:
logger.error(f"Ошибка загрузки подписок: {e}")
return []
def save_subscriptions(channels: List[str]) -> bool:
"""Сохранить список подписок в файл"""
try:
with open(SUBSCRIPTIONS_FILE, 'w', encoding='utf-8') as f:
json.dump({'channels': channels}, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
logger.error(f"Ошибка сохранения подписок: {e}")
return False
async def fetch_channel_messages(username: str, limit: int = 10) -> List[Dict]:
"""
Получить сообщения из публичного Telegram-канала через t.me
Args:
username: Имя канала (без @)
limit: Количество сообщений для получения
Returns:
Список сообщений
"""
url = f"https://t.me/s/{username}"
messages = []
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=10) as response:
if response.status != 200:
logger.error(f"Ошибка доступа к каналу {username}: {response.status}")
return messages
html = await response.text()
soup = BeautifulSoup(html, 'html.parser')
# Найти все сообщения
telegram_messages = soup.find_all('div', class_='tgme_widget_message')
for msg in telegram_messages[-limit:]:
try:
# Текст сообщения
text_elem = msg.find('div', class_='tgme_widget_message_text')
text = text_elem.get_text(strip=True) if text_elem else ""
# Дата
date_elem = msg.find('a', class_='tgme_widget_message_date')
date_text = date_elem.get_text(strip=True) if date_elem else ""
# Ссылка на сообщение
link = date_elem.get('href') if date_elem else ""
# Автор (если есть)
author_elem = msg.find('div', class_='tgme_widget_message_author')
author = author_elem.get_text(strip=True) if author_elem else username
messages.append({
'text': text,
'date': date_text,
'link': link,
'author': author
})
except Exception as e:
logger.debug(f"Ошибка парсинга сообщения: {e}")
continue
except Exception as e:
logger.error(f"Ошибка получения канала {username}: {e}")
return messages
async def telegram_web_tool(action: str, **kwargs) -> Dict:
"""
Основной инструмент для работы с Telegram-каналами
Actions:
- add: добавить канал (username)
- list: показать список каналов
- read: прочитать сообщения (username, limit)
Args:
action: Действие
**kwargs: Параметры для действия
Returns:
Dict с результатом
"""
if action == 'add':
username = kwargs.get('username')
if not username:
return {
'success': False,
'error': 'username обязателен',
'action': 'add'
}
# Очистить username от @ и пробелов
username = username.strip().lstrip('@')
channels = load_subscriptions()
if username in channels:
return {
'success': False,
'error': f'Канал @{username} уже в списке',
'action': 'add'
}
channels.append(username)
save_subscriptions(channels)
return {
'success': True,
'message': f'Канал @{username} добавлен',
'action': 'add',
'username': username
}
elif action == 'list':
channels = load_subscriptions()
return {
'success': True,
'channels': channels,
'count': len(channels),
'action': 'list'
}
elif action == 'read':
username = kwargs.get('username')
limit = kwargs.get('limit', 10)
if not username:
return {
'success': False,
'error': 'username обязателен',
'action': 'read'
}
username = username.strip().lstrip('@')
messages = await fetch_channel_messages(username, limit)
return {
'success': True,
'channel': username,
'messages': messages,
'count': len(messages),
'action': 'read'
}
elif action == 'fetch_all':
"""Получить новые сообщения из всех каналов"""
channels = load_subscriptions()
limit = kwargs.get('limit', 5)
all_messages = {}
for channel in channels:
messages = await fetch_channel_messages(channel, limit)
if messages:
all_messages[channel] = messages
return {
'success': True,
'channels_checked': len(channels),
'messages': all_messages,
'action': 'fetch_all'
}
else:
return {
'success': False,
'error': f'Неизвестное действие: {action}',
'action': action
}
@register_tool
class TelegramWebTool(BaseTool):
"""Инструмент для работы с Telegram-каналами через web"""
name = "telegram_web_tool"
description = "Чтение публичных Telegram-каналов через t.me/s/username. Actions: add (username), list, read (username, limit), fetch_all (limit)"
category = "telegram"
async def execute(self, action: str = 'list', **kwargs) -> ToolResult:
"""Выполнить инструмент"""
result = await telegram_web_tool(action, **kwargs)
if result['success']:
return ToolResult(
success=True,
data=result,
metadata={'action': action, 'tool_name': self.name}
)
else:
return ToolResult(
success=False,
error=result.get('error', 'Неизвестная ошибка'),
metadata={'action': action, 'tool_name': self.name}
)