from __future__ import annotations from datetime import datetime from html import escape from aiogram import Bot, F, Router from aiogram.exceptions import TelegramForbiddenError from aiogram.enums import ChatMemberStatus, ChatType from aiogram.filters import Command, CommandStart from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.types import CallbackQuery, Message from app.categories import Category, category_label, classify, suggest_subcategory from app.config import Settings from app.database import Database, FamilyJoinError from app.domain import TransactionKind from app.keyboards import ( ALL_TOTAL, ANALYTICS, CATEGORIES, DEBT, FAMILY, HELP, MONTH_TOTAL, RECENT, analytics_period_keyboard, category_management_keyboard, category_tree_keyboard, category_keyboard, debt_keyboard, family_setup_keyboard, family_approval_keyboard, main_keyboard, parent_category_keyboard, transaction_actions, ) from app.messages import ( HELP_TEXT, MONTHS, WELCOME, analytics_text, debt_short_text, debt_status_text, family_status_text, family_transaction_notice, recent_text, summary_text, transaction_confirmation, transaction_category_text, ) from app.parser import ParseError, parse_money_amount, parse_transaction from app.periods import month_bounds class FamilySetup(StatesGroup): waiting_for_code = State() class DebtSetup(StatesGroup): waiting_for_amount = State() class CategorySetup(StatesGroup): waiting_for_name = State() def create_router(database: Database, settings: Settings) -> Router: root_router = Router(name=__name__) group_router = Router(name=f"{__name__}.groups") router = Router(name=f"{__name__}.private") router.message.filter(F.chat.type == ChatType.PRIVATE) @group_router.message( Command("family_chat"), F.chat.type.in_({ChatType.GROUP, ChatType.SUPERGROUP}), ) async def bind_family_chat(message: Message, bot: Bot) -> None: if message.from_user is None: return member = await bot.get_chat_member(message.chat.id, message.from_user.id) if member.status not in {ChatMemberStatus.CREATOR, ChatMemberStatus.ADMINISTRATOR}: await message.reply("Привязать семейный чат может только его администратор.") return try: info = await database.bind_family_chat( message.from_user.id, message.chat.id, message.chat.title or "Семейный чат", ) except FamilyJoinError as exc: await message.reply(f"Не удалось привязать чат: {escape(str(exc))}") return await message.reply( "✅ Чат подключён к семейному бюджету.\n\n" "Сюда будут приходить новые записи обоих супругов и один общий вечерний отчёт." ) async def register(message: Message) -> None: if message.from_user is None: return await database.upsert_user( user_id=message.from_user.id, chat_id=message.chat.id, username=message.from_user.username, full_name=message.from_user.full_name, timezone=settings.timezone.key, report_time=settings.daily_report_time.strftime("%H:%M"), now=datetime.now(settings.timezone), ) @router.message(CommandStart()) async def start(message: Message) -> None: await register(message) await message.answer(WELCOME, reply_markup=main_keyboard()) @router.message(Command("help")) @router.message(F.text == HELP) async def help_message(message: Message) -> None: await register(message) await message.answer(HELP_TEXT, reply_markup=main_keyboard()) @router.message(Command("month")) @router.message(F.text == MONTH_TOTAL) async def month_total(message: Message) -> None: await register(message) now = datetime.now(settings.timezone) start_at, end_at = month_bounds(now) totals = await database.totals(message.from_user.id, start_at, end_at) await message.answer(summary_text(f"Итог за {MONTHS[now.month]}", totals)) @router.message(Command("total")) @router.message(F.text == ALL_TOTAL) async def all_total(message: Message) -> None: await register(message) totals = await database.totals(message.from_user.id) await message.answer(summary_text("Итог за всё время", totals)) @router.message(Command("analytics")) @router.message(F.text == ANALYTICS) async def analytics(message: Message) -> None: await register(message) now = datetime.now(settings.timezone) start_at, end_at = month_bounds(now) categories = await database.category_totals(message.from_user.id, start_at, end_at) await message.answer( analytics_text(f"за {MONTHS[now.month]}", categories, sum(categories.values())), reply_markup=analytics_period_keyboard("month"), ) @router.callback_query(F.data.in_({"analytics:month", "analytics:all"})) async def switch_analytics_period(callback: CallbackQuery) -> None: period = (callback.data or "").split(":", maxsplit=1)[1] now = datetime.now(settings.timezone) if period == "month": start_at, end_at = month_bounds(now) categories = await database.category_totals( callback.from_user.id, start_at, end_at ) period_name = f"за {MONTHS[now.month]}" else: categories = await database.category_totals(callback.from_user.id) period_name = "за всё время" if callback.message: await callback.message.edit_text( analytics_text(period_name, categories, sum(categories.values())), reply_markup=analytics_period_keyboard(period), ) await callback.answer() @router.message(Command("recent")) @router.message(F.text == RECENT) async def recent(message: Message) -> None: await register(message) transactions = await database.recent_transactions(message.from_user.id) await message.answer(recent_text(transactions)) @router.message(Command("family")) @router.message(F.text == FAMILY) async def family_menu(message: Message) -> None: await register(message) info = await database.family_info(message.from_user.id) await message.answer( family_status_text(info), reply_markup=family_setup_keyboard() if info is None else None, ) @router.message(Command("debt")) @router.message(F.text == DEBT) async def debt_menu(message: Message) -> None: await register(message) info = await database.family_info(message.from_user.id) if info is None: await message.answer( "Сначала создайте семейный бюджет через кнопку «👨‍👩‍👧 Семья»." ) return debt = await database.family_debt(message.from_user.id) can_edit = info.owner_user_id == message.from_user.id await message.answer( debt_status_text(debt, can_edit), reply_markup=debt_keyboard(can_edit), ) @router.message(Command("categories")) @router.message(F.text == CATEGORIES) async def categories_menu(message: Message) -> None: await register(message) categories = await database.budget_categories(message.from_user.id) if not categories: await message.answer( "Настраиваемые категории доступны после подключения семейного бюджета." ) return lines = ["🗂 Категории семейного бюджета", ""] for category in categories: prefix = " ↳ " if category.parent_id is not None else "" lines.append(f"{prefix}{escape(category.icon)} {escape(category.name)}") await message.answer( "\n".join(lines), reply_markup=category_management_keyboard() ) @router.callback_query(F.data == "catmanage:add-main") async def ask_main_category_name(callback: CallbackQuery, state: FSMContext) -> None: await state.set_state(CategorySetup.waiting_for_name) await state.set_data({"parent_id": None}) if callback.message: await callback.message.answer( "Введите название новой категории, например Здоровье.\n\n" "Для отмены отправьте /cancel" ) await callback.answer() @router.callback_query(F.data == "catmanage:add-sub") async def choose_parent_category(callback: CallbackQuery) -> None: categories = await database.budget_categories(callback.from_user.id) if callback.message: await callback.message.answer( "К какой основной категории добавить подкатегорию?", reply_markup=parent_category_keyboard(categories), ) await callback.answer() @router.callback_query(F.data.startswith("catmanage:parent:")) async def ask_subcategory_name(callback: CallbackQuery, state: FSMContext) -> None: try: parent_id = int((callback.data or "").rsplit(":", maxsplit=1)[1]) except (ValueError, IndexError): await callback.answer("Категория не найдена", show_alert=True) return await state.set_state(CategorySetup.waiting_for_name) await state.set_data({"parent_id": parent_id}) if callback.message: await callback.message.answer( "Введите название подкатегории, например Врачи.\n\n" "Для отмены отправьте /cancel" ) await callback.answer() @router.callback_query(F.data == "catmanage:cancel") async def cancel_category_callback(callback: CallbackQuery, state: FSMContext) -> None: await state.clear() if callback.message: await callback.message.edit_reply_markup(reply_markup=None) await callback.answer("Отменено") @router.message(CategorySetup.waiting_for_name, Command("cancel")) async def cancel_new_category(message: Message, state: FSMContext) -> None: await state.clear() await message.answer("Добавление категории отменено.", reply_markup=main_keyboard()) @router.message(CategorySetup.waiting_for_name, F.text) async def save_new_category(message: Message, state: FSMContext) -> None: data = await state.get_data() try: category = await database.create_budget_category( user_id=message.from_user.id, name=message.text or "", parent_id=data.get("parent_id"), now=datetime.now(settings.timezone), ) except (ValueError, FamilyJoinError) as exc: await message.answer(f"Не получилось добавить категорию: {escape(str(exc))}") return await state.clear() await message.answer( f"✅ Добавлено: {escape(category.display_name)}", reply_markup=main_keyboard(), ) @router.callback_query(F.data == "debt:set") async def ask_debt_amount(callback: CallbackQuery, state: FSMContext) -> None: info = await database.family_info(callback.from_user.id) if info is None or info.owner_user_id != callback.from_user.id: await callback.answer("Изменить долг может только создатель бюджета", show_alert=True) return await state.set_state(DebtSetup.waiting_for_amount) if callback.message: await callback.message.answer( "Введите текущую сумму общего долга.\n" "Например: 530713 или 530 713 ₽\n\n" "Для отмены отправьте /cancel" ) await callback.answer() @router.message(DebtSetup.waiting_for_amount, Command("cancel")) async def cancel_debt(message: Message, state: FSMContext) -> None: await state.clear() await message.answer("Изменение долга отменено.", reply_markup=main_keyboard()) @router.message(DebtSetup.waiting_for_amount, F.text) async def save_debt(message: Message, state: FSMContext, bot: Bot) -> None: try: amount = parse_money_amount(message.text or "") await database.set_family_debt( message.from_user.id, amount, datetime.now(settings.timezone) ) except (ParseError, FamilyJoinError) as exc: await message.answer(f"Не получилось сохранить сумму: {escape(str(exc))}") return await state.clear() await message.answer( debt_status_text(amount, True), reply_markup=debt_keyboard(True), ) for chat_id in await database.family_notification_chats(message.from_user.id): try: await bot.send_message( chat_id, f"💳 {escape(message.from_user.full_name)} обновил(а) общий долг.\n\n" f"{debt_short_text(amount)}", ) except TelegramForbiddenError: pass @router.callback_query(F.data == "family:create") async def create_family(callback: CallbackQuery) -> None: info = await database.create_family( callback.from_user.id, datetime.now(settings.timezone) ) if callback.message: await callback.message.answer(family_status_text(info)) await callback.message.edit_reply_markup(reply_markup=None) await callback.answer("Общий бюджет создан") @router.callback_query(F.data == "family:join") async def ask_family_code(callback: CallbackQuery, state: FSMContext) -> None: await state.set_state(FamilySetup.waiting_for_code) if callback.message: await callback.message.answer( "Введите код приглашения, который показан у супруга.\n" "Например: FIN-ABC123\n\n" "Для отмены отправьте /cancel" ) await callback.answer() @router.message(FamilySetup.waiting_for_code, Command("cancel")) async def cancel_family_join(message: Message, state: FSMContext) -> None: await state.clear() await message.answer("Подключение отменено.", reply_markup=main_keyboard()) @router.message(FamilySetup.waiting_for_code, F.text) async def join_family(message: Message, state: FSMContext, bot: Bot) -> None: await register(message) try: request = await database.request_family_join( message.from_user.id, message.text or "", datetime.now(settings.timezone), ) except FamilyJoinError as exc: await message.answer( f"Не получилось подключиться: {exc}\n\n" "Проверьте код или отправьте /cancel." ) return await state.clear() await message.answer( "🔐 Запрос отправлен владельцу бюджета.\n\n" "Доступ появится только после его подтверждения.", reply_markup=main_keyboard(), ) try: await bot.send_message( request.owner_chat_id, f"🔐 {escape(request.candidate_name)} просит доступ к семейному бюджету.\n\n" "Подтвердите только знакомого вам человека.", reply_markup=family_approval_keyboard(request.candidate_user_id), ) except TelegramForbiddenError: await message.answer("Владелец бюджета заблокировал бота, запрос нельзя подтвердить.") @router.callback_query(F.data.startswith("family:approve:")) @router.callback_query(F.data.startswith("family:reject:")) async def resolve_family_request(callback: CallbackQuery, bot: Bot) -> None: parts = (callback.data or "").split(":") try: approve = parts[1] == "approve" candidate_user_id = int(parts[2]) approved, candidate_chat_id, info = await database.resolve_family_join( owner_user_id=callback.from_user.id, candidate_user_id=candidate_user_id, approve=approve, now=datetime.now(settings.timezone), ) except (IndexError, ValueError, FamilyJoinError) as exc: await callback.answer(str(exc), show_alert=True) return if callback.message: await callback.message.edit_reply_markup(reply_markup=None) await callback.message.answer( "✅ Доступ подтверждён." if approved else "🚫 Запрос отклонён." ) if approved and info is not None: await bot.send_message( candidate_chat_id, family_status_text(info), reply_markup=main_keyboard(), ) else: await bot.send_message( candidate_chat_id, "🚫 Владелец семейного бюджета отклонил запрос на подключение.", ) await callback.answer() @router.callback_query(F.data.startswith("choose:")) async def choose_category(callback: CallbackQuery) -> None: transaction_id = _callback_id(callback.data) transaction = await database.get_transaction(transaction_id, callback.from_user.id) if transaction is None or transaction.kind is TransactionKind.INCOME: await callback.answer("Запись не найдена", show_alert=True) return categories = await database.budget_categories(callback.from_user.id) if callback.message: await callback.message.edit_reply_markup( reply_markup=( category_tree_keyboard(transaction_id, categories) if categories else category_keyboard(transaction_id) ) ) await callback.answer("Выберите категорию") @router.callback_query(F.data.startswith("bcat:")) async def set_budget_category(callback: CallbackQuery, bot: Bot) -> None: try: _, transaction_id_raw, category_id_raw = (callback.data or "").split(":") transaction_id = int(transaction_id_raw) category_id = int(category_id_raw) except (ValueError, TypeError): await callback.answer("Некорректная категория", show_alert=True) return changed = await database.update_transaction_budget_category( transaction_id, callback.from_user.id, category_id ) transaction = await database.get_transaction(transaction_id, callback.from_user.id) if not changed or transaction is None: await callback.answer("Запись или категория не найдена", show_alert=True) return debt = await database.family_debt(callback.from_user.id) if callback.message: await callback.message.edit_text( transaction_confirmation(transaction, debt), reply_markup=transaction_actions(transaction.id, is_expense=True), ) for chat_id in await database.family_notification_chats(callback.from_user.id): try: await bot.send_message( chat_id, f"🗂 {escape(callback.from_user.full_name)} изменил(а) категорию записи.\n\n" f"{escape(transaction.description)} → {transaction_category_text(transaction)}", ) except TelegramForbiddenError: pass await callback.answer("Категория изменена") @router.callback_query(F.data.startswith("category:")) async def set_category(callback: CallbackQuery) -> None: try: _, transaction_id_raw, category_raw = (callback.data or "").split(":", maxsplit=2) transaction_id = int(transaction_id_raw) category = Category(category_raw) if category is Category.INCOME: raise ValueError except (ValueError, TypeError): await callback.answer("Некорректная категория", show_alert=True) return changed = await database.update_category(transaction_id, callback.from_user.id, category) transaction = await database.get_transaction(transaction_id, callback.from_user.id) if not changed or transaction is None: await callback.answer("Запись не найдена", show_alert=True) return if callback.message: debt = await database.family_debt(callback.from_user.id) await callback.message.edit_text( transaction_confirmation(transaction, debt), reply_markup=transaction_actions(transaction.id, is_expense=True), ) await callback.answer(f"Категория: {category_label(category)}") @router.callback_query(F.data.startswith("cancel:")) async def cancel_category(callback: CallbackQuery) -> None: transaction_id = _callback_id(callback.data) transaction = await database.get_transaction(transaction_id, callback.from_user.id) if transaction is None: await callback.answer("Запись не найдена", show_alert=True) return if callback.message: await callback.message.edit_reply_markup( reply_markup=transaction_actions( transaction.id, is_expense=transaction.kind is TransactionKind.EXPENSE ) ) await callback.answer() @router.callback_query(F.data.startswith("delete:")) async def delete_transaction(callback: CallbackQuery) -> None: transaction_id = _callback_id(callback.data) deleted = await database.delete_transaction(transaction_id, callback.from_user.id) if not deleted: await callback.answer("Запись уже удалена", show_alert=True) return if callback.message: debt = await database.family_debt(callback.from_user.id) debt_line = f"\n\n{debt_short_text(debt)}" if debt is not None else "" await callback.message.edit_text(f"🗑 Запись удалена{debt_line}") await callback.answer("Удалено") @router.message(F.text) async def add_transaction(message: Message, bot: Bot) -> None: await register(message) try: parsed = parse_transaction(message.text or "") except ParseError: await message.answer( "Не получилось распознать запись 🤔\n\n" "Напишите сумму и описание, например: 467 ярче\n" "Доход: + 50000 зарплата" ) return category = ( Category.INCOME if parsed.kind is TransactionKind.INCOME else classify(parsed.description) ) subcategory_key = ( None if parsed.kind is TransactionKind.INCOME else suggest_subcategory(category, parsed.description) ) budget_category_id = await database.resolve_budget_category( message.from_user.id, category, subcategory_key ) transaction = await database.add_transaction( user_id=message.from_user.id, amount_kopecks=parsed.amount_kopecks, description=parsed.description, kind=parsed.kind, category=category, budget_category_id=budget_category_id, occurred_at=datetime.now(settings.timezone), ) debt = await database.family_debt(message.from_user.id) await message.answer( transaction_confirmation(transaction, debt), reply_markup=transaction_actions( transaction.id, is_expense=transaction.kind is TransactionKind.EXPENSE ), ) for chat_id in await database.family_notification_chats(message.from_user.id): try: await bot.send_message( chat_id, family_transaction_notice(transaction, debt) ) except TelegramForbiddenError: pass @router.message() async def unsupported(message: Message) -> None: await message.answer("Пока я понимаю только текстовые записи — например, 3000 бензин.") root_router.include_routers(group_router, router) return root_router def _callback_id(data: str | None) -> int: try: return int((data or "").split(":", maxsplit=1)[1]) except (ValueError, IndexError) as exc: raise ValueError("Некорректный идентификатор операции") from exc