from __future__ import annotations import json import logging import re from datetime import UTC, datetime from pathlib import Path from threading import Thread from typing import Any import telebot from telebot import types from app.config import Settings from app.db import Database, TransactionInput, amount_to_cents logger = logging.getLogger(__name__) CATEGORY_PATTERN = re.compile(r"^[^\x00-\x1f]{1,40}$") def parse_transaction(raw: str) -> TransactionInput: try: payload: dict[str, Any] = json.loads(raw) except (json.JSONDecodeError, TypeError) as exc: raise ValueError("Не удалось прочитать данные") from exc if payload.get("type") != "transaction" or payload.get("v") != 1: raise ValueError("Неизвестный формат данных") kind = payload.get("kind") if kind not in {"income", "expense"}: raise ValueError("Некорректный тип операции") category = str(payload.get("category", "")).strip() if not CATEGORY_PATTERN.fullmatch(category): raise ValueError("Категория должна содержать от 1 до 40 символов") note = str(payload.get("note", "")).strip() or None if note and len(note) > 160: raise ValueError("Комментарий слишком длинный") occurred_at_raw = str(payload.get("occurred_at", "")).strip() try: occurred_at = datetime.fromisoformat(occurred_at_raw.replace("Z", "+00:00")) except ValueError: occurred_at = datetime.now(UTC) if occurred_at.tzinfo is None: occurred_at = occurred_at.replace(tzinfo=UTC) return TransactionInput( kind=kind, amount_cents=amount_to_cents(payload.get("amount")), category=category, note=note, occurred_at=occurred_at.astimezone(UTC).isoformat(), ) class TelegramBotService: def __init__(self, settings: Settings, database: Database): self.settings = settings self.database = database self.bot = telebot.TeleBot(settings.telegram_token, threaded=True) self.thread: Thread | None = None self._register_handlers() def _keyboard(self) -> types.ReplyKeyboardMarkup: keyboard = types.ReplyKeyboardMarkup(resize_keyboard=True) keyboard.add( types.KeyboardButton( text="Открыть Пожитки", web_app=types.WebAppInfo(url=self.settings.webapp_url), ) ) return keyboard def _register_handlers(self) -> None: @self.bot.message_handler(commands=["start"]) def start(message: types.Message) -> None: user = message.from_user if user is None: return self._save_user(user) parts = (message.text or "").split(maxsplit=1) friend_added = False if len(parts) == 2 and parts[1].startswith("friend_"): try: inviter_id = int(parts[1].removeprefix("friend_")) except ValueError: inviter_id = 0 if inviter_id > 0: friend_added = self.database.add_friendship(user.id, inviter_id) prefix = "Теперь вы друзья!\n\n" if friend_added else "" self.bot.send_message( message.chat.id, prefix + "Пожитки считают доходы, расходы и процент накоплений. " "Нажмите кнопку ниже, чтобы открыть приложение.", reply_markup=self._keyboard(), ) @self.bot.message_handler(content_types=["web_app_data"]) def web_app_data(message: types.Message) -> None: user = message.from_user if user is None or message.web_app_data is None: return self._save_user(user) try: item = parse_transaction(message.web_app_data.data) self.database.add_transaction(user.id, item) except ValueError as exc: self.bot.send_message( message.chat.id, f"Не получилось сохранить операцию: {exc}", reply_markup=self._keyboard(), ) return operation = "Доход" if item.kind == "income" else "Расход" self.bot.send_message( message.chat.id, f"{operation} сохранён в категории «{item.category}». Откройте приложение снова, чтобы увидеть обновлённую статистику.", reply_markup=self._keyboard(), ) @self.bot.message_handler(commands=["help"]) def help_message(message: types.Message) -> None: self.bot.send_message( message.chat.id, "Добавляйте операции через WebApp. Ссылка «Стать друзьями» находится на вкладке рейтинга.", reply_markup=self._keyboard(), ) def _save_user(self, user: types.User) -> None: self.database.upsert_user( user_id=user.id, first_name=user.first_name or "Пользователь", last_name=user.last_name, username=user.username, ) try: photos = self.bot.get_user_profile_photos(user.id, limit=1) if not photos.photos: return photo = photos.photos[0][-1] if self.database.get_avatar_file_id(user.id) == photo.file_id: return file_info = self.bot.get_file(photo.file_id) content = self.bot.download_file(file_info.file_path) suffix = Path(file_info.file_path).suffix or ".jpg" filename = f"{user.id}{suffix}" target = self.settings.avatars_dir / filename self.settings.avatars_dir.mkdir(parents=True, exist_ok=True) target.write_bytes(content) self.database.upsert_user( user_id=user.id, first_name=user.first_name or "Пользователь", last_name=user.last_name, username=user.username, avatar_file_id=photo.file_id, avatar_path=filename, ) except Exception: logger.exception("Could not update avatar for Telegram user %s", user.id) def start(self) -> None: me = self.bot.get_me() if me.username: self.database.set_meta("bot_username", me.username) self.bot.set_my_commands( [ types.BotCommand("start", "Открыть приложение"), types.BotCommand("help", "Как пользоваться"), ] ) self.thread = Thread( target=self.bot.infinity_polling, kwargs={"skip_pending": True, "timeout": 30, "long_polling_timeout": 30}, name="telegram-polling", daemon=True, ) self.thread.start() logger.info("Telegram bot @%s started", me.username) def stop(self) -> None: self.bot.stop_polling()