Skip to content

Commit

Permalink
Глава "Платежи" (#88)
Browse files Browse the repository at this point in the history
В главе описываются платежи в Telegram при помощи Stars
  • Loading branch information
MasterGroosha authored Jun 9, 2024
1 parent e1270b1 commit 1b4c01f
Show file tree
Hide file tree
Showing 20 changed files with 914 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ docs/

# .env-файлы
.env
/code/09_payments/settings.toml
Binary file added book_src/images/payments/cmd_donate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added book_src/images/payments/payment_video.MP4
Binary file not shown.
Binary file added book_src/images/payments/pre_checkout_failed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added book_src/images/payments/refunds.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
371 changes: 371 additions & 0 deletions book_src/payments.md

Large diffs are not rendered by default.

Empty file.
48 changes: 48 additions & 0 deletions code/09_payments/bot/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import asyncio

import structlog
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from structlog.typing import FilteringBoundLogger

from bot.config_reader import get_config, BotConfig, LogConfig
from bot.fluent_loader import get_fluent_localization
from bot.handlers import get_routers
from bot.logs import get_structlog_config
from bot.middlewares import L10nMiddleware


async def main():
log_config: LogConfig = get_config(model=LogConfig, root_key="logs")
structlog.configure(**get_structlog_config(log_config))

locale = get_fluent_localization()

dp = Dispatcher()

# Регистрация мидлвари на типы Message и PreCheckoutQuery
dp.message.outer_middleware(L10nMiddleware(locale))
dp.pre_checkout_query.outer_middleware(L10nMiddleware(locale))

dp.include_routers(*get_routers())

bot_config: BotConfig = get_config(model=BotConfig, root_key="bot")
bot = Bot(
token=bot_config.token.get_secret_value(),
default=DefaultBotProperties(
parse_mode=ParseMode.HTML
)
)

logger: FilteringBoundLogger = structlog.get_logger()
await logger.ainfo("Starting polling...")

try:
await dp.start_polling(bot)
finally:
await bot.session.close()


if __name__ == '__main__':
asyncio.run(main())
58 changes: 58 additions & 0 deletions code/09_payments/bot/config_reader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from enum import StrEnum, auto
from functools import lru_cache
from os import getenv
from tomllib import load
from typing import Type, TypeVar

from pydantic import BaseModel, SecretStr, field_validator

ConfigType = TypeVar("ConfigType", bound=BaseModel)


class LogRenderer(StrEnum):
JSON = auto()
CONSOLE = auto()


class BotConfig(BaseModel):
token: SecretStr


class LogConfig(BaseModel):
show_datetime: bool
datetime_format: str
show_debug_logs: bool
time_in_utc: bool
use_colors_in_console: bool
renderer: LogRenderer

@field_validator('renderer', mode="before")
@classmethod
def log_renderer_to_lower(cls, v: str):
return v.lower()


class Config(BaseModel):
bot: BotConfig


@lru_cache
def parse_config_file() -> dict:
# Проверяем наличие переменной окружения, которая переопределяет путь к конфигу
file_path = getenv("CONFIG_FILE_PATH")
if file_path is None:
error = "Could not find settings file"
raise ValueError(error)
# Читаем сам файл, пытаемся его распарсить как TOML
with open(file_path, "rb") as file:
config_data = load(file)
return config_data


@lru_cache
def get_config(model: Type[ConfigType], root_key: str) -> ConfigType:
config_dict = parse_config_file()
if root_key not in config_dict:
error = f"Key {root_key} not found"
raise ValueError(error)
return model.model_validate(config_dict[root_key])
34 changes: 34 additions & 0 deletions code/09_payments/bot/fluent_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from pathlib import Path

from fluent.runtime import FluentLocalization, FluentResourceLoader


def get_fluent_localization() -> FluentLocalization:
"""
Загрузка файла с локалями 'locale.ftl' из каталога 'l10n' в текущем расположении
:return: объект FluentLocalization
"""

# Проверки, чтобы убедиться
# в наличии правильного файла в правильном каталоге
locale_dir = Path(__file__).parent.joinpath("l10n")
if not locale_dir.exists():
error = "'l10n' directory not found"
raise FileNotFoundError(error)
if not locale_dir.is_dir():
error = "'l10n' is not a directory"
raise NotADirectoryError(error)
locale_file = Path(locale_dir, "locale.ftl")
if not locale_file.exists():
error = "locale.txt file not found"
raise FileNotFoundError(error)

# Создание необходимых объектов и возврат объекта FluentLocalization
l10n_loader = FluentResourceLoader(
str(locale_file.absolute()),
)
return FluentLocalization(
locales=["ru"],
resource_ids=[str(locale_file.absolute())],
resource_loader=l10n_loader
)
10 changes: 10 additions & 0 deletions code/09_payments/bot/handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from aiogram import Router

from . import donate


def get_routers() -> list[Router]:
return [
donate.router
]

167 changes: 167 additions & 0 deletions code/09_payments/bot/handlers/donate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import structlog
from aiogram import F, Router, Bot
from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import CommandStart, Command, CommandObject
from aiogram.types import Message, LabeledPrice, PreCheckoutQuery
from fluent.runtime import FluentLocalization

router = Router()
logger = structlog.get_logger()


@router.message(CommandStart())
async def cmd_start(
message: Message,
l10n: FluentLocalization,
):
await message.answer(
l10n.format_value("cmd-start"),
parse_mode=None,
)


@router.message(Command("donate_1"))
@router.message(Command("donate_25"))
@router.message(Command("donate_50"))
@router.message(Command("donate"))
async def cmd_donate(
message: Message,
command: CommandObject,
l10n: FluentLocalization,
):
# Если это команда /donate ЧИСЛО,
# тогда вытаскиваем число из текста команды
if command.command != "donate":
amount = int(command.command.split("_")[1])
# В противном случае пытаемся парсить пользовательский ввод
else:
# Проверка на число и на его диапазон
if (
command.args is None
or not command.args.isdigit()
or not 1 <= int(command.args) <= 2500
):
await message.answer(
l10n.format_value("custom-donate-input-error")
)
return
amount = int(command.args)

# Для платежей в Telegram Stars список цен
# ОБЯЗАН состоять РОВНО из 1 элемента
prices = [LabeledPrice(label="XTR", amount=amount)]
await message.answer_invoice(
title=l10n.format_value("invoice-title"),
description=l10n.format_value(
"invoice-description",
{"starsCount": amount}
),
prices=prices,
# provider_token Должен быть пустым
provider_token="",
# В пейлоайд можно передать что угодно,
# например, айди того, что именно покупается
payload=f"{amount}_stars",
# XTR - это код валюты Telegram Stars
currency="XTR"
)


@router.message(Command("paysupport"))
async def cmd_paysupport(
message: Message,
l10n: FluentLocalization
):
await message.answer(l10n.format_value("cmd-paysupport"))


@router.message(Command("refund"))
async def cmd_refund(
message: Message,
bot: Bot,
command: CommandObject,
l10n: FluentLocalization,
):
transaction_id = command.args
if transaction_id is None:
await message.answer(
l10n.format_value("refund-no-code-provided")
)
return
try:
await bot.refund_star_payment(
user_id=message.from_user.id,
telegram_payment_charge_id=transaction_id
)
await message.answer(
l10n.format_value("refund-successful")
)
except TelegramBadRequest as error:
if "CHARGE_NOT_FOUND" in error.message:
text = l10n.format_value("refund-code-not-found")
elif "CHARGE_ALREADY_REFUNDED" in error.message:
text = l10n.format_value("refund-already-refunded")
else:
# При всех остальных ошибках – такой же текст,
# как и в первом случае
text = l10n.format_value("refund-code-not-found")
await message.answer(text)
return


@router.message(Command("donate_link"))
async def cmd_link(
message: Message,
bot: Bot,
l10n: FluentLocalization,
):
invoice_link = await bot.create_invoice_link(
title=l10n.format_value("invoice-title"),
description=l10n.format_value(
"invoice-description",
{"starsCount": 1}
),
prices=[LabeledPrice(label="XTR", amount=1)],
provider_token="",
payload="demo",
currency="XTR"
)
await message.answer(
l10n.format_value(
"invoice-link-text",
{"link": invoice_link}
)
)


@router.pre_checkout_query()
async def on_pre_checkout_query(
pre_checkout_query: PreCheckoutQuery,
l10n: FluentLocalization,
):
await pre_checkout_query.answer(ok=True)
# await pre_checkout_query.answer(
# ok=False,
# error_message=l10n.format_value("pre-checkout-failed-reason")
# )


@router.message(F.successful_payment)
async def on_successful_payment(
message: Message,
l10n: FluentLocalization,
):
await logger.ainfo(
"Получен новый донат!",
amount=message.successful_payment.total_amount,
from_user_id=message.from_user.id,
user_username=message.from_user.username
)
await message.answer(
l10n.format_value(
"payment-successful",
{"id": message.successful_payment.telegram_payment_charge_id}
),
# Это эффект "огонь" из стандартных реакций
message_effect_id="5104841245755180586",
)
49 changes: 49 additions & 0 deletions code/09_payments/bot/l10n/locale.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@

cmd-start =
Здравствуйте! Спасибо, что решили воспользоваться ботом. Доступны следующие команды:
• /donate_1: подарить 1 звезду.
• /donate_25: подарить 25 звёзд.
• /donate_50: подарить 50 звёзд.
• /donate <число>: подарить <число> звёзд.
• /paysupport: помощь с покупками.
• /refund: возврат платежа (рефанд).
custom-donate-input-error = Пожалуйста, введите сумму в формате <code>/donate ЧИСЛО</code>, где ЧИСЛО от 1 до 2500 включительно.
invoice-title = Добровольное пожертвование
invoice-description =
{$starsCount ->
[one] {$starsCount} звезда
[few] {$starsCount} звезды
*[other] {$starsCount} звёзд
}
pre-checkout-failed-reason = Нет больше места для денег 😭
cmd-paysupport =
Если вы хотите вернуть средства за покупку, воспользуйтесь командой /refund
refund-successful =
Возврат произведён успешно. Потраченные звёзды уже вернулись на ваш счёт в Telegram.
refund-no-code-provided =
Пожалуйста, введите команду <code>/refund КОД</code>, где КОД – айди транзакции.
Его можно увидеть после выполнения платежа, а также в разделе "Звёзды" в приложении Telegram.
refund-code-not-found =
Такой код покупки не найден. Пожалуйста, проверьте вводимые данные и повторите ещё раз.
refund-already-refunded =
За эту покупку уже ранее был произведён возврат средств.
payment-successful =
<b>Огромное спасибо!</b>
Ваш айди транзакции:
<code>{$id}</code>
Сохраните его, если вдруг сделать рефанд в будущем 😢
invoice-link-text =
Воспользуйтесь <a href="{$link}">этой ссылкой</a> для доната в размере 1 звезды.
Loading

0 comments on commit 1b4c01f

Please sign in to comment.