bot_pozhitki/app/bot.py
2026-09-03 18:18:08 +00:00

189 lines
7.3 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.

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