admin v0.2.2 [base]
Админ-ядро TG (AdminMenu): главное меню, права, скрипты, журнал, бэкапы.
| version | date | commit | файлов |
|---|---|---|---|
| 0.2.2 | 2026-09-16 | 2d7d04615b31 | 12 |
README
# admin `0.2.2`
Админ-ядро TG (AdminMenu): главное меню, права, скрипты, журнал, бэкапы. Две стороны: меню в Telegram и карточки в кабинете.
Тип: Модуль. Категория: `base`. Зависимостей нет.
## Описание
- В Telegram — главное меню `/start` с кнопками (администрирование, управление ботом, скрипты, уведомления, свой ID), видимость кнопок — по правам.
- Управление: перезапуск службы бота, перезагрузка и выключение хоста; каждое действие под своим правом, пароль sudo — только из `.env`.
- Админы: назначение и снятие TG-админов (пишет в `.env`, права сидит через `access`).
- Скрипты: каталог `scripts_admin/` удалён, скрипты обслуживания теперь живут в `services/scripts` — список, запуск, просмотр и правка текста из кабинета.
- Журнал: сообщения пользователей боту (включая группы) — кто, когда, что писал; поиск и фильтры в кабинете.
- Раскладка кнопок `/start` (порядок, ширина, скрытие) правится в кабинете без правки кода.
## Возможности
- `admin.menu {}` → движок AdminMenu (тот же объект, что обслуживает кнопки TG)
- `admin.toggle_right {key: 'telegram|vk|web:<id>', right}` → переключить право, вернуть новое значение
- `admin.journal {limit=200}` → хвост журнала сообщений (кто/что/когда, включая группы)
## Права
- `restart_bot` — Перезапуск службы бота (POST /api/admin/system action=restart_bot)
- `restart_machine` — Перезагрузка хоста (POST /api/admin/system action=restart_machine)
- `shutdown_host` — Выключение хоста (POST /api/admin/system action=shutdown)
- `manage_admins` — Назначение/снятие TG-админов (POST /api/admin/admins)
- `receive_notifications_new_user` — Уведомления о новых пользователях (модуль admin)
- `receive_notifications_new_user_edit` — Право менять себе уведомления о новых пользователях
- `run_scripts` — Запуск скриптов обслуживания (GET/POST /api/admin/scripts)
- `view_user_messages` — Журнал сообщений пользователей (GET /api/admin/messages)
- `menu_edit` — Группы Telegram (TG-команды group*, /api/admin/group*)
## Telegram
- `/start` — главное меню (кнопки по правам)
- `/id` — свой TG id (нужен для выдачи прав)
## Web
- `POST /api/admin/admins` — назначение/снятие админов
- `POST /api/admin/system` — restart_bot / restart_machine / shutdown
- `GET /api/admin/scripts`, `POST /api/admin/scripts` — список и запуск скриптов из `services/scripts`
- `GET /api/admin/messages` — журнал сообщений с поиском
- `GET /api/admin/group`, `POST /api/admin/group`, `GET /api/admin/group/messages` — группы Telegram
- `GET /api/admin/tgmenu`, `POST /api/admin/tgmenu` — раскладка кнопок /start
- Карточки кабинета: «Система», «Скрипты», «Сообщения», «Меню /start»
## Конфигурация (`config.schema.json`)
- Обязательных нет.
- Опциональные (дефолты): `PAGE_SIZE` = `20`, `LOG_CONSOLE_MAX_MB` = `50`; остальные пусты по умолчанию: `ADMIN_PASS`, `CACHE_USER_MESSAGES`, `CACHE_FILE_NETWORK`, `SCAN_NETWORK_IGNORED_MACS`, `LOG_CONSOLE`, `BACKUP_DIR`, `F2B_CACHE`, `SYSTEMD_SERVICE`, `REMOTE_PASSWORD_WIN`, `REMOTE_PASSWORD_DOCKER`, `LOCAL_PASSWORD`, `REMOTE_HOST_WIN`, `REMOTE_USER_WIN`, `REMOTE_HOST_DOCKER`, `REMOTE_USER_DOCKER`
Манифест
{
"name": "admin",
"version": "0.2.2",
"category": "base",
"description": "Админ-ядро TG (AdminMenu): главное меню, права, скрипты, журнал, бэкапы.",
"requires": [],
"rights": [],
"api_methods": [],
"web_routes": [],
"commit": "2d7d04615b31be9f5bbd91d5d61671182164bf39",
"updated": "2026-09-16 16:35:50 +0300",
"integrity": "sha256:77902a4a084812eea8ee9419310a5a95957c43c134a8f010277cadbba20f4cea",
"tree_url": "http://10.10.10.30:3000/euk0r/bot_modular/src/branch/main/modules/admin",
"raw_url": "http://10.10.10.30:3000/euk0r/bot_modular/raw/branch/main/modules/admin/module.py",
"files": "[12 файлов — см. вкладки ниже]",
"note": "Управление ботом",
"_extra": {
"api": "",
"events_emitted": "[]",
"events_subscribed": "[]",
"audit_allow": "",
"uses_rights": "",
"tg_commands": "",
"shared_routes": "",
"config_schema": "config.schema.json",
"env_example": ".env.example"
}
} Файлы и исходники
Дерево файлов
- · корень
- 4.3 КБ
- 1.0 КБ
- 0.5 КБ
- 61.9 КБ
- 2.9 КБ
- 5.2 КБ
- 0.3 КБ
- 6.9 КБ
- 1.4 КБ
- 17.2 КБ
- web/
- 5.0 КБ
- 20.5 КБ
Предпросмотр
Выберите файл в дереве выше — код откроется здесь.
Все исходники (.py) одним списком
config.py 1.0 КБ
"""Пакетный config-шим для admin engine (вендор admin_menu.py пишет config.X).
Значения ставит Module из ModuleConfig + абсолютные пути.
Права и ADMIN_IDS — только через модуль access
(движок делегирует; дефолты — из реестра ядра).
"""
import os
_vals: dict = {}
def _int(values: dict, key: str, default: int) -> int:
try:
return int(str(values.get(key, default) or default))
except (ValueError, TypeError):
return default
def install(values: dict, files: dict) -> None:
_vals.clear()
_vals.update(values)
_vals.update(files)
_vals["PAGE_SIZE"] = _int(values, "PAGE_SIZE", 20)
_vals["LOG_CONSOLE_MAX_MB"] = _int(values, "LOG_CONSOLE_MAX_MB", 50)
_vals["ADMIN_PASS"] = values.get("ADMIN_PASS") or ""
def __getattr__(name: str):
try:
return _vals[name]
except KeyError:
raise AttributeError("admin.config: нет ключа %s" % name) from None
engine.py 61.9 КБ
import logging
import os
import re
import subprocess
import json
import time
import select
import copy
import threading
from datetime import datetime, timedelta
from telegram import BotCommand, BotCommandScopeChat, InlineKeyboardMarkup, InlineKeyboardButton
from telegram.ext import CommandHandler, MessageHandler, Filters, CallbackQueryHandler, DispatcherHandlerStop
from telegram.utils.helpers import escape_markdown
from logging.handlers import RotatingFileHandler
from . import config
import pexpect
import threading
logger = logging.getLogger(__name__)
# Небольшой кэш прав администраторов (TTL) – снижает количество чтений файла с диска
# при отрисовке меню, где get_admin_right вызывается десятки раз подряд.
# Лок защищает файл от гонок: права теперь трогают и другие потоки (web-канал).
_rights_lock = threading.RLock()
_rights_cache = {"data": None, "ts": 0.0}
_RIGHTS_TTL = 5.0
# Пространство имён мессенджера для ключей прав. Telegram- и VK-пользователи
# имеют независимые ID, поэтому ключи вида "telegram:<id>" / "vk:<id>".
_RIGHTS_NS = "telegram"
def _access():
"""Модуль access (единый владелец ADMIN_IDS/прав). Лениво — без порядка загрузки."""
from botmod_access import engine as _acc
return _acc
def _admin_ids():
try:
return _access().admin_ids()
except Exception:
return []
def _norm_key(uid):
"""Квалифицированный ключ прав. Идемпотентен: уже квалифицированный
("telegram:123") возвращает как есть, сырой числовой id оборачивает."""
uid_s = str(uid)
if ":" in uid_s:
return uid_s
return f"{_RIGHTS_NS}:{uid_s}"
def _numeric_uid(uid):
"""Числовой id из квалифицированного ключа прав ('telegram:123' -> 123).
Возвращает None, если цифр в конце нет."""
import re as _re
m = _re.search(r"(\d+)$", str(uid))
return int(m.group(1)) if m else None
def load_admin_rights():
"""BOTMOD: живьём из access (без TTL-кеша и прямых чтений файла)."""
try:
return _access().load_rights()
except Exception:
return {}
def save_admin_rights(rights):
"""BOTMOD: дифф-запись через ядро (атомарно)."""
try:
_access().save_rights(rights or {})
except Exception as e:
logger.error(f"Ошибка сохранения прав администраторов: {e}")
def delete_message(context):
job = context.job
try:
context.bot.delete_message(chat_id=job.context["chat_id"], message_id=job.context["message_id"])
except Exception as e:
if "Message to delete not found" in str(e):
logger.debug("Сообщение для удаления не найдено, пропускаем.")
else:
logger.error(f"Ошибка при удалении сообщения: {e}")
def flush_buffer(child):
"""
Читает доступные данные из буфера, чтобы очистить его перед выполнением новой команды.
"""
flushed = ""
try:
while True:
flushed += child.read_nonblocking(size=1024, timeout=0)
except (pexpect.exceptions.TIMEOUT, pexpect.exceptions.EOF):
pass
return flushed
_console_handler = None
def log_console(user_id, text):
"""Записывает команды/вывод консольных сессий в отдельный файл (config.LOG_CONSOLE).
Файл ротируется по размеру, суммарный объём ограничен config.LOG_CONSOLE_MAX_MB."""
global _console_handler
try:
log_path = config.LOG_CONSOLE
os.makedirs(os.path.dirname(log_path), exist_ok=True)
if _console_handler is None or _console_handler.baseFilename != os.path.abspath(log_path):
backup_count = 1
max_bytes = max(1024, int(config.LOG_CONSOLE_MAX_MB * 1024 * 1024) // (backup_count + 1))
_console_handler = RotatingFileHandler(
log_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
)
_console_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
_console_handler.emit(logging.LogRecord(
name="console_sessions",
level=logging.INFO,
pathname=__file__,
lineno=0,
msg=f"[{timestamp}] [user:{user_id}] {text}",
args=(),
exc_info=None,
))
_console_handler.flush()
except Exception as e:
logger.error(f"Ошибка записи лога консоли: {e}")
def format_online(seconds):
"""Форматирует секунды в человекочитаемый вид: «3д 04:12» / «1:05:33» / «00:42»."""
seconds = int(seconds or 0)
d, rem = divmod(seconds, 86400)
h, rem = divmod(rem, 3600)
m, s = divmod(rem, 60)
if d:
return f"{d}д {h:02d}:{m:02d}"
if h:
return f"{h:02d}:{m:02d}:{s:02d}"
return f"{m:02d}:{s:02d}"
# --- Функционал кеширования сообщений и действий пользователей ---
def load_user_message_cache():
cache = {"messages": [], "users": []}
try:
if os.path.exists(config.CACHE_USER_MESSAGES):
with open(config.CACHE_USER_MESSAGES, "r", encoding="utf-8") as f:
cache = json.load(f)
if "messages" not in cache:
cache["messages"] = []
if "users" not in cache:
cache["users"] = []
# Удаляем сообщения старше 30 дней
threshold = datetime.now() - timedelta(days=30)
cache["messages"] = [msg for msg in cache["messages"] if datetime.fromisoformat(msg["time"]) >= threshold]
except Exception as e:
logger.error(f"Ошибка загрузки кеша сообщений: {e}")
return cache
def save_user_message_cache(cache):
try:
with open(config.CACHE_USER_MESSAGES, "w", encoding="utf-8") as f:
json.dump(cache, f, indent=4)
except Exception as e:
logger.error(f"Ошибка сохранения кеша сообщений: {e}")
# --- Конец блока кеширования ---
class AdminMenu:
def __init__(self, dispatcher):
self.dispatcher = dispatcher
# BOTMOD: список администраторов — только через access (_admin_ids()).
self.sudo_password = config.ADMIN_PASS # Пароль sudo из переменной окружения ADMIN_PASS
self.check_and_update_admin_rights()
# Словарь для хранения последнего отправленного ботом сообщения по каждому чату
self.last_bot_message = {}
# Словарь активных консольных сессий: ключ – user_id, значение – объект (pexpect или subprocess)
self.console_sessions = {}
# --- Логирование сообщений и действий пользователей ---
def log_user_message(self, user, text, context):
cache = load_user_message_cache()
new_user = user.id not in cache["users"]
if new_user:
cache["users"].append(user.id)
self.notify_new_user(user, context)
# определяем чат для группы/лички
chat = None
try:
chat = context.bot.get_me # dummy to keep context ref
# эффективный чат из context
if hasattr(context, 'chat_data'):
pass
except Exception:
pass
# пробуем взять из update через context? fallback
chat_id = None
chat_type = "private"
chat_title = ""
try:
# context may have effective_chat in some calls, but here we use passed update? caller passes user+text+context, we need update
# попробуем достать из context.chat_data? не надёжно, поэтому требуем чтобы вызывающий передал chat
pass
except Exception:
pass
message_entry = {
"user_id": user.id,
"name": user.username if user.username else user.first_name,
"text": text,
"time": datetime.now().isoformat(),
"chat_id": None,
"chat_type": "private",
"chat_title": "",
"is_group": False
}
# попытаемся извлечь чат из контекста вызывающего — если в context есть _group_log_chat
try:
gc = getattr(context, '_group_log_chat', None)
if gc:
message_entry["chat_id"] = str(gc.get("id") or gc.get("chat_id"))
message_entry["chat_type"] = gc.get("type") or "private"
message_entry["chat_title"] = gc.get("title") or ""
message_entry["is_group"] = message_entry["chat_type"] in ("group","supergroup")
except Exception:
pass
cache["messages"].append(message_entry)
save_user_message_cache(cache)
def log_user_message_with_chat(self, user, text, chat):
# прямой вызов с объектом чата (для групп)
cache = load_user_message_cache()
if user.id not in cache["users"]:
cache["users"].append(user.id)
entry = {
"user_id": user.id,
"name": user.username if user.username else user.first_name,
"text": text,
"time": datetime.now().isoformat(),
"chat_id": str(getattr(chat, 'id', '') or ''),
"chat_type": getattr(chat, 'type', 'private') or 'private',
"chat_title": getattr(chat, 'title', '') or '',
"is_group": getattr(chat, 'type', '') in ('group','supergroup')
}
cache["messages"].append(entry)
save_user_message_cache(cache)
def notify_new_user(self, user, context):
"""
Отправляет уведомление о новом пользователе администраторам, у которых включены
и общее право receive_notifications, и подчинённое receive_notifications_new_user.
Если включено receive_notifications_no_sound – отправляет без звука.
"""
for admin_id in _admin_ids():
if (self.get_admin_right(admin_id, "receive_notifications") and
self.get_admin_right(admin_id, "receive_notifications_new_user")):
no_sound = self.get_admin_right(admin_id, "receive_notifications_no_sound")
try:
self.dispatcher.bot.send_message(
chat_id=admin_id,
text=f"Новый пользователь: ID: {user.id}, Имя: {user.username if user.username else user.first_name}",
disable_notification=no_sound
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления администратору {admin_id}: {e}")
# --- Конец логирования ---
def check_and_update_admin_rights(self):
# BOTMOD: сидинг/чистка — access.seed(); pve_manage_hosts дотягивает pve-модуль.
try:
_access().seed()
except Exception as e:
logger.error(f"Ошибка синхронизации прав: {e}")
def get_admin_right(self, user_id, key):
try:
return _access().get_right(user_id, key)
except Exception:
return False
def toggle_admin_right(self, target_id, key):
try:
return _access().toggle_right(_norm_key(target_id), key)
except Exception as e:
logger.error(f"Ошибка переключения права: {e}")
return False
# Обработка уведомлений по добавлению/удалению администраторов.
def process_add_admin(self, update, context, admin_id_text):
chat_id = update.message.chat.id if update.message else update.callback_query.message.chat.id
user = update.effective_user
if not self.get_admin_right(user.id, "manage_admins"):
self.send_msg(chat_id, "Доступ запрещён!", context=context)
return
new_admin = admin_id_text.strip()
if not new_admin.isdigit():
self.send_msg(chat_id, "Неверный формат ID.", context=context)
return
try:
# BOTMOD: скрипт правил старый config.py — теперь access (.env + seed).
changed, output = _access().add_admin_id(new_admin)
self.send_msg(chat_id, f"Результат добавления администратора:\n{output}", context=context)
notify_msg = (
f"Внимание!\nАдминистратор {new_admin} добавлен пользователем "
f"{user.username} ({user.id}).\nДля вступления изменений перезапустите бота."
)
for admin in _admin_ids():
if admin != user.id:
self.dispatcher.bot.send_message(chat_id=admin, text=notify_msg)
except Exception as e:
logger.error(f"Ошибка при добавлении администратора: {e}")
self.send_msg(chat_id, "Ошибка при добавлении администратора.", context=context)
def process_remove_admin(self, update, context, admin_id_text):
chat_id = update.message.chat.id if update.message else update.callback_query.message.chat.id
user = update.effective_user
if not self.get_admin_right(user.id, "manage_admins"):
self.send_msg(chat_id, "Доступ запрещён!", context=context)
return
rem_admin = admin_id_text.strip()
if not rem_admin.isdigit():
self.send_msg(chat_id, "Неверный формат ID.", context=context)
return
try:
# BOTMOD: см. выше — access вместо скрипта.
cfg_changed, _rights_changed = _access().remove_admin_id(rem_admin)
output = (f"Администратор {rem_admin} удалён."
+ (" (требуется перезапуск)" if cfg_changed else ""))
self.send_msg(chat_id, f"Результат удаления администратора:\n{output}", context=context)
notify_msg = (
f"Внимание!\nАдминистратор {rem_admin} удалён пользователем "
f"{user.username} ({user.id}).\nДля вступления изменений перезапустите бота."
)
for admin in _admin_ids():
if admin != user.id:
self.dispatcher.bot.send_message(chat_id=admin, text=notify_msg)
except Exception as e:
logger.error(f"Ошибка при удалении администратора: {e}")
self.send_msg(chat_id, "Ошибка при удалении администратора.", context=context)
# --- Xiaomi miIO ---
def send_msg(self, chat_id, text, reply_markup=None, context=None, auto_delete=True, parse_mode=None):
if chat_id in self.last_bot_message:
try:
self.dispatcher.bot.delete_message(chat_id, self.last_bot_message[chat_id])
except Exception as e:
if "Message to delete not found" in str(e):
logger.debug("Сообщение для удаления не найдено, пропускаем.")
else:
logger.error(f"Ошибка при удалении сообщения: {e}")
self.last_bot_message.pop(chat_id, None)
msg = self.dispatcher.bot.send_message(
chat_id=chat_id, text=text, reply_markup=reply_markup, parse_mode=parse_mode
)
self.last_bot_message[chat_id] = msg.message_id
if auto_delete and context is not None:
self.schedule_deletion(context, chat_id, msg.message_id)
return msg
def schedule_deletion(self, context, chat_id, message_id, delay=60):
try:
context.job_queue.run_once(
delete_message, delay, context={"chat_id": chat_id, "message_id": message_id}
)
except Exception as e:
logger.error(f"Ошибка планирования удаления: {e}")
def register_handlers(self):
self.dispatcher.add_handler(CommandHandler("start", self.start))
self.dispatcher.add_handler(CommandHandler("id", self.get_my_id))
self.dispatcher.add_handler(CallbackQueryHandler(self.button))
self.dispatcher.add_handler(MessageHandler(Filters.text & ~Filters.command, self.handle_text))
self.set_commands()
def set_commands(self, user_id=None):
"""Устанавливает команды бота.
Глобально (user_id is None) — только базовые /start и /id, чтобы
пользователи без прав не видели привилегированные команды.
Для конкретного user_id — персональный набор через BotCommandScopeChat
с учётом прав (ai_chat, opencode_chat, bot_status и т.д.).
"""
# BOTMOD: глобальный список команд публикует transport_telegram
# (динамически из TG_COMMANDS модулей) — движок его не затирает.
# Персональный scope по правам — как в старом боте.
if user_id is None:
return
try:
commands = [
BotCommand("start", "Запуск меню"),
BotCommand("id", "Получить свой ID"),
BotCommand("groupid", "Узнать ID группы"),
]
if self.get_admin_right(user_id, "ai_chat"):
commands.append(BotCommand("ai", "Задать вопрос ИИ (без контекста)"))
commands.append(BotCommand("aiclose", "Завершить разговор с ИИ"))
commands.append(BotCommand("aimodel", "Выбор модели AI-чата"))
commands.append(BotCommand("aithink", "Переключить thinking"))
if self.get_admin_right(user_id, "opencode_chat"):
commands.append(BotCommand("oc", "Задать вопрос OpenCode (MCP)"))
commands.append(BotCommand("ocreset", "Завершить разговор с OpenCode"))
commands.append(BotCommand("model", "Выбор модели OpenCode"))
commands.append(BotCommand("octhink", "Переключить thinking OC"))
if self.get_admin_right(user_id, "schedule_manager"):
commands.append(BotCommand("ocsched", "Отложенные задачи"))
if self.get_admin_right(user_id, "bot_status"):
commands.append(BotCommand("status", "Статус бота"))
if self.get_admin_right(user_id, "group_manage"):
commands.append(BotCommand("groupadd", "Добавить группу"))
commands.append(BotCommand("groupdel", "Удалить группу"))
commands.append(BotCommand("grouptoggle", "Вкл/выкл группу"))
# groupid уже публично выше
self.dispatcher.bot.set_my_commands(commands, scope=BotCommandScopeChat(user_id))
except Exception as e:
logger.error(f"Ошибка при установке команд: {e}")
def is_admin(self, user_id):
try:
return _access().is_admin(user_id)
except Exception:
return False
def show_main_menu(self, chat_id, user_id, context=None):
try:
provider = getattr(self, "tgmenu_provider", None)
spec = provider(user_id) if callable(provider) else None
except Exception: # noqa: BLE001
spec = None
keyboard = []
if spec is not None:
# Динамическое меню: кнопки загруженных модулей (порядок/ширина/
# hidden — data/tg_menu.json, права уже отфильтрованы провайдером).
# Ширина — доли строки, ряд добирается жадно (auto = половина).
slots = {"1/4": 0.25, "2/4": 0.5, "3/4": 0.75,
"1/3": 1.0 / 3.0, "2/3": 2.0 / 3.0}
row, fill = [], 0.0
for b in spec:
btn = InlineKeyboardButton(
b.get("title") or b.get("id"), callback_data=b.get("callback"))
w = b.get("width") or "auto"
if w == "full":
if row:
keyboard.append(row)
row, fill = [], 0.0
keyboard.append([btn])
continue
need = slots.get(w, 0.5)
if row and fill + need > 1.0 + 1e-9:
keyboard.append(row)
row, fill = [], 0.0
row.append(btn)
fill += need
if row:
keyboard.append(row)
reply_markup = InlineKeyboardMarkup(keyboard)
self.send_msg(chat_id, "Главное меню:", reply_markup=reply_markup, context=context)
def start(self, update, context):
if update.message:
try:
context.bot.delete_message(chat_id=update.message.chat.id,
message_id=update.message.message_id)
except Exception as e:
logger.error(f"Ошибка удаления команды /start: {e}")
msg = update.message if update.message is not None else update.callback_query.message
user = update.effective_user
user_id = user.id
self.set_commands(user_id=user_id)
if not self.is_admin(user_id):
keyboard = [[InlineKeyboardButton("Получить мой ID", callback_data="my_id")]]
reply_markup = InlineKeyboardMarkup(keyboard)
self.send_msg(msg.chat.id,
"Доступ к основному меню ограничен.\nНо вы можете узнать свой ID:",
reply_markup=reply_markup, context=context)
return
self.show_main_menu(msg.chat.id, user_id, context)
def get_my_id(self, update, context):
if update.message:
try:
context.bot.delete_message(chat_id=update.message.chat.id,
message_id=update.message.message_id)
except Exception as e:
logger.error(f"Ошибка удаления команды /id: {e}")
user = update.effective_user
self.send_msg(
update.effective_chat.id,
f"Ваш ID: {user.id}",
context=context,
auto_delete=True
)
def shutdown_host(self, chat_id, context, user_id):
if not self.get_admin_right(user_id, "shutdown_host"):
self.send_msg(chat_id, "🚫 Доступ запрещён!", context=context)
return
try:
subprocess.run(
["sudo", "-S", "shutdown", "-h", "now"],
input=self.sudo_password + "\n",
text=True,
capture_output=True,
check=True
)
self.send_msg(chat_id, "Хост выключается.", context=context)
except Exception as e:
logger.error(f"Ошибка при выключении хоста: {e}")
self.send_msg(chat_id, "Ошибка при выключении хоста.", context=context)
def start_console_session(self, chat_id, context, user_id):
if not self.get_admin_right(user_id, "console_session"):
self.send_msg(chat_id, "🚫 Доступ запрещён!", context=context)
return
if user_id in self.console_sessions:
self.send_msg(chat_id, "Консоль уже запущена. Для завершения введите #quitconsole.", context=context)
return
try:
child = pexpect.spawn("/bin/bash", encoding="utf-8", echo=False, timeout=5)
child.sendline("export PS1='[PEXPECT]>'")
child.expect_exact("[PEXPECT]>")
self.console_sessions[user_id] = child
log_console(user_id, "СЕССИЯ ЗАПУЩЕНА")
self.send_msg(
chat_id,
"Консоль запущена. Введите команды. Для завершения сессии введите #quitconsole.",
context=context
)
except Exception as e:
logger.error(f"Ошибка при запуске консоли: {e}")
self.send_msg(chat_id, "Ошибка при запуске консоли.", context=context)
def process_console_command(self, chat_id, context, user_id, command):
child = self.console_sessions.get(user_id)
if not child:
self.send_msg(chat_id, "Консольная сессия не активна.", context=context)
return
if command.strip() == "#quitconsole":
try:
child.sendline("exit")
child.expect(pexpect.EOF)
except Exception:
pass
del self.console_sessions[user_id]
log_console(user_id, "СЕССИЯ ЗАВЕРШЕНА")
self.send_msg(chat_id, "Консоль завершена.", context=context)
return
try:
flush_buffer(child)
marker = "__END_OF_CMD__"
composite_command = f"{command}; echo {marker}"
child.sendline(composite_command)
output = ""
while True:
index = child.expect([marker, r"Are you sure you want to continue connecting", pexpect.EOF, pexpect.TIMEOUT], timeout=10)
if index == 0:
output += child.before
break
elif index == 1:
output += child.before
child.sendline("yes")
elif index in (2, 3):
output += child.before
break
if output.startswith(command):
output = output[len(command):].strip()
if not output:
output = "Команда выполнена, без вывода."
log_console(user_id, f"$ {command}\n{output}")
self.send_msg(chat_id, f"Вывод команды:\n{output}", context=context)
except Exception as e:
logger.error(f"Ошибка выполнения команды в консоли: {e}")
self.send_msg(chat_id, "Ошибка выполнения команды в консоли.", context=context)
def show_admin_menu(self, chat_id, context, user_id):
if not self.get_admin_right(user_id, "manage_admins"):
self.send_msg(chat_id, "🚫 Доступ запрещён!", context=context)
return
keyboard = []
row = []
row.append(InlineKeyboardButton("➕ Добавить", callback_data="add_admin"))
row.append(InlineKeyboardButton("❌ Удалить", callback_data="remove_admin"))
keyboard.append(row)
keyboard.append([InlineKeyboardButton("🔙 Назад", callback_data="main_menu")])
reply_markup = InlineKeyboardMarkup(keyboard)
self.send_msg(chat_id, "👮♂️ Меню администрирования:", reply_markup=reply_markup, context=context)
def show_bot_menu(self, chat_id, user_id, context):
# Проверяем наличие прав для управления ботом
if not (self.get_admin_right(user_id, "restart_bot") or
self.get_admin_right(user_id, "restart_machine") or
self.get_admin_right(user_id, "shutdown_host") or
self.get_admin_right(user_id, "view_user_messages")):
self.send_msg(chat_id, "🚫 Доступ запрещён!", context=context)
return
keyboard = []
row = []
if self.get_admin_right(user_id, "restart_bot"):
row.append(InlineKeyboardButton("🤖 Перезапуск бота", callback_data="restart_bot"))
if self.get_admin_right(user_id, "restart_machine"):
row.append(InlineKeyboardButton("🔄 Перезагрузка", callback_data="restart_machine"))
if row:
keyboard.append(row)
row = []
if self.get_admin_right(user_id, "shutdown_host"):
row.append(InlineKeyboardButton("⏻ Выключить хост", callback_data="shutdown_host"))
if self.get_admin_right(user_id, "view_user_messages"):
row.append(InlineKeyboardButton("📩 Получить последние сообщения", callback_data="get_latest_messages"))
if row:
keyboard.append(row)
keyboard.append([InlineKeyboardButton("🔙 Назад", callback_data="main_menu")])
reply_markup = InlineKeyboardMarkup(keyboard)
self.send_msg(chat_id, "🤖 Меню управления ботом:", reply_markup=reply_markup, context=context)
def _scripts_dir(self):
"""Каталог пользовательских скриптов: <cwd>/scripts, fallback —
каталог скриптов модуля services (BOTMOD)."""
d = os.path.join(os.getcwd(), "scripts")
if not os.path.isdir(d):
d = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "services", "scripts")
return d
def show_script_menu(self, chat_id, context, user_id):
if not self.get_admin_right(user_id, "run_scripts"):
self.send_msg(chat_id, "🚫 Доступ запрещён!", context=context)
return
scripts_dir = self._scripts_dir()
try:
files = os.listdir(scripts_dir)
except Exception as e:
self.send_msg(chat_id, "Ошибка доступа к каталогу скриптов.", context=context)
return
script_files = [f for f in files if f.endswith(".sh")]
if not script_files:
self.send_msg(chat_id, "Нет доступных скриптов.", context=context)
return
keyboard = []
row = []
for script in sorted(script_files):
row.append(InlineKeyboardButton(f"📄 {script}", callback_data=f"script_menu:{script}"))
if len(row) == 2:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
keyboard.append([InlineKeyboardButton("🔙 Назад", callback_data="main_menu")])
reply_markup = InlineKeyboardMarkup(keyboard)
self.send_msg(chat_id, "📜 Выберите скрипт для выполнения:", reply_markup=reply_markup, context=context)
def run_script_common(self, chat_id, context, folder, script_name, required_right, params=None, user_id=None):
if not self.get_admin_right(user_id, required_right):
self.send_msg(chat_id, "🚫 Доступ запрещён!", context=context)
return
# BOTMOD: скрипты — вендор пакета; относительный folder резолвится
# от директории пакета, а не от CWD процесса.
if os.path.isabs(folder):
script_path = os.path.join(folder, script_name)
else:
script_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), folder, script_name)
if not os.path.exists(script_path):
self.send_msg(chat_id, f"❌ Скрипт {script_name} не найден.", context=context)
return
cmd_list = ["sudo", "-S", "bash", script_path]
if params:
dangerous_pattern = re.compile(r"[;&|`$()]")
param_list = params.split()
for param in param_list:
if dangerous_pattern.search(param):
self.send_msg(chat_id, f"🚫 Недопустимый параметр: {param}", context=context)
return
cmd_list.extend(param_list)
try:
# Не поднимаем исключение при ненулевом коде возврата — проверяем вручную
result = subprocess.run(
cmd_list,
input=self.sudo_password + "\n",
text=True,
capture_output=True
)
if result.returncode != 0:
raw_help_text = result.stdout.strip() or result.stderr.strip() or f"Скрипт {script_name} завершился с ошибкой."
help_text = escape_markdown(raw_help_text, version=2)
self.send_msg(
chat_id,
f"⚠️ Ошибка выполнения скрипта {escape_markdown(script_name, version=2)}:\n```\n{help_text}\n```",
context=context,
parse_mode="MarkdownV2"
)
else:
output = result.stdout.strip() or f"Скрипт {script_name} выполнен без вывода."
output_escaped = escape_markdown(output, version=2)
self.send_msg(
chat_id,
f"📄 Вывод скрипта {escape_markdown(script_name, version=2)}:\n```\n{output_escaped}\n```",
context=context,
parse_mode="MarkdownV2"
)
except Exception as e:
logger.error(f"Ошибка run_script_common для {script_name}: {e}")
err_text = escape_markdown(str(e), version=2)
self.send_msg(
chat_id,
f"⚠️ Ошибка при выполнении скрипта {escape_markdown(script_name, version=2)}:\n```\n{err_text}\n```",
context=context,
parse_mode="MarkdownV2"
)
def show_notifications_menu(self, chat_id, context, user_id):
# Доступ к меню уведомлений только при наличии общего права receive_notifications
if not self.get_admin_right(user_id, "receive_notifications"):
self.send_msg(chat_id, "У вас нет общего права receive_notifications, поэтому меню уведомлений недоступно.", context=context)
return
# Кнопка для переключения отправки уведомлений без звука
no_sound_status = "✅" if self.get_admin_right(user_id, "receive_notifications_no_sound") else "❌"
keyboard = [[InlineKeyboardButton(f"Отправлять без звука {no_sound_status}", callback_data="toggle_no_sound_notification")]]
# Добавляем кнопки для подчинённых уведомлений, если у администратора есть соответствующие _edit права
subordinate_buttons = []
if self.get_admin_right(user_id, "receive_notifications_nmap_edit"):
stat = "✅" if self.get_admin_right(user_id, "receive_notifications_nmap") else "❌"
subordinate_buttons.append(InlineKeyboardButton(f"Nmap {stat}", callback_data="toggle_nmap_notification"))
if self.get_admin_right(user_id, "receive_notifications_guacamole_edit"):
stat = "✅" if self.get_admin_right(user_id, "receive_notifications_guacamole") else "❌"
subordinate_buttons.append(InlineKeyboardButton(f"Guacamole {stat}", callback_data="toggle_guacamole_notification"))
if self.get_admin_right(user_id, "receive_notifications_f2b_edit"):
stat = "✅" if self.get_admin_right(user_id, "receive_notifications_f2b") else "❌"
subordinate_buttons.append(InlineKeyboardButton(f"Fail2Ban {stat}", callback_data="toggle_f2b_notification"))
if self.get_admin_right(user_id, "receive_notifications_wireguard_edit"):
stat = "✅" if self.get_admin_right(user_id, "receive_notifications_wireguard") else "❌"
subordinate_buttons.append(InlineKeyboardButton(f"Wireguard {stat}", callback_data="toggle_wireguard_notification"))
if self.get_admin_right(user_id, "receive_notifications_status_edit"):
stat = "✅" if self.get_admin_right(user_id, "receive_notifications_status") else "❌"
subordinate_buttons.append(InlineKeyboardButton(f"Status {stat}", callback_data="toggle_status_notification"))
if self.get_admin_right(user_id, "receive_notifications_new_user_edit"):
stat = "✅" if self.get_admin_right(user_id, "receive_notifications_new_user") else "❌"
subordinate_buttons.append(InlineKeyboardButton(f"Новые пользователи {stat}", callback_data="toggle_new_user_notification"))
if self.get_admin_right(user_id, "receive_notifications_zabbix_edit"):
stat = "✅" if self.get_admin_right(user_id, "receive_notifications_zabbix") else "❌"
subordinate_buttons.append(InlineKeyboardButton(f"Zabbix {stat}", callback_data="toggle_zabbix_notification"))
# Разбиваем кнопки по 2 в строке
if subordinate_buttons:
rows = [subordinate_buttons[i:i+2] for i in range(0, len(subordinate_buttons), 2)]
keyboard.extend(rows)
else:
keyboard.append([InlineKeyboardButton("Нет дополнительных уведомлений", callback_data="noop")])
keyboard.append([InlineKeyboardButton("🔙 Назад", callback_data="main_menu")])
reply_markup = InlineKeyboardMarkup(keyboard)
self.send_msg(chat_id, "Меню уведомлений бота:", reply_markup=reply_markup, context=context)
def show_latest_messages(self, chat_id, context, user_id):
if not self.get_admin_right(user_id, "view_user_messages"):
self.send_msg(chat_id, "🚫 Доступ запрещён!", context=context)
return
cache = load_user_message_cache()
messages = []
for msg in cache.get("messages", []):
dt_str = msg["time"][:19]
messages.append(f"{dt_str} - ID: {msg['user_id']} - {msg['name']}: {msg['text']}")
result = "\n".join(messages)
while result and len(result) > 4096 and messages:
messages.pop(0)
result = "\n".join(messages)
if not result:
result = "Нет сообщений в кеше."
self.send_msg(chat_id, result, context=context)
def button(self, update, context):
query = update.callback_query
user = query.from_user
user_id = user.id
self.log_user_message(user, f"[Callback] {query.data}", context)
logger.info("AdminMenu: User %s (ID: %s) нажал кнопку с данными: %s", user.username, user_id, query.data)
if not self.is_admin(user_id) and not query.data.startswith("my_id"):
query.answer()
query.edit_message_text("🚫 Доступ запрещён!")
return
query.answer()
data = query.data
chat_id = query.message.chat.id
if data == "main_menu":
self.show_main_menu(chat_id, user_id, context)
elif data == "admin_menu":
self.show_admin_menu(chat_id, context, user_id)
elif data == "add_admin":
context.chat_data["awaiting"] = "add_admin"
self.send_msg(chat_id, "Введите ID нового администратора:", context=context)
elif data == "remove_admin":
context.chat_data["awaiting"] = "remove_admin"
self.send_msg(chat_id, "Введите ID администратора для удаления:", context=context)
elif data == "bot_menu":
self.show_bot_menu(chat_id, user_id, context)
elif data == "restart_bot":
if not self.get_admin_right(user_id, "restart_bot"):
query.edit_message_text("🚫 Доступ запрещён!")
return
# Не редактируем уже удалённое сообщение – удаляем меню и отправляем результат новой строкой
try:
self.dispatcher.bot.delete_message(chat_id, query.message.message_id)
except Exception as e:
logger.error(f"Ошибка удаления меню: {e}")
unit = (getattr(config, "SYSTEMD_SERVICE", "") or "").strip()
if unit:
# A: рестарт через systemd (полный, как раньше; юнит из конфига).
try:
subprocess.run(
["sudo", "-S", "systemctl", "restart", unit],
input=self.sudo_password + "\n",
text=True,
capture_output=True,
check=True
)
self.send_msg(chat_id, "Бот перезапущен (systemd).", context=context)
except Exception as e:
logger.error(f"Ошибка при перезапуске бота: {e}")
self.send_msg(chat_id, "Ошибка перезапуска.", context=context)
else:
# B: самоперезапуск execv (без sudo/systemd; сначала ответ).
try:
from core import restart as _restart
self.send_msg(chat_id, "Перезапускаюсь…", context=context)
_restart.restart_self()
except Exception as e:
logger.error(f"Ошибка самоперезапуска: {e}")
self.send_msg(chat_id, "Ошибка перезапуска.", context=context)
elif data == "restart_machine":
if not self.get_admin_right(user_id, "restart_machine"):
query.edit_message_text("🚫 Доступ запрещён!")
return
try:
self.dispatcher.bot.delete_message(chat_id, query.message.message_id)
except Exception as e:
logger.error(f"Ошибка удаления меню: {e}")
try:
subprocess.run(
["sudo", "-S", "shutdown", "-r", "now"],
input=self.sudo_password + "\n",
text=True,
capture_output=True,
check=True
)
self.send_msg(chat_id, "Машина перезагружается.", context=context)
except Exception as e:
logger.error(f"Ошибка при перезагрузке машины: {e}")
self.send_msg(chat_id, "Ошибка при перезагрузке машины.", context=context)
elif data == "shutdown_host":
if not self.get_admin_right(user_id, "shutdown_host"):
self.send_msg(chat_id, "🚫 Доступ запрещён!", context=context)
return
try:
self.dispatcher.bot.delete_message(chat_id, query.message.message_id)
except Exception as e:
logger.error(f"Ошибка удаления меню: {e}")
self.shutdown_host(chat_id, context, user_id)
elif data == "script_menu":
self.show_script_menu(chat_id, context, user_id)
elif data.startswith("script_menu:"):
script_name = data.split(":", 1)[1]
keyboard = [
[InlineKeyboardButton("Без параметров", callback_data=f"run_script_noparams:{script_name}")],
[InlineKeyboardButton("С параметрами", callback_data=f"run_script_withparams:{script_name}")],
[InlineKeyboardButton("🔙 Назад", callback_data="script_menu")]
]
reply_markup = InlineKeyboardMarkup(keyboard)
query.edit_message_text(f"Выберите режим выполнения скрипта {script_name}:", reply_markup=reply_markup)
elif data.startswith("run_script_noparams:"):
script_name = data.split(":", 1)[1]
self.run_script_common(chat_id, context, self._scripts_dir(), script_name, "run_scripts", params="", user_id=user_id)
elif data.startswith("run_script_withparams:"):
script_name = data.split(":", 1)[1]
context.chat_data["awaiting"] = "script_withparams"
context.chat_data["script_name"] = script_name
self.send_msg(chat_id, f"Введите параметры для скрипта {script_name} (или оставьте поле пустым):", context=context)
elif data == "rights_menu":
self.show_admin_selection_menu(chat_id, context, user_id)
elif data.startswith("select_admin_rights:"):
target_id = data.split(":", 1)[1]
self.show_rights_menu_for_admin(chat_id, target_id, context, user_id)
elif data.startswith("notif_rights_menu:"):
target_id = data.split(":", 1)[1]
self.show_notifications_rights_menu(chat_id, target_id, context, user_id)
elif data.startswith("toggle_return:"):
target_id = data.split(":", 1)[1]
self.show_rights_menu_for_admin(chat_id, target_id, context, user_id)
elif data.startswith("toggle:"):
# Формат toggle:<uid>:<right_key>; uid содержит ':' ("telegram:123"),
# имя права — никогда. Разбираем с конца.
parts_ = data[len("toggle:"):].rsplit(":", 1)
if len(parts_) != 2 or not parts_[0] or not parts_[1]:
query.edit_message_text("Неверный формат команды.")
return
target_id, right_key = parts_[0], parts_[1]
if not self.get_admin_right(user_id, "manage_rights"):
query.edit_message_text("🚫 Доступ запрещён!")
return
new_status = self.toggle_admin_right(target_id, right_key)
# Если изменяется право с суффиксом _edit и новое значение True, устанавливаем соответствующее базовое право
if right_key.endswith("_edit") and new_status:
base_key = right_key[:-5]
rights = load_admin_rights()
rights[_norm_key(target_id)][base_key] = True
save_admin_rights(rights)
# Обновляем список команд для пользователя, у которого изменились права
target_num = _numeric_uid(target_id)
if right_key in ("ai_chat", "opencode_chat", "bot_status") and target_num:
self.set_commands(user_id=target_num)
# Отправляем уведомление администратору о смене рабочего значения (для включения и выключения)
try:
if target_num:
self.dispatcher.bot.send_message(
chat_id=target_num,
text=f"Ваше право '{right_key}' было переключено на {'✅' if new_status else '❌'}."
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении права '{right_key}' для администратора {target_id}: {e}")
query.answer(text=f"{right_key} переключено на {'✅' if new_status else '❌'} для ID {target_id}", show_alert=True)
# Если переключается право уведомлений, остаёмся в подменю уведомлений, иначе возвращаемся к основному меню прав
if right_key.startswith("receive_notifications_"):
self.show_notifications_rights_menu(chat_id, target_id, context, user_id)
else:
self.show_rights_menu_for_admin(chat_id, target_id, context, user_id)
# Обработка подменю уведомлений:
elif data == "bot_notifications":
self.show_notifications_menu(chat_id, context, user_id)
elif data == "toggle_no_sound_notification":
new_status = self.toggle_admin_right(user_id, "receive_notifications_no_sound")
self.dispatcher.bot.answer_callback_query(callback_query_id=query.id, text=f"Отправлять без звука переключено на {'✅' if new_status else '❌'}", show_alert=True)
self.show_notifications_menu(chat_id, context, user_id)
elif data == "toggle_nmap_notification":
new_status = self.toggle_admin_right(user_id, "receive_notifications_nmap")
self.dispatcher.bot.answer_callback_query(callback_query_id=query.id, text=f"Уведомления Nmap переключены на {'✅' if new_status else '❌'}", show_alert=True)
self.show_notifications_menu(chat_id, context, user_id)
elif data == "toggle_guacamole_notification":
new_status = self.toggle_admin_right(user_id, "receive_notifications_guacamole")
self.dispatcher.bot.answer_callback_query(callback_query_id=query.id, text=f"Уведомления Guacamole переключены на {'✅' if new_status else '❌'}", show_alert=True)
self.show_notifications_menu(chat_id, context, user_id)
elif data == "toggle_f2b_notification":
new_status = self.toggle_admin_right(user_id, "receive_notifications_f2b")
self.dispatcher.bot.answer_callback_query(callback_query_id=query.id, text=f"Уведомления F2B переключены на {'✅' if new_status else '❌'}", show_alert=True)
self.show_notifications_menu(chat_id, context, user_id)
elif data == "toggle_wireguard_notification":
new_status = self.toggle_admin_right(user_id, "receive_notifications_wireguard")
self.dispatcher.bot.answer_callback_query(callback_query_id=query.id, text=f"Уведомления Wireguard переключены на {'✅' if new_status else '❌'}", show_alert=True)
self.show_notifications_menu(chat_id, context, user_id)
elif data == "toggle_status_notification":
new_status = self.toggle_admin_right(user_id, "receive_notifications_status")
self.dispatcher.bot.answer_callback_query(callback_query_id=query.id, text=f"Уведомления Status переключены на {'✅' if new_status else '❌'}", show_alert=True)
self.show_notifications_menu(chat_id, context, user_id)
elif data == "toggle_new_user_notification":
new_status = self.toggle_admin_right(user_id, "receive_notifications_new_user")
self.dispatcher.bot.answer_callback_query(callback_query_id=query.id, text=f"Уведомления о новых пользователях переключены на {'✅' if new_status else '❌'}", show_alert=True)
self.show_notifications_menu(chat_id, context, user_id)
elif data == "toggle_zabbix_notification":
new_status = self.toggle_admin_right(user_id, "receive_notifications_zabbix")
self.dispatcher.bot.answer_callback_query(callback_query_id=query.id, text=f"Уведомления Zabbix переключены на {'✅' if new_status else '❌'}", show_alert=True)
self.show_notifications_menu(chat_id, context, user_id)
elif data == "get_latest_messages":
self.show_latest_messages(chat_id, context, user_id)
# service_* обслуживает модуль services (ui_tg, группа -1).
elif data == "noop":
query.answer()
elif data == "my_id":
query.edit_message_text(f"Ваш ID: {user_id}")
elif data == "console_session":
self.start_console_session(chat_id, context, user_id)
else:
query.edit_message_text("Неизвестная команда.")
def show_admin_selection_menu(self, chat_id, context, user_id):
if not self.get_admin_right(user_id, "manage_rights"):
self.send_msg(chat_id, "🚫 Доступ запрещён!", context=context)
return
rights = load_admin_rights()
admin_ids = set(_norm_key(x) for x in _admin_ids()) | set(rights.keys())
buttons = []
for aid in sorted(admin_ids):
buttons.append(InlineKeyboardButton(f"👤 ID: {aid}", callback_data=f"select_admin_rights:{aid}"))
rows = [buttons[i:i+2] for i in range(0, len(buttons), 2)]
rows.append([InlineKeyboardButton("🔙 Назад", callback_data="main_menu")])
reply_markup = InlineKeyboardMarkup(rows)
self.send_msg(chat_id, "🛠 Выберите администратора для управления правами:", reply_markup=reply_markup, context=context)
def show_rights_menu_for_admin(self, chat_id, target_id, context, user_id):
if not self.get_admin_right(user_id, "manage_rights"):
self.send_msg(chat_id, "🚫 Доступ запрещён!", context=context)
return
rights = load_admin_rights()
uid = _norm_key(target_id)
if uid not in rights:
rights[uid] = _access().guest_defaults()
save_admin_rights(rights)
# Подтянем новые ключи из реестра ядра (динамика)
changed = False
for k, v in _access().guest_defaults().items():
if k not in rights[uid]:
rights[uid][k] = v
changed = True
if changed:
save_admin_rights(rights)
user_rights = rights[uid]
text_lines = [f"🛠 Права для ID: {uid}"]
# Разбиваем ключи на общие и уведомлений (те, что начинаются с "receive_notifications_")
general_keys = [key for key in user_rights.keys() if not key.startswith("receive_notifications_") and not key.startswith("pve_manage_hosts")]
notif_keys = [key for key in user_rights.keys() if key.startswith("receive_notifications_")]
buttons = []
# Формируем кнопки для общих прав
for key in general_keys:
allowed = user_rights.get(key, False)
emoji = "✅" if allowed else "❌"
buttons.append(InlineKeyboardButton(f"{key.capitalize()} {emoji}", callback_data=f"toggle:{uid}:{key}"))
# Добавляем отдельную кнопку для перехода в подменю прав уведомлений
if notif_keys:
buttons.append(InlineKeyboardButton("🔔 Права уведомлений", callback_data=f"notif_rights_menu:{uid}"))
# Добавляем кнопку для прав PVE
buttons.append(InlineKeyboardButton("🏠 Права PVE", callback_data=f"pve_rights_menu:{uid}"))
rows = [buttons[i:i+2] for i in range(0, len(buttons), 2)]
rows.append([InlineKeyboardButton("🔙 Назад", callback_data="rights_menu"),
InlineKeyboardButton("🏠 Главное меню", callback_data="main_menu")])
reply_markup = InlineKeyboardMarkup(rows)
text = "\n".join(text_lines)
self.send_msg(chat_id, text, reply_markup=reply_markup, context=context)
def show_notifications_rights_menu(self, chat_id, target_id, context, user_id):
rights = load_admin_rights()
uid = _norm_key(target_id)
if uid not in rights:
rights[uid] = _access().guest_defaults()
save_admin_rights(rights)
# Подтянем новые ключи из реестра ядра (динамика)
changed = False
for k, v in _access().guest_defaults().items():
if k not in rights[uid]:
rights[uid][k] = v
changed = True
if changed:
save_admin_rights(rights)
user_rights = rights[uid]
text_lines = [f"🔔 Права уведомлений для ID: {uid}:"]
# Выбираем только ключи, начинающиеся с "receive_notifications_"
notif_keys = [key for key in user_rights.keys() if key.startswith("receive_notifications_")]
buttons = []
for key in notif_keys:
allowed = user_rights.get(key, False)
emoji = "✅" if allowed else "❌"
# Для наглядности убираем префикс в названии кнопки
display_name = key.replace("receive_notifications_", "").capitalize()
buttons.append(InlineKeyboardButton(f"{display_name} {emoji}", callback_data=f"toggle:{uid}:{key}"))
rows = [buttons[i:i+2] for i in range(0, len(buttons), 2)]
# Кнопка возврата в основное меню прав
rows.append([InlineKeyboardButton("🔙 Назад", callback_data=f"toggle_return:{uid}")])
reply_markup = InlineKeyboardMarkup(rows)
text = "\n".join(text_lines)
self.send_msg(chat_id, text, reply_markup=reply_markup, context=context)
def handle_text(self, update, context):
chat_id = update.message.chat.id
message_id = update.message.message_id
user = update.effective_user
user_id = user.id
text = update.message.text or ""
# Режим OpenCode (модуль opencode_chat, группа -2) имеет приоритет:
# если у пользователя активна сессия opencode — пропускаем обработку здесь.
if self._opencode_active(user_id):
raise DispatcherHandlerStop()
logger.info("AdminMenu: User %s (ID: %s) отправил текст в меню: %s", user.username, user_id, text)
# для групп сохраняем с chat_id
try:
context._group_log_chat = {"id": update.effective_chat.id, "type": update.effective_chat.type, "title": getattr(update.effective_chat, 'title', '')}
except Exception:
pass
self.log_user_message(user, update.message.text, context)
state = context.chat_data.get("awaiting")
if state:
context.chat_data.pop("awaiting", None)
if state == "add_admin":
self.process_add_admin(update, context, update.message.text)
elif state == "remove_admin":
self.process_remove_admin(update, context, update.message.text)
elif state == "script_withparams":
script_name = context.chat_data.pop("script_name", None)
self.run_script_common(chat_id, context, self._scripts_dir(), script_name, "run_scripts", params=update.message.text, user_id=user_id)
else:
self.send_msg(chat_id, "Неожиданный ввод.", context=context)
try:
context.bot.delete_message(chat_id, message_id)
except Exception as e:
logger.error(f"Ошибка удаления сообщения: {e}")
return
if user_id in self.console_sessions:
self.process_console_command(chat_id, context, user_id, update.message.text)
try:
context.bot.delete_message(chat_id, message_id)
except Exception as e:
logger.error(f"Ошибка удаления сообщения: {e}")
return
# В группах не спамим "Нераспознанная команда" — мешает групповому чату (реплаи/! триггеры)
if update.effective_chat.type != "private":
return
# Если ни одно из условий не выполнено, выводим сообщение об ошибке.
self.send_msg(chat_id, "Нераспознанная команда. Используйте меню через /start.", context=context)
try:
context.bot.delete_message(chat_id, message_id)
except Exception as e:
logger.error(f"Ошибка удаления сообщения: {e}")
def _opencode_active(self, user_id):
"""True, если у пользователя активна сессия opencode_chat."""
refs = getattr(self, "_module_refs", None)
if not isinstance(refs, dict):
return False
oc = refs.get("opencode_chat")
return bool(oc and getattr(oc, "is_active", None) and oc.is_active(user_id))
def error_handler(self, update, context):
if update and update.message:
user = update.message.from_user
logger.error(f"Ошибка от {user.username} ({user.id}): {context.error}")
module.py 5.2 КБ
"""admin.Module — вендор AdminMenu (TG-меню, права, скрипты, журнал, бэкапы).
Legacy-режим (как ai_chat): engine регистрирует PTB-хендлеры сам
(/start, /id, кнопки, текстовый журнал сообщений). Состояние —
data/: user_messages.json (журнал, ОТДЕЛЬНЫЙ от прод-бота: общий файл
дал бы гонку записей), console_sessions.log. Права — ОБЩИЙ
data/admin_rights.json (ядро перечитывает по mtime).
"""
import logging
import os
from core.base_module import BaseModule
from core.legacy import AdminRightsShim
from . import config as pkg_config
logger = logging.getLogger(__name__)
class Module(BaseModule):
def setup(self, ctx) -> None:
mod_dir = os.path.dirname(os.path.abspath(__file__))
self.config = ctx.module_config("admin", mod_dir)
self.ctx = ctx
net_dir = os.path.join(ctx.data_dir, "cache", "network")
def _f(key, default):
return (self.config.get(key, "") or "").strip() or default
def _fp(key, default):
from core.paths import abs_data
return abs_data(ctx.data_dir,
(self.config.get(key, "") or "").strip(),
default)
files = {
"ADMIN_RIGHTS_FILE": os.path.join(ctx.data_dir, "admin_rights.json"),
"CACHE_USER_MESSAGES": _fp(
"CACHE_USER_MESSAGES", os.path.join(ctx.data_dir, "user_messages.json")),
"CACHE_FILE_NETWORK": _fp(
"CACHE_FILE_NETWORK", os.path.join(net_dir, "networkscan_cache.json")),
"SCAN_NETWORK_IGNORED_MACS": _fp(
"SCAN_NETWORK_IGNORED_MACS", os.path.join(net_dir, "networkscan_ignored.json")),
"LOG_CONSOLE": _fp(
"LOG_CONSOLE", os.path.join(ctx.data_dir, "console_sessions.log")),
}
pkg_config.install(dict(self.config.values), files)
# Скрипты scripts_admin/ читают IDE/PASS из окружения процесса
# (старый бот получал их через EnvironmentFile юнита). Экспортируем
# только то, что нужно скриптам, без перезаписи уже заданного.
for key in ("REMOTE_PASSWORD_WIN", "REMOTE_PASSWORD_DOCKER",
"LOCAL_PASSWORD", "REMOTE_HOST_WIN", "REMOTE_USER_WIN",
"REMOTE_HOST_DOCKER", "REMOTE_USER_DOCKER"):
val = (self.config.get(key, "") or "").strip()
if val and not os.environ.get(key):
os.environ[key] = val
try:
from . import engine as engine_mod
except ImportError as e:
from core.errors import ConfigError
raise ConfigError("admin: движок требует venv (telegram/pexpect): %s" % e)
self.menu = engine_mod.AdminMenu(dispatcher=None)
from . import tgmenu as _tgmenu
_tglog = ctx.get_logger("admin")
def _menu_spec(user_id):
catalog = _tgmenu.collect_buttons(ctx.loaded_modules, _tglog)
layout = _tgmenu.load_layout(ctx.data_dir, catalog)
return _tgmenu.visible_buttons(
catalog, layout, self.menu.get_admin_right, user_id)
self.menu.tgmenu_provider = _menu_spec
self._shim = AdminRightsShim(ctx.rights)
ctx.api.register("admin", "menu", lambda: self.menu)
ctx.api.register(
"admin", "toggle_right",
lambda key, right, actor="": self._toggle_right(
key, right, actor))
ctx.api.register(
"admin", "journal",
lambda limit=200: self._journal(limit))
def _toggle_right(self, key, right, actor=""):
"""Переключить право key.right через RightsService (возврат bool)."""
import re as _re
if not _re.match(r"^(telegram|vk|web):\d+$", key or ""):
raise ValueError("Некорректный ключ пользователя")
value = self.ctx.rights.toggle_right(key, right, actor=actor)
# notify пользователю о смене его права (как в проде).
try:
self.ctx.api.call("notify.rights_changed", key, right, value)
except Exception: # noqa: BLE001
pass
return value
def _journal(self, limit=200):
"""Хвост журнала сообщений пользователей [{user_id,name,text,time,...}]."""
from . import engine as engine_mod
try:
cache = engine_mod.load_user_message_cache()
except Exception: # noqa: BLE001
return []
msgs = (cache or {}).get("messages") or []
try:
limit = max(1, min(int(limit), 2000))
except (ValueError, TypeError):
limit = 200
return msgs[-limit:]
def health(self) -> dict:
menu = getattr(self, "menu", None)
try:
n = len(getattr(menu, "console_sessions", {}) or {})
except Exception: # noqa: BLE001
n = -1
return {"ok": menu is not None, "module": self.name, "consoles": n}
tgmenu.py 6.9 КБ
"""admin.tgmenu — динамическое меню /start.
Модули заявляют кнопки верхнего уровня через TG_MENU в своём ui_tg:
TG_MENU = [{"id": "pve", "title": "🏠 PVE", "callback": "pve_menu",
"right": "pve_manage"}]
right: None (все) | одно право | "a;b;..." (ИЛИ). Раскладка data/tg_menu.json:
порядок, wide (своя строка), hidden. Правка — /api/admin/tgmenu (menu_edit).
Колбэки обслуживают сами модули либо legacy-движок admin (pve_menu и др.).
"""
import json
import logging
import os
logger = logging.getLogger(__name__)
LAYOUT_FILE = "tg_menu.json"
LAYOUT_MAX_BUTTONS = 100
# Доли строки в TG-меню. auto — поток по 2 в ряд (как раньше), full — своя строка.
LAYOUT_WIDTHS = ("auto", "1/4", "2/4", "3/4", "1/3", "2/3", "full")
def _valid_entry(entry) -> bool:
return (isinstance(entry, dict) and isinstance(entry.get("id"), str)
and entry["id"] and isinstance(entry.get("title"), str)
and isinstance(entry.get("callback"), str))
def collect_buttons(loaded_modules, log=None):
"""Каталог кнопок загруженных модулей (порядок каталога = порядок модулей)."""
out = []
seen = set()
import importlib as _il
for mod in loaded_modules or []:
try:
ui = _il.import_module("botmod_%s.ui_tg" % mod.name)
except Exception: # noqa: BLE001
continue
raw = getattr(ui, "TG_MENU", None)
if not raw:
continue
items = raw if isinstance(raw, list) else [raw]
for item in items:
if not _valid_entry(item):
(log.info if log else (lambda *a: None))(
"модуль %s: битая кнопка меню: %r", mod.name, item)
continue
bid = item["id"].strip()
if bid in seen:
(log.warning if log else (lambda *a: None))(
"модуль %s: кнопка %s — дубль id, пропущена", mod.name, bid)
continue
seen.add(bid)
out.append({
"id": bid,
"title": item["title"].strip()[:64],
"callback": item["callback"].strip()[:64],
"right": item.get("right"),
"module": mod.name,
})
return out
def _layout_path(data_dir):
return os.path.join(data_dir, LAYOUT_FILE)
def _valid_width(value) -> bool:
return value in LAYOUT_WIDTHS
def _entry_width(entry) -> str:
if isinstance(entry, dict):
w = entry.get("width") or ("full" if entry.get("wide") else "auto")
if _valid_width(w):
return w
return "auto"
def _entry_title(entry):
if isinstance(entry, dict):
t = entry.get("title")
if isinstance(t, str) and t.strip():
return t.strip()[:64]
return None
def load_layout(data_dir, catalog):
"""Раскладка, сведённая с каталогом: [{id, width, hidden, title}]."""
known = {b["id"] for b in catalog}
try:
with open(_layout_path(data_dir), "r", encoding="utf-8") as f:
saved = json.load(f) or {}
except Exception: # noqa: BLE001
saved = {}
order, flags, seen = [], {}, set()
for entry in (saved.get("buttons") or []):
if not isinstance(entry, dict):
continue
bid = entry.get("id")
if bid not in known or bid in seen:
continue
seen.add(bid)
flags[bid] = {"width": _entry_width(entry),
"hidden": bool(entry.get("hidden", False)),
"title": _entry_title(entry)}
order.append(bid)
for b in catalog:
if b["id"] not in seen:
flags[b["id"]] = {"width": "auto", "hidden": False, "title": None}
order.append(b["id"])
return [{"id": bid, **flags[bid]} for bid in order]
def save_layout(data_dir, catalog, entries):
"""Проверить и сохранить раскладку. Вернуть (ok, error)."""
known = {b["id"] for b in catalog}
if not isinstance(entries, list) or len(entries) > LAYOUT_MAX_BUTTONS:
return False, "buttons должен быть списком ≤%d" % LAYOUT_MAX_BUTTONS
seen, clean = set(), []
for entry in entries:
if not isinstance(entry, dict):
return False, "запись кнопки должна быть объектом"
bid = entry.get("id")
if bid not in known or bid in seen:
return False, "неизвестная или повторная кнопка: %r" % (bid,)
seen.add(bid)
raw_width = entry.get("width") if isinstance(entry, dict) else None
if raw_width is None and isinstance(entry, dict) and entry.get("wide"):
raw_width = "full"
width = raw_width or "auto"
if not _valid_width(width):
return False, "некорректная ширина кнопки %r" % (bid,)
raw_title = entry.get("title") if isinstance(entry, dict) else None
if raw_title is not None and (
not isinstance(raw_title, str) or not raw_title.strip()
or len(raw_title.strip()) > 64):
return False, "некорректное название кнопки %r (1..64 символа)" % (bid,)
clean.append({"id": bid, "width": width,
"hidden": bool(entry.get("hidden", False)),
"title": raw_title.strip()[:64] if isinstance(raw_title, str) and raw_title.strip() else None})
try:
tmp = _layout_path(data_dir) + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"buttons": clean}, f, ensure_ascii=False, indent=2)
os.replace(tmp, _layout_path(data_dir))
except Exception as e: # noqa: BLE001
return False, "ошибка записи: %s" % e
return True, None
def has_any_right(check, user_id, right) -> bool:
"""Право кнопки: None — все; 'a;b' — ИЛИ."""
if not right:
return True
for name in str(right).split(";"):
name = name.strip()
if name and check(user_id, name):
return True
return False
def visible_buttons(catalog, layout, check, user_id):
"""Кнопки для пользователя: порядок+wide из layout, фильтр прав."""
by_id = {b["id"]: b for b in catalog}
out = []
for entry in layout:
btn = by_id.get(entry.get("id"))
if btn is None or entry.get("hidden"):
continue
if not has_any_right(check, user_id, btn.get("right")):
continue
btn = {**btn, "width": _entry_width(entry)}
title = _entry_title(entry)
if title:
btn["title"] = title
out.append(btn)
return out
ui_tg.py 1.4 КБ
"""admin.ui_tg — LEGACY: AdminMenu регистрирует PTB-хендлеры сам.
Здесь только декларации для /menu и прав + хук register_ptb.
Текстовый хендлер движка (журнал сообщений) — внутри engine.
"""
TG_COMMANDS = {
"start": {"desc": "Главное меню", "right": None, "menu": True},
"id": {"desc": "Получить свой ID", "right": None, "menu": True},
}
# Кнопки /start от admin (колбэки обслуживает engine ниже).
TG_MENU = [
{"id": "admin_main", "title": "👮 Администрирование", "callback": "admin_menu",
"right": "manage_admins"},
{"id": "bot_ctrl", "title": "🤖 Управление ботом", "callback": "bot_menu",
"right": "restart_bot;restart_machine;shutdown_host;view_user_messages"},
{"id": "scripts", "title": "📜 Скрипты", "callback": "script_menu",
"right": "run_scripts"},
{"id": "notify_setup", "title": "🔔 Уведомления", "callback": "bot_notifications",
"right": "receive_notifications"},
{"id": "my_id", "title": "🆔 Мой ID", "callback": "my_id",
"right": None},
]
def register_ptb(facade, mod):
"""Legacy-хук: движок регистрируется на фасаде напрямую."""
mod.menu.dispatcher = facade
mod.menu.register_handlers()
ui_web.py 17.2 КБ
"""admin.ui_web — веб-панель администрирования.
Порт AdminServices/ServiceAction, AdminAdmins, AdminBackups/Restore/Delete,
AdminSystem, AdminF2B, AdminScripts, AdminMessages, GroupAdmin/GroupMessages
из старого messengers/web.py. Скрипты и пути — из .config пакета
(вендор scripts_admin/, ADMIN_PASS). Группы — через ctx.api ai_chat
(файлы и модели — владелец ai_chat). Журнал — через ctx.api admin.journal.
"""
ROUTES = [
("POST", "/api/admin/admins", "admins", {"right": "manage_admins",
"desc": "Назначить/снять TG-админа {action: add|remove, id}"}),
("POST", "/api/admin/system", "system", {"right": None,
"desc": "Перезапуск/выключение {action: restart_bot|restart_machine|shutdown} (права по действию)"}),
("GET", "/api/admin/scripts", "scripts", {"right": "run_scripts",
"desc": "Список scripts/*.sh"}),
("POST", "/api/admin/scripts", "scripts", {"right": "run_scripts",
"desc": "Запуск скрипта {script, params?}"}),
("GET", "/api/admin/scripts/content", "script_content", {"right": "run_scripts",
"desc": "Текст скрипта (?name)"}),
("POST", "/api/admin/scripts/save", "script_save", {"right": "run_scripts",
"desc": "Создать/сохранить скрипт {name, content} (bash -n проверка)"}),
("POST", "/api/admin/scripts/delete", "script_delete", {"right": "run_scripts",
"desc": "Удалить скрипт {name}"}),
("GET", "/api/admin/messages", "messages", {"right": "view_user_messages",
"desc": "Журнал сообщений пользователей (limit/offset/q/user_id/sort/filter_group)"}),
("GET", "/api/admin/tgmenu", "tgmenu", {"right": "menu_edit",
"desc": "Каталог кнопок /start + раскладка (для редактора)"}),
("POST", "/api/admin/tgmenu", "tgmenu", {"right": "menu_edit",
"desc": "Сохранить раскладку /start {buttons: [{id, wide, hidden}]}"}),
]
SERVICE = [
{"key": "system", "icon": "⚙️", "title": "Система", "right": "restart_bot",
"phase": 3, "desc": "Перезапуск бота и хоста (бэкапы — карточка «Бэкапы»)"},
{"key": "run_scripts", "icon": "▶️", "title": "Скрипты", "right": "run_scripts",
"phase": 3, "desc": "Запуск скриптов обслуживания"},
{"key": "view_user_messages", "icon": "📨", "title": "Сообщения пользователей",
"right": "view_user_messages", "phase": 3,
"desc": "Журнал сообщений пользователей бота (включая группы)"},
{"key": "tgmenu_edit", "icon": "🗂️", "title": "Меню /start", "right": "menu_edit",
"phase": 2, "desc": "Раскладка кнопок Telegram-меню: порядок, ширина, скрытие"},
]
PANELS = []
def _check(ctx, sess, right) -> bool:
try:
return bool(ctx.api.call("transport_web.check", sess, right))
except Exception: # noqa: BLE001
return False
def _pkg():
from . import config as pkg_config
return pkg_config
def _sudo_run(cmd, pwd, timeout=120):
import subprocess as _sp
r = _sp.run(["sudo", "-S", "-p", "", *cmd], input=(pwd or "") + "\n",
text=True, capture_output=True, timeout=timeout)
return r
def handle_api(ctx, config, method, req):
import json as _json
import os as _os
import re as _re
import subprocess as _sp
import time as _time
from core.errors import UserError
pkg = _pkg()
sess = req.get("session") or {}
uid = sess.get("uid")
if method == "admins":
body = req.get("body") or {}
action = (body.get("action") or "").strip()
tid = str(body.get("id") or "").strip()
if action not in ("add", "remove"):
return 400, {"ok": False, "error": "action должен быть add/remove"}
if not tid.isdigit():
return 400, {"ok": False, "error": "ID должен быть числом"}
# Владелец списка — access (единственный ADMIN_IDS).
try:
if action == "add":
changed, msg = ctx.api.call("access.add_admin", tid)
return {"ok": True, "changed": changed, "msg": msg}
try:
cfg_changed, rights_changed = ctx.api.call("access.remove_admin", tid)
except LookupError as e:
return 400, {"ok": False, "error": str(e)}
return {"ok": True, "cfg_changed": cfg_changed,
"rights_changed": rights_changed,
"msg": f"Администратор {tid} удалён"
+ (" (требуется перезапуск)" if cfg_changed else "")}
except (ValueError, RuntimeError) as e:
return 400, {"ok": False, "error": str(e)}
except Exception as e: # noqa: BLE001
return 500, {"ok": False, "error": str(e)[:400]}
if method == "system":
if uid is None:
return 401, {"ok": False, "error": "Требуется вход"}
body = req.get("body") or {}
action = (body.get("action") or "").strip()
need = {"restart_bot": "restart_bot", "restart_machine": "restart_machine",
"shutdown": "shutdown_host"}.get(action)
if not need:
return 400, {"ok": False, "error": "Недопустимое действие"}
if not _check(ctx, sess, need):
return 403, {"ok": False, "error": f"Нет права {need}"}
pwd = getattr(pkg, "ADMIN_PASS", "") or ""
try:
if action == "restart_bot":
try:
unit = (config.get("SYSTEMD_SERVICE", "") or "").strip() \
if config else ""
except Exception: # noqa: BLE001
unit = ""
mode = (body.get("mode") or "auto").strip()
if mode == "self" or (mode == "auto" and not unit):
# B: самоперезапуск execv (без sudo/systemd; ответ уйдёт
# до рестарта благодаря задержке).
from core import restart as _restart
try:
_restart.restart_self()
except RuntimeError as e:
return 400, {"ok": False, "error": str(e)}
return {"ok": True, "msg": "Перезапускаюсь (execv)"}
if not unit:
return 400, {"ok": False,
"error": "Служба не настроена (SYSTEMD_SERVICE пуст)"}
r = _sp.run(["sudo", "-S", "-p", "", "systemctl", "restart", unit],
input=pwd + "\n", text=True, capture_output=True, timeout=30)
if r.returncode != 0:
return 500, {"ok": False,
"error": ((r.stderr or r.stdout).strip()
or f"exit {r.returncode}")[:400]}
return {"ok": True, "msg": "Бот перезапущен"}
if action == "restart_machine":
_sp.Popen(["sudo", "-S", "-p", "", "shutdown", "-r", "now"],
stdin=_sp.PIPE, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,
text=True).communicate(input=pwd + "\n", timeout=5)
return {"ok": True, "msg": "Хост перезагружается"}
_sp.Popen(["sudo", "-S", "-p", "", "shutdown", "-h", "now"],
stdin=_sp.PIPE, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,
text=True).communicate(input=pwd + "\n", timeout=5)
return {"ok": True, "msg": "Хост выключается"}
except Exception as e: # noqa: BLE001
return 500, {"ok": False, "error": str(e)[:400]}
def _scripts_dir():
# Канонический каталог пользовательских скриптов — services/scripts.
return _os.path.join(_os.path.dirname(_os.path.abspath(__file__)),
"..", "services", "scripts")
if method == "scripts":
sdir = _scripts_dir()
if req.get("http_method") == "GET":
try:
files = _os.listdir(sdir)
except Exception: # noqa: BLE001
return {"scripts": []}
out = []
for f in sorted(files):
if not f.endswith(".sh"):
continue
path = _os.path.join(sdir, f)
try:
st = _os.stat(path)
out.append({"name": f, "size": st.st_size,
"mtime": _time.strftime("%Y-%m-%d %H:%M:%S",
_time.localtime(st.st_mtime))})
except Exception: # noqa: BLE001
out.append({"name": f})
return {"scripts": out}
body = req.get("body") or {}
name = (body.get("script") or body.get("name") or "").strip()
params = (body.get("params") or "").strip()
if not _re.match(r"^[A-Za-z0-9_.\-]+\.sh$", name):
return 400, {"ok": False, "error": "Некорректное имя скрипта"}
if params and _re.search(r"[;&|`$()]", params):
return 400, {"ok": False, "error": "Недопустимые символы в параметрах"}
path = _os.path.join(sdir, name)
if not _os.path.isfile(path):
return 400, {"ok": False, "error": "Скрипт не найден"}
pwd = getattr(pkg, "ADMIN_PASS", "") or ""
cmd = ["bash", path]
if params:
cmd += params.split()
try:
r = _sudo_run(cmd, pwd, timeout=120)
except Exception as e: # noqa: BLE001
return 500, {"ok": False, "error": str(e)[:2000]}
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
if r.returncode != 0:
return 500, {"ok": False, "error": (out.strip() or f"exit {r.returncode}")[:2000]}
return {"ok": True, "output": (out.strip() or "OK")[:4000]}
def _script_name(raw):
name = (raw or "").strip()
if not _re.match(r"^[A-Za-z0-9_.\-]+\.sh$", name):
return ""
return name
if method == "script_content":
name = _script_name((req.get("query") or {}).get("name"))
if not name:
return 400, {"ok": False, "error": "Некорректное имя скрипта"}
path = _os.path.join(_scripts_dir(), name)
if not _os.path.isfile(path):
return 400, {"ok": False, "error": "Скрипт не найден"}
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
return 500, {"ok": False, "error": str(e)[:200]}
if len(content) > 262144:
return 400, {"ok": False, "error": "Скрипт больше 256 КБ"}
return {"ok": True, "name": name, "content": content}
if method == "script_save":
body = req.get("body") or {}
name = _script_name(body.get("name"))
content = body.get("content") or ""
if not name:
return 400, {"ok": False, "error": "Некорректное имя скрипта (латиница, .sh)"}
if not isinstance(content, str) or not content.strip():
return 400, {"ok": False, "error": "Пустое содержимое"}
if len(content) > 262144:
return 400, {"ok": False, "error": "Скрипт больше 256 КБ"}
if not content.lstrip().startswith("#!"):
content = "#!/usr/bin/env bash\n" + content
path = _os.path.join(_scripts_dir(), name)
tmp = path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
f.write(content if content.endswith("\n") else content + "\n")
# синтаксис — до замены боевого файла
r = _sp.run(["bash", "-n", tmp], text=True,
capture_output=True, timeout=15)
if r.returncode != 0:
_os.remove(tmp)
return 400, {"ok": False,
"error": "Синтаксис: %s" % ((r.stderr or "").strip()[:2000])}
_os.replace(tmp, path)
_os.chmod(path, 0o755)
except OSError as e:
try:
_os.remove(tmp)
except OSError:
pass
return 500, {"ok": False, "error": str(e)[:200]}
return {"ok": True, "name": name, "size": len(content)}
if method == "script_delete":
body = req.get("body") or {}
name = _script_name(body.get("name"))
if not name:
return 400, {"ok": False, "error": "Некорректное имя скрипта"}
path = _os.path.join(_scripts_dir(), name)
if not _os.path.isfile(path):
return 400, {"ok": False, "error": "Скрипт не найден"}
try:
_os.remove(path)
except OSError as e:
return 500, {"ok": False, "error": str(e)[:200]}
return {"ok": True, "name": name}
if method == "messages":
query = req.get("query") or {}
try:
limit = int(query.get("limit", "50") or 50)
except (ValueError, TypeError):
limit = 50
limit = max(1, min(200, limit))
try:
offset = int(query.get("offset", "0") or 0)
except (ValueError, TypeError):
offset = 0
offset = max(0, offset)
q = (query.get("q", "") or "").strip().lower()
q_user = (query.get("user_id", "") or "").strip()
sort_by = (query.get("sort", "") or "").strip().lower()
filter_group = (query.get("filter_group", "") or "").strip().lower()
try:
msgs = ctx.api.call("admin.journal", 2000)
except Exception as e: # noqa: BLE001
return 500, {"error": str(e)[:400]}
enriched = []
seen = set()
for m in msgs:
key = (m.get("text") or "", str(m.get("user_id") or ""),
str(m.get("chat_id") or ""), (m.get("time") or "")[:19])
if key in seen:
continue
seen.add(key)
u = str(m.get("user_id") or "")
name_cached = m.get("name") or ""
try:
disp = ctx.api.call("tg_names.resolve", u) or name_cached
except Exception: # noqa: BLE001
disp = name_cached
ct = m.get("chat_type") or "private"
is_grp = bool(m.get("is_group")) or ct in ("group", "supergroup")
enriched.append({
"user_id": u, "name": disp, "name_cached": name_cached,
"text": m.get("text") or "", "time": m.get("time") or "",
"time_short": (m.get("time") or "")[:19].replace("T", " "),
"chat_id": str(m.get("chat_id") or ""), "chat_type": ct,
"chat_title": m.get("chat_title") or "", "is_group": is_grp,
})
if q_user:
enriched = [x for x in enriched if x["user_id"] == q_user]
if q:
enriched = [x for x in enriched if q in x["text"].lower()
or q in x["name"].lower() or q in x["user_id"]
or q in (x.get("chat_title") or "").lower()
or q in (x.get("chat_id") or "")]
if filter_group == "group":
enriched = [x for x in enriched if x["is_group"]]
elif filter_group == "private":
enriched = [x for x in enriched if not x["is_group"]]
if sort_by == "group":
enriched.sort(key=lambda x: x["time"], reverse=True)
enriched.sort(key=lambda x: x["is_group"], reverse=True)
elif sort_by == "chat":
enriched.sort(key=lambda x: (x["chat_title"] or x["chat_id"] or "").lower())
else:
enriched.sort(key=lambda x: x["time"], reverse=True)
total = len(enriched)
users_map = {}
for x in enriched:
u = x["user_id"]
if u not in users_map:
users_map[u] = {"user_id": u, "name": x["name"], "count": 0}
users_map[u]["count"] += 1
users = sorted(users_map.values(), key=lambda u: u["count"], reverse=True)
return {"total": total, "messages": enriched[offset:offset + limit],
"users": users, "limit": limit, "offset": offset}
if method == "tgmenu":
from . import tgmenu as _tgmenu
catalog = _tgmenu.collect_buttons(ctx.loaded_modules)
if req.get("http_method") == "GET":
layout = _tgmenu.load_layout(ctx.data_dir, catalog)
return {"ok": True, "catalog": catalog, "layout": layout}
body = req.get("body") or {}
ok, error = _tgmenu.save_layout(
ctx.data_dir, catalog, body.get("buttons"))
if not ok:
return 400, {"ok": False, "error": error}
return {"ok": True}
raise UserError("неизвестный метод: %s" % method)