finance-telegram-bot/app/handlers.py

616 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_kind_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(
"✅ <b>Чат подключён к семейному бюджету.</b>\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 = ["🗂 <b>Категории семейного бюджета</b>", ""]
for category in categories:
prefix = "" if category.parent_id is not None else ""
kind_icon = "↗️" if category.kind == "income" else "↘️"
lines.append(f"{prefix}{kind_icon} {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 choose_main_category_kind(callback: CallbackQuery, state: FSMContext) -> None:
await state.set_data({"parent_id": None})
if callback.message:
await callback.message.answer(
"Как операции этой категории влияют на бюджет и общий долг?",
reply_markup=category_kind_keyboard(),
)
await callback.answer()
@router.callback_query(F.data.startswith("catmanage:kind:"))
async def ask_main_category_name(callback: CallbackQuery, state: FSMContext) -> None:
kind = (callback.data or "").rsplit(":", maxsplit=1)[-1]
if kind not in {"expense", "income"}:
await callback.answer("Некорректный тип категории", show_alert=True)
return
await state.set_state(CategorySetup.waiting_for_name)
await state.update_data(kind=kind)
if callback.message:
await callback.message.answer(
"Введите название новой категории, например <code>Здоровье</code>.\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(
"Введите название подкатегории, например <code>Врачи</code>.\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"),
kind=data.get("kind", "expense"),
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"✅ Добавлено: <b>{escape(category.display_name)}</b>",
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"
"Например: <code>530713</code> или <code>530 713 ₽</code>\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"💳 <b>{escape(message.from_user.full_name)} обновил(а) общий долг.</b>\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"
"Например: <code>FIN-ABC123</code>\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(
"🔐 <b>Запрос отправлен владельцу бюджета.</b>\n\n"
"Доступ появится только после его подтверждения.",
reply_markup=main_keyboard(),
)
try:
await bot.send_message(
request.owner_chat_id,
f"🔐 <b>{escape(request.candidate_name)} просит доступ к семейному бюджету.</b>\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:
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"🗂 <b>{escape(callback.from_user.full_name)} изменил(а) категорию записи.</b>\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"🗑 <i>Запись удалена</i>{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"
"Напишите сумму и описание, например: <code>467 ярче</code>\n"
"Доход: <code>+ 50000 зарплата</code>"
)
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
)
if parsed.kind is TransactionKind.INCOME and any(
word in {"зп", "зарплата"}
for word in parsed.description.casefold().replace("ё", "е").split()
):
budget_category_id = await database.resolve_salary_category(message.from_user.id)
transaction = await database.add_transaction(
user_id=message.from_user.id,
amount_kopecks=parsed.amount_kopecks,
description=parsed.description,
kind=parsed.kind,
category=category,
debt_effect=parsed.debt_effect,
debt_effect_override=parsed.debt_effect_override,
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("Пока я понимаю только текстовые записи — например, <code>3000 бензин</code>.")
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