Files
drivers_bot/bot/main.py
2026-05-14 21:19:37 +09:00

570 lines
25 KiB
Python
Raw 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.

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())