-
Notifications
You must be signed in to change notification settings - Fork 132
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
В главе описываются платежи в Telegram при помощи Stars
- Loading branch information
1 parent
e1270b1
commit 1b4c01f
Showing
20 changed files
with
914 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,3 +6,4 @@ docs/ | |
|
||
# .env-файлы | ||
.env | ||
/code/09_payments/settings.toml |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Large diffs are not rendered by default.
Oops, something went wrong.
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
] | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 звезды. |
Oops, something went wrong.