MAATRIX GAMES / Блог / Discord-бот для синхронизации ролей с донатами

Discord-бот для синхронизации ролей с донатами

MAATRIX GAMES

Если ты выдаёшь VIP-роль в Discord руками — открываешь табличку с донатами, ищешь ник, тыкаешь "добавить роль" — рано или поздно ты либо забудешь снять роль у того, кто не продлил, либо провозишься полчаса в разгар вечернего онлайна, пока десяток игроков пишут в тикеты "где мой донат". Дальше разберём, как построить связку "платёж → роль на сервере" так, чтобы бот сам выдавал доступ сразу после оплаты и сам снимал его, когда подписка истекла — без твоего участия и без гонки условий, когда роль выдана, а игрок уже давно не платит.

Как устроена связка платёж-бот-роль

Схема с любой стороны выглядит одинаково: платёжная система шлёт вебхук на твой сервер, обработчик вебхука пишет запись в базу (кто, что купил, до какого числа), а Discord-бот периодически или по событию сверяет базу с фактическими ролями на сервере и приводит их в соответствие.

Три звена цепи:

  1. Приём оплаты — платёжный шлюз (ЮKassa, CloudPayments, Boosty, свой донат-виджет) при успешной транзакции дёргает твой webhook-эндпоинт с данными о заказе.
  2. Хранение состояния — минимальная база (SQLite достаточно для сервера на 500-2000 игроков) с таблицей вида discord_id, tier, expires_at, last_payment_id.
  3. Применение к Discord — бот на discord.py/discord.js с правами Manage Roles, который либо слушает внутренние события от шага 2, либо гоняет по крону сверку раз в 10-15 минут.

Отдельно замечу: сам вебхук от платёжки и бот в Discord — это разные процессы, и часто разные машины. Проще всего держать их на одном VPS, чтобы обработчик писал прямо в общую SQLite-базу, а бот читал из неё же — так не нужен отдельный брокер сообщений вроде Redis, если нагрузка небольшая.

Приём вебхука от платёжной системы

Возьмём для примера связку "донат-виджет + ЮKassa" — она чаще всего встречается у русскоязычных серверов, потому что принимает карты РФ и СБП напрямую. Обработчик — простой Flask или FastAPI-сервис.

# webhook_handler.py
from fastapi import FastAPI, Request, HTTPException
import sqlite3
import hmac
import hashlib
from datetime import datetime, timedelta

app = FastAPI()
SECRET = "твой_секретный_ключ_из_кабинета_платежки"

def verify_signature(body: bytes, signature: str) -> bool:
    expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

@app.post("/webhook/payment")
async def payment_webhook(request: Request):
    body = await request.body()
    signature = request.headers.get("X-Signature", "")
    if not verify_signature(body, signature):
        raise HTTPException(status_code=403, detail="bad signature")

    data = await request.json()
    if data.get("status") != "succeeded":
        return {"ok": True}

    discord_id = data["metadata"]["discord_id"]
    tier = data["metadata"]["tier"]  # vip, vip_plus, elite
    duration_days = int(data["metadata"].get("duration_days", 30))

    conn = sqlite3.connect("donations.db")
    expires = datetime.utcnow() + timedelta(days=duration_days)
    conn.execute("""
        INSERT INTO subscriptions (discord_id, tier, expires_at, payment_id)
        VALUES (?, ?, ?, ?)
        ON CONFLICT(discord_id) DO UPDATE SET
            tier=excluded.tier,
            expires_at=excluded.expires_at,
            payment_id=excluded.payment_id
    """, (discord_id, tier, expires.isoformat(), data["id"]))
    conn.commit()
    conn.close()
    return {"ok": True}

Ключевой момент — проверка подписи. Без неё любой желающий, зная адрес твоего эндпоинта, отправит POST-запрос и выдаст себе VIP бесплатно. У каждой платёжки свой формат подписи (у ЮKassa это Basic Auth на IP-уровне плюс опциональный HMAC у самописных виджетов, у Boosty — свой набор заголовков), но принцип один: не доверяй телу запроса, пока не проверил, что оно действительно от платёжного шлюза, а не от анонимного POST-запроса извне.

Второй момент — metadata.discord_id. Платёжка должна знать Discord ID плательщика ещё на этапе создания счёта. Проще всего это решить командой в самом боте: игрок пишет /donate vip, бот генерирует ссылку на оплату с уже зашитым его discord_id в metadata, и дальше вебхук приходит с готовой привязкой — не нужно просить игрока вручную вбивать свой ID в форму оплаты, где он неизбежно ошибётся в паре цифр.

Нужен игровой сервер?

MAATRIX GAMES — 45+ игр, деплой за минуты, DDoS-защита, автобэкапы. Тарифы от 399 ₽/мес.

Создать сервер

Структура базы и таблица тарифов

Держи тарифы отдельной таблицей, а не хардкодь их в коде бота — так проще завести новый тир или поменять цену без деплоя.

CREATE TABLE subscriptions (
    discord_id TEXT PRIMARY KEY,
    tier TEXT NOT NULL,
    expires_at TEXT NOT NULL,
    payment_id TEXT,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE tiers (
    tier TEXT PRIMARY KEY,
    role_id TEXT NOT NULL,
    price_rub INTEGER NOT NULL,
    duration_days INTEGER NOT NULL DEFAULT 30
);

INSERT INTO tiers VALUES
    ('vip', '1234567890123456789', 199, 30),
    ('vip_plus', '1234567890123456790', 399, 30),
    ('elite', '1234567890123456791', 799, 30);

Пример распределения тиров по цене и привилегиям — ориентировочный, у тебя своя экономика сервера:

ТирDiscord-рольИгровые привилегииПримерная цена/мес
VIPцветной ник, доступ к #vip-chatдоп. слот в очереди, кит раз в день150-250 ₽
VIP+всё из VIP + иконка в списке онлайнадоп. дом/база, приоритет в тикетах350-450 ₽
Eliteвсё из VIP+ + отдельный голосовой каналкастомный префикс, ранний доступ к ивентам700-900 ₽

Если у тебя уже есть прозрачная система донатов и уровней VIP на сервере — просто перенеси её тиры в эту таблицу один в один, чтобы не путать игроков сменой названий.

Бот: выдача роли по событию

Обработчик вебхука пишет в базу, но саму роль в Discord назначает бот — потому что именно у него открыт gateway-коннект и объект discord.Member. Самый надёжный вариант — не полагаться на "бот подхватит на следующем цикле крона", а дёргать бота сразу после записи в базу через внутренний HTTP-запрос или локальный unix-сокет.

# bot.py (discord.py)
import discord
from discord.ext import commands, tasks
import sqlite3
from datetime import datetime

intents = discord.Intents.default()
intents.members = True
bot = commands.Bot(command_prefix="!", intents=intents)

GUILD_ID = 111111111111111111

async def grant_role(discord_id: int, role_id: int):
    guild = bot.get_guild(GUILD_ID)
    member = guild.get_member(discord_id) or await guild.fetch_member(discord_id)
    role = guild.get_role(role_id)
    if role not in member.roles:
        await member.add_roles(role, reason="Донат оплачен")
        try:
            await member.send(f"Роль {role.name} выдана. Спасибо за поддержку сервера!")
        except discord.Forbidden:
            pass  # у игрока закрыты личные сообщения от участников сервера

fetch_member — не опечатка вместо get_member. Если игрок оплатил, но давно не заходил на сервер (кэш участников в discord.py не бесконечный), get_member вернёт None, и роль не выдастся. fetch_member дожмёт запрос к API Discord напрямую, но у него есть rate limit — не гоняй его в цикле по сотне игроков разом, только точечно.

Снятие роли по истечении подписки

Это самая частая грабля: выдать роль просто, а вот снять её вовремя — обычно забывают до первой жалобы "а почему у него до сих пор VIP, он же не платил два месяца". Решается фоновой задачей, которая раз в 10-15 минут проходит по базе и сверяет expires_at с текущим временем.

@tasks.loop(minutes=15)
async def sync_subscriptions():
    guild = bot.get_guild(GUILD_ID)
    conn = sqlite3.connect("donations.db")
    now = datetime.utcnow().isoformat()

    expired = conn.execute(
        "SELECT discord_id, tier FROM subscriptions WHERE expires_at < ?", (now,)
    ).fetchall()

    tiers = dict(conn.execute("SELECT tier, role_id FROM tiers").fetchall())

    for discord_id, tier in expired:
        member = guild.get_member(int(discord_id))
        if member is None:
            continue
        role = guild.get_role(int(tiers.get(tier, 0)))
        if role and role in member.roles:
            await member.remove_roles(role, reason="Подписка истекла")

    conn.execute("DELETE FROM subscriptions WHERE expires_at < ?", (now,))
    conn.commit()
    conn.close()

@bot.event
async def on_ready():
    sync_subscriptions.start()

Здесь стоит заложить запас: если снимать роль ровно в секунду истечения, игрок, который оплатил продление за пару минут до дедлайна, но платёжка обрабатывает транзакцию не мгновенно (у некоторых шлюзов это 1-5 минут), на короткое время окажется без роли, а потом получит её обратно — не страшно, но лучше добавить грейс-период в 30-60 минут прямо в SQL-условие (expires_at < datetime('now', '-1 hour')), чтобы не создавать лишний шум в логах и жалобы в тикетах.

Если подписка привязана не к игровому донату, а к бусту через Boosty или аналогичный сервис, логика та же — просто источник вебхука меняется, а таблицы subscriptions/tiers остаются как есть.

Синхронизация роли с игровым сервером

Discord-роль сама по себе не даёт привилегий в игре — её нужно прокинуть дальше, в игровой сервер. Как именно, зависит от платформы:

  • Minecraft (Paper/Spigot) — плагин LuckPerms с модулем синхронизации через веб-хук или собственный REST-эндпоинт: бот при выдаче роли дёргает POST /api/luckperms/user/{uuid}/promote, привязка Discord ID к Minecraft UUID делается заранее через команду верификации в самом боте (/link <ник> с кодом подтверждения).
  • Rust/ARK через фреймворк типа Oxide/uMod — плагин, слушающий вебхук от бота и выдающий permission-группу игроку по SteamID, который тоже нужно привязать к Discord ID отдельной командой линковки.
  • FiveM/RedM (RP-серверы) — обычно проще всего: фреймворки ESX/QBCore уже хранят Discord ID игрока при первом заходе (Discord — стандартный способ идентификации на RP), так что достаточно синхронизировать группу через oxmysql-запрос из ресурса, который слушает тот же вебхук.

Если у тебя уже настроена интеграция Discord-бота с RCON-командами, это тот же принцип: бот дёргает RCON-команду выдачи привилегии сразу после add_roles, вместо того чтобы полагаться только на плагин-синхронизатор внутри игры. Дублирование (и роль в Discord, и permission в игре выставляются одним обработчиком) снижает риск рассинхрона, если один из плагинов упадёт или зависнет.

async def grant_role(discord_id: int, role_id: int, tier: str):
    # ... код выдачи роли в Discord из примера выше ...
    import mcrcon
    with mcrcon.MCRcon("127.0.0.1", "rcon_пароль", port=25575) as rcon:
        rcon.command(f"lp user {discord_id} parent add {tier}")

Нужен игровой сервер?

MAATRIX GAMES — 45+ игр, деплой за минуты, DDoS-защита, автобэкапы. Тарифы от 399 ₽/мес.

Создать сервер

Частые вопросы

Что делать, если бот упал и пропустил вебхуки?

Обработчик вебхука должен писать в базу независимо от того, онлайн ли бот — база это переживёт, а при следующем старте бот через on_ready сразу может прогнать полную сверку subscriptions против фактических ролей на сервере, а не только ждать следующего 15-минутного цикла.

Нужно ли верифицировать Discord ID перед оплатой?

Да, обязательно. Проще всего сделать это через slash-команду /donate, которая сама подставляет ID автора команды в ссылку оплаты — тогда игроку физически негде ошибиться, в отличие от формы, где ID вводится руками.

Как быть с возвратом платежа (чарджбэк)?

Платёжки шлют отдельный вебхук со статусом refunded или canceled — обработай его так же, как и succeeded, только удаляя запись из subscriptions и сразу снимая роль, а не дожидаясь плановой сверки.

Можно ли обойтись без своей базы, храня всё в самом Discord?

Технически можно вешать на роль expiry через сторонние боты вроде Carl-bot, но это неудобно, если у тебя несколько тиров и своя логика продления — своя SQLite-таблица даёт полный контроль и историю платежей для отчётности.

Что, если игрок вышел с сервера Discord и вернулся?

Discord не хранит роли участника после его выхода — при повторном заходе роль нужно выдать заново. Добавь в on_member_join проверку по subscriptions: если активная подписка есть, роль выставляется автоматически в момент возвращения игрока.