feat(proj): init

This commit is contained in:
vl.arkhangelskii
2026-09-21 04:06:43 +03:00
commit c956b94983
1076 changed files with 50876 additions and 0 deletions
+781
View File
@@ -0,0 +1,781 @@
from __future__ import annotations
from datetime import date
from aiogram import F, Router
from aiogram.filters import Command, CommandObject, CommandStart, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
Message,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
)
from bot.clients.budget_api import BudgetApiError
from bot.handlers.yandex_gate import YandexLoginGateMiddleware
from bot.handlers.states import BudgetSetup, DatedExpense
from bot.services.budget import BudgetService, PeriodExpensesPage
from bot.services.parsing import (
format_date,
format_money,
format_status,
parse_amount,
parse_end_date,
parse_expense_message,
parse_spent_date,
)
router = Router()
router.message.middleware(YandexLoginGateMiddleware())
router.callback_query.middleware(YandexLoginGateMiddleware())
HELP_TEXT = """\
Я помогаю дотянуть до зарплаты без сюрпризов.
Команды:
/budget — новый бюджет (имя · сумма · дата)
/budgets — список бюджетов, выбрать / вкл-выкл / удалить
/status — сколько можно тратить сегодня
/today — траты за сегодня
/history — траты за весь период (постранично)
/day 12.09 — траты за дату
/spend — трата за другую дату (диалог)
/undo — отменить последнюю трату
/cancel — отменить текущий диалог
/help — эта справка
Быстрый ввод трат:
• 250
• 250 кофе
• кофе 250
• за 12.09 250 кофе
• 250 кофе за 12.09
• 12.09 250 кофе
"""
MENU_STATUS = "📊 Статус"
MENU_TODAY = "🧾 Сегодня"
MENU_HISTORY = "📒 Период"
MENU_DATED = "📅 За дату"
MENU_UNDO = "↩️ Отмена траты"
MENU_BUDGET = "💰 Новый бюджет"
MENU_BUDGETS = "🗂 Бюджеты"
HISTORY_CB_PREFIX = "hist:"
BUDGET_SELECT_PREFIX = "bsel:"
BUDGET_TOGGLE_PREFIX = "btgl:"
BUDGET_DELETE_PREFIX = "bdel:"
def main_keyboard() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text=MENU_STATUS), KeyboardButton(text=MENU_TODAY)],
[KeyboardButton(text=MENU_HISTORY), KeyboardButton(text=MENU_DATED)],
[KeyboardButton(text=MENU_BUDGETS), KeyboardButton(text=MENU_BUDGET)],
[KeyboardButton(text=MENU_UNDO)],
],
resize_keyboard=True,
)
def format_period_page(page_data: PeriodExpensesPage) -> str:
budget = page_data.budget
header = (
f"📒 {budget.name}\n"
f"Траты {format_date(budget.start_date)}{format_date(budget.end_date)}\n"
f"Всего записей: {page_data.total_count} · "
f"сумма: {format_money(page_data.total_sum)}\n"
f"Страница {page_data.page + 1}/{page_data.total_pages}"
)
if not page_data.items:
return header + "\n\nПока нет трат за период."
lines = []
for item in page_data.items:
note = f"{item.note}" if item.note else ""
lines.append(
f"{format_date(item.spent_at)} · {format_money(item.amount)}{note}"
)
return header + "\n\n" + "\n".join(lines)
def format_budgets_list(items) -> str:
if not items:
return "Бюджетов пока нет. Создай: /budget"
lines = ["Твои бюджеты:"]
for status in items:
b = status.budget
marks = []
if status.selected:
marks.append("текущий")
marks.append("активен" if b.is_active else "выкл")
mark = ", ".join(marks)
lines.append(
f"• #{b.id} {b.name}{format_money(b.total_amount)} "
f"до {format_date(b.end_date)} ({mark})"
)
lines.append("\nКнопки: выбрать · вкл/выкл · удалить.")
return "\n".join(lines)
def budgets_keyboard(items) -> InlineKeyboardMarkup | None:
if not items:
return None
rows: list[list[InlineKeyboardButton]] = []
for status in items:
b = status.budget
select_label = f"{'' if status.selected else ''}{b.name}"[:28]
toggle_label = "Выкл" if b.is_active else "Вкл"
rows.append(
[
InlineKeyboardButton(
text=select_label,
callback_data=f"{BUDGET_SELECT_PREFIX}{b.id}",
),
InlineKeyboardButton(
text=toggle_label,
callback_data=f"{BUDGET_TOGGLE_PREFIX}{b.id}",
),
InlineKeyboardButton(
text="🗑",
callback_data=f"{BUDGET_DELETE_PREFIX}{b.id}",
),
]
)
return InlineKeyboardMarkup(inline_keyboard=rows)
def history_keyboard(page_data: PeriodExpensesPage) -> InlineKeyboardMarkup | None:
if page_data.total_pages <= 1:
return None
buttons: list[InlineKeyboardButton] = []
if page_data.page > 0:
buttons.append(
InlineKeyboardButton(
text=" Назад",
callback_data=f"{HISTORY_CB_PREFIX}{page_data.page - 1}",
)
)
buttons.append(
InlineKeyboardButton(
text=f"{page_data.page + 1}/{page_data.total_pages}",
callback_data=f"{HISTORY_CB_PREFIX}nop",
)
)
if page_data.page + 1 < page_data.total_pages:
buttons.append(
InlineKeyboardButton(
text="Вперёд ",
callback_data=f"{HISTORY_CB_PREFIX}{page_data.page + 1}",
)
)
return InlineKeyboardMarkup(inline_keyboard=[buttons])
async def _reply_expense_saved(
message: Message,
*,
amount: float,
note: str | None,
spent_at: date | None,
status,
) -> None:
note_part = f" ({note})" if note else ""
date_part = f" за {format_date(spent_at)}" if spent_at else ""
await message.answer(
f"Записал {format_money(amount)}{note_part}{date_part}.\n\n"
f"{format_status(status)}",
reply_markup=main_keyboard(),
)
async def _save_expense(
message: Message,
budget_service: BudgetService,
*,
amount: float,
note: str | None,
spent_at: date | None,
state: FSMContext | None = None,
) -> bool:
try:
status = await budget_service.add_expense(
user_id=message.from_user.id,
amount=amount,
note=note,
spent_at=spent_at,
)
except ValueError as exc:
await message.answer(str(exc), reply_markup=main_keyboard())
if state is not None:
await state.clear()
return False
except BudgetApiError as exc:
await message.answer(
f"API недоступен: {exc}",
reply_markup=main_keyboard(),
)
if state is not None:
await state.clear()
return False
if state is not None:
await state.clear()
await _reply_expense_saved(
message,
amount=amount,
note=note,
spent_at=spent_at,
status=status,
)
return True
# --- Global commands / menu (always win over FSM) ---
@router.message(CommandStart())
async def cmd_start(message: Message, state: FSMContext) -> None:
await state.clear()
await message.answer(
"Привет! Я бот «от зарплаты до зарплаты».\n\n"
"1) Задай бюджет: /budget\n"
"2) Пиши траты: 250 или кофе 250\n"
"3) За другую дату: за 12.09 250 кофе\n"
"4) Смотри лимит: /status",
reply_markup=main_keyboard(),
)
@router.message(Command("help"))
async def cmd_help(message: Message, state: FSMContext) -> None:
await state.clear()
await message.answer(HELP_TEXT, reply_markup=main_keyboard())
@router.message(Command("cancel"))
@router.message(F.text.casefold() == "отмена")
async def cmd_cancel(message: Message, state: FSMContext) -> None:
current = await state.get_state()
if current is None:
await message.answer("Нечего отменять.", reply_markup=main_keyboard())
return
await state.clear()
await message.answer("Ок, отменил.", reply_markup=main_keyboard())
@router.message(Command("status"))
@router.message(F.text == MENU_STATUS)
async def cmd_status(
message: Message,
state: FSMContext,
budget_service: BudgetService,
) -> None:
await state.clear()
try:
status = await budget_service.get_status(message.from_user.id)
except ValueError as exc:
await message.answer(str(exc), reply_markup=main_keyboard())
return
await message.answer(format_status(status), reply_markup=main_keyboard())
@router.message(Command("today"))
@router.message(F.text == MENU_TODAY)
async def cmd_today(
message: Message,
state: FSMContext,
budget_service: BudgetService,
) -> None:
await state.clear()
try:
status = await budget_service.get_status(message.from_user.id)
except ValueError as exc:
await message.answer(str(exc), reply_markup=main_keyboard())
return
expenses = await budget_service.today_expenses(message.from_user.id)
if not expenses:
body = "Сегодня трат пока нет."
else:
lines = []
for item in expenses:
note = f"{item.note}" if item.note else ""
lines.append(f"{format_money(item.amount)}{note}")
body = "Траты сегодня:\n" + "\n".join(lines)
await message.answer(
f"{body}\n\n{format_status(status)}",
reply_markup=main_keyboard(),
)
@router.message(Command("history"))
@router.message(F.text == MENU_HISTORY)
async def cmd_history(
message: Message,
state: FSMContext,
budget_service: BudgetService,
) -> None:
await state.clear()
try:
page_data = await budget_service.get_period_expenses_page(
message.from_user.id,
page=0,
)
except ValueError as exc:
await message.answer(str(exc), reply_markup=main_keyboard())
return
await message.answer(
format_period_page(page_data),
reply_markup=history_keyboard(page_data) or main_keyboard(),
)
@router.callback_query(F.data.startswith(HISTORY_CB_PREFIX))
async def cb_history_page(
callback: CallbackQuery,
budget_service: BudgetService,
) -> None:
raw = (callback.data or "")[len(HISTORY_CB_PREFIX) :]
if raw == "nop":
await callback.answer()
return
try:
page = int(raw)
except ValueError:
await callback.answer("Некорректная страница", show_alert=True)
return
try:
page_data = await budget_service.get_period_expenses_page(
callback.from_user.id,
page=page,
)
except ValueError as exc:
await callback.answer(str(exc), show_alert=True)
return
text = format_period_page(page_data)
markup = history_keyboard(page_data)
if callback.message:
await callback.message.edit_text(text, reply_markup=markup)
await callback.answer()
@router.message(Command("day"))
async def cmd_day(
message: Message,
command: CommandObject,
state: FSMContext,
budget_service: BudgetService,
) -> None:
await state.clear()
args = (command.args or "").strip()
if not args:
await message.answer(
"Укажи дату: /day 12.09\n"
"Или список трат за сегодня: /today",
reply_markup=main_keyboard(),
)
return
try:
day = parse_spent_date(args)
except ValueError as exc:
await message.answer(str(exc), reply_markup=main_keyboard())
return
try:
status = await budget_service.get_status(message.from_user.id)
except ValueError as exc:
await message.answer(str(exc), reply_markup=main_keyboard())
return
expenses = await budget_service.expenses_on_date(message.from_user.id, day)
if not expenses:
body = f"За {format_date(day)} трат нет."
else:
lines = []
for item in expenses:
note = f"{item.note}" if item.note else ""
lines.append(f"{format_money(item.amount)}{note}")
body = f"Траты за {format_date(day)}:\n" + "\n".join(lines)
await message.answer(
f"{body}\n\n{format_status(status)}",
reply_markup=main_keyboard(),
)
@router.message(Command("undo"))
@router.message(F.text == MENU_UNDO)
async def cmd_undo(
message: Message,
state: FSMContext,
budget_service: BudgetService,
) -> None:
await state.clear()
status, amount = await budget_service.undo_last_expense(message.from_user.id)
if amount is None:
await message.answer("Нечего отменять.", reply_markup=main_keyboard())
return
text = f"Удалил последнюю трату: {format_money(amount)}."
if status is not None:
text += f"\n\n{format_status(status)}"
await message.answer(text, reply_markup=main_keyboard())
@router.message(Command("budget"))
@router.message(F.text == MENU_BUDGET)
async def cmd_budget(message: Message, state: FSMContext) -> None:
await state.set_state(BudgetSetup.waiting_name)
await message.answer(
"Новый бюджет. Как назвать? (например: Зарплата, Отпуск)\n"
"Или «-» чтобы оставить «Бюджет».\n"
"Отмена: /cancel",
reply_markup=ReplyKeyboardRemove(),
)
@router.message(Command("budgets"))
@router.message(F.text == MENU_BUDGETS)
async def cmd_budgets(
message: Message,
state: FSMContext,
budget_service: BudgetService,
) -> None:
await state.clear()
try:
items = await budget_service.list_user_statuses(message.from_user.id)
except (ValueError, BudgetApiError) as exc:
await message.answer(f"Не удалось загрузить: {exc}", reply_markup=main_keyboard())
return
await message.answer(
format_budgets_list(items),
reply_markup=budgets_keyboard(items) or main_keyboard(),
)
@router.callback_query(F.data.startswith(BUDGET_SELECT_PREFIX))
async def cb_budget_select(
callback: CallbackQuery,
budget_service: BudgetService,
) -> None:
raw = (callback.data or "")[len(BUDGET_SELECT_PREFIX) :]
try:
budget_id = int(raw)
except ValueError:
await callback.answer("Некорректный id", show_alert=True)
return
try:
status = await budget_service.select_budget(callback.from_user.id, budget_id)
items = await budget_service.list_user_statuses(callback.from_user.id)
except ValueError as exc:
await callback.answer(str(exc), show_alert=True)
return
if callback.message:
await callback.message.edit_text(
format_budgets_list(items) + f"\n\nТекущий: {status.budget.name}",
reply_markup=budgets_keyboard(items),
)
await callback.answer(f"Выбран: {status.budget.name}")
@router.callback_query(F.data.startswith(BUDGET_TOGGLE_PREFIX))
async def cb_budget_toggle(
callback: CallbackQuery,
budget_service: BudgetService,
) -> None:
raw = (callback.data or "")[len(BUDGET_TOGGLE_PREFIX) :]
try:
budget_id = int(raw)
except ValueError:
await callback.answer("Некорректный id", show_alert=True)
return
try:
current = await budget_service.get_status(
callback.from_user.id,
budget_id=budget_id,
)
status = await budget_service.set_budget_active(
callback.from_user.id,
budget_id,
not current.budget.is_active,
)
items = await budget_service.list_user_statuses(callback.from_user.id)
except ValueError as exc:
await callback.answer(str(exc), show_alert=True)
return
if callback.message:
await callback.message.edit_text(
format_budgets_list(items),
reply_markup=budgets_keyboard(items),
)
state_label = "включён" if status.budget.is_active else "выключен"
await callback.answer(f"{status.budget.name}: {state_label}")
@router.callback_query(F.data.startswith(BUDGET_DELETE_PREFIX))
async def cb_budget_delete(
callback: CallbackQuery,
budget_service: BudgetService,
) -> None:
raw = (callback.data or "")[len(BUDGET_DELETE_PREFIX) :]
try:
budget_id = int(raw)
except ValueError:
await callback.answer("Некорректный id", show_alert=True)
return
try:
current = await budget_service.get_status(
callback.from_user.id,
budget_id=budget_id,
)
name = current.budget.name
await budget_service.delete_budget(callback.from_user.id, budget_id)
items = await budget_service.list_user_statuses(callback.from_user.id)
except (ValueError, BudgetApiError) as exc:
await callback.answer(str(exc), show_alert=True)
return
if callback.message:
await callback.message.edit_text(
format_budgets_list(items),
reply_markup=budgets_keyboard(items) or None,
)
await callback.answer(f"Удалён: {name}")
@router.message(Command("spend"))
@router.message(F.text == MENU_DATED)
async def cmd_spend_dated(message: Message, state: FSMContext) -> None:
await state.set_state(DatedExpense.waiting_date)
await message.answer(
"Трата за дату — одним сообщением или по шагам.\n\n"
"Сразу: 12.09 250 кб\n"
"Или только дата: 12.09\n"
"Отмена: /cancel",
reply_markup=ReplyKeyboardRemove(),
)
# --- FSM: budget ---
@router.message(BudgetSetup.waiting_name)
async def budget_name(message: Message, state: FSMContext) -> None:
raw = (message.text or "").strip()
name = "Бюджет" if raw in {"", "-", ""} else raw[:64]
await state.update_data(name=name)
await state.set_state(BudgetSetup.waiting_amount)
await message.answer(
f"Название: {name}.\n"
"Сколько денег в этом бюджете? (например: 25000)"
)
@router.message(BudgetSetup.waiting_amount)
async def budget_amount(message: Message, state: FSMContext) -> None:
try:
amount = parse_amount(message.text or "")
except ValueError as exc:
await message.answer(f"{exc}\nПопробуй ещё раз, например: 25000")
return
await state.update_data(amount=amount)
await state.set_state(BudgetSetup.waiting_end_date)
await message.answer(
"До какой даты нужно протянуть?\n"
"Форматы: 25.09 · 25.09.2026 · 2026-09-25"
)
@router.message(BudgetSetup.waiting_end_date)
async def budget_end_date(
message: Message,
state: FSMContext,
budget_service: BudgetService,
) -> None:
try:
end_date = parse_end_date(message.text or "")
except ValueError as exc:
await message.answer(f"{exc}\nПример: 25.09.2026")
return
data = await state.get_data()
amount = float(data["amount"])
name = str(data.get("name") or "Бюджет")
try:
status = await budget_service.create_budget(
user_id=message.from_user.id,
total_amount=amount,
end_date=end_date,
name=name,
)
except ValueError as exc:
await message.answer(str(exc))
return
except BudgetApiError as exc:
await message.answer(f"API недоступен: {exc}")
return
await state.clear()
await message.answer(
f"Бюджет «{name}» создан: {format_money(amount)} до {format_date(end_date)}.\n\n"
f"{format_status(status)}",
reply_markup=main_keyboard(),
)
# --- FSM: dated expense ---
@router.message(DatedExpense.waiting_date)
async def dated_expense_date(
message: Message,
state: FSMContext,
budget_service: BudgetService,
) -> None:
text = (message.text or "").strip()
if not text:
await message.answer("Введи дату, например: 12.09")
return
# One-shot: "12.09 250 кб" / "за 12.09 250 кб"
try:
amount, note, spent_at = parse_expense_message(text)
except ValueError:
amount, note, spent_at = None, None, None
if amount is not None and spent_at is not None:
await _save_expense(
message,
budget_service,
amount=amount,
note=note,
spent_at=spent_at,
state=state,
)
return
# Date only, or "12.09 кб" (date + note without amount)
tokens = text.split()
try:
spent_at = parse_spent_date(tokens[0].rstrip(":"))
except ValueError:
await message.answer(
"Не понял дату.\n"
"Примеры: 12.09 или 12.09 250 кб\n"
"Отмена: /cancel"
)
return
if spent_at > date.today():
await message.answer("Нельзя добавить трату на будущую дату. Введи другую:")
return
rest = " ".join(tokens[1:]).strip()
if rest:
try:
amount, note, nested = parse_expense_message(rest)
except ValueError:
# "12.09 кб" → дата есть, суммы нет: запомним заметку и спросим сумму
await state.update_data(spent_at=spent_at.isoformat(), note_hint=rest)
await state.set_state(DatedExpense.waiting_expense)
await message.answer(
f"Дата: {format_date(spent_at)}, заметка: {rest}.\n"
"Теперь сумму, например: 250"
)
return
if nested is not None:
spent_at = nested
await _save_expense(
message,
budget_service,
amount=amount,
note=note,
spent_at=spent_at,
state=state,
)
return
await state.update_data(spent_at=spent_at.isoformat(), note_hint=None)
await state.set_state(DatedExpense.waiting_expense)
await message.answer(
f"Дата: {format_date(spent_at)}.\n"
"Теперь сумма (и комментарий): 250 или 250 кб"
)
@router.message(DatedExpense.waiting_expense)
async def dated_expense_amount(
message: Message,
state: FSMContext,
budget_service: BudgetService,
) -> None:
data = await state.get_data()
spent_at = date.fromisoformat(data["spent_at"])
note_hint = data.get("note_hint")
try:
amount, note, nested_date = parse_expense_message(message.text or "")
except ValueError as exc:
await message.answer(f"{exc}")
return
if nested_date is not None:
spent_at = nested_date
if note is None and note_hint:
note = note_hint
await _save_expense(
message,
budget_service,
amount=amount,
note=note,
spent_at=spent_at,
state=state,
)
# --- Free-text expense (no active dialog) ---
@router.message(StateFilter(None), F.text)
async def add_expense_from_text(
message: Message,
budget_service: BudgetService,
) -> None:
text = (message.text or "").strip()
if text.startswith("/"):
return
try:
amount, note, spent_at = parse_expense_message(text)
except ValueError:
await message.answer(
"Не понял. Примеры:\n"
"• 250 / 250 кофе / кофе 250\n"
"• за 12.09 250 кофе\n"
"• /spend — диалог за дату"
)
return
await _save_expense(
message,
budget_service,
amount=amount,
note=note,
spent_at=spent_at,
)