Discord-бот для синхронизации ролей с донатами
Если ты выдаёшь VIP-роль в Discord руками — открываешь табличку с донатами, ищешь ник, тыкаешь "добавить роль" — рано или поздно ты либо забудешь снять роль у того, кто не продлил, либо провозишься полчаса в разгар вечернего онлайна, пока десяток игроков пишут в тикеты "где мой донат". Дальше разберём, как построить связку "платёж → роль на сервере" так, чтобы бот сам выдавал доступ сразу после оплаты и сам снимал его, когда подписка истекла — без твоего участия и без гонки условий, когда роль выдана, а игрок уже давно не платит.
Содержание
Как устроена связка платёж-бот-роль
Схема с любой стороны выглядит одинаково: платёжная система шлёт вебхук на твой сервер, обработчик вебхука пишет запись в базу (кто, что купил, до какого числа), а Discord-бот периодически или по событию сверяет базу с фактическими ролями на сервере и приводит их в соответствие.
Три звена цепи:
- Приём оплаты — платёжный шлюз (ЮKassa, CloudPayments, Boosty, свой донат-виджет) при успешной транзакции дёргает твой webhook-эндпоинт с данными о заказе.
- Хранение состояния — минимальная база (SQLite достаточно для сервера на 500-2000 игроков) с таблицей вида
discord_id, tier, expires_at, last_payment_id. - Применение к 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: если активная подписка есть, роль выставляется автоматически в момент возвращения игрока.