Initial commit: Telegram CLI Bot with AI memory, tools and multi-provider support
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Модели данных бота."""
|
||||
|
||||
from bot.models.server import Server, ServerManager
|
||||
from bot.models.user_state import UserState, StateManager
|
||||
from bot.models.session import (
|
||||
SSHSession,
|
||||
SSHSessionManager,
|
||||
LocalSession,
|
||||
LocalSessionManager,
|
||||
INPUT_PATTERNS
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Server",
|
||||
"ServerManager",
|
||||
"UserState",
|
||||
"StateManager",
|
||||
"SSHSession",
|
||||
"SSHSessionManager",
|
||||
"LocalSession",
|
||||
"LocalSessionManager",
|
||||
"INPUT_PATTERNS",
|
||||
]
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Модели серверов и управление ими."""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import getpass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from dotenv import load_dotenv
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Server:
|
||||
"""Конфигурация сервера."""
|
||||
name: str
|
||||
host: str
|
||||
port: int
|
||||
user: str
|
||||
tags: List[str] = field(default_factory=list)
|
||||
password: str = ""
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Отображаемое имя с иконкой."""
|
||||
icon = "🖥️"
|
||||
if "local" in self.tags:
|
||||
icon = "💻"
|
||||
elif "db" in self.tags:
|
||||
icon = "🗄️"
|
||||
elif "web" in self.tags:
|
||||
icon = "🌐"
|
||||
return f"{icon} {self.name}"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Краткое описание сервера."""
|
||||
return f"{self.user}@{self.host}:{self.port}"
|
||||
|
||||
|
||||
class ServerManager:
|
||||
"""Управление серверами."""
|
||||
|
||||
def __init__(self):
|
||||
self._servers: Dict[str, Server] = {}
|
||||
self._default_server: str = "local"
|
||||
self._ssh_key_path: Optional[str] = None
|
||||
|
||||
# Локальный сервер всегда доступен
|
||||
try:
|
||||
local_user = getpass.getuser()
|
||||
except Exception:
|
||||
local_user = "user"
|
||||
|
||||
self._servers["local"] = Server(
|
||||
name="local",
|
||||
host="localhost",
|
||||
port=22,
|
||||
user=local_user,
|
||||
tags=["local", "dev"]
|
||||
)
|
||||
|
||||
def load_from_env(self):
|
||||
"""Загрузка серверов из переменных окружения."""
|
||||
self._ssh_key_path = os.getenv("SSH_KEY_PATH")
|
||||
self._default_server = os.getenv("DEFAULT_SERVER", "local")
|
||||
|
||||
servers_str = os.getenv("SERVERS", "")
|
||||
if not servers_str.strip():
|
||||
return
|
||||
|
||||
# Парсинг формата: name|host|port|user|tags|password,name|host|port|user|tags|password
|
||||
for server_line in servers_str.split(","):
|
||||
if not server_line.strip():
|
||||
continue
|
||||
|
||||
parts = server_line.strip().split("|")
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
|
||||
try:
|
||||
name = parts[0].strip()
|
||||
host = parts[1].strip()
|
||||
port = int(parts[2].strip())
|
||||
user = parts[3].strip()
|
||||
|
||||
# Теги (часть 4) и пароль (часть 5) могут отсутствовать
|
||||
tags = []
|
||||
password = ""
|
||||
|
||||
if len(parts) >= 5 and parts[4].strip():
|
||||
tags = [t.strip() for t in parts[4].split(",") if t.strip()]
|
||||
|
||||
if len(parts) >= 6:
|
||||
password = parts[5].strip()
|
||||
|
||||
server = Server(name=name, host=host, port=port, user=user, tags=tags, password=password)
|
||||
self._servers[name] = server
|
||||
logger.info(f"Загружен сервер: {server.display_name} ({server.description})")
|
||||
except ValueError as e:
|
||||
logger.warning(f"Ошибка парсинга сервера: {parts} - {e}")
|
||||
|
||||
def get(self, name: str) -> Optional[Server]:
|
||||
"""Получить сервер по имени."""
|
||||
return self._servers.get(name)
|
||||
|
||||
def list_servers(self) -> List[Server]:
|
||||
"""Список всех серверов."""
|
||||
return list(self._servers.values())
|
||||
|
||||
def get_by_tags(self, tags: List[str]) -> List[Server]:
|
||||
"""Получить серверы по тегам."""
|
||||
result = []
|
||||
for server in self._servers.values():
|
||||
if any(tag in server.tags for tag in tags):
|
||||
result.append(server)
|
||||
return result
|
||||
|
||||
@property
|
||||
def default_server(self) -> str:
|
||||
"""Имя сервера по умолчанию."""
|
||||
return self._default_server
|
||||
|
||||
@property
|
||||
def ssh_key_path(self) -> Optional[str]:
|
||||
"""Путь к SSH ключу."""
|
||||
return self._ssh_key_path
|
||||
|
||||
def get_keyboard(self, exclude_local: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Создать клавиатуру с выбором сервера."""
|
||||
keyboard = []
|
||||
for server in self._servers.values():
|
||||
if exclude_local and server.name == "local":
|
||||
continue
|
||||
button = InlineKeyboardButton(
|
||||
server.display_name,
|
||||
callback_data=f"server_select_{server.name}"
|
||||
)
|
||||
keyboard.append([button])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
def add_server(self, name: str, host: str, port: int, user: str, tags: List[str] = None, password: str = "") -> bool:
|
||||
"""Добавить сервер."""
|
||||
if name in self._servers:
|
||||
return False
|
||||
self._servers[name] = Server(name=name, host=host, port=port, user=user, tags=tags or [], password=password)
|
||||
self.save_to_env()
|
||||
return True
|
||||
|
||||
def update_server(self, name: str, host: str = None, port: int = None,
|
||||
user: str = None, tags: List[str] = None, password: str = None) -> bool:
|
||||
"""Обновить сервер."""
|
||||
if name not in self._servers or name == "local":
|
||||
return False
|
||||
server = self._servers[name]
|
||||
if host:
|
||||
server.host = host
|
||||
if port:
|
||||
server.port = port
|
||||
if user:
|
||||
server.user = user
|
||||
if tags is not None:
|
||||
server.tags = tags
|
||||
if password is not None:
|
||||
server.password = password
|
||||
self.save_to_env()
|
||||
return True
|
||||
|
||||
def rename_server(self, old_name: str, new_name: str, state_manager=None) -> bool:
|
||||
"""Переименовать сервер."""
|
||||
if old_name not in self._servers or old_name == "local":
|
||||
return False
|
||||
if new_name in self._servers or new_name == "local":
|
||||
logger.warning(f"Сервер с именем '{new_name}' уже существует")
|
||||
return False
|
||||
|
||||
# Получаем старый сервер и создаём копию с новым именем
|
||||
old_server = self._servers[old_name]
|
||||
new_server = Server(
|
||||
name=new_name,
|
||||
host=old_server.host,
|
||||
port=old_server.port,
|
||||
user=old_server.user,
|
||||
tags=old_server.tags.copy(),
|
||||
password=old_server.password
|
||||
)
|
||||
|
||||
# Удаляем старый и добавляем новый
|
||||
del self._servers[old_name]
|
||||
self._servers[new_name] = new_server
|
||||
|
||||
# Обновляем current_server у всех пользователей кто подключён к этому серверу
|
||||
if state_manager:
|
||||
for user_id, user_state in state_manager._states.items():
|
||||
if user_state.current_server == old_name:
|
||||
user_state.current_server = new_name
|
||||
logger.debug(f"У пользователя {user_id} current_server обновлён с '{old_name}' на '{new_name}'")
|
||||
if user_state.editing_server == old_name:
|
||||
user_state.editing_server = new_name
|
||||
logger.debug(f"У пользователя {user_id} editing_server обновлён с '{old_name}' на '{new_name}'")
|
||||
|
||||
self.save_to_env()
|
||||
logger.info(f"Сервер '{old_name}' переименован в '{new_name}'")
|
||||
return True
|
||||
|
||||
def delete_server(self, name: str, state_manager=None) -> bool:
|
||||
"""Удалить сервер."""
|
||||
if name not in self._servers or name == "local":
|
||||
return False
|
||||
|
||||
# Сбрасываем current_server у всех пользователей кто подключён к этому серверу
|
||||
if state_manager:
|
||||
for user_id, user_state in state_manager._states.items():
|
||||
if user_state.current_server == name:
|
||||
user_state.current_server = "local"
|
||||
logger.debug(f"У пользователя {user_id} current_server сброшен на 'local' после удаления сервера '{name}'")
|
||||
if user_state.editing_server == name:
|
||||
user_state.editing_server = None
|
||||
logger.debug(f"У пользователя {user_id} editing_server сброшен после удаления сервера '{name}'")
|
||||
|
||||
del self._servers[name]
|
||||
self.save_to_env()
|
||||
return True
|
||||
|
||||
def save_to_env(self):
|
||||
"""Сохранить серверы в .env файл."""
|
||||
env_file = Path(__file__).parent.parent.parent / ".env"
|
||||
|
||||
# Читаем существующий файл
|
||||
lines = []
|
||||
if env_file.exists():
|
||||
with open(env_file, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Формируем строку серверов
|
||||
server_parts = []
|
||||
for server in self._servers.values():
|
||||
if server.name == "local":
|
||||
continue
|
||||
tags_str = ",".join(server.tags) if server.tags else ""
|
||||
# Формат: name|host|port|user|tags|password
|
||||
server_parts.append(f"{server.name}|{server.host}|{server.port}|{server.user}|{tags_str}|{server.password}")
|
||||
|
||||
servers_line = f"SERVERS={','.join(server_parts)}\n"
|
||||
|
||||
# Ищем и обновляем или добавляем строку SERVERS
|
||||
found = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("SERVERS="):
|
||||
lines[i] = servers_line
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
lines.append("\n" + servers_line)
|
||||
|
||||
# Записываем обратно
|
||||
with open(env_file, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
logger.debug(f"Серверы сохранены в {env_file}")
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Модели интерактивных сессий (SSH и локальные)."""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
import asyncssh
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Импортируем Server из соседнего модуля
|
||||
from bot.models.server import Server
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSHSession:
|
||||
"""Интерактивная SSH-сессия."""
|
||||
user_id: int
|
||||
server: Server
|
||||
working_dir: str
|
||||
conn: asyncssh.SSHClientConnection
|
||||
process: asyncssh.SSHClientProcess
|
||||
output_buffer: str = ""
|
||||
waiting_for_input: bool = False
|
||||
input_type: str = "" # "password", "confirm", "text"
|
||||
last_activity: datetime = field(default_factory=datetime.now)
|
||||
command: str = ""
|
||||
|
||||
SESSION_TIMEOUT = timedelta(minutes=5) # Таймаут неактивности
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Проверка истечения таймаута сессии."""
|
||||
return datetime.now() - self.last_activity > self.SESSION_TIMEOUT
|
||||
|
||||
|
||||
class SSHSessionManager:
|
||||
"""Менеджер интерактивных SSH-сессий."""
|
||||
|
||||
def __init__(self):
|
||||
self._sessions: Dict[int, SSHSession] = {}
|
||||
|
||||
def create_session(self, user_id: int, server: Server, working_dir: str,
|
||||
conn: asyncssh.SSHClientConnection, process: asyncssh.SSHClientProcess,
|
||||
command: str = "") -> SSHSession:
|
||||
"""Создать новую сессию."""
|
||||
session = SSHSession(
|
||||
user_id=user_id,
|
||||
server=server,
|
||||
working_dir=working_dir,
|
||||
conn=conn,
|
||||
process=process,
|
||||
command=command
|
||||
)
|
||||
self._sessions[user_id] = session
|
||||
logger.info(f"Создана SSH-сессия для пользователя {user_id} на сервере {server.name}")
|
||||
return session
|
||||
|
||||
def get_session(self, user_id: int) -> Optional[SSHSession]:
|
||||
"""Получить сессию пользователя."""
|
||||
session = self._sessions.get(user_id)
|
||||
if session and session.is_expired():
|
||||
self.close_session(user_id)
|
||||
return None
|
||||
return session
|
||||
|
||||
def close_session(self, user_id: int):
|
||||
"""Закрыть сессию пользователя."""
|
||||
session = self._sessions.pop(user_id, None)
|
||||
if session:
|
||||
try:
|
||||
if session.process:
|
||||
session.process.stdin.close()
|
||||
session.process.stdout.feed_eof()
|
||||
if session.conn:
|
||||
session.conn.close()
|
||||
logger.info(f"Закрыта SSH-сессия для пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка при закрытии сессии: {e}")
|
||||
|
||||
def has_active_session(self, user_id: int) -> bool:
|
||||
"""Проверка наличия активной сессии."""
|
||||
return self.get_session(user_id) is not None
|
||||
|
||||
def cleanup_expired(self):
|
||||
"""Очистка истёкших сессий."""
|
||||
expired = [uid for uid, s in self._sessions.items() if s.is_expired()]
|
||||
for uid in expired:
|
||||
self.close_session(uid)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalSession:
|
||||
"""Интерактивная локальная сессия."""
|
||||
user_id: int
|
||||
command: str
|
||||
master_fd: int
|
||||
pid: int
|
||||
output_buffer: str = ""
|
||||
waiting_for_input: bool = False
|
||||
input_type: str = ""
|
||||
last_activity: datetime = field(default_factory=datetime.now)
|
||||
context: Dict = field(default_factory=dict) # Для хранения pexpect child и другого
|
||||
|
||||
SESSION_TIMEOUT = timedelta(minutes=5)
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
return datetime.now() - self.last_activity > self.SESSION_TIMEOUT
|
||||
|
||||
|
||||
class LocalSessionManager:
|
||||
"""Менеджер локальных интерактивных сессий."""
|
||||
|
||||
def __init__(self):
|
||||
self._sessions: Dict[int, LocalSession] = {}
|
||||
|
||||
def create_session(self, user_id: int, command: str, master_fd: int, pid: int) -> LocalSession:
|
||||
session = LocalSession(
|
||||
user_id=user_id,
|
||||
command=command,
|
||||
master_fd=master_fd,
|
||||
pid=pid
|
||||
)
|
||||
self._sessions[user_id] = session
|
||||
logger.info(f"Создана локальная сессия для пользователя {user_id}")
|
||||
return session
|
||||
|
||||
def get_session(self, user_id: int) -> Optional[LocalSession]:
|
||||
session = self._sessions.get(user_id)
|
||||
if session and session.is_expired():
|
||||
self.close_session(user_id)
|
||||
return None
|
||||
return session
|
||||
|
||||
def close_session(self, user_id: int):
|
||||
session = self._sessions.pop(user_id, None)
|
||||
if session:
|
||||
try:
|
||||
# Закрываем pexpect процесс если есть
|
||||
child = session.context.get('child') if session.context else None
|
||||
if child:
|
||||
child.close(force=True)
|
||||
else:
|
||||
# Старый способ для PTY
|
||||
os.close(session.master_fd)
|
||||
os.kill(session.pid, 9)
|
||||
except:
|
||||
pass
|
||||
logger.info(f"Закрыта локальная сессия для пользователя {user_id}")
|
||||
|
||||
def has_active_session(self, user_id: int) -> bool:
|
||||
return self.get_session(user_id) is not None
|
||||
|
||||
|
||||
# Паттерны для детектирования запросов ввода
|
||||
INPUT_PATTERNS = {
|
||||
"password": [
|
||||
r"[Pp]assword[:\s]*$",
|
||||
r"[Pp]assphrase[:\s]*$",
|
||||
r"Enter password[:\s]*$",
|
||||
r"sudo password[:\s]*$",
|
||||
r"\[sudo\] password for .*:",
|
||||
r"[Пп]ароль[:\s]*$",
|
||||
r"\[sudo\] пароль для .*:",
|
||||
r"Введите пароль[:\s]*$",
|
||||
],
|
||||
"confirm": [
|
||||
r"[Yy]es/[Nn]o[?:\s]*$",
|
||||
r"\[?[Yy]\]?/?\[?[Nn]\]?",
|
||||
r"Do you want to continue",
|
||||
r"Continue\?",
|
||||
r"Are you sure",
|
||||
r"Is this OK",
|
||||
r"[Yy]es or [Nn]o",
|
||||
r"[Дд]а/[Нн]ет",
|
||||
r"[Пп]родолжить",
|
||||
],
|
||||
"shell_prompt": [
|
||||
r"[$#]\s*$",
|
||||
r"[>$]\s*$",
|
||||
r"[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+:.*[$#]\s*$",
|
||||
],
|
||||
}
|
||||
|
||||
# Глобальные менеджеры сессий
|
||||
ssh_session_manager = SSHSessionManager()
|
||||
local_session_manager = LocalSessionManager()
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Модели состояния пользователя."""
|
||||
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
# Пресеты AI-провайдеров
|
||||
AI_PRESET_OFF = "off" # ИИ отключен, режим CLI команд
|
||||
AI_PRESET_QWEN = "qwen" # Qwen Code (бесплатно, локально)
|
||||
AI_PRESET_GIGA_AUTO = "giga_auto" # GigaChat авто-переключение (Lite/Pro)
|
||||
AI_PRESET_GIGA_LITE = "giga_lite" # GigaChat Lite (дешевле)
|
||||
AI_PRESET_GIGA_PRO = "giga_pro" # GigaChat Pro (максимальное качество)
|
||||
AI_PRESET_GIGA_MAX = "giga_max" # GigaChat Max (топовая модель)
|
||||
AI_PRESET_OPENCODE = "opencode" # Opencode (бесплатно, локально)
|
||||
|
||||
# Модели Opencode
|
||||
OPENCODE_MODEL_MINIMAX = "minimax"
|
||||
OPENCODE_MODEL_BIG_PICKLE = "big_pickle"
|
||||
OPENCODE_MODEL_GPT5 = "gpt5"
|
||||
|
||||
# Модели GigaChat
|
||||
GIGACHAT_MODEL_LITE = "lite"
|
||||
GIGACHAT_MODEL_PRO = "pro"
|
||||
GIGACHAT_MODEL_MAX = "max"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserState:
|
||||
"""Состояние пользователя в диалоге."""
|
||||
current_menu: str = "main"
|
||||
waiting_for_input: bool = False
|
||||
input_type: Optional[str] = None # "name", "host", "port", "user", "tags", "server_action"
|
||||
parent_menu: Optional[str] = None
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
working_directory: Optional[str] = None
|
||||
current_server: str = "local" # Имя текущего сервера
|
||||
editing_server: Optional[str] = None # Имя сервера, который редактируем
|
||||
ai_chat_mode: bool = True # Режим чата с ИИ агентом (включен по умолчанию)
|
||||
ai_chat_history: List[str] = field(default_factory=list) # История диалога с ИИ
|
||||
messages_since_fact_extract: int = 0 # Счётчик для извлечения фактов
|
||||
ai_preset: str = AI_PRESET_QWEN # Текущий AI-пресет
|
||||
current_ai_provider: str = "qwen" # Текущий AI-провайдер (для совместимости)
|
||||
opencode_model: str = OPENCODE_MODEL_MINIMAX # Модель Opencode
|
||||
gigachat_model: str = GIGACHAT_MODEL_LITE # Модель GigaChat
|
||||
|
||||
# Для управления длинным выводом
|
||||
waiting_for_output_control: bool = False # Ожидание решения пользователя
|
||||
output_remaining: int = 0 # Сколько сообщений осталось
|
||||
output_wait_message = None # Сообщение с кнопками
|
||||
output_continue_event = None # asyncio.Event для разблокировки
|
||||
continue_output: bool = True # Решение пользователя
|
||||
output_next_index: Optional[int] = None # Индекс следующего сообщения для отправки
|
||||
output_text: Optional[str] = None # Текст для продолжения отправки
|
||||
output_parse_mode: Optional[str] = None # Parse mode для продолжения
|
||||
output_prev_code_closed: bool = True # Был ли закрыт блок кода в предыдущем сообщении
|
||||
|
||||
# Для команды /restart_bot
|
||||
waiting_for_restart_password: bool = False # Ожидание пароля sudo для перезапуска
|
||||
|
||||
# Для OAuth авторизации Qwen
|
||||
waiting_for_qwen_oauth: bool = False # Ожидание завершения OAuth авторизации
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""Управление состояниями пользователей."""
|
||||
|
||||
def __init__(self):
|
||||
self._states: Dict[int, UserState] = {}
|
||||
self._history_loaded: Dict[int, bool] = {} # Флаг загрузки истории из БД
|
||||
|
||||
def get(self, user_id: int) -> UserState:
|
||||
if user_id not in self._states:
|
||||
self._states[user_id] = UserState()
|
||||
# Помечаем что история ещё не загружена
|
||||
self._history_loaded[user_id] = False
|
||||
return self._states[user_id]
|
||||
|
||||
def mark_history_loaded(self, user_id: int):
|
||||
"""Пометить что история для пользователя загружена из БД."""
|
||||
self._history_loaded[user_id] = True
|
||||
|
||||
def is_history_loaded(self, user_id: int) -> bool:
|
||||
"""Проверить загружена ли история из БД."""
|
||||
return self._history_loaded.get(user_id, False)
|
||||
|
||||
def reset(self, user_id: int):
|
||||
self._states[user_id] = UserState()
|
||||
self._history_loaded[user_id] = False
|
||||
Reference in New Issue
Block a user