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

This commit is contained in:
mirivlad
2026-03-16 03:47:15 +08:00
commit 6b8bd8d7a6
69 changed files with 20857 additions and 0 deletions
+24
View File
@@ -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",
]
+264
View File
@@ -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}")
+189
View 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()
+89
View File
@@ -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