801 lines
34 KiB
Python
801 lines
34 KiB
Python
import asyncio
|
||
import html
|
||
import io
|
||
import logging
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
from contextlib import asynccontextmanager, nullcontext
|
||
from pathlib import Path
|
||
|
||
from aiogram import Bot, Dispatcher, F, Router
|
||
from aiogram.client.session.aiohttp import AiohttpSession
|
||
from aiogram.client.telegram import TEST
|
||
from aiogram.filters import CommandStart
|
||
from aiogram.fsm.context import FSMContext
|
||
from aiogram.fsm.state import State, StatesGroup
|
||
from aiogram.fsm.storage.memory import MemoryStorage
|
||
from aiogram.types import (
|
||
BotCommand,
|
||
CallbackQuery,
|
||
InlineKeyboardButton,
|
||
InlineKeyboardMarkup,
|
||
InputRichMessage,
|
||
Message,
|
||
)
|
||
|
||
import payments
|
||
import user_store
|
||
|
||
from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, transcribe_audio
|
||
from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain
|
||
from keyboards import mode_keyboard, resolve_button_text
|
||
from payments import send_subscription_invoice
|
||
from user_store import (
|
||
add_post_samples,
|
||
clear_user_data,
|
||
get_deploy_notice_version,
|
||
get_idea_history,
|
||
get_post_samples,
|
||
get_recent_post_history_text,
|
||
get_tone_of_voice,
|
||
init_db,
|
||
is_subscribed,
|
||
save_idea,
|
||
save_generated_posts,
|
||
save_tone_of_voice,
|
||
set_deploy_notice_version,
|
||
SUBSCRIPTION_PRICE_STARS,
|
||
ensure_subscription,
|
||
subscription_row,
|
||
)
|
||
|
||
logging.basicConfig(level=logging.INFO)
|
||
logger = logging.getLogger(__name__)
|
||
VERSION = Path(__file__).with_name("VERSION").read_text(encoding="utf-8").strip()
|
||
BOT_ENV = os.getenv("BOT_ENV", "test").strip().lower()
|
||
DEPLOY_ENV = os.getenv("DEPLOY_ENV", "production")
|
||
_deploy_notice_lock = asyncio.Lock()
|
||
|
||
router = Router()
|
||
MAX_AUDIO_BYTES = 20 * 1024 * 1024
|
||
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||
TYPING_REFRESH_SECONDS = 4
|
||
MEDIA_GROUP_COLLECT_SECONDS = 0.8
|
||
_media_groups: dict[tuple[int, str], dict[str, object]] = {}
|
||
_media_groups_lock = asyncio.Lock()
|
||
|
||
|
||
|
||
class BotMode(StatesGroup):
|
||
lobby = State()
|
||
training = State()
|
||
generation = State()
|
||
subscription = State()
|
||
help = State()
|
||
|
||
|
||
MODE_STATES: dict[str, State] = {
|
||
"lobby": BotMode.lobby,
|
||
"training": BotMode.training,
|
||
"generation": BotMode.generation,
|
||
"subscription": BotMode.subscription,
|
||
"help": BotMode.help,
|
||
}
|
||
MODE_BY_STATE = {value.state: key for key, value in MODE_STATES.items()}
|
||
MODE_COMMANDS = {
|
||
"/lobby": "lobby",
|
||
"/training": "training",
|
||
"/generate": "generation",
|
||
"/subscription": "subscription",
|
||
"/help": "help",
|
||
}
|
||
MODE_COPY = {
|
||
"lobby": ("Лобби", "Выбери режим работы в меню ниже."),
|
||
"training": ("Обучение", "Пришли скриншоты своих постов, чтобы настроить стиль."),
|
||
"generation": ("Генерация", "Опиши тему поста, попроси идею или ответь на мой вариант с правкой."),
|
||
"subscription": ("Подписка", "Здесь можно проверить статус или оплатить доступ."),
|
||
"help": ("Помощь", "Выбери нужный раздел справки в сообщении выше."),
|
||
}
|
||
|
||
|
||
async def current_mode(state: FSMContext) -> str:
|
||
return MODE_BY_STATE.get(await state.get_state(), "lobby")
|
||
|
||
|
||
async def enter_mode(message: Message, state: FSMContext, mode: str) -> None:
|
||
await state.clear()
|
||
await state.set_state(MODE_STATES[mode])
|
||
title, description = MODE_COPY[mode]
|
||
if mode == "help":
|
||
await send_rich_message(message, help_view(None), "помощи")
|
||
await message.answer(
|
||
f"Режим «{title}». {description}",
|
||
reply_markup=mode_keyboard(mode),
|
||
)
|
||
|
||
|
||
async def require_mode(message: Message, state: FSMContext, mode: str) -> bool:
|
||
if await current_mode(state) == mode:
|
||
return True
|
||
title = MODE_COPY[mode][0]
|
||
command = next(command for command, target in MODE_COMMANDS.items() if target == mode)
|
||
await message.answer(
|
||
f"Эта команда работает только в режиме «{title}». Переключись: {command}.",
|
||
reply_markup=mode_keyboard(await current_mode(state)),
|
||
)
|
||
return False
|
||
|
||
|
||
def is_development_environment() -> bool:
|
||
"""Enable developer-only behavior only for the explicit Coolify dev value."""
|
||
return DEPLOY_ENV == "development"
|
||
|
||
|
||
def _read_version() -> str:
|
||
version_path = Path(__file__).with_name("VERSION")
|
||
return version_path.read_text(encoding="utf-8").strip()
|
||
|
||
|
||
def split_text(text: str, limit: int = 4000) -> list[str]:
|
||
chunks: list[str] = []
|
||
remaining = text.strip()
|
||
while len(remaining) > limit:
|
||
split_at = remaining.rfind(" ", 0, limit + 1)
|
||
if split_at < limit // 2:
|
||
split_at = limit
|
||
chunks.append(remaining[:split_at].strip())
|
||
remaining = remaining[split_at:].strip()
|
||
if remaining:
|
||
chunks.append(remaining)
|
||
return chunks
|
||
|
||
|
||
async def report_error(message: Message, stage: str, exc: Exception) -> None:
|
||
detail = str(exc).strip()
|
||
detail = re.sub(r"(?i)(?:sk-[A-Za-z0-9_-]{12,}|[0-9]{8,}:[A-Za-z0-9_-]{20,})", "[секрет скрыт]", detail)
|
||
detail = re.sub(r"https?://\S+", "[внешний сервис]", detail)
|
||
detail = detail or "нет подробностей"
|
||
try:
|
||
await message.answer(f"❌ Не получилось: {stage}. Попробуй ещё раз позже.")
|
||
if is_development_environment():
|
||
await message.answer(
|
||
f"🧪 Dev-диагностика: {stage}. Причина: <code>{html.escape(detail[:240])}</code>",
|
||
parse_mode="HTML",
|
||
)
|
||
except Exception as send_exc:
|
||
logger.error("Unable to report processing error: %s", type(send_exc).__name__)
|
||
logger.error("User operation failed at %s (%s): %s", stage, type(exc).__name__, detail[:240])
|
||
|
||
|
||
async def notify_development_deploy(bot: Bot) -> None:
|
||
if not is_development_environment():
|
||
return
|
||
chat_id = os.getenv("DEV_CHAT_ID", "").strip()
|
||
if not chat_id:
|
||
logger.warning("DEV_CHAT_ID is missing; no version notice will be sent")
|
||
return
|
||
async with _deploy_notice_lock:
|
||
previous = await asyncio.to_thread(get_deploy_notice_version)
|
||
if previous == VERSION:
|
||
return
|
||
try:
|
||
await asyncio.wait_for(
|
||
bot.send_message(chat_id=int(chat_id), text=f"🚀 Бот обновлён\nВерсия: {VERSION}"),
|
||
timeout=8,
|
||
)
|
||
except Exception as exc:
|
||
logger.error("Development deploy notice failed (%s): %s", type(exc).__name__, str(exc)[:160])
|
||
return
|
||
await asyncio.to_thread(set_deploy_notice_version, VERSION)
|
||
logger.info("Development deploy notice sent: version=%s", VERSION)
|
||
|
||
|
||
async def send_rich_message(message: Message, rich_html: str, stage: str) -> Message | None:
|
||
bot = message.bot
|
||
try:
|
||
sent = await bot.send_rich_message(
|
||
chat_id=message.chat.id,
|
||
rich_message=InputRichMessage(html=rich_html),
|
||
message_thread_id=message.message_thread_id,
|
||
)
|
||
return sent
|
||
except Exception as exc:
|
||
logger.warning("Rich %s send failed (%s): %s", stage, type(exc).__name__, str(exc)[:180])
|
||
if is_development_environment():
|
||
await report_error(message, f"отправка Rich Message: {stage}", exc)
|
||
await message.answer(help_plain_view(None) if stage == "помощи" else rich_to_plain(rich_html))
|
||
return None
|
||
|
||
|
||
async def edit_rich_message(message: Message, rich_html: str) -> bool:
|
||
bot = message.bot
|
||
if message.message_id is None:
|
||
return False
|
||
try:
|
||
await bot.edit_message_text(
|
||
chat_id=message.chat.id,
|
||
message_id=message.message_id,
|
||
rich_message=InputRichMessage(html=rich_html),
|
||
)
|
||
return True
|
||
except Exception as exc:
|
||
logger.warning("Rich help edit failed (%s): %s", type(exc).__name__, str(exc)[:180])
|
||
return False
|
||
|
||
|
||
@router.callback_query(F.data.startswith("help:"))
|
||
async def help_navigation(callback: CallbackQuery, state: FSMContext) -> None:
|
||
message = callback.message
|
||
if not isinstance(message, Message):
|
||
return
|
||
if await current_mode(state) != "help":
|
||
await callback.answer("Сначала открой режим /help.", show_alert=True)
|
||
return
|
||
section = (callback.data or "help:menu").split(":", 1)[1]
|
||
if not await edit_rich_message(message, help_view(section)):
|
||
await callback.answer("Не удалось обновить справку, открой /help заново", show_alert=True)
|
||
return
|
||
await callback.answer()
|
||
|
||
|
||
@router.callback_query(F.data.startswith("dev_paid:"))
|
||
async def confirm_development_payment(callback: CallbackQuery) -> None:
|
||
if not is_development_environment():
|
||
await callback.answer()
|
||
return
|
||
try:
|
||
user_id = int((callback.data or "").split(":", 1)[1])
|
||
except (IndexError, ValueError):
|
||
await callback.answer("Некорректное подтверждение.", show_alert=True)
|
||
return
|
||
if callback.from_user is None or callback.from_user.id != user_id:
|
||
await callback.answer("Эта кнопка предназначена другому пользователю.", show_alert=True)
|
||
return
|
||
await asyncio.to_thread(ensure_subscription, user_id)
|
||
days = await asyncio.to_thread(user_store.activate_subscription, user_id, months=1)
|
||
await callback.answer()
|
||
if isinstance(callback.message, Message):
|
||
await callback.message.edit_reply_markup(reply_markup=None)
|
||
await callback.message.answer(
|
||
f"🧪 Dev-платёж подтверждён. Подписка включена на {days} дн. Реальная оплата не проводилась."
|
||
)
|
||
|
||
|
||
@router.message(CommandStart())
|
||
async def start(message: Message, state: FSMContext) -> None:
|
||
if message.from_user is None:
|
||
await message.answer("Не удалось определить пользователя.")
|
||
return
|
||
await state.clear()
|
||
await state.set_state(BotMode.lobby)
|
||
await notify_development_deploy(message.bot)
|
||
await asyncio.to_thread(ensure_subscription, message.from_user.id)
|
||
await message.answer(
|
||
"В нижнем меню выбери режим: обучение, генерация постов, подписка или помощь."
|
||
)
|
||
await send_rich_message(message, WELCOME_RICH_HTML, "приветствия")
|
||
await message.answer("Ты в лобби. Выбери режим работы.", reply_markup=mode_keyboard("lobby"))
|
||
|
||
|
||
async def _show_subscription_info(
|
||
message: Message, sub: sqlite3.Row | None, mode: str = "subscription"
|
||
) -> None:
|
||
if not sub:
|
||
payment_hint = (
|
||
"Переключись в режим «Подписка» командой /subscription, затем используй /subscribe."
|
||
if mode != "subscription"
|
||
else "Начни с команды /subscribe."
|
||
)
|
||
await message.answer(
|
||
f"У тебя нет активной подписки или триала. {payment_hint}",
|
||
reply_markup=mode_keyboard(mode),
|
||
)
|
||
return
|
||
days_left = user_store._days_until(sub)
|
||
if days_left >= 0:
|
||
if sub["last_paid_at"]:
|
||
await message.answer(
|
||
f"Подписка активна — осталось {days_left} дн.\n"
|
||
f"Следующее списание через {days_left} дн."
|
||
)
|
||
else:
|
||
await message.answer(
|
||
f"Триал активен — осталось {days_left} дн.\n"
|
||
f"Когда триал закончится, нужно оплатить подписку — команда /subscribe."
|
||
)
|
||
else:
|
||
payment_hint = (
|
||
"Переключись в режим «Подписка» командой /subscription, затем используй /subscribe."
|
||
if mode != "subscription"
|
||
else "Оплати /subscribe, чтобы продолжить."
|
||
)
|
||
await message.answer(
|
||
f"Твой период (триал или подписка) истёк. {payment_hint} "
|
||
f"Стоимость — {SUBSCRIPTION_PRICE_STARS} звёзд в месяц.",
|
||
reply_markup=mode_keyboard(mode),
|
||
)
|
||
if sub["last_paid_at"] is None:
|
||
await message.answer(
|
||
f"Чтобы активировать — используй /subscribe в режиме «Подписка» "
|
||
f"и пришли {SUBSCRIPTION_PRICE_STARS} звёзд."
|
||
)
|
||
|
||
|
||
async def balance_command(message: Message, state: FSMContext) -> None:
|
||
if not await require_mode(message, state, "subscription"):
|
||
return
|
||
if message.from_user is None:
|
||
await message.answer("Не удалось определить пользователя.")
|
||
return
|
||
sub = await asyncio.to_thread(subscription_row, message.from_user.id)
|
||
await _show_subscription_info(message, sub)
|
||
|
||
|
||
async def subscribe_command(message: Message, state: FSMContext) -> None:
|
||
if not await require_mode(message, state, "subscription"):
|
||
return
|
||
if message.from_user is None:
|
||
await message.answer("Не удалось определить пользователя.")
|
||
return
|
||
await asyncio.to_thread(ensure_subscription, message.from_user.id)
|
||
try:
|
||
await send_subscription_invoice(
|
||
message.bot, message.chat.id, message.message_thread_id
|
||
)
|
||
except Exception as exc:
|
||
logger.error("Invoice send failed (%s): %s", type(exc).__name__, str(exc)[:180])
|
||
if is_development_environment():
|
||
await report_error(message, "отправка счёта на оплату", exc)
|
||
else:
|
||
await message.answer(
|
||
"Не получилось открыть счёт на оплату. Попробуй ещё раз через минуту "
|
||
"или напиши поддержке."
|
||
)
|
||
return
|
||
await message.answer(f"Счёт на {SUBSCRIPTION_PRICE_STARS} звёзд на 30 дней выше.")
|
||
if is_development_environment():
|
||
await message.answer(
|
||
"🧪 Dev-режим: для продолжения разработки нажми кнопку ниже. "
|
||
"Она включит доступ без реального платежа.",
|
||
reply_markup=InlineKeyboardMarkup(
|
||
inline_keyboard=[[InlineKeyboardButton(
|
||
text="🧪 Оплатил (dev)",
|
||
callback_data=f"dev_paid:{message.from_user.id}",
|
||
)]]
|
||
),
|
||
)
|
||
else:
|
||
await message.answer(
|
||
"Подписка активируется сразу после оплаты — отдельного подтверждения не нужно."
|
||
)
|
||
|
||
|
||
async def style_command(message: Message, state: FSMContext) -> None:
|
||
if not await require_mode(message, state, "training"):
|
||
return
|
||
if message.from_user is None:
|
||
await message.answer("Не удалось определить пользователя.")
|
||
return
|
||
tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id)
|
||
samples = await asyncio.to_thread(get_post_samples, message.from_user.id)
|
||
if not tone and not samples:
|
||
await message.answer(
|
||
"Пока не настроен. Пришли скриншоты своих опубликованных постов — "
|
||
"я распознаю текст, сохраню образцы и построю правила твоего тона.",
|
||
reply_markup=mode_keyboard("training"),
|
||
)
|
||
return
|
||
await message.answer(
|
||
f"<b>Твой стиль</b>\n\n{tone or 'Правила пока не сформированы.'}",
|
||
parse_mode="HTML",
|
||
reply_markup=mode_keyboard("training"),
|
||
)
|
||
if samples:
|
||
await message.answer(f"Образцов сохранено: {len(samples)}.")
|
||
|
||
|
||
async def clear_command(message: Message, state: FSMContext) -> None:
|
||
if not await require_mode(message, state, "training"):
|
||
return
|
||
if message.from_user is None:
|
||
await message.answer("Не удалось определить пользователя.")
|
||
return
|
||
await asyncio.to_thread(clear_user_data, message.from_user.id)
|
||
await message.answer(
|
||
"Данные обучения и история очищены: стиль, примеры постов, история публикаций и идей. "
|
||
"Подписка и триал не изменены. Служебные API-логи сохраняются. "
|
||
"Пришли новые скриншоты, чтобы настроить стиль заново.",
|
||
reply_markup=mode_keyboard("training"),
|
||
)
|
||
|
||
|
||
async def help_command(message: Message, state: FSMContext | None = None) -> None:
|
||
if state is not None:
|
||
await enter_mode(message, state, "help")
|
||
return
|
||
await send_rich_message(message, help_view(None), "помощи")
|
||
await message.answer(
|
||
"Режим «Помощь». Выбери раздел выше или другой режим в меню.",
|
||
reply_markup=mode_keyboard("help"),
|
||
)
|
||
|
||
|
||
|
||
async def _check_subscription(message: Message) -> bool:
|
||
if message.from_user is None:
|
||
return False
|
||
if not await asyncio.to_thread(is_subscribed, message.from_user.id):
|
||
sub = await asyncio.to_thread(subscription_row, message.from_user.id)
|
||
await _show_subscription_info(message, sub, mode="generation")
|
||
return False
|
||
return True
|
||
|
||
|
||
async def _send_idea(message: Message, idea: dict[str, str], user_id: int) -> None:
|
||
await message.answer(f"{idea['title']}\n\n{idea['angle']}\n\nЗачем: {idea['why']}")
|
||
await asyncio.to_thread(save_idea, user_id, idea)
|
||
|
||
|
||
async def _send_three_posts(message: Message, posts: list[str], user_id: int | None = None, origin: str = "generated") -> None:
|
||
for post in posts:
|
||
await message.answer(post)
|
||
if user_id is not None:
|
||
await asyncio.to_thread(save_generated_posts, user_id, posts, origin)
|
||
|
||
|
||
|
||
@asynccontextmanager
|
||
async def typing_status(bot: Bot, chat_id: int):
|
||
try:
|
||
await bot.send_chat_action(chat_id, "typing")
|
||
except Exception as exc:
|
||
logger.debug("Typing indicator unavailable: %s", type(exc).__name__)
|
||
|
||
async def refresh_typing() -> None:
|
||
while True:
|
||
await asyncio.sleep(TYPING_REFRESH_SECONDS)
|
||
try:
|
||
await bot.send_chat_action(chat_id, "typing")
|
||
except Exception as exc:
|
||
logger.debug("Typing indicator refresh failed: %s", type(exc).__name__)
|
||
|
||
task = asyncio.create_task(refresh_typing())
|
||
try:
|
||
yield
|
||
finally:
|
||
task.cancel()
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
|
||
async def _classify_and_respond(
|
||
message: Message,
|
||
text: str,
|
||
show_typing: bool = True,
|
||
state: FSMContext | None = None,
|
||
) -> None:
|
||
if message.from_user is None:
|
||
await message.answer("Не удалось определить пользователя.")
|
||
return
|
||
if text.strip().casefold() == "/help":
|
||
await help_command(message, state)
|
||
return
|
||
if not await _check_subscription(message):
|
||
return
|
||
reply_message = message.reply_to_message
|
||
original_post = ""
|
||
if reply_message and reply_message.from_user and reply_message.from_user.is_bot:
|
||
original_post = (reply_message.text or reply_message.caption or "").strip()
|
||
try:
|
||
async with (typing_status(message.bot, message.chat.id) if show_typing else nullcontext()):
|
||
intent = await classify_intent(text, original_post or None, user_id=message.from_user.id)
|
||
if intent["intent"] == "help":
|
||
await help_command(message, state)
|
||
return
|
||
if intent["intent"] == "generate_ideas":
|
||
tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id)
|
||
history = await asyncio.to_thread(
|
||
get_recent_post_history_text, message.from_user.id,
|
||
POST_HISTORY_LIMIT, POST_HISTORY_CHAR_LIMIT,
|
||
)
|
||
idea_history = await asyncio.to_thread(get_idea_history, message.from_user.id, 30)
|
||
idea = await generate_ideas(text, tone, history, idea_history, user_id=message.from_user.id)
|
||
await _send_idea(message, idea, message.from_user.id)
|
||
return
|
||
if intent["intent"] == "revise_post":
|
||
if not original_post:
|
||
await message.answer("Чтобы исправить пост, ответь реплаем на сообщение с его вариантом.")
|
||
return
|
||
tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id)
|
||
posts = await generate_posts(
|
||
intent, tone, original_post=original_post, edit_request=text,
|
||
user_id=message.from_user.id,
|
||
)
|
||
origin = "revised"
|
||
else:
|
||
tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id)
|
||
posts = await generate_posts(intent, tone, user_id=message.from_user.id)
|
||
origin = "generated"
|
||
await _send_three_posts(message, posts, user_id=message.from_user.id, origin=origin)
|
||
except Exception as exc:
|
||
await report_error(message, "классификация или генерация поста", exc)
|
||
|
||
|
||
async def _download_message_image(message: Message, bot: Bot) -> tuple[bytes, str] | None:
|
||
document = message.document
|
||
if document:
|
||
mime_type = (document.mime_type or "").lower()
|
||
if not mime_type.startswith("image/"):
|
||
return None
|
||
file_obj = document
|
||
elif message.photo:
|
||
file_obj = message.photo[-1]
|
||
mime_type = "image/jpeg"
|
||
else:
|
||
return None
|
||
if file_obj.file_size and file_obj.file_size > MAX_IMAGE_BYTES:
|
||
await message.answer("Скриншот слишком большой. Пришли изображение до 10 МБ.")
|
||
return None
|
||
output = io.BytesIO()
|
||
await bot.download(file_obj, destination=output)
|
||
image = output.getvalue()
|
||
if not image:
|
||
await message.answer("Не получилось скачать изображение. Попробуй ещё раз.")
|
||
return None
|
||
if len(image) > MAX_IMAGE_BYTES:
|
||
await message.answer("Скриншот слишком большой. Пришли изображение до 10 МБ.")
|
||
return None
|
||
return image, mime_type
|
||
|
||
|
||
async def _process_image_group(messages: list[Message], bot: Bot) -> None:
|
||
first = messages[0]
|
||
if first.from_user is None:
|
||
await first.answer("Не удалось определить пользователя.")
|
||
return
|
||
if len(messages) > 10:
|
||
await first.answer("В одной галерее можно обработать не более 10 изображений.")
|
||
return
|
||
images: list[tuple[bytes, str]] = []
|
||
for message in messages:
|
||
image_payload = await _download_message_image(message, bot)
|
||
if image_payload:
|
||
images.append(image_payload)
|
||
if not images:
|
||
return
|
||
previous_tone = await asyncio.to_thread(get_tone_of_voice, first.from_user.id)
|
||
new_samples = await extract_posts_from_images(images, user_id=first.from_user.id)
|
||
if not new_samples:
|
||
await first.answer("Не нашла читаемых текстов постов на скриншотах. Стиль не меняла.")
|
||
return
|
||
all_samples = await asyncio.to_thread(add_post_samples, first.from_user.id, new_samples)
|
||
tone = await create_tone_profile(all_samples, previous_tone, user_id=first.from_user.id)
|
||
await asyncio.to_thread(save_tone_of_voice, first.from_user.id, tone)
|
||
await first.answer(
|
||
f"Обновила только правила стиля по накопленным примерам ({len(all_samples)} постов).\n\n{tone}\n\n"
|
||
"Распознанные тексты сохранены для следующих обновлений и не переписывались."
|
||
)
|
||
|
||
|
||
async def _collect_media_group(message: Message, bot: Bot) -> list[Message]:
|
||
media_group_id = message.media_group_id
|
||
if not media_group_id:
|
||
return [message]
|
||
key = (message.chat.id, media_group_id)
|
||
future: asyncio.Future[list[Message]] = asyncio.get_running_loop().create_future()
|
||
async with _media_groups_lock:
|
||
group = _media_groups.get(key)
|
||
if group is None:
|
||
group = {"messages": [message], "future": future}
|
||
_media_groups[key] = group
|
||
asyncio.create_task(_finish_media_group(key))
|
||
else:
|
||
group["messages"].append(message)
|
||
future = group["future"]
|
||
try:
|
||
return await future
|
||
except Exception:
|
||
async with _media_groups_lock:
|
||
group = _media_groups.get(key)
|
||
if group:
|
||
try:
|
||
group["messages"].remove(message)
|
||
except ValueError:
|
||
pass
|
||
raise
|
||
|
||
|
||
async def _finish_media_group(key: tuple[int, str]) -> None:
|
||
await asyncio.sleep(MEDIA_GROUP_COLLECT_SECONDS)
|
||
async with _media_groups_lock:
|
||
group = _media_groups.pop(key, None)
|
||
if group is None:
|
||
return
|
||
group["future"].set_result(group["messages"])
|
||
|
||
|
||
async def _handle_image_message(message: Message, bot: Bot) -> None:
|
||
await notify_development_deploy(bot)
|
||
try:
|
||
if message.media_group_id:
|
||
messages = await _collect_media_group(message, bot)
|
||
if messages[0].message_id != message.message_id:
|
||
return
|
||
else:
|
||
messages = [message]
|
||
async with typing_status(bot, message.chat.id):
|
||
await _process_image_group(messages, bot)
|
||
except Exception as exc:
|
||
await report_error(message, "чтение скриншота или обновление ToV", exc)
|
||
|
||
|
||
@router.message(F.photo | (F.document & F.document.mime_type.startswith("image/")))
|
||
async def update_tone_from_image(message: Message, bot: Bot, state: FSMContext) -> None:
|
||
if not await require_mode(message, state, "training"):
|
||
return
|
||
await _handle_image_message(message, bot)
|
||
|
||
|
||
async def _process_voice(message: Message, bot: Bot, state: FSMContext) -> None:
|
||
if not os.environ.get("APINEX_API_KEY", "").strip():
|
||
await message.answer("Расшифровка голосовых пока не настроена.")
|
||
return
|
||
audio_file = message.voice or message.audio
|
||
if audio_file is None:
|
||
return
|
||
if audio_file.file_size and audio_file.file_size > MAX_AUDIO_BYTES:
|
||
await message.answer("Аудиофайл слишком большой. Максимальный размер — 20 МБ.")
|
||
return
|
||
audio_buffer = io.BytesIO()
|
||
await bot.download(audio_file, destination=audio_buffer)
|
||
audio = audio_buffer.getvalue()
|
||
if not audio:
|
||
await message.answer("Не удалось получить аудиофайл.")
|
||
return
|
||
if len(audio) > MAX_AUDIO_BYTES:
|
||
await message.answer("Аудиофайл слишком большой. Максимальный размер — 20 МБ.")
|
||
return
|
||
content_type = "audio/ogg" if message.voice else (message.audio.mime_type or "audio/mpeg")
|
||
transcript = await transcribe_audio(audio, content_type, user_id=message.from_user.id if message.from_user else None)
|
||
if not transcript:
|
||
await message.answer("Не удалось распознать речь в аудио.")
|
||
return
|
||
await _classify_and_respond(message, transcript, show_typing=False, state=state)
|
||
|
||
|
||
@router.message(F.voice | F.audio)
|
||
async def transcribe_voice(message: Message, bot: Bot, state: FSMContext) -> None:
|
||
if not await require_mode(message, state, "generation"):
|
||
return
|
||
await notify_development_deploy(bot)
|
||
try:
|
||
async with typing_status(bot, message.chat.id):
|
||
await _process_voice(message, bot, state)
|
||
except Exception as exc:
|
||
await report_error(message, "обработка голосового и генерация", exc)
|
||
|
||
|
||
@router.message(F.text & F.text.startswith("/"))
|
||
async def handle_command_text(message: Message, state: FSMContext) -> None:
|
||
raw = (message.text or "").strip()
|
||
resolved = resolve_button_text(raw) or raw
|
||
command = resolved.split(maxsplit=1)[0].split("@", 1)[0].casefold()
|
||
|
||
if command == "/start":
|
||
await start(message, state)
|
||
elif command in MODE_COMMANDS:
|
||
await enter_mode(message, state, MODE_COMMANDS[command])
|
||
elif command == "/clear":
|
||
await clear_command(message, state)
|
||
elif command == "/style":
|
||
await style_command(message, state)
|
||
elif command == "/idea":
|
||
if await require_mode(message, state, "generation"):
|
||
await _classify_and_respond(
|
||
message, "предложи идею для нового поста", state=state
|
||
)
|
||
elif command == "/post":
|
||
if await require_mode(message, state, "generation"):
|
||
await _classify_and_respond(message, "Напиши пост", state=state)
|
||
elif command == "/balance":
|
||
await balance_command(message, state)
|
||
elif command == "/subscribe":
|
||
await subscribe_command(message, state)
|
||
else:
|
||
await message.answer("Неизвестная команда. Выбери режим или открой /help.")
|
||
|
||
|
||
@router.message(F.text & ~F.text.startswith("/"))
|
||
async def handle_text(message: Message, state: FSMContext) -> None:
|
||
await notify_development_deploy(message.bot)
|
||
text = (message.text or "").strip()
|
||
if not text:
|
||
return
|
||
button = resolve_button_text(text)
|
||
if button and button.startswith("/"):
|
||
await handle_command_text(message, state)
|
||
return
|
||
|
||
mode = await current_mode(state)
|
||
if mode != "generation":
|
||
prompts = {
|
||
"lobby": "Выбери режим работы кнопкой ниже.",
|
||
"training": "В режиме «Обучение» пришли скриншоты постов или выбери действие в меню.",
|
||
"subscription": "В режиме «Подписка» выбери статус или оплату в меню.",
|
||
"help": "Открой нужный раздел справки выше или выбери другой режим.",
|
||
}
|
||
await message.answer(prompts.get(mode, "Выбери режим работы."), reply_markup=mode_keyboard(mode))
|
||
return
|
||
if button:
|
||
text = button
|
||
await _classify_and_respond(message, text, state=state)
|
||
|
||
|
||
@router.message()
|
||
async def handle_other_message(message: Message, state: FSMContext) -> None:
|
||
mode = await current_mode(state)
|
||
if mode == "training":
|
||
text = "В режиме «Обучение» пришли скриншоты постов."
|
||
elif mode == "generation":
|
||
text = "Для генерации отправь текстовый запрос или голосовое сообщение."
|
||
elif mode == "subscription":
|
||
text = "В режиме «Подписка» выбери действие в меню."
|
||
elif mode == "help":
|
||
text = "Выбери раздел справки или другой режим в меню."
|
||
else:
|
||
text = "Выбери режим работы кнопкой ниже."
|
||
await message.answer(text, reply_markup=mode_keyboard(mode))
|
||
|
||
|
||
async def set_main_menu(bot: Bot) -> None:
|
||
"""Публикует команды переключения режимов и действия текущих режимов."""
|
||
try:
|
||
await bot.set_my_commands(
|
||
commands=[
|
||
BotCommand(command="start", description="Вернуться в лобби"),
|
||
BotCommand(command="lobby", description="Режим: лобби"),
|
||
BotCommand(command="training", description="Режим: обучение"),
|
||
BotCommand(command="generate", description="Режим: генерация"),
|
||
BotCommand(command="subscription", description="Режим: подписка"),
|
||
BotCommand(command="help", description="Режим: помощь"),
|
||
BotCommand(command="post", description="Написать пост (генерация)"),
|
||
BotCommand(command="style", description="Мой стиль (обучение)"),
|
||
BotCommand(command="clear", description="Сбросить данные (обучение)"),
|
||
BotCommand(command="idea", description="Идея (генерация)"),
|
||
BotCommand(command="balance", description="Статус подписки"),
|
||
BotCommand(command="subscribe", description="Оплатить подписку"),
|
||
]
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("Bot commands publish failed (%s): %s", type(exc).__name__, str(exc)[:120])
|
||
|
||
|
||
async def main() -> None:
|
||
init_db()
|
||
payments.init_payments_table()
|
||
token = os.environ.get("BOT_TOKEN", "").strip()
|
||
if not token:
|
||
raise RuntimeError("BOT_TOKEN is required")
|
||
|
||
test_env = os.environ.get("BOT_ENV", "test").strip().lower() == "test"
|
||
session = AiohttpSession(api=TEST) if test_env else None
|
||
bot = Bot(token=token, session=session)
|
||
await notify_development_deploy(bot)
|
||
dp = Dispatcher(storage=MemoryStorage())
|
||
dp.include_router(payments.router)
|
||
dp.include_router(router)
|
||
# Ставим меню один раз при старте, чтобы оно было у всех, кто писал боту.
|
||
await set_main_menu(bot)
|
||
|
||
try:
|
||
await dp.start_polling(bot)
|
||
finally:
|
||
await bot.session.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|