Initial commit: Telegram CLI Bot with AI memory, tools and multi-provider support
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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-пресетов зарегистрированы")
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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("Обработчики файлов зарегистрированы")
|
||||
Reference in New Issue
Block a user