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.types import (
BotCommand,
CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
InputRichMessage,
Message,
)
import keyboards
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 LOCKED_MENU, MAIN_MENU, resolve_button_text
from payments import send_subscription_invoice
from user_store import (
add_post_samples,
clear_user_data,
deactivate_subscription,
get_deploy_notice_version,
get_idea_history,
get_post_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")
DEV_CHAT_ID = os.getenv("DEV_CHAT_ID", "").strip()
HELP_COMMANDS = {"help", "помощь"}
_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()
_deploy_notice_sent = False
_deploy_notice_lock = asyncio.Lock()
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}. Причина: {html.escape(detail[:240])}",
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) -> None:
message = callback.message
if not isinstance(message, Message):
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) -> None:
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
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 asyncio.sleep(0.8)
await help_command(message)
await message.answer("Меню обновлено.", reply_markup=MAIN_MENU)
async def _show_subscription_info(message: Message, sub: sqlite3.Row | None) -> None:
if not sub:
await message.answer(
"У тебя нет активной подписки или триала. Начни с команды /subscribe.",
reply_markup=LOCKED_MENU,
)
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:
await message.answer(
"Твой период (триал или подписка) истёк. "
"Оплати /subscribe, чтобы продолжить. Стоимость — 100 звёзд в месяц.",
reply_markup=LOCKED_MENU,
)
if sub["last_paid_at"] is None:
await message.answer(
f"Чтобы активировать — пришли {SUBSCRIPTION_PRICE_STARS} звёзд командой /subscribe."
)
async def balance_command(message: Message) -> None:
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) -> None:
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) -> None:
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=MAIN_MENU,
)
return
await message.answer(
f"Твой стиль\n\n{tone or 'Правила пока не сформированы.'}",
parse_mode="HTML",
reply_markup=MAIN_MENU,
)
if samples:
await message.answer(f"Образцов сохранено: {len(samples)}.")
async def clear_command(message: Message) -> None:
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
await asyncio.to_thread(clear_user_data, message.from_user.id)
deactivate_subscription(message.from_user.id)
await message.answer(
"Команда /clear полностью обнулила твои данные: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки. "
"Служебные API-логи сохраняются по установленному сроку хранения. Пришли новые скриншоты, чтобы настроить стиль заново."
)
await asyncio.sleep(0.5)
await message.answer(
"Подписка обнулена. У тебя нет активной подписки и триала — сгенерировать пост нельзя. "
f"Чтобы продолжить, купи подписку за {user_store.SUBSCRIPTION_PRICE_STARS} звёзд командой /subscribe."
)
async def help_command(message: Message) -> None:
await send_rich_message(message, help_view(None), "помощи")
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)
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) -> None:
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
# /help всегда доступен, даже без подписки
if text.strip().casefold() == "/help":
await help_command(message)
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)
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) -> None:
await _handle_image_message(message, bot)
async def _process_voice(message: Message, bot: Bot) -> 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)
@router.message(F.voice | F.audio)
async def transcribe_voice(message: Message, bot: Bot) -> None:
await notify_development_deploy(bot)
try:
async with typing_status(bot, message.chat.id):
await _process_voice(message, bot)
except Exception as exc:
await report_error(message, "обработка голосового и генерация", exc)
@router.message(F.text & F.text.startswith("/"))
async def handle_command_text(message: Message) -> None:
raw = (message.text or "").strip()
# Кнопки reply-клавиатуры отправляют текст, который мапится на команду/запрос.
resolved = resolve_button_text(raw) or raw
command = resolved.split(maxsplit=1)[0].split("@", 1)[0].casefold()
if command == "/help":
await help_command(message)
elif command == "/start":
await start(message)
elif command == "/clear":
await clear_command(message)
elif command == "/style":
await style_command(message)
elif command == "/idea":
await _classify_and_respond(message, "предложи идею для нового поста")
elif command == "/balance":
await balance_command(message)
elif command == "/subscribe":
await subscribe_command(message)
elif resolved != raw:
# Это была кнопка меню, а не команда — отправляем её как обычный запрос.
await _classify_and_respond(message, resolved)
else:
await message.answer("Неизвестная команда. Используй /help.")
@router.message(F.text & ~F.text.startswith("/"))
async def handle_text(message: Message) -> None:
await notify_development_deploy(message.bot)
text = (message.text or "").strip()
if not text:
return
button = resolve_button_text(text)
if button:
if button.startswith("/"):
await handle_command_text(message)
return
text = button
await _classify_and_respond(message, text)
async def set_main_menu(bot: Bot) -> None:
"""Публикует reply-клавиатуру для всех пользователей бота."""
try:
await bot.set_my_commands(
commands=[
BotCommand(command="help", description="Справка по боту"),
BotCommand(command="style", description="Мой Tone of Voice"),
BotCommand(command="idea", description="Идея для поста"),
BotCommand(command="balance", description="Статус подписки"),
BotCommand(command="subscribe", description="Оплатить подписку"),
BotCommand(command="clear", 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()
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())