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
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""Обработчики событий бота."""
from bot.handlers.commands import (
start_command,
menu_command,
help_command,
settings_command,
)
from bot.handlers.callbacks import menu_callback
__all__ = [
"start_command",
"menu_command",
"help_command",
"settings_command",
"menu_callback",
]
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""
Обработчики команд для переключения AI-пресетов.
Доступные пресеты:
- off: ИИ отключен, режим CLI команд
- qwen: Qwen Code (бесплатно, локально)
- giga_auto: GigaChat авто-переключение (Lite/Pro)
- giga_lite: GigaChat Lite (дешевле)
- giga_pro: GigaChat Pro (максимальное качество)
"""
import logging
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import CommandHandler, CallbackQueryHandler
from bot.models.user_state import (
AI_PRESET_OFF,
AI_PRESET_QWEN,
AI_PRESET_GIGA_AUTO,
AI_PRESET_GIGA_LITE,
AI_PRESET_GIGA_PRO,
AI_PRESET_GIGA_MAX,
AI_PRESET_OPENCODE,
)
from bot.config import state_manager
logger = logging.getLogger(__name__)
# Описание пресетов
PRESET_DESCRIPTIONS = {
AI_PRESET_OFF: {
"name": "❌ ИИ Отключен",
"description": "Режим CLI команд. Бот выполняет команды напрямую.",
"icon": "⌨️"
},
AI_PRESET_QWEN: {
"name": "🤖 Qwen Code",
"description": "Бесплатно, локально. Лучший для кода и работы с файлами.",
"icon": "💻"
},
AI_PRESET_GIGA_AUTO: {
"name": "🔄 GigaChat Авто",
"description": "Умное переключение Lite/Pro. Простые → Lite, сложные → Pro.",
"icon": "🧠"
},
AI_PRESET_GIGA_LITE: {
"name": "⚡ GigaChat Lite",
"description": "Быстро и дёшево. Для простых вопросов и чата.",
"icon": "🚀"
},
AI_PRESET_GIGA_PRO: {
"name": "🔥 GigaChat Pro",
"description": "Максимальное качество. Для сложных творческих задач.",
"icon": "👑"
},
AI_PRESET_GIGA_MAX: {
"name": "💎 GigaChat Max",
"description": "Топовая модель для самых сложных задач.",
"icon": "💎"
},
AI_PRESET_OPENCODE: {
"name": "⚡ Opencode",
"description": "Бесплатные модели (minimax, big-pickle, gpt-5-nano).",
"icon": "🚀"
},
}
def get_preset_display_name(preset: str) -> str:
"""Получить отображаемое имя пресета."""
desc = PRESET_DESCRIPTIONS.get(preset, {})
return f"{desc.get('icon', '❓')} {desc.get('name', preset)}"
async def ai_presets_command(update: Update, context):
"""Показать меню выбора AI-пресета."""
user_id = update.effective_user.id
state = state_manager.get(user_id)
current_preset = state.ai_preset
# Формируем меню - Opencode и GigaChat теперь открывают подменю выбора моделей
keyboard = [
[
InlineKeyboardButton(
f"{'✅' if current_preset == AI_PRESET_OFF else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_OFF]['icon']} ИИ Отключен",
callback_data=f"ai_preset_{AI_PRESET_OFF}"
)
],
[
InlineKeyboardButton(
f"{'✅' if current_preset == AI_PRESET_QWEN else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['icon']} Qwen Code",
callback_data=f"ai_preset_{AI_PRESET_QWEN}"
)
],
[
InlineKeyboardButton(
f"{'✅' if current_preset == AI_PRESET_GIGA_AUTO else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['icon']} GigaChat Авто",
callback_data=f"ai_preset_{AI_PRESET_GIGA_AUTO}"
)
],
# Кнопка GigaChat с подменю - убираем Lite и Pro из основного меню
[
InlineKeyboardButton(
f"{'✅' if current_preset in [AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO, AI_PRESET_GIGA_MAX] else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat ▶",
callback_data="gigachat_submenu"
)
],
# Кнопка Opencode с подменю
[
InlineKeyboardButton(
f"{'✅' if current_preset == AI_PRESET_OPENCODE else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} Opencode ▶",
callback_data="opencode_submenu"
)
],
]
reply_markup = InlineKeyboardMarkup(keyboard)
current_name = get_preset_display_name(current_preset)
output = f"🎛️ **Панель управления AI**\n\n"
output += f"**Текущий пресет:** {current_name}\n\n"
output += "Выберите AI-провайдер:\n\n"
output += "ℹ️ **Описание пресетов:**\n"
output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_OFF]['icon']} **ИИ Отключен** — {PRESET_DESCRIPTIONS[AI_PRESET_OFF]['description']}\n"
output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['icon']} **Qwen Code** — {PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['description']}\n"
output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['icon']} **GigaChat Авто** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['description']}\n"
output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} **GigaChat** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['description']}\n"
output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} **Opencode** — {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['description']}"
await update.message.reply_text(output, parse_mode="Markdown", reply_markup=reply_markup)
async def ai_preset_callback(update: Update, context):
"""Обработка выбора пресета из инлайн-меню."""
user_id = update.effective_user.id
query = update.callback_query
await query.answer()
# Извлекаем название пресета из callback_data
preset = query.data.replace("ai_preset_", "")
if preset not in PRESET_DESCRIPTIONS:
await query.edit_message_text("❌ Неверный пресет")
return
state = state_manager.get(user_id)
old_preset = state.ai_preset
state.ai_preset = preset
# Обновляем ai_chat_mode и current_ai_provider для совместимости
if preset == AI_PRESET_OFF:
state.ai_chat_mode = False
state.current_ai_provider = "none"
else:
state.ai_chat_mode = True
# Для совместимости с существующим кодом
if preset == AI_PRESET_QWEN:
state.current_ai_provider = "qwen"
elif preset == AI_PRESET_OPENCODE:
state.current_ai_provider = "opencode"
else: # Любой GigaChat
state.current_ai_provider = "gigachat"
preset_name = get_preset_display_name(preset)
output = f"✅ **Переключено на:** {preset_name}\n\n"
output += f"{PRESET_DESCRIPTIONS[preset]['description']}"
# Обновляем инлайн-меню - с подменю для Opencode и GigaChat
keyboard = [
[
InlineKeyboardButton(
f"{'✅' if preset == AI_PRESET_OFF else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_OFF]['icon']} ИИ Отключен",
callback_data=f"ai_preset_{AI_PRESET_OFF}"
)
],
[
InlineKeyboardButton(
f"{'✅' if preset == AI_PRESET_QWEN else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['icon']} Qwen Code",
callback_data=f"ai_preset_{AI_PRESET_QWEN}"
)
],
[
InlineKeyboardButton(
f"{'✅' if preset == AI_PRESET_GIGA_AUTO else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['icon']} GigaChat Авто",
callback_data=f"ai_preset_{AI_PRESET_GIGA_AUTO}"
)
],
[
InlineKeyboardButton(
f"{'✅' if preset in [AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO, AI_PRESET_GIGA_MAX] else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat ▶",
callback_data="gigachat_submenu"
)
],
[
InlineKeyboardButton(
f"{'✅' if preset == AI_PRESET_OPENCODE else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} Opencode ▶",
callback_data="opencode_submenu"
)
],
]
reply_markup = InlineKeyboardMarkup(keyboard)
await query.edit_message_text(output, parse_mode="Markdown", reply_markup=reply_markup)
logger.info(f"Пользователь {user_id} переключил AI-пресет: {old_preset} → {preset}")
# Быстрые команды для переключения одним сообщением
async def ai_off_command(update: Update, context):
"""Быстрое переключение на ИИ отключен."""
await switch_preset(update, AI_PRESET_OFF)
async def ai_qwen_command(update: Update, context):
"""Быстрое переключение на Qwen Code."""
await switch_preset(update, AI_PRESET_QWEN)
async def ai_giga_auto_command(update: Update, context):
"""Быстрое переключение на GigaChat Авто."""
await switch_preset(update, AI_PRESET_GIGA_AUTO)
async def ai_giga_lite_command(update: Update, context):
"""Быстрое переключение на GigaChat Lite."""
await switch_preset(update, AI_PRESET_GIGA_LITE)
async def ai_giga_pro_command(update: Update, context):
"""Быстрое переключение на GigaChat Pro."""
await switch_preset(update, AI_PRESET_GIGA_PRO)
async def ai_giga_max_command(update: Update, context):
"""Быстрое переключение на GigaChat Max."""
await switch_preset(update, AI_PRESET_GIGA_MAX)
async def ai_opencode_command(update: Update, context):
"""Быстрое переключение на Opencode."""
await switch_preset(update, AI_PRESET_OPENCODE)
async def switch_preset(update: Update, preset: str):
"""Переключить пресет и показать уведомление."""
user_id = update.effective_user.id
state = state_manager.get(user_id)
old_preset = state.ai_preset
state.ai_preset = preset
# Обновляем совместимость
if preset == AI_PRESET_OFF:
state.ai_chat_mode = False
state.current_ai_provider = "none"
else:
state.ai_chat_mode = True
if preset == AI_PRESET_QWEN:
state.current_ai_provider = "qwen"
elif preset == AI_PRESET_OPENCODE:
state.current_ai_provider = "opencode"
else:
state.current_ai_provider = "gigachat"
preset_name = get_preset_display_name(preset)
output = f"✅ **AI-пресет переключен**\n\n"
output += f"**Текущий:** {preset_name}\n"
output += f"_{PRESET_DESCRIPTIONS[preset]['description']}_\n\n"
if old_preset != preset:
output += f"~{get_preset_display_name(old_preset)}~ → ✅ {preset_name}"
await update.message.reply_text(output, parse_mode="Markdown")
logger.info(f"Пользователь {user_id} переключил AI-пресет: {old_preset} → {preset}")
def register_ai_preset_handlers(dispatcher):
"""Зарегистрировать обработчики AI-пресетов."""
# Основное меню
dispatcher.add_handler(CommandHandler("ai_presets", ai_presets_command))
# Callback для инлайн-меню
dispatcher.add_handler(CallbackQueryHandler(ai_preset_callback, pattern="^ai_preset_"))
# Быстрые команды
dispatcher.add_handler(CommandHandler("ai_off", ai_off_command))
dispatcher.add_handler(CommandHandler("ai_qwen", ai_qwen_command))
dispatcher.add_handler(CommandHandler("ai_giga_auto", ai_giga_auto_command))
dispatcher.add_handler(CommandHandler("ai_giga_lite", ai_giga_lite_command))
dispatcher.add_handler(CommandHandler("ai_giga_pro", ai_giga_pro_command))
dispatcher.add_handler(CommandHandler("ai_giga_max", ai_giga_max_command))
dispatcher.add_handler(CommandHandler("ai_opencode", ai_opencode_command))
logger.info("Обработчики AI-пресетов зарегистрированы")
+878
View File
@@ -0,0 +1,878 @@
#!/usr/bin/env python3
"""Обработчик callback-запросов от меню."""
import logging
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ContextTypes
from bot.config import config, state_manager, server_manager, menu_builder
from bot.utils.decorators import check_access
from bot.services.command_executor import execute_cli_command
from bot.models.user_state import (
AI_PRESET_OFF,
AI_PRESET_QWEN,
AI_PRESET_GIGA_AUTO,
AI_PRESET_GIGA_LITE,
AI_PRESET_GIGA_PRO,
AI_PRESET_GIGA_MAX,
AI_PRESET_OPENCODE,
)
from memory_system import memory_manager, get_user_profile_summary
logger = logging.getLogger(__name__)
async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка нажатий на кнопки меню."""
query = update.callback_query
user_id = query.from_user.id
state = state_manager.get(user_id)
await query.answer()
callback = query.data
logger.info(f"Callback: {callback} от пользователя {user_id}")
# Обработка навигации
if callback == "main":
state.current_menu = "main"
# Проверяем режим чата с ИИ для обновления текста кнопки
ai_status = "✅ ВКЛ" if state.ai_chat_mode else "❌ ВЫКЛ"
await query.edit_message_text(
f"🏠 **Главное меню**\n\n"
f"💬 **Чат с ИИ:** {ai_status}",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("main", user_id=query.from_user.id, state=state)
)
elif callback == "ai_presets":
# Открываем меню AI-пресетов
state = state_manager.get(user_id)
from bot.handlers.ai_presets import ai_presets_command
# Создаём фейковое сообщение для совместимости
class FakeMessage:
async def reply_text(self, text, parse_mode=None, reply_markup=None):
# Вместо отправки сообщения редактируем callback
await query.edit_message_text(text, parse_mode=parse_mode, reply_markup=reply_markup)
return None
fake_update = type('FakeUpdate', (), {'message': FakeMessage(), 'effective_user': query.from_user})()
await ai_presets_command(fake_update, context)
return
# Обработчики подменю AI-пресетов
elif callback == "opencode_submenu":
# Подменю выбора моделей Opencode из AI-пресетов
state = state_manager.get(user_id)
current_model = state.opencode_model
state.ai_preset = AI_PRESET_OPENCODE
state.current_ai_provider = "opencode"
keyboard = [
[InlineKeyboardButton(f"{'✅' if current_model == 'minimax' else '⬜'} ⚡ minimax", callback_data="opencode_model_minimax")],
[InlineKeyboardButton(f"{'✅' if current_model == 'big_pickle' else '⬜'} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")],
[InlineKeyboardButton(f"{'✅' if current_model == 'gpt5' else '⬜'} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")],
[InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")],
]
model_descriptions = {
"minimax": "Быстрая, бесплатная модель. Хорошо справляется с простыми задачами.",
"big_pickle": "Большая бесплатная модель. Лучше для сложных задач.",
"gpt5": "Самая мощная бесплатная модель. Требует больше времени."
}
await query.edit_message_text(
f"📡 **Выбор модели Opencode**\n\n"
f"Текущая модель: **{current_model}**\n\n"
f"ℹ️ Описание моделей:\n"
f"• ⚡ **minimax** — {model_descriptions['minimax']}\n"
f"• 🗃️ **big-pickle** — {model_descriptions['big_pickle']}\n"
f"• 🔬 **gpt-5-nano** — {model_descriptions['gpt5']}\n\n"
f"Выберите модель для использования:",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback == "gigachat_submenu":
# Подменю выбора моделей GigaChat из AI-пресетов
state = state_manager.get(user_id)
current_model = state.gigachat_model
keyboard = [
[InlineKeyboardButton(f"{'✅' if current_model == 'lite' else '⬜'} 📱 GigaChat Lite", callback_data="ai_preset_giga_lite")],
[InlineKeyboardButton(f"{'✅' if current_model == 'pro' else '⬜'} 🚀 GigaChat Pro", callback_data="ai_preset_giga_pro")],
[InlineKeyboardButton(f"{'✅' if current_model == 'max' else '⬜'} 💎 GigaChat Max", callback_data="ai_preset_giga_max")],
[InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")],
]
model_descriptions = {
"lite": "Быстрая и экономичная модель для простых задач",
"pro": "Баланс скорости и качества для большинства задач",
"max": "Самая мощная модель для сложных задач"
}
await query.edit_message_text(
f"🧠 **Выбор модели GigaChat**\n\n"
f"Текущая модель: **{current_model.upper()}**\n\n"
f"ℹ️ Описание моделей:\n"
f"• 📱 **Lite** — {model_descriptions['lite']}\n"
f"• 🚀 **Pro** — {model_descriptions['pro']}\n"
f"• 💎 **Max** — {model_descriptions['max']}\n\n"
f"Выберите модель для использования:",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback.startswith("continue_output_"):
# Пользователь нажал "Продолжить"
parts = callback.replace("continue_output_", "").split("_")
remaining = int(parts[0])
next_index = int(parts[1]) if len(parts) > 1 else 0
state = state_manager.get(user_id)
logger.info(f"callback continue_output: remaining={remaining}, next_index={next_index}, user_id={user_id}")
# Сначала отвечаем на callback
await query.answer()
# Удаляем сообщение с кнопками
try:
if state.output_wait_message:
await state.output_wait_message.delete()
except:
pass
# Продолжаем отправку сообщений
if state.output_text:
from bot.utils.formatters import send_long_message
# Создаём фейковый update для совместимости
class FakeMessage:
async def reply_text(self, text, parse_mode=None, reply_markup=None):
return await query.message.reply_text(text, parse_mode=parse_mode, reply_markup=reply_markup)
fake_update = type('FakeUpdate', (), {
'message': FakeMessage(),
'effective_user': query.from_user
})()
# Продолжаем отправку
has_more = await send_long_message(
fake_update,
state.output_text,
parse_mode=state.output_parse_mode,
start_from=next_index
)
# Если ещё есть сообщения — сохраняем состояние
if has_more:
logger.info(f"Продолжение отправлено, ещё есть пауза")
else:
logger.info(f"Все сообщения отправлены")
state.output_text = None
else:
logger.warning(f"output_text не найден в состоянии")
return
elif callback == "cancel_output":
# Пользователь нажал "Отменить"
logger.info(f"callback cancel_output: user_id={user_id}")
state = state_manager.get(user_id)
# Сначала отвечаем на callback
await query.answer()
# Удаляем сообщение с кнопками
try:
if state.output_wait_message:
await state.output_wait_message.delete()
except:
pass
# Очищаем состояние
state.waiting_for_output_control = False
state.output_remaining = None
state.output_wait_message = None
state.output_text = None
state.output_next_index = None
await query.message.reply_text("❌ Вывод отменён пользователем")
return
elif callback == "preset_menu":
state.current_menu = "preset"
await query.edit_message_text(
"📋 **Предустановленные команды**",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("preset")
)
elif callback == "fs_menu":
await query.edit_message_text(
"📁 **Файловая система**",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("fs")
)
elif callback == "search_menu":
await query.edit_message_text(
"🔍 **Поиск**",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("search")
)
elif callback == "system_menu":
await query.edit_message_text(
"📊 **Система**",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("system")
)
elif callback == "network_menu":
await query.edit_message_text(
"🌐 **Сеть**",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("network")
)
elif callback == "opencode_models_menu":
# Меню выбора моделей Opencode в предустановленных командах
state = state_manager.get(user_id)
current_model = state.opencode_model
state.current_ai_provider = "opencode"
keyboard = [
[InlineKeyboardButton(f"{'✅' if current_model == 'minimax' else '⬜'} ⚡ minimax", callback_data="opencode_model_minimax")],
[InlineKeyboardButton(f"{'✅' if current_model == 'big_pickle' else '⬜'} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")],
[InlineKeyboardButton(f"{'✅' if current_model == 'gpt5' else '⬜'} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")],
[InlineKeyboardButton("⬅️ Назад", callback_data="preset_menu")],
]
model_descriptions = {
"minimax": "Быстрая, бесплатная модель",
"big_pickle": "Большая бесплатная модель",
"gpt5": "Самая мощная бесплатная модель"
}
await query.edit_message_text(
f"🤖 **AI модели Opencode**\n\n"
f"Текущая модель: **{current_model}**\n\n"
f"ℹ️ Описание:\n"
f"• ⚡ **minimax** — {model_descriptions['minimax']}\n"
f"• 🗃️ **big-pickle** — {model_descriptions['big_pickle']}\n"
f"• 🔬 **gpt-5-nano** — {model_descriptions['gpt5']}\n\n"
f"Выберите модель:",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback == "server_menu":
# Сброс состояния редактирования/добавления сервера
state.waiting_for_input = False
state.input_type = None
state.editing_server = None
state.context.clear()
# Динамическое обновление меню серверов с кнопками управления
servers = server_manager.list_servers()
keyboard = []
for srv in servers:
# Кнопка выбора сервера + кнопка управления (для не-local)
row = [InlineKeyboardButton(
srv.display_name,
callback_data=f"server_select_{srv.name}"
)]
if srv.name != "local":
row.append(InlineKeyboardButton(
"⚙️",
callback_data=f"server_manage_{srv.name}"
))
keyboard.append(row)
keyboard.append([
InlineKeyboardButton("➕ Добавить", callback_data="server_add"),
InlineKeyboardButton("⬅️ Назад", callback_data="main")
])
state.current_menu = "server"
await query.edit_message_text(
"🖥️ **Управление серверами**\n\n"
"Выберите сервер для подключения или добавьте новый.\n"
"⚙️ — редактировать/удалить сервер",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback == "server_add":
state.waiting_for_input = True
state.input_type = "add_server_name"
state.context["new_server"] = {}
await query.edit_message_text(
"➕ **Добавление сервера**\n\n"
"Введите **имя сервера** (латиница, без пробелов):\n"
"Пример: `web-prod`, `db-backup`",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("❌ Отмена", callback_data="server_menu")
]])
)
elif callback.startswith("server_manage_"):
server_name = callback.replace("server_manage_", "")
server = server_manager.get(server_name)
if server and server_name != "local":
state.editing_server = server_name
await query.edit_message_text(
f"⚙️ **Управление сервером**\n\n"
f"{server.display_name}\n"
f"📍 `{server.description}`\n"
f"🏷️ Теги: `{','.join(server.tags) if server.tags else 'нет'}`\n\n"
f"Выберите действие:",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("✏️ Редактировать", callback_data=f"server_edit_{server_name}")],
[InlineKeyboardButton("🗑️ Удалить", callback_data=f"server_delete_{server_name}")],
[InlineKeyboardButton("⬅️ Назад", callback_data="server_menu")]
])
)
else:
await query.edit_message_text(
f"❌ **Сервер не найден**\n\n"
f"Сервер `{server_name}` отсутствует в конфигурации.",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("server")
)
elif callback.startswith("server_edit_"):
server_name = callback.replace("server_edit_", "")
server = server_manager.get(server_name)
if server and server_name != "local":
state.editing_server = server_name
state.waiting_for_input = True
state.input_type = "edit_server_field"
password_status = "установлен" if server.password else "не установлен"
await query.edit_message_text(
f"✏️ **Редактирование сервера: {server_name}**\n\n"
f"Текущие значения:\n"
f"• Host: `{server.host}`\n"
f"• Port: `{server.port}`\n"
f"• User: `{server.user}`\n"
f"• Tags: `{','.join(server.tags) if server.tags else 'нет'}`\n"
f"• Password: {password_status}\n\n"
f"Введите номер поля для изменения:\n"
f"1 — Host\n"
f"2 — Port\n"
f"3 — User\n"
f"4 — Tags\n"
f"5 — Password",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("❌ Отмена", callback_data="server_menu")
]])
)
else:
await query.edit_message_text(
"❌ Ошибка: сервер не найден",
reply_markup=menu_builder.get_keyboard("server")
)
elif callback.startswith("server_delete_"):
server_name = callback.replace("server_delete_", "")
server = server_manager.get(server_name)
if server and server_name != "local":
# Удаляем сразу с подтверждением
if server_manager.delete_server(server_name):
await query.edit_message_text(
f"🗑️ **Сервер удалён**\n\n"
f"Сервер `{server_name}` успешно удалён из конфигурации.",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("server")
)
else:
await query.edit_message_text(
"❌ Ошибка при удалении сервера",
reply_markup=menu_builder.get_keyboard("server")
)
else:
await query.edit_message_text(
"❌ Нельзя удалить local сервер",
reply_markup=menu_builder.get_keyboard("server")
)
elif callback == "srv_skip_password":
# Пропуск пароля при добавлении сервера
user_id = query.from_user.id
state = state_manager.get(user_id)
state.context["new_server"]["password"] = ""
state.input_type = "add_server_tags"
await query.edit_message_text(
"✅ Пароль пропущен (будет использоваться только ключ)\n\n"
"Введите **теги** через запятую (или нажмите Пропустить):\n"
"Пример: `web,prod`, `db,backup`\n\n"
"Теги помогают группировать серверы.",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("⏭️ Пропустить", callback_data="srv_skip_tags")],
[InlineKeyboardButton("❌ Отмена", callback_data="server_menu")]
])
)
elif callback == "srv_skip_tags":
# Пропуск тегов при добавлении сервера
user_id = query.from_user.id
state = state_manager.get(user_id)
new_server = state.context.get("new_server", {})
if new_server.get("name") and new_server.get("host") and new_server.get("port") and new_server.get("user"):
if server_manager.add_server(
name=new_server["name"],
host=new_server["host"],
port=new_server["port"],
user=new_server["user"],
tags=[],
password=new_server.get("password", "")
):
await query.edit_message_text(
"✅ **Сервер добавлен**\n\n"
f"Имя: `{new_server['name']}`\n"
f"Host: `{new_server['host']}`\n"
f"Port: `{new_server['port']}`\n"
f"User: `{new_server['user']}`\n"
f"Tags: нет\n"
f"Password: {'установлен' if new_server.get('password') else 'не установлен'}\n\n"
f"Сервер сохранён в `.env` и доступен для выбора.",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("server")
)
else:
await query.edit_message_text(
"❌ Ошибка: сервер с таким именем уже существует",
reply_markup=menu_builder.get_keyboard("server")
)
else:
await query.edit_message_text(
"❌ Ошибка: неполные данные сервера",
reply_markup=menu_builder.get_keyboard("server")
)
state.waiting_for_input = False
state.input_type = None
state.context.clear()
elif callback.startswith("server_select_"):
server_name = callback.replace("server_select_", "")
server = server_manager.get(server_name)
if server:
state.current_server = server_name
# Сброс рабочей директории при смене сервера
state.working_directory = None
await query.edit_message_text(
f"✅ **Сервер изменён**\n\n"
f"{server.display_name}\n"
f"📍 `{server.description}`\n\n"
f"Теперь команды выполняются на этом сервере.",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("main", user_id=query.from_user.id, state=state)
)
state.current_menu = "main"
else:
await query.edit_message_text(
f"❌ **Сервер не найден**\n\n"
f"Сервер `{server_name}` отсутствует в конфигурации.",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("server")
)
elif callback == "settings_menu":
state.current_menu = "settings"
await query.edit_message_text(
"⚙️ **Настройки бота**",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("settings")
)
# Обработка команд выполнения
elif callback.startswith("cmd_"):
# Поиск команды в меню
command = None
for menu_items in menu_builder._menus.values():
for item in menu_items:
if item.callback == callback and item.command:
command = item.command
break
if command:
await execute_cli_command(query, command)
else:
await query.edit_message_text("❌ Команда не найдена")
# Настройки бота - только просмотр, изменение через .env
elif callback == "set_name":
await query.edit_message_text(
"📝 **Изменение имени бота**\n\n"
f"Текущее имя: `{config.name}`\n\n"
"Для изменения отредактируйте `.env`:\n"
"```\nBOT_NAME=Ваше имя\n```",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("settings")
)
elif callback == "set_description":
await query.edit_message_text(
"📄 **Изменение описания бота**\n\n"
f"Текущее описание: `{config.description}`\n\n"
"Для изменения отредактируйте `.env`:\n"
"```\nBOT_DESCRIPTION=Ваше описание\n```",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("settings")
)
elif callback == "set_icon":
await query.edit_message_text(
"🎨 **Изменение иконки бота**\n\n"
f"Текущая иконка: `{config.icon}`\n\n"
"Для изменения отредактируйте `.env`:\n"
"```\nBOT_ICON_EMOJI=🤖\n```",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("settings")
)
elif callback == "about":
await query.edit_message_text(
f"ℹ️ **О боте**\n\n"
f"**{config.icon} {config.name}**\n"
f"_{config.description}_\n\n"
f"**Версия:** `2.1.0`\n\n"
f"**Возможности:**\n"
f"• Выполнение CLI команд через Telegram\n"
f"• Поддержка локальных команд и SSH\n"
f"• Интерактивный ввод пароля (sudo)\n"
f"• Предустановленные команды\n"
f"• Управление серверами\n"
f"• Очистка ANSI-кодов и прогресс-баров\n"
f"• Форматирование длинного вывода\n"
f"• 💬 Чат с ИИ агентом (Qwen Code)\n\n"
f"**Рабочая директория:**\n"
f"`{config.working_directory}`\n\n"
f"Бот позволяет безопасно выполнять команды\n"
f"на вашем сервере через интерфейс Telegram.",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("main", user_id=query.from_user.id, state=state)
)
state.current_menu = "main"
elif callback in ["toggle_ai_chat", "toggle_ai_chat_on", "toggle_ai_chat_off"]:
# Переключаем режим чата с ИИ
state.ai_chat_mode = not state.ai_chat_mode
logger.info(f"toggle_ai_chat: user_id={user_id}, new_mode={state.ai_chat_mode}")
ai_status = "✅ ВКЛЮЧЕН" if state.ai_chat_mode else "❌ ВЫКЛЮЧЕН"
action = "включён" if state.ai_chat_mode else "выключен"
await query.edit_message_text(
f"🏠 **Главное меню**\n\n"
f"💬 **Чат с ИИ:** {ai_status}\n\n"
f"Режим чата с агентом {action}.\n"
f"Теперь все сообщения будут отправляться в Qwen Code.",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("main", user_id=query.from_user.id, state=state)
)
state.current_menu = "main"
# --- Обработчики меню памяти ---
elif callback == "memory_menu":
state.current_menu = "memory"
await query.edit_message_text(
"🧠 **Память ИИ**\n\n"
"Управление памятью чата с ИИ:\n"
"• Профиль — факты о вас, которые запомнил ИИ\n"
"• Статистика — количество сообщений и сессий\n"
"• Очистить — удалить историю переписки",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("memory")
)
elif callback == "memory_profile":
profile_summary = get_user_profile_summary(user_id)
if not profile_summary:
profile_summary = "📭 Профиль пуст\n\nФакты ещё не извлечены.\nНачните общаться с ИИ в чате."
await query.edit_message_text(
f"📋 **Ваш профиль**\n\n{profile_summary}",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("memory")
)
elif callback == "memory_stats":
stats = memory_manager.get_stats(user_id)
await query.edit_message_text(
f"📊 **Статистика памяти**\n\n"
f"• Сессий: `{stats['total_sessions']}`\n"
f"• Сообщений: `{stats['total_messages']}`\n"
f"• Фактов: `{stats['total_facts']}`",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("memory")
)
elif callback == "memory_clear":
# Показываем подтверждение
await query.edit_message_text(
"🗑️ **Очистка истории**\n\n"
"Вы уверены?\n"
"Это удалит всю историю сообщений.\n"
"Факты останутся (их можно удалить отдельно).",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("🗑️ Да, очистить", callback_data="memory_clear_confirm")],
[InlineKeyboardButton("❌ Отмена", callback_data="memory_menu")]
])
)
elif callback == "memory_clear_confirm":
# Очищаем историю сообщений (в будущем можно добавить метод в memory_manager)
from memory_system import MemoryStorage
# Пока просто уведомляем
await query.edit_message_text(
"✅ **История очищена**\n\n"
"Функция полной очистки будет добавлена в следующей версии.\n"
"Пока очищается только история сессии в памяти бота.",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("memory")
)
# Сбрасываем историю чата в состоянии
state.ai_chat_history = []
elif callback == "memory_compact":
# Вызываем команду /compact через send_message
await query.edit_message_text(
"🔄 **Запуск компактификации истории...**\n\n"
"_Сжатие старой истории в структурированный summary._\n"
"_Это может занять несколько секунд._",
parse_mode="Markdown"
)
# Получаем compactor и выполняем компактификацию
from bot.compaction import get_compactor
try:
compactor = get_compactor()
result = await compactor.compact()
if result.success:
if result.messages_compressed > 0:
await query.edit_message_text(
f"✅ **Компактификация завершена!**\n\n"
f"📊 Сжато сообщений: `{result.messages_compressed}`\n"
f"📝 Длина summary: `{result.summary_length}` символов\n"
f"💾 Экономия токенов: ~`{result.tokens_saved}`\n\n"
f"_Summary автоматически используется в контексте диалога._",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("memory")
)
else:
await query.edit_message_text(
"ℹ️ **Компактификация не требуется**\n\n"
"История пуста или уже компактная.",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("memory")
)
else:
await query.edit_message_text(
f"❌ **Ошибка компактификации:**\n\n{result.error}",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("memory")
)
except Exception as e:
await query.edit_message_text(
f"❌ **Ошибка:** {e}",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("memory")
)
elif callback == "opencode_model_menu":
state = state_manager.get(user_id)
current_model = state.opencode_model
state.current_ai_provider = "opencode"
keyboard = [
[InlineKeyboardButton(f"{'✅' if current_model == 'minimax' else '⬜'} ⚡ minimax", callback_data="opencode_model_minimax")],
[InlineKeyboardButton(f"{'✅' if current_model == 'big_pickle' else '⬜'} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")],
[InlineKeyboardButton(f"{'✅' if current_model == 'gpt5' else '⬜'} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")],
[InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")],
]
model_descriptions = {
"minimax": "Быстрая, бесплатная модель. Хорошо справляется с простыми задачами.",
"big_pickle": "Большая бесплатная модель. Лучше для сложных задач.",
"gpt5": "Самая мощная бесплатная модель. Требует больше времени."
}
await query.edit_message_text(
f"📡 **Выбор модели Opencode**\n\n"
f"Текущая модель: **{current_model}**\n\n"
f"ℹ️ Описание моделей:\n"
f"• ⚡ **minimax** — {model_descriptions['minimax']}\n"
f"• 🗃️ **big-pickle** — {model_descriptions['big_pickle']}\n"
f"• 🔬 **gpt-5-nano** — {model_descriptions['gpt5']}",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback.startswith("opencode_model_"):
model = callback.replace("opencode_model_", "")
state = state_manager.get(user_id)
state.opencode_model = model
# Обновляем модель в OpencodeProvider
from bot.providers.opencode_provider import OpencodeProvider
provider = OpencodeProvider()
provider.set_model(user_id, model)
model_names = {
"minimax": "⚡ minimax",
"big_pickle": "🗃️ big-pickle",
"gpt5": "🔬 gpt-5-nano"
}
await query.answer(f"✅ Модель изменена на {model_names.get(model, model)}")
# Показываем меню снова
keyboard = [
[InlineKeyboardButton(f"{'✅' if model == 'minimax' else '⬜'} ⚡ minimax", callback_data="opencode_model_minimax")],
[InlineKeyboardButton(f"{'✅' if model == 'big_pickle' else '⬜'} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")],
[InlineKeyboardButton(f"{'✅' if model == 'gpt5' else '⬜'} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")],
[InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")],
]
await query.edit_message_text(
f"📡 **Выбор модели Opencode**\n\n"
f"Текущая модель: **{model_names.get(model, model)}**\n\n"
f"✅ **Модель изменена!**\n\n"
f"Выберите модель или вернитесь назад:",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
# --- Обработчики меню выбора AI-провайдера ---
elif callback == "ai_provider_selection_menu":
state = state_manager.get(user_id)
current_provider = state.current_ai_provider
keyboard = [
[InlineKeyboardButton(f"{'✅' if current_provider == 'qwen' else '⬜'} 🔄 Qwen Code", callback_data="ai_provider_qwen")],
[InlineKeyboardButton(f"{'✅' if current_provider == 'opencode' else '⬜'} 📡 Opencode ▶", callback_data="opencode_model_menu")],
[InlineKeyboardButton(f"{'✅' if current_provider == 'gigachat' else '⬜'} 🧠 GigaChat ▶", callback_data="gigachat_model_menu")],
[InlineKeyboardButton("⬅️ Назад", callback_data="settings")],
]
provider_descriptions = {
"qwen": "Бесплатный локальный AI от Alibaba",
"opencode": "Бесплатные модели (minimax, big-pickle, gpt-5-nano)",
"gigachat": "Российский AI от Сбера (Lite, Pro, Max)"
}
await query.edit_message_text(
f"🤖 **Выбор AI-провайдера**\n\n"
f"Текущий провайдер: **{current_provider.upper()}**\n\n"
f"ℹ️ Описание провайдеров:\n"
f"• 🔄 **Qwen Code** — {provider_descriptions['qwen']}\n"
f"• 📡 **Opencode** — {provider_descriptions['opencode']}\n"
f"• 🧠 **GigaChat** — {provider_descriptions['gigachat']}",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback == "ai_provider_qwen":
state = state_manager.get(user_id)
state.current_ai_provider = "qwen"
await query.answer("✅ Переключено на Qwen Code")
current_provider = "qwen"
keyboard = [
[InlineKeyboardButton(f"{'✅' if current_provider == 'qwen' else '⬜'} 🔄 Qwen Code", callback_data="ai_provider_qwen")],
[InlineKeyboardButton(f"{'✅' if current_provider == 'opencode' else '⬜'} 📡 Opencode ▶", callback_data="opencode_model_menu")],
[InlineKeyboardButton(f"{'✅' if current_provider == 'gigachat' else '⬜'} 🧠 GigaChat ▶", callback_data="gigachat_model_menu")],
[InlineKeyboardButton("⬅️ Назад", callback_data="settings")],
]
await query.edit_message_text(
f"🤖 **Выбор AI-провайдера**\n\n"
f"Текущий провайдер: **QWEN CODE**\n\n"
f"✅ **Провайдер изменён!**\n\n"
f"ℹ️ Описание провайдеров:\n"
f"• 🔄 **Qwen Code** — Бесплатный локальный AI от Alibaba\n"
f"• 📡 **Opencode** — Бесплатные модели (minimax, big-pickle, gpt-5-nano)\n"
f"• 🧠 **GigaChat** — Российский AI от Сбера (Lite, Pro, Max)",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback == "gigachat_model_menu":
state = state_manager.get(user_id)
current_model = state.gigachat_model
state.current_ai_provider = "gigachat"
keyboard = [
[InlineKeyboardButton(f"{'✅' if current_model == 'lite' else '⬜'} 📱 GigaChat Lite", callback_data="gigachat_model_lite")],
[InlineKeyboardButton(f"{'✅' if current_model == 'pro' else '⬜'} 🚀 GigaChat Pro", callback_data="gigachat_model_pro")],
[InlineKeyboardButton(f"{'✅' if current_model == 'max' else '⬜'} 💎 GigaChat Max", callback_data="gigachat_model_max")],
[InlineKeyboardButton("⬅️ Назад", callback_data="ai_provider_selection_menu")],
]
model_descriptions = {
"lite": "Быстрая и экономичная модель для простых задач",
"pro": "Баланс скорости и качества для большинства задач",
"max": "Самая мощная модель для сложных задач"
}
await query.edit_message_text(
f"🧠 **Выбор модели GigaChat**\n\n"
f"Текущая модель: **{current_model.upper()}**\n\n"
f"ℹ️ Описание моделей:\n"
f"• 📱 **Lite** — {model_descriptions['lite']}\n"
f"• 🚀 **Pro** — {model_descriptions['pro']}\n"
f"• 💎 **Max** — {model_descriptions['max']}",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback.startswith("gigachat_model_"):
model = callback.replace("gigachat_model_", "")
state = state_manager.get(user_id)
state.gigachat_model = model
state.current_ai_provider = "gigachat"
model_names = {
"lite": "📱 GigaChat Lite",
"pro": "🚀 GigaChat Pro",
"max": "💎 GigaChat Max"
}
await query.answer(f"✅ Модель изменена на {model_names.get(model, model)}")
current_model = model
keyboard = [
[InlineKeyboardButton(f"{'✅' if current_model == 'lite' else '⬜'} 📱 GigaChat Lite", callback_data="gigachat_model_lite")],
[InlineKeyboardButton(f"{'✅' if current_model == 'pro' else '⬜'} 🚀 GigaChat Pro", callback_data="gigachat_model_pro")],
[InlineKeyboardButton(f"{'✅' if current_model == 'max' else '⬜'} 💎 GigaChat Max", callback_data="gigachat_model_max")],
[InlineKeyboardButton("⬅️ Назад", callback_data="ai_provider_selection_menu")],
]
await query.edit_message_text(
f"🧠 **Выбор модели GigaChat**\n\n"
f"Текущая модель: **{model_names.get(model, model)}**\n\n"
f"✅ **Модель изменена!**\n\n"
f"Теперь используется GigaChat с выбранной моделью.",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
+528
View File
@@ -0,0 +1,528 @@
#!/usr/bin/env python3
"""Обработчики команд бота (/start, /menu, /help, /settings, /cron)."""
import logging
from datetime import datetime
from telegram import Update
from telegram.ext import ContextTypes
# Импорты из модулей bot/
from bot.config import config, state_manager, server_manager, menu_builder
from bot.utils.decorators import check_access
from bot.utils.formatters import escape_html
from bot.tools import tools_registry
from bot.ai_agent import ai_agent
logger = logging.getLogger(__name__)
@check_access
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка команды /start."""
user = update.effective_user
logger.info(f"Пользователь {user.username} ({user.id}) запустил бота")
# Сбрасываем состояние НО сохраняем ai_chat_mode (по умолчанию True)
state = state_manager.get(user.id)
state_manager.reset(user.id)
state = state_manager.get(user.id)
# ai_chat_mode уже True по умолчанию из UserState
# Показать текущую директорию и сервер
working_dir = config.working_directory
server = server_manager.get("local")
server_desc = server.description if server else "localhost"
await update.message.reply_text(
f"👋 Привет, {user.first_name}!\n\n"
f"{config.icon} *{config.name}*\n"
f"_{config.description}_\n\n"
f"*Просто отправьте CLI команду в чат* — я её выполню!\n\n"
f"🖥️ *Текущий сервер:* `{server_desc}`\n"
f"📁 *Рабочая директория:* `{working_dir}`\n\n"
f"Используйте `cd путь` для смены директории.\n"
f"Или выберите сервер в меню.\n"
f"Команда /help покажет справку.",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("main", user_id=update.effective_user.id, state=state)
)
@check_access
async def menu_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка команды /menu - показывает главное меню."""
user = update.effective_user
state = state_manager.get(user.id)
# Не сбрасываем состояние - сохраняем ai_chat_mode и другие настройки
state.current_menu = "main"
# Показать текущую директорию и сервер
working_dir = state.working_directory or config.working_directory
server = server_manager.get(state.current_server)
server_desc = server.description if server else state.current_server
await update.message.reply_text(
f"🏠 *Главное меню*\n\n"
f"🖥️ *Сервер:* `{server_desc}`\n"
f"📁 *Директория:* `{working_dir}`\n\n"
f"Выберите действие:",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("main", user_id=update.effective_user.id, state=state)
)
@check_access
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка команды /help."""
help_text = f"""
📖 *Справка по боту {config.name}*
*Как использовать:*
Просто отправьте любую CLI команду в чат — бот выполнит её!
*Примеры:*
• `ls -la` — список файлов
• `pwd` — текущая директория
• `df -h` — свободное место на диске
• `git status` — статус git
*Навигация по директориям:*
• `cd путь` — сменить директорию (например, `cd git/project`)
• `cd ..` — на уровень вверх
• `cd ~` — в домашнюю директорию
• `pwd` — показать текущую директорию
*Кнопки меню:*
• 📋 Предустановленные команды — быстрые команды по категориям
• ⚙️ Настройки бота — изменение имени, описания, иконки
• ℹ️ О боте — информация
*Команды управления:*
/start — Запустить бота, главное меню
/menu — Показать главное меню с кнопками
/help — Эта справка
/settings — Настройки
*Безопасность:*
Команды выполняются от вашего имени.
Будьте осторожны с деструктивными командами!
"""
await update.message.reply_text(help_text, parse_mode="Markdown")
@check_access
async def settings_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка команды /settings."""
state = state_manager.get(update.effective_user.id)
state.current_menu = "settings"
await update.message.reply_text(
"⚙️ *Настройки бота*",
parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("settings")
)
@check_access
async def cron_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Обработка команды /cron - управление задачами.
Использование:
/cron list - показать все задачи
/cron add <name> <schedule> <prompt> - добавить задачу
/cron run <id> - выполнить задачу немедленно
/cron remove <id> - удалить задачу
/cron toggle <id> - включить/выключить задачу
"""
user_id = update.effective_user.id
args = context.args
# Получаем cron инструмент
cron_tool = tools_registry.get('cron_tool')
if not cron_tool:
await update.message.reply_text("❌ Ошибка: cron инструмент не найден")
return
# Парсим команду
if not args:
# По умолчанию показываем список задач
action = 'list'
else:
action = args[0].lower()
try:
if action == 'list':
# Показать все задачи пользователя
result = await cron_tool.execute(action='list', user_id=user_id)
if result.success and result.data:
output = "⏰ **Ваши задачи:**\n\n"
for job in result.data:
status = "✅" if job.get('enabled') else "❌"
notify_icon = "🔔" if job.get('notify') else "🔕"
log_icon = "📝" if job.get('log_results') else "🚫"
output += f"{status} **{job.get('name', 'Без названия')}** (ID: {job.get('id')})\n"
output += f" {notify_icon}{log_icon} Промпт: _{job.get('prompt', '')[:100]}_{'...' if len(job.get('prompt', '')) > 100 else ''}\n"
output += f" Расписание: `{job.get('schedule', '')}`\n"
if job.get('next_run'):
output += f" Следующий запуск: {job.get('next_run')}\n"
if job.get('last_run'):
output += f" Последний запуск: {job.get('last_run')}\n"
output += "\n"
if not output.strip():
output = "📭 У вас пока нет задач.\n\nДобавьте задачу командой:\n`/cron add <name> <schedule> <prompt>`"
await update.message.reply_text(output, parse_mode="Markdown")
else:
await update.message.reply_text("📭 У вас пока нет задач.")
elif action == 'add':
if len(args) < 4:
await update.message.reply_text(
"❌ **Недостаточно аргументов**\n\n"
"**Использование:**\n"
"`/cron add <name> <schedule> <prompt> [notify] [log]`\n\n"
"**Примеры:**\n"
"`/cron check_disk Ежедневно проверять диск на сервере`\n"
"`/cron news hourly Что нового в Linux сегодня`\n\n"
"**Расписание:**\n"
"• `@hourly` - каждый час\n"
"• `@daily` - каждый день\n"
"• `@weekly` - каждую неделю\n"
"• `*/5 * * * *` - каждые 5 минут",
parse_mode="Markdown"
)
return
name = args[1]
schedule = args[2]
# Промпт может содержать пробелы - берём всё после schedule
prompt = ' '.join(args[3:])
# Парсим опциональные параметры
notify = 'notify' in prompt.lower()
log_results = 'no_log' not in prompt.lower() and 'без_лога' not in prompt.lower()
result = await cron_tool.execute(
action='add',
name=name,
prompt=prompt,
schedule=schedule,
user_id=user_id,
notify=notify,
log_results=log_results
)
if result.success:
notify_status = "🔔 Уведомлять" if notify else "🔕 Без уведомлений"
log_status = "📝 Логировать" if log_results else "🚫 Без логов"
await update.message.reply_text(
f"✅ **Задача добавлена:**\n"
f"• ID: {result.data.get('id')}\n"
f"• Название: {name}\n"
f"• Промпт: _{prompt}_\n"
f"• Расписание: `{schedule}`\n"
f"• {notify_status}, {log_status}\n"
f"• Следующий запуск: {result.data.get('next_run', 'N/A')}",
parse_mode="Markdown"
)
else:
await update.message.reply_text(f"❌ Ошибка: {result.error}")
elif action == 'run':
if len(args) < 2:
await update.message.reply_text("❌ Укажите ID задачи: `/cron run <id>`")
return
try:
job_id = int(args[1])
except ValueError:
await update.message.reply_text("❌ ID должен быть числом")
return
status_msg = await update.message.reply_text("⏳ Выполняю задачу...")
# Выполняем задачу через AI-агент
result = await cron_tool.execute(
action='run',
job_id=job_id,
ai_agent=ai_agent,
user_id=user_id
)
await status_msg.delete()
if result.success:
result_text = result.metadata.get('result_text', 'Задача выполнена')
tool_used = result.data.get('tool_used', 'не указан')
await update.message.reply_text(
f"✅ **Задача выполнена:**\n\n{result_text}\n\n🔧 Инструмент: {tool_used}",
parse_mode="Markdown"
)
else:
await update.message.reply_text(f"❌ Ошибка: {result.error}")
elif action == 'remove':
if len(args) < 2:
await update.message.reply_text("❌ Укажите ID задачи: `/cron remove <id>`")
return
try:
job_id = int(args[1])
except ValueError:
await update.message.reply_text("❌ ID должен быть числом")
return
result = await cron_tool.execute(action='remove', job_id=job_id)
if result.success:
await update.message.reply_text(f"✅ Задача удалена: ID {job_id}")
else:
await update.message.reply_text(f"❌ Ошибка: {result.error}")
elif action == 'toggle':
if len(args) < 2:
await update.message.reply_text("❌ Укажите ID задачи: `/cron toggle <id>`")
return
try:
job_id = int(args[1])
except ValueError:
await update.message.reply_text("❌ ID должен быть числом")
return
# Получаем текущее состояние задачи
list_result = await cron_tool.execute(action='list', user_id=user_id)
current_state = True
for job in list_result.data:
if job['id'] == job_id:
current_state = job.get('enabled', True)
break
new_state = not current_state
result = await cron_tool.execute(action='toggle', job_id=job_id, enabled=new_state)
if result.success:
state_text = "включена" if new_state else "выключена"
await update.message.reply_text(f"✅ Задача ID {job_id} {state_text}")
else:
await update.message.reply_text(f"❌ Ошибка: {result.error}")
else:
await update.message.reply_text(
"❌ Неизвестная команда.\n\n"
"**Доступные команды:**\n"
"• `/cron list` - показать все задачи\n"
"• `/cron add <name> <schedule> <prompt>` - добавить задачу\n"
"• `/cron run <id>` - выполнить задачу\n"
"• `/cron remove <id>` - удалить задачу\n"
"• `/cron toggle <id>` - включить/выключить задачу",
parse_mode="Markdown"
)
except Exception as e:
logger.exception(f"Ошибка в команде /cron: {e}")
await update.message.reply_text(f"❌ Ошибка: {e}")
@check_access
async def rss_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Обработка команды /rss - показывает последние 15 новостей с AI-переводом.
Формат:
Заголовок (переведенный ИИ)
Описание (переведенный ИИ)
Ссылка на полную новость
"""
user_id = update.effective_user.id
# Отправляем статус
status_msg = await update.message.reply_text("📰 **Загрузка новостей...**\n\n_Получение лент и перевод заголовков_")
try:
# Получаем rss_tool
rss_tool = tools_registry.get('rss_tool')
if not rss_tool:
await status_msg.delete()
await update.message.reply_text("❌ Ошибка: RSS инструмент не найден")
return
# Сначала обновляем ленты
fetch_result = await rss_tool.execute(action='fetch')
if fetch_result.success:
logger.info(f"Получено {fetch_result.data.get('total_new_items', 0)} новых элементов")
# Получаем последние 15 новостей (undigested_only=False чтобы все новости)
news_result = await rss_tool.execute(action='list', limit=15, undigested_only=False)
if not news_result.success or not news_result.data:
await status_msg.delete()
await update.message.reply_text("📭 **Нет новостей**\n\n_Пока нет новостей в базах данных._\n_Добавьте RSS ленту через AI: \"добавь RSS ленту <url>\"_")
return
news_list = news_result.data
# Помечаем новости как прочитанные
for item in news_list:
news_id = item.get('id')
if news_id:
await rss_tool.mark_digest(news_id)
# Формируем вывод с AI-переводом заголовков и описаний
output = "📰 **Последние новости:**\n\n"
for i, item in enumerate(news_list, 1):
title = item.get('title', 'Без названия')
link = item.get('link', '')
pub_date = item.get('pub_date', '')
description = item.get('description', '')
# Переводим заголовок через AI
translated_title = await _translate_text(title, max_length=120)
# Экранируем специальные символы HTML
translated_title = escape_html(translated_title)
# Форматируем дату
date_str = ""
if pub_date:
try:
dt = datetime.strptime(pub_date[:19], '%Y-%m-%d %H:%M:%S')
date_str = dt.strftime('%d.%m.%Y %H:%M')
except:
date_str = pub_date[:16]
# Обрезаем заголовок если слишком длинный
if len(translated_title) > 120:
translated_title = translated_title[:117] + "..."
output += f"**{i}. {translated_title}**\n"
if date_str:
output += f"📅 {date_str}\n"
# Переводим описание если есть
if description:
translated_desc = await _translate_text(description, max_length=300)
# Экранируем специальные символы Markdown
translated_desc = escape_markdown(translated_desc)
if translated_desc:
output += f"{translated_desc}\n"
if link:
short_link = link[:60] + "..." if len(link) > 63 else link
output += f"🔗 {short_link}\n"
output += "\n"
await status_msg.delete()
await update.message.reply_text(output, parse_mode="Markdown")
except Exception as e:
logger.exception(f"Ошибка в команде /rss: {e}")
await status_msg.delete()
await update.message.reply_text(f"❌ **Ошибка:** {e}")
async def _translate_text(text: str, max_length: int = 300) -> str:
"""
Краткий перевод текста на русский через ИИ.
Если перевод не удался — возвращает оригинал.
"""
if not text or not text.strip():
return ""
try:
# Быстрый промпт для перевода
prompt = f"Переведи на русский язык этот текст (максимум {max_length} символов, без кавычек и пояснений, только перевод):\n{text[:400]}"
# Используем qwen_manager для перевода
from qwen_integration import qwen_manager
# Создаём временную сессию для перевода
import hashlib
temp_user_id = f"translator_{hashlib.md5(text.encode()).hexdigest()}"
result = await qwen_manager.run_task(
temp_user_id,
prompt,
on_output=lambda x: None,
on_oauth_url=lambda x: None,
use_system_prompt=False
)
# Извлекаем текст из результата
import re
text_matches = re.findall(r'"text":"([^"]+)"', result)
if text_matches:
translated = " ".join(text_matches).replace("\\n", " ").strip()
# Убираем кавычки если есть
translated = translated.strip('"\'')
if translated and len(translated) > 3:
# Экранируем специальные символы HTML
translated = escape_html(translated[:max_length])
return translated
return text[:max_length]
except Exception as e:
logger.debug(f"Ошибка перевода: {e}")
return text[:max_length]
@check_access
async def ai_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка команды /ai - переключение AI-провайдера."""
user_id = update.effective_user.id
state = state_manager.get(user_id)
# Получаем менеджер провайдеров
from bot.ai_provider_manager import get_ai_provider_manager
provider_manager = get_ai_provider_manager()
# Если нет аргументов - показываем текущий статус
if not context.args:
current_provider = provider_manager.get_current_provider(state)
providers_info = provider_manager.get_all_providers_info(current_provider)
output = "🤖 **AI-провайдеры**\n\n"
output += f"**Текущий:** "
for info in providers_info:
icon = "✅" if info.is_active else "⬜"
status = "✓" if info.available else "✗"
output += f"\n{icon} **{info.name}** {status}\n"
output += f" _{info.description}_\n"
output += "\n**Использование:**\n"
output += "`/ai qwen` - переключиться на Qwen Code\n"
output += "`/ai gigachat` - переключиться на GigaChat\n"
await update.message.reply_text(output, parse_mode="Markdown")
return
# Переключаем провайдер
new_provider = context.args[0].lower()
if new_provider not in ["qwen", "gigachat"]:
await update.message.reply_text(
f"❌ Неизвестный провайдер: `{new_provider}`\n\n"
f"Доступные: `qwen`, `gigachat`",
parse_mode="Markdown"
)
return
success, message = provider_manager.switch_provider(user_id, new_provider, state_manager)
if success:
# Получаем информацию о новом провайдере
provider_info = provider_manager.get_provider_info(new_provider, is_active=True)
await update.message.reply_text(
f"{message}\n\n"
f"**{provider_info.name}**\n"
f"_{provider_info.description}_",
parse_mode="Markdown"
)
else:
await update.message.reply_text(message)
+507
View File
@@ -0,0 +1,507 @@
#!/usr/bin/env python3
"""Обработчики файлов для Telegram бота.
Функционал:
- Прием файлов от пользователя с сохранением в uploads/YYYY-MM-DD/filename
- Команда /get filename — отправка файла пользователю
- Команда /files [date] — список файлов за дату (сегодня по умолчанию)
- Проверка размера файлов (max 20MB)
- Логирование операций в uploads/files.log
"""
import os
import logging
from pathlib import Path
from datetime import datetime, timedelta
from typing import Optional
from telegram import Update
from telegram.ext import CommandHandler, MessageHandler, filters, ContextTypes
from bot.config import state_manager
from bot.utils.decorators import check_access
from vector_memory import save_message
logger = logging.getLogger(__name__)
# ============================================================================
# КОНСТАНТЫ
# ============================================================================
# Максимальный размер файла: 20MB (ограничение Telegram)
MAX_FILE_SIZE_DOWNLOAD = 20 * 1024 * 1024 # 20 MB в байтах
MAX_FILE_SIZE = 20 * 1024 * 1024 # 20 MB в байтах
# Базовая директория для загрузок
BASE_DIR = Path(__file__).parent.parent.parent
UPLOADS_DIR = BASE_DIR / "uploads"
# Файл лога операций
LOG_FILE = UPLOADS_DIR / "files.log"
# ============================================================================
# ЛОГИРОВАНИЕ
# ============================================================================
def log_operation(operation: str, user_id: int, username: str, details: str = ""):
"""Логирование операций с файлами."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] {operation} | user_id={user_id} | username={username}"
if details:
log_entry += f" | {details}"
try:
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(log_entry + "\n")
except Exception as e:
logger.error(f"Ошибка записи в лог файлов: {e}")
def setup_file_logging():
"""Настройка отдельного логгера для операций с файлами."""
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
file_handler.setFormatter(logging.Formatter(
"[%(asctime)s] %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
))
file_logger = logging.getLogger("files")
file_logger.addHandler(file_handler)
file_logger.setLevel(logging.INFO)
return file_logger
file_logger = setup_file_logging()
# ============================================================================
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# ============================================================================
def get_date_dir(date: Optional[datetime] = None) -> Path:
"""Получить директорию для указанной даты."""
if date is None:
date = datetime.now()
date_str = date.strftime("%Y-%m-%d")
date_dir = UPLOADS_DIR / date_str
# Создаем директорию если нет
date_dir.mkdir(parents=True, exist_ok=True)
return date_dir
def get_file_path(filename: str, date: Optional[datetime] = None) -> Path:
"""Получить полный путь к файлу."""
date_dir = get_date_dir(date)
return date_dir / filename
def file_exists(filename: str, date: Optional[datetime] = None) -> bool:
"""Проверить существование файла."""
file_path = get_file_path(filename, date)
return file_path.exists()
def list_files_for_date(date: Optional[datetime] = None) -> list:
"""Получить список файлов за указанную дату."""
date_dir = get_date_dir(date)
if not date_dir.exists():
return []
files = []
for item in date_dir.iterdir():
if item.is_file() and item.name != "files.log":
stat = item.stat()
files.append({
"name": item.name,
"size": stat.st_size,
"path": str(item),
"modified": datetime.fromtimestamp(stat.st_mtime)
})
# Сортируем по имени
files.sort(key=lambda x: x["name"])
return files
def format_file_size(size_bytes: int) -> str:
"""Форматировать размер файла в человекочитаемом виде."""
for unit in ["B", "KB", "MB", "GB"]:
if size_bytes < 1024:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.1f} TB"
def parse_date_string(date_str: Optional[str]) -> Optional[datetime]:
"""Распарсить строку даты."""
if not date_str:
return None
# Поддерживаемые форматы
formats = [
"%Y-%m-%d", # 2024-01-15
"%d.%m.%Y", # 15.01.2024
"%d-%m-%Y", # 15-01-2024
"%Y%m%d", # 20240115
]
for fmt in formats:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
# Относительные даты
if date_str.lower() in ["yesterday", "вчера"]:
return datetime.now() - timedelta(days=1)
elif date_str.lower() in ["today", "сегодня"]:
return datetime.now()
return None
# ============================================================================
# ОБРАБОТЧИК ФАЙЛОВ
# ============================================================================
@check_access
async def handle_file_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка входящих файлов от пользователя."""
user_id = update.effective_user.id
username = update.effective_user.username or str(user_id)
# Определяем тип файла и получаем файл
file = None
file_type = None
if update.message.document:
file = update.message.document
file_type = "document"
elif update.message.photo:
# Берем фото в максимальном разрешении (последнее в списке)
file = update.message.photo[-1]
file_type = "photo"
elif update.message.audio:
file = update.message.audio
file_type = "audio"
elif update.message.voice:
file = update.message.voice
file_type = "voice"
elif update.message.video:
file = update.message.video
file_type = "video"
elif update.message.video_note:
file = update.message.video_note
file_type = "video_note"
elif update.message.sticker:
file = update.message.sticker
file_type = "sticker"
elif update.message.animation:
file = update.message.animation
file_type = "animation"
if not file:
return
# Получаем информацию о файле
file_name = file.file_name or f"file_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
file_size = file.file_size or 0
logger.info(f"Получен файл: {file_name}, размер: {file_size}, тип: {file_type}")
# Проверка размера файла
if file_size > MAX_FILE_SIZE:
log_operation("REJECTED_SIZE", user_id, username, f"file={file_name}, size={file_size}")
await update.message.reply_text(
f"❌ **Файл слишком большой**\n\n"
f"Максимальный размер: {format_file_size(MAX_FILE_SIZE)}\n"
f"Размер файла: {format_file_size(file_size)}",
parse_mode="Markdown"
)
return
# Создаем директорию для сегодняшней даты
date_dir = get_date_dir()
file_path = date_dir / file_name
# Обработка дубликатов имен
if file_path.exists():
base, ext = os.path.splitext(file_name)
counter = 1
while file_path.exists():
new_name = f"{base}_{counter}{ext}"
file_path = date_dir / new_name
counter += 1
file_name = file_path.name
logger.info(f"Сохранение файла: {file_path}")
# Отправляем статус
status_msg = await update.message.reply_text(
f"⏳ **Загрузка файла...**\n\n"
f"📁 {file_name}\n"
f"📊 Размер: {format_file_size(file_size)}",
parse_mode="Markdown"
)
try:
# Скачиваем файл
telegram_file = await context.bot.get_file(file.file_id)
await telegram_file.download_to_drive(file_path)
# Логирование
log_operation("UPLOAD", user_id, username, f"file={file_name}, size={file_size}, path={file_path}")
file_logger.info(f"UPLOAD: user={username}, file={file_name}, size={file_size}")
# === СОХРАНЕНИЕ В ПАМЯТЬ ИИ ===
# Добавляем информацию о файле в историю диалога и векторную память
state = state_manager.get(user_id)
if state:
# Формируем сообщение о файле с ПОЛНЫМ абсолютным путём!
# Это важно чтобы ИИ правильно понимал где файл
absolute_path = str(file_path.absolute())
file_info = f"Пользователь загрузил файл: {file_name} (тип: {file_type}, размер: {format_file_size(file_size)}, полный путь: {absolute_path})"
# Добавляем в историю диалога
state.ai_chat_history.append(f"User: {file_info}")
# Сохраняем в векторную память
save_message(user_id, "user", file_info)
logger.info(f"Информация о файле сохранена в памяти ИИ: {file_name}, путь: {absolute_path}")
# ===============================
# Обновляем статус
await status_msg.edit_text(
f"✅ **Файл сохранен!**\n\n"
f"📁 **Имя:** `{file_name}`\n"
f"📊 **Размер:** {format_file_size(file_size)}\n"
f"📂 **Директория:** `{date_dir.name}/`\n"
f"📅 **Дата:** {datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n"
f"Используйте `/files` для просмотра всех файлов за сегодня.",
parse_mode="Markdown"
)
except Exception as e:
logger.exception(f"Ошибка сохранения файла: {e}")
log_operation("UPLOAD_ERROR", user_id, username, f"file={file_name}, error={str(e)}")
await status_msg.edit_text(
f"❌ **Ошибка сохранения файла**\n\n"
f"Файл: `{file_name}`\n"
f"Ошибка: `{str(e)}`",
parse_mode="Markdown"
)
# ============================================================================
# КОМАНДА /GET - ПОЛУЧЕНИЕ ФАЙЛА
# ============================================================================
@check_access
async def get_file_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Команда /get filename - отправка файла пользователю.
Использование:
/get filename.txt - получить файл за сегодня
/get filename.txt 2024-01-15 - получить файл за указанную дату
"""
user_id = update.effective_user.id
username = update.effective_user.username or str(user_id)
if not context.args:
await update.message.reply_text(
"❌ *Использование:*\n\n"
"`/get <имя_файла>` - получить файл за сегодня\n"
"`/get <имя_файла> <дата>` - получить файл за указанную дату\n\n"
"*Примеры:*\n"
"`/get document.pdf`\n"
"`/get photo.jpg 2024-01-15`\n"
"`/get data.csv вчера`",
parse_mode="Markdown"
)
return
# Парсим аргументы
filename = context.args[0]
date_arg = context.args[1] if len(context.args) > 1 else None
file_date = parse_date_string(date_arg)
# Проверяем существование файла
file_path = get_file_path(filename, file_date)
if not file_path.exists():
# Ищем файл во всех директориях за сегодня
date_dir = get_date_dir(file_date)
await update.message.reply_text(
f"❌ *Файл не найден*\n\n"
f"Файл: `{filename}`\n"
f"Директория: `{date_dir.name}/`\n\n"
f"Используйте `/files` для просмотра доступных файлов.",
parse_mode="Markdown"
)
log_operation("GET_NOT_FOUND", user_id, username, f"file={filename}, date={file_date}")
return
# Проверка размера перед отправкой
file_size = file_path.stat().st_size
if file_size > MAX_FILE_SIZE:
await update.message.reply_text(
f"❌ *Файл слишком большой для отправки*\n\n"
f"Максимальный размер: {format_file_size(MAX_FILE_SIZE)}\n"
f"Размер файла: {format_file_size(file_size)}",
parse_mode="Markdown"
)
log_operation("GET_TOO_LARGE", user_id, username, f"file={filename}, size={file_size}")
return
# Отправляем статус
status_msg = await update.message.reply_text(
f"⏳ **Отправка файла...**\n\n"
f"📁 `{filename}`\n"
f"📊 {format_file_size(file_size)}",
parse_mode="Markdown"
)
try:
# Отправляем файл
with open(file_path, "rb") as f:
await update.message.reply_document(
document=f,
filename=filename,
caption=f"📁 **{filename}**\n📊 {format_file_size(file_size)}",
parse_mode="Markdown"
)
# Логирование
log_operation("GET", user_id, username, f"file={filename}, size={file_size}")
file_logger.info(f"GET: user={username}, file={filename}, size={file_size}")
# Удаляем статус
await status_msg.delete()
except Exception as e:
logger.exception(f"Ошибка отправки файла: {e}")
log_operation("GET_ERROR", user_id, username, f"file={filename}, error={str(e)}")
await status_msg.edit_text(
f"❌ **Ошибка отправки файла**\n\n"
f"Файл: `{filename}`\n"
f"Ошибка: `{str(e)}`",
parse_mode="Markdown"
)
# ============================================================================
# КОМАНДА /FILES - СПИСОК ФАЙЛОВ
# ============================================================================
@check_access
async def list_files_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Команда /files [date] - список файлов за указанную дату.
Использование:
/files - список файлов за сегодня
/files 2024-01-15 - список файлов за указанную дату
/files вчера - список файлов за вчера
"""
user_id = update.effective_user.id
username = update.effective_user.username or str(user_id)
# Парсим дату из аргументов
date_arg = context.args[0] if context.args else None
file_date = parse_date_string(date_arg)
if file_date is None and date_arg:
await update.message.reply_text(
f"❌ **Неверный формат даты:** `{date_arg}`\n\n"
f"**Поддерживаемые форматы:**\n"
f"• `2024-01-15` (YYYY-MM-DD)\n"
f"• `15.01.2024` (DD.MM.YYYY)\n"
f"• `сегодня` / `today`\n"
f"• `вчера` / `yesterday`",
parse_mode="Markdown"
)
return
# Получаем список файлов
files = list_files_for_date(file_date)
date_str = (file_date or datetime.now()).strftime("%Y-%m-%d")
date_dir = get_date_dir(file_date)
if not files:
await update.message.reply_text(
f"📭 **Нет файлов за {date_str}**\n\n"
f"Директория: `{date_dir.name}/`\n\n"
f"Отправьте файл в чат чтобы сохранить его.",
parse_mode="Markdown"
)
log_operation("LIST_EMPTY", user_id, username, f"date={date_str}")
return
# Формируем вывод
total_size = sum(f["size"] for f in files)
output = f"📁 **Файлы за {date_str}**\n\n"
output += f"📂 Директория: `{date_dir.name}/`\n"
output += f"📊 Всего файлов: {len(files)}\n"
output += f"💾 Общий размер: {format_file_size(total_size)}\n\n"
for i, file_info in enumerate(files, 1):
name = file_info["name"]
size = format_file_size(file_info["size"])
modified = file_info["modified"].strftime("%H:%M")
# Обрезаем длинные имена
if len(name) > 40:
display_name = name[:37] + "..."
else:
display_name = name
output += f"{i}. `{display_name}` - {size} ({modified})\n"
output += f"\n💡 *Использование:*\n"
output += f"`/get <имя_файла>` - скачать файл\n"
output += f"`/get <имя_файла> {date_str}` - скачать файл за дату"
await update.message.reply_text(output, parse_mode="Markdown")
log_operation("LIST", user_id, username, f"date={date_str}, count={len(files)}")
# ============================================================================
# РЕГИСТРАЦИЯ ХЕНДЛЕРОВ
# ============================================================================
def register_file_handlers(application):
"""Регистрация обработчиков файлов в приложении."""
# Обработчик входящих файлов (документы, фото, аудио, видео и т.д.)
application.add_handler(MessageHandler(
filters.Document.ALL |
filters.PHOTO |
filters.AUDIO |
filters.VOICE |
filters.VIDEO |
filters.VIDEO_NOTE |
filters.Sticker.ALL |
filters.ANIMATION,
handle_file_message
))
# Команда /get - получение файла
application.add_handler(CommandHandler("get", get_file_command))
# Команда /files - список файлов
application.add_handler(CommandHandler("files", list_files_command))
logger.info("Обработчики файлов зарегистрированы")