import asyncio import logging from datetime import date from decimal import Decimal import httpx from aiogram import Bot, Dispatcher, F from aiogram.filters import Command, CommandObject from aiogram.types import ( CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, Message, ReplyKeyboardMarkup, WebAppInfo, ) from app.core.config import settings from bot.api_client import ApiClient logging.basicConfig(level=logging.INFO) dp = Dispatcher() api = ApiClient() def main_keyboard() -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup( keyboard=[ [KeyboardButton(text="Меню"), KeyboardButton(text="Мои авто")], [KeyboardButton(text="Помощь")], ], resize_keyboard=True, ) def webapp_inline_keyboard(text: str = "Открыть CarPass") -> InlineKeyboardMarkup: return InlineKeyboardMarkup( inline_keyboard=[ [InlineKeyboardButton(text=text, web_app=WebAppInfo(url=settings.effective_webapp_url))], ] ) def menu_inline_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( inline_keyboard=[ [InlineKeyboardButton(text="Открыть Mini App", web_app=WebAppInfo(url=settings.effective_webapp_url))], [ InlineKeyboardButton(text="Мои авто", callback_data="menu:garage"), InlineKeyboardButton(text="Аналитика", callback_data="menu:analytics"), ], [ InlineKeyboardButton(text="Добавить запись", callback_data="menu:add_record"), InlineKeyboardButton(text="СТО", callback_data="menu:sto"), ], ] ) def admin_card_keyboard(center_id: int) -> InlineKeyboardMarkup: return InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton(text="Одобрить", callback_data=f"admin:approve:{center_id}"), InlineKeyboardButton(text="Правки", callback_data=f"admin:changes:{center_id}"), ], [ InlineKeyboardButton(text="Отклонить", callback_data=f"admin:reject:{center_id}"), InlineKeyboardButton(text="Заморозить", callback_data=f"admin:suspend:{center_id}"), ], ] ) async def safe_answer(message: Message, text: str, **kwargs) -> None: await message.answer(text, **kwargs) async def upsert(message: Message) -> dict: return await api.upsert_user(message.from_user) async def list_user_cars(message: Message) -> list[dict]: user = await upsert(message) return await api.list_cars(user["id"], message.from_user.id) async def require_one_car(message: Message) -> dict | None: cars = await list_user_cars(message) if not cars: await message.answer( "В гараже пока нет автомобиля. Добавь его командой /add_car Название или через Mini App.", reply_markup=webapp_inline_keyboard("Добавить авто"), ) return None if len(cars) > 1: await message.answer( "У тебя несколько авто. Для точной записи открой Mini App и выбери нужную машину.", reply_markup=webapp_inline_keyboard("Выбрать авто"), ) return None return cars[0] def money(value) -> str: return f"{Decimal(str(value or 0)).quantize(Decimal('0.01'))}" def parse_amount_arg(args: str | None) -> Decimal | None: if not args: return None import re matches = re.findall(r"\d+(?:[.,]\d+)?", args) if not matches: return None return Decimal(matches[-1].replace(",", ".")) @dp.message(Command("start")) async def start(message: Message) -> None: user = await upsert(message) text = ( f"Готово, {user.get('first_name') or 'водитель'}.\n\n" "CarPass ведет цифровой паспорт автомобиля: заправки, ТО, страховку, штрафы, стоимость владения и подтвержденную историю СТО.\n\n" "Mini App открывай кнопкой под сообщением: так Telegram передает защищенную авторизацию." ) await message.answer(text, reply_markup=menu_inline_keyboard()) await message.answer("Клавиатура ниже открывает команды бота.", reply_markup=main_keyboard()) @dp.message(F.text == "Меню") @dp.message(Command("menu")) async def menu(message: Message) -> None: await upsert(message) await message.answer( "Главное меню CarPass. Быстрые команды доступны здесь, а полный интерфейс работает в Mini App.", reply_markup=menu_inline_keyboard(), ) @dp.message(Command("garage")) @dp.message(Command("cars")) @dp.message(F.text == "Мои авто") async def cars(message: Message) -> None: items = await list_user_cars(message) if not items: await message.answer("Автомобилей пока нет. Добавь через Mini App или /add_car Название.", reply_markup=webapp_inline_keyboard("Добавить авто")) return buttons = [[InlineKeyboardButton(text=car["name"], callback_data=f"stats:{car['id']}")] for car in items] buttons.append([InlineKeyboardButton(text="Открыть гараж", web_app=WebAppInfo(url=settings.effective_webapp_url))]) await message.answer("Твой гараж:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) @dp.message(Command("add_car")) async def add_car(message: Message, command: CommandObject) -> None: user = await upsert(message) name = command.args.strip() if command.args else "" if not name: await message.answer( "Напиши так: /add_car Toyota Camry\n\nVIN, госномер, кредит и параметры масла удобнее заполнить в Mini App.", reply_markup=webapp_inline_keyboard("Заполнить карточку"), ) return try: car = await api.create_car(user["id"], name, message.from_user.id) except httpx.HTTPStatusError as error: await message.answer(f"Не удалось добавить авто: {error.response.text}") return await message.answer(f"Добавил авто: {car['name']}", reply_markup=webapp_inline_keyboard("Открыть карточку")) @dp.message(Command("add_record")) async def add_record(message: Message) -> None: await upsert(message) await message.answer( "Добавить запись можно командой или через Mini App.\n\n" "/fuel заправил полный бак 43 литра на 72000, пробег 184230\n" "/service замена масла 70000 пробег 184900\n" "/insurance страховка 1200000 на 12 месяцев\n" "/tax налог 180000\n" "/fine штраф 40000\n\n" "При заправке бот распознает фразы «полный бак», «до полного», «full tank».", reply_markup=webapp_inline_keyboard("Добавить запись"), ) @dp.message(Command("fuel")) async def fuel(message: Message, command: CommandObject) -> None: args = command.args or "" if not args.strip(): await message.answer( "Напиши заправку текстом: /fuel заправил полный бак 43 литра на 72000, пробег 184230\n\n" "Бак был заправлен до полного? Укажи «полный бак», «нет» или «не знаю».", reply_markup=webapp_inline_keyboard("Заполнить заправку"), ) return await create_record_from_text(message, args, expected="fuel") @dp.message(Command("service")) async def service(message: Message, command: CommandObject) -> None: args = command.args or "" if not args.strip(): await message.answer( "Напиши сервисную запись: /service замена масла 70000 пробег 184900\n\n" "Для фото, следующего ТО и детальных работ удобнее Mini App.", reply_markup=webapp_inline_keyboard("Добавить ТО"), ) return await create_record_from_text(message, args, expected="service") @dp.message(Command("insurance")) async def insurance(message: Message, command: CommandObject) -> None: await create_expense_command(message, command, "insurance", "Страховка") @dp.message(Command("tax")) async def tax(message: Message, command: CommandObject) -> None: await create_expense_command(message, command, "tax", "Налог") @dp.message(Command("fine")) async def fine(message: Message, command: CommandObject) -> None: await create_expense_command(message, command, "fine", "Штраф") async def create_expense_command(message: Message, command: CommandObject, category: str, title: str) -> None: args = command.args or "" if not args.strip(): await message.answer(f"Напиши сумму: /{category if category != 'fine' else 'fine'} {title.lower()} 40000", reply_markup=webapp_inline_keyboard(f"Добавить {title.lower()}")) return car = await require_one_car(message) if not car: return parsed = await api.parse_record(message.from_user.id, f"{title} {args}") amount = parsed.get("data", {}).get("amount") or parse_amount_arg(args) if not amount: await message.answer("Не нашёл сумму. Проверь запись или открой форму в Mini App.", reply_markup=webapp_inline_keyboard("Открыть форму")) return payload = { "car_id": car["id"], "entry_date": date.today().isoformat(), "category": category, "title": title, "total_cost": float(amount), "currency": parsed.get("data", {}).get("currency") or car.get("currency") or "RUB", "is_recurring": category in {"insurance", "tax"}, } if category == "insurance": payload["period_months"] = 12 if "12" in args else None payload["payment_period_months"] = payload["period_months"] try: await api.create_expense(message.from_user.id, payload) except httpx.HTTPStatusError as error: await message.answer(f"Запись не сохранена: {error.response.text}") return await message.answer(f"{title} сохранен для {car['name']}.") @dp.message(Command("analytics")) async def analytics(message: Message) -> None: await cars(message) @dp.message(Command("sto")) async def sto(message: Message) -> None: await upsert(message) try: centers = await api.public_service_centers(message.from_user.id) except httpx.HTTPStatusError: centers = [] if not centers: await message.answer( "Проверенных СТО пока нет в каталоге. Можно зарегистрировать свое СТО через Mini App или командой /register_sto Название.", reply_markup=webapp_inline_keyboard("Открыть СТО"), ) return text = "Проверенные СТО:\n" + "\n".join( f"{item['id']}. {item.get('display_name') or item.get('name')} — {item.get('city') or 'город не указан'}" for item in centers[:10] ) await message.answer(text, reply_markup=webapp_inline_keyboard("Каталог СТО")) @dp.message(Command("register_sto")) async def register_sto(message: Message, command: CommandObject) -> None: await upsert(message) name = command.args.strip() if command.args else "" if not name: await message.answer( "Для заявки СТО нужны название, адрес, телефон, специализация и фото документов. Открой форму в Mini App.\n\n" "Быстрый черновик можно создать так: /register_sto Smart Service", reply_markup=webapp_inline_keyboard("Зарегистрировать СТО"), ) return try: center = await api.register_service_center(message.from_user.id, {"display_name": name}) except httpx.HTTPStatusError as error: await message.answer(f"Не удалось отправить заявку: {error.response.text}") return await message.answer( f"Заявка СТО «{center['display_name'] or center['name']}» отправлена на модерацию. Статус: {center['verification_status']}.", reply_markup=webapp_inline_keyboard("Дополнить заявку"), ) @dp.message(Command("admin_sto_pending")) async def admin_sto_pending(message: Message) -> None: await upsert(message) try: centers = await api.pending_service_centers(message.from_user.id) except httpx.HTTPStatusError as error: await message.answer(f"Нет доступа к модерации: {error.response.text}") return if not centers: await message.answer("Pending-заявок СТО нет.") return for center in centers[:20]: text = "\n".join( [ f"Заявка СТО #{center['id']}", center.get("display_name") or center.get("name") or "Без названия", f"Юр. название: {center.get('legal_name') or '-'}", f"Рег. номер: {center.get('business_registration_number') or '-'}", f"Адрес: {', '.join(x for x in [center.get('country'), center.get('city'), center.get('address')] if x) or '-'}", f"Телефон: {center.get('phone') or center.get('contact_phone') or '-'}", f"Контакт: {center.get('contact_person') or '-'}", f"Документы: {len(center.get('document_photo_urls') or [])}", ] ) await message.answer(text, reply_markup=admin_card_keyboard(center["id"])) async def admin_action(message: Message, command: CommandObject, action: str) -> None: args = (command.args or "").split(maxsplit=1) if not args: await message.answer("Укажи id заявки. Например: /admin_sto_approve 12") return try: center_id = int(args[0]) except ValueError: await message.answer("id заявки должен быть числом.") return comment = args[1] if len(args) > 1 else None try: center = await api.moderate_service_center( message.from_user.id, center_id, action, {"reason": comment, "comment": comment}, ) except httpx.HTTPStatusError as error: await message.answer(f"Не удалось выполнить действие: {error.response.text}") return await message.answer(f"Готово. СТО #{center['id']} теперь в статусе {center['verification_status']}.") @dp.message(Command("admin_sto_approve")) async def admin_sto_approve(message: Message, command: CommandObject) -> None: await admin_action(message, command, "approve") @dp.message(Command("admin_sto_reject")) async def admin_sto_reject(message: Message, command: CommandObject) -> None: await admin_action(message, command, "reject") @dp.message(Command("admin_sto_changes")) async def admin_sto_changes(message: Message, command: CommandObject) -> None: await admin_action(message, command, "changes") @dp.message(Command("admin_sto_suspend")) async def admin_sto_suspend(message: Message, command: CommandObject) -> None: await admin_action(message, command, "suspend") @dp.callback_query(F.data.startswith("stats:")) async def show_stats(callback: CallbackQuery) -> None: car_id = int(callback.data.split(":", 1)[1]) try: stats = await api.stats(car_id, callback.from_user.id) except httpx.HTTPStatusError as error: await callback.message.answer(f"Не удалось получить статистику: {error.response.text}") await callback.answer() return consumption = stats["avg_consumption_l_per_100km"] cost_per_km = stats["cost_per_km"] lines = [ "Статистика авто:", f"Расходы всего: {money(stats['total_cost'])}", f"Фиксированные: {money(stats.get('fixed_costs'))}", f"Переменные: {money(stats.get('variable_costs'))}", f"Топливо: {money(stats['fuel_cost'])}", f"Сервис и ремонты: {money(stats['service_cost'])}", f"Пробег по записям: {stats['distance_km']} км", f"Средний расход: {consumption:.2f} л/100 км" if consumption else "Средний расход: нет данных", f"Стоимость 1 км: {cost_per_km:.2f}" if cost_per_km else "Стоимость 1 км: нет данных", ] if stats.get("cost_warning"): lines.append(stats["cost_warning"]) await callback.message.answer("\n".join(lines)) await callback.answer() @dp.callback_query(F.data.startswith("menu:")) async def menu_callback(callback: CallbackQuery) -> None: action = callback.data.split(":", 1)[1] if action in {"garage", "analytics"}: user = await api.upsert_user(callback.from_user) items = await api.list_cars(user["id"], callback.from_user.id) if not items: await callback.message.answer("Автомобилей пока нет.", reply_markup=webapp_inline_keyboard("Добавить авто")) else: buttons = [[InlineKeyboardButton(text=car["name"], callback_data=f"stats:{car['id']}")] for car in items] await callback.message.answer("Твой гараж:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) elif action == "sto": centers = await api.public_service_centers(callback.from_user.id) if not centers: await callback.message.answer("Проверенных СТО пока нет.", reply_markup=webapp_inline_keyboard("Каталог СТО")) else: await callback.message.answer( "Проверенные СТО:\n" + "\n".join(f"{item['id']}. {item.get('display_name') or item.get('name')}" for item in centers[:10]) ) else: await callback.message.answer("Открой Mini App для добавления записи.", reply_markup=webapp_inline_keyboard("Добавить запись")) await callback.answer() @dp.callback_query(F.data.startswith("admin:")) async def admin_callback(callback: CallbackQuery) -> None: _, action, center_id = callback.data.split(":", 2) try: center = await api.moderate_service_center( callback.from_user.id, int(center_id), action, {"reason": "Решение из Telegram-кнопки", "comment": "Решение из Telegram-кнопки"}, ) except httpx.HTTPStatusError as error: await callback.message.answer(f"Модерация не выполнена: {error.response.text}") await callback.answer() return await callback.message.answer(f"СТО #{center['id']} теперь в статусе {center['verification_status']}.") await callback.answer() @dp.message(F.text == "Помощь") @dp.message(Command("help")) async def help_message(message: Message) -> None: await message.answer( "CarPass помогает вести цифровой паспорт автомобиля.\n\n" "Главное:\n" "• /garage — список автомобилей;\n" "• /add_car Название — быстро добавить авто;\n" "• /fuel — заправка, включая полный бак;\n" "• /service — ТО и ремонт;\n" "• /insurance, /tax, /fine — регулярные и разовые расходы;\n" "• /analytics — стоимость владения и расход;\n" "• /sto — каталог проверенных СТО;\n" "• /register_sto — заявка на СТО.\n\n" "Mini App открывай только кнопкой под сообщением: Telegram передает initData, и авторизация проходит корректно.", reply_markup=menu_inline_keyboard(), ) @dp.message(F.text == "Открыть CarPass") @dp.message(F.text == "Открыть гараж") async def old_open_buttons(message: Message) -> None: await message.answer( "Эта кнопка больше не используется как ReplyButton. Открой CarPass через защищенную кнопку ниже.", reply_markup=webapp_inline_keyboard(), ) @dp.message(F.text) async def parse_free_text(message: Message) -> None: if message.text.startswith("/"): return parsed = await api.parse_record(message.from_user.id, message.text) if parsed.get("event_type") == "unknown" or parsed.get("confidence", 0) < 0.55: await message.answer("Не понял запись. Открой /menu или Mini App, там все формы под рукой.", reply_markup=menu_inline_keyboard()) return await create_record_from_parsed(message, parsed) async def create_record_from_text(message: Message, text: str, expected: str | None = None) -> None: parsed = await api.parse_record(message.from_user.id, text) if expected and parsed.get("event_type") != expected: parsed["event_type"] = expected await create_record_from_parsed(message, parsed) async def create_record_from_parsed(message: Message, parsed: dict) -> None: car = await require_one_car(message) if not car: return data = parsed.get("data", {}) event_type = parsed.get("event_type") try: if event_type == "fuel": missing = [field for field in ("fuel_liters", "amount", "odometer_km") if not data.get(field)] if missing: await message.answer("Для заправки нужны литры, сумма и пробег. Открой форму, чтобы не ошибиться.", reply_markup=webapp_inline_keyboard("Добавить заправку")) return await api.create_fuel( message.from_user.id, { "car_id": car["id"], "entry_date": date.today().isoformat(), "odometer": int(data["odometer_km"]), "liters": float(data["fuel_liters"]), "price_per_liter": float(data["price_per_liter"] or Decimal(str(data["amount"])) / Decimal(str(data["fuel_liters"]))), "total_cost": float(data["amount"]), "is_full_tank": data.get("is_full_tank"), }, ) await message.answer("Заправка сохранена.") elif event_type == "service": await api.create_service( message.from_user.id, { "car_id": car["id"], "entry_date": date.today().isoformat(), "odometer": data.get("odometer_km"), "service_type": data.get("service_type") or "maintenance", "title": data.get("title") or "Сервисная запись", "total_cost": float(data.get("amount") or 0), }, ) await message.answer("Сервисная запись сохранена.") elif event_type in {"insurance", "tax", "fine"}: await api.create_expense( message.from_user.id, { "car_id": car["id"], "entry_date": date.today().isoformat(), "category": event_type, "title": {"insurance": "Страховка", "tax": "Налог", "fine": "Штраф"}[event_type], "total_cost": float(data.get("amount") or 0), "currency": data.get("currency") or "RUB", "is_recurring": event_type in {"insurance", "tax"}, }, ) await message.answer("Расход сохранен.") else: await message.answer("Эту запись лучше проверить в Mini App перед сохранением.", reply_markup=webapp_inline_keyboard("Открыть форму")) except httpx.HTTPStatusError as error: await message.answer(f"Запись не сохранена: {error.response.text}") async def main() -> None: if not settings.bot_token: raise RuntimeError("BOT_TOKEN is empty") if not settings.internal_api_token: raise RuntimeError("INTERNAL_API_TOKEN is empty") settings.validate_webapp_url_for_telegram() bot = Bot(settings.bot_token) await dp.start_polling(bot) if __name__ == "__main__": asyncio.run(main())