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