Перейти к содержанию

REST API

Бости прыгает за мячом

В bot.rest есть метод для каждой операции REST API Discord, доступной боту, и методы сгруппированы по ресурсу, с которым работают: bot.rest.messages.create, bot.rest.members.add_role, bot.rest.guilds.fetch. Запросы идут через HTTP-клиент bostie на Rust, который соблюдает лимиты запросов Discord.

import asyncio
from datetime import UTC, datetime, timedelta
from pathlib import Path

import bostie
from bostie import ChannelType, Context, File, OverwriteType, Permissions

bot = bostie.Bot()


@bot.command(default_permissions=Permissions.MANAGE_CHANNELS)
async def private_room(ctx: Context, name: str) -> None:
    """Create a channel only you and the bot can see."""
    if ctx.guild_id is None:
        await ctx.respond("Use this in a server.", ephemeral=True)
        return
    me = bot.user.id if bot.user is not None else 0
    channel = await bot.rest.channels.create(
        ctx.guild_id,
        name=name,
        type=ChannelType.GUILD_TEXT,
        permission_overwrites=[
            {
                "id": ctx.guild_id,
                "type": OverwriteType.ROLE,
                "deny": Permissions.VIEW_CHANNEL,
            },
            {
                "id": ctx.user.id,
                "type": OverwriteType.MEMBER,
                "allow": Permissions.VIEW_CHANNEL,
            },
            {"id": me, "type": OverwriteType.MEMBER, "allow": Permissions.VIEW_CHANNEL},
        ],
        reason=f"/private_room by {ctx.user.username}",
    )
    await ctx.respond(f"Created {channel.mention}.", ephemeral=True)


@bot.command
async def today(ctx: Context) -> None:
    """Count the messages sent here in the last 24 hours."""
    if ctx.channel_id is None:
        await ctx.respond("Use this in a channel.", ephemeral=True)
        return
    since = datetime.now(UTC) - timedelta(days=1)
    count = 0
    async for _ in bot.rest.messages.list(ctx.channel_id, after=since, limit=1000):
        count += 1
    await ctx.respond(f"{count} messages in the last 24 hours.")


@bot.command(default_permissions=Permissions.MANAGE_GUILD_EXPRESSIONS)
async def add_logo(ctx: Context) -> None:
    """Upload the logo as an emoji."""
    if ctx.guild_id is None:
        await ctx.respond("Use this in a server.", ephemeral=True)
        return
    image = await asyncio.to_thread(Path("logo.png").read_bytes)
    emoji = await bot.rest.emojis.create(
        ctx.guild_id, name="logo", image=File("logo.png", image)
    )
    await ctx.respond(f"Added <:{emoji.name}:{emoji.id}>.")


@bot.command
async def gateway_url(ctx: Context) -> None:
    """Show what Discord answers on an endpoint, through the raw client."""
    answer = await bot.http.request("GET", "/gateway")
    await ctx.respond(f"The gateway is at {answer['url']}.", ephemeral=True)

Соглашения

  • Сначала идут ID из URL — только позиционно и в порядке URL: bot.rest.members.add_role(guild_id, user_id, role_id).
  • Всё остальное — именованные аргументы с именами из документации Discord, проверяемые по TypedDict для каждой операции, так что опечатка — это ошибка типов и TypeError во время выполнения.
  • Операции, которые Discord записывает в журнал аудита, принимают reason= длиной от 1 до 512 символов — его видят модераторы сервера.
  • Поле изображения или звука, например image эмодзи или icon сервера, принимает File; bostie отправляет его как data URI, которого ждёт Discord. Поля прав принимают Permissions.
  • Эмодзи, например тот, который добавляет reactions.create, принимает эмодзи Unicode, собственный эмодзи в виде <:name:id>, <a:name:id> или name:id или модель Emoji либо PartialEmoji.
  • Методы с именем fetch получают один объект, list перебирает несколько, edit изменяет, create создаёт, а delete удаляет. Остальные глаголы сохраняют слово из документации, например members.add_role или guilds.begin_prune.
  • Методы возвращают модели или None, если Discord отвечает без содержимого. Они не меняют bot.cache, за исключением того, что channels.create_dm сохраняет личный канал, когда это позволяет max_dm_channels; кэш обновляют события шлюза, которые следуют за изменением.

Постраничный перебор

Методы list для эндпоинтов, которые Discord отдаёт по страницам, возвращают асинхронный итератор, который получает следующую страницу по ходу перебора: async for message in bot.rest.messages.list(channel_id, limit=500):, как в /today выше. Без limit он проходит все элементы. before, after и around начинают с другого места, причём можно указать не больше одного из них; они принимают ID или datetime, который bostie превращает в ID этого момента. Сообщения идут от новых к старым, а с after — от старых к новым. Метод list эндпоинта, который Discord не разбивает на страницы, — это корутина, возвращающая кортеж. audit_log.list выдаёт целые объекты AuditLogPage с пользователями, вебхуками и другими объектами, на которые ссылаются записи.

Массовое удаление сообщений

messages.purge(channel_id, limit=100, check=None) проходит последние limit сообщений от новых к старым и удаляет те, для которых check возвращает истину, или все, если check не задан; limit=None проходит весь канал, а before и after начинают с другого места, как в list. Сообщения моложе 14 дней удаляются пакетами до 100 штук, более старые — по одному запросу на каждое, поскольку Discord пакетно удаляет только молодые сообщения; тип, который Discord не позволяет удалять, например стартовое сообщение ветки, пропускается. bulk=False удаляет по одному, как в личных сообщениях, где пакетного удаления нет. Метод возвращает удалённые сообщения, а reason попадает в журнал аудита. Для пакетного удаления и удаления чужих сообщений нужно право «Управлять сообщениями».

import bostie
from bostie import Context, Message, Permissions, Range, User, WebhookMessage

bot = bostie.Bot()


@bot.command(default_permissions=Permissions.MANAGE_MESSAGES)
async def purge(
    ctx: Context, count: Range[int, 1, 1000], user: User | None = None
) -> None:
    """Delete recent messages here, or only those of one user."""
    if ctx.channel_id is None:
        await ctx.respond("Use this in a channel.", ephemeral=True)
        return

    def wanted(message: Message | WebhookMessage) -> bool:
        return user is None or message.author.id == user.id

    await ctx.defer(ephemeral=True)
    deleted = await bot.rest.messages.purge(
        ctx.channel_id,
        limit=count,
        check=wanted,
        reason=f"/purge by {ctx.user.username}",
    )
    await ctx.respond(f"Deleted {len(deleted)} messages.")

Журнал аудита

audit_log.list(guild_id) выдаёт объекты AuditLogPage, а шлюз присылает каждую новую запись как событие AuditLogEntryCreate. changes записи содержат по одному AuditLogChange на каждое изменённое поле, у которого new_value и old_value имеют тот тип, который это поле имеет у изменённого объекта: Permissions для permissions роли, datetime для communication_disabled_until участника, кортеж Overwrite для permission_overwrites канала. Исключения Discord следуют его документации: $add и $remove для ролей участника хранят объекты AuditLogRole, avatar_hash вебхука и channel_id приглашения получают тип avatar и ID соответственно, а изменение прав команды индексируется ID роли, пользователя или канала и хранит CommandPermission. Ключ, которого bostie не знает, или значение в форме, которую поле не принимает, сохраняет своё значение JSON, а значение, которое Discord не прислал, равно None.

import os

import bostie
from bostie import AuditLogEvent, Permissions
from bostie.events import AuditLogEntryCreate

ALERTS_CHANNEL_ID = int(os.environ.get("ALERTS_CHANNEL_ID", "0"))

bot = bostie.Bot()


@bot.listen
async def admin_granted(event: AuditLogEntryCreate) -> None:
    entry = event.entry
    if entry.action_type is not AuditLogEvent.ROLE_UPDATE:
        return
    for change in entry.changes:
        before = change.old_value or Permissions(0)
        after = change.new_value or Permissions(0)
        if change.key == "permissions" and after & ~before & Permissions.ADMINISTRATOR:
            await bot.rest.messages.create(
                ALERTS_CHANNEL_ID,
                f"<@{entry.user_id}> gave <@&{entry.target_id}> Administrator.",
            )

Ошибки

Запрос, на который Discord отвечает статусом ошибки, вызывает HTTPException: BadRequest для 400, Forbidden для 403, NotFound для 404, ServerError для 5xx. У исключения есть status, code Discord в виде JSONErrorCode, его message, ошибки по полям errors и сырое body; в тексте исключения каждая ошибка поля стоит на своей строке. UnexpectedResponse означает, что Discord принял запрос, но его ответ не декодировался; в сообщении сказано, вступило ли изменение в силу, а error.raw хранит ответ.

Лимиты запросов, повторы и тайм-ауты

Клиент на Rust отслеживает бакет лимита запросов каждого маршрута и глобальный лимит Discord и придерживает запрос, пока в его бакете не появится место. Bot(http_settings=HTTPSettings(...)) меняет поведение:

Поле HTTPSettings По умолчанию Значение
global_rate_limit 50 запросов в секунду по всем маршрутам; увеличьте, если Discord дал боту более высокий лимит
max_rate_limit_wait 30.0 максимальное ожидание из-за лимита запросов; запрос, который ждал бы дольше, вызывает RateLimited и не отправляется
retries 2 сколько раз повторяется неудавшийся запрос, с экспоненциальной задержкой не больше max_rate_limit_wait
connect_timeout 10.0 секунд на открытие соединения
read_timeout 60.0 секунд на ожидание ответа
rate_limiter True False отправляет сразу и лишь выжидает вернувшиеся 429 — для работы за прокси лимитов запросов
proxy None URL прокси HTTP или HTTPS для всех запросов, включая звук voice.Url и загрузки из CDN, с учётными данными в виде http://user:password@host:3128

429 повторяется после задержки, которую указывает Discord, если она укладывается в max_rate_limit_wait. 500, 502, 503, 504 или 524, а также оборвавшееся соединение повторяются retries раз для всех методов, кроме POST, который мог уже сработать; соединение, которое так и не открылось, повторяется для любого метода.

Discord банит IP, который отправил 10 000 недействительных запросов (401, 403, 429) за 10 минут. bostie их считает и пишет предупреждение в лог на 1000, 5000 и 9000. На 9500 он отклоняет каждый запрос с RequestBudgetExhausted вместо отправки, пока счётчик за последние 11 минут не опустится ниже 9000.

Любой эндпоинт

bot.http.request(method, route, **params) отправляет любой запрос через тот же клиент и возвращает декодированный JSON или None для пустого ответа. Маршрут сохраняет свои плейсхолдеры, которые заполняют именованные аргументы: await bot.http.request("GET", "/channels/{channel_id}/pins", channel_id=123). Метод принимает json=, files= для multipart-загрузки, form= для именованных полей формы, urlencoded= для тела form-urlencoded, query= и reason= и выбрасывает те же исключения. bot.http.send(...) принимает те же аргументы и возвращает сырые байты ответа — для эндпоинта, который отвечает не JSON.

Скачивание файлов

Вложения и изображения лежат в CDN Discord, а не за API, поэтому при скачивании токен не отправляется и лимит запросов не расходуется. bot.http получает их через тот же клиент на Rust, с его тайм-аутами, повторами и proxy:

Метод Что делает
read(source) возвращает байты
save(source, path) записывает их в файл по пути path
to_file(source) возвращает File для повторной загрузки; из Attachment он сохраняет имя файла, описание, заголовок, спойлер и данные голосового сообщения, а из URL берёт имя из последней части пути

source — это Attachment или URL, например построенный функциями bostie.cdn. Отсутствующий файл вызывает NotFound.

import os

import bostie
from bostie import Context, Message, WebhookMessage, cdn

ARCHIVE_CHANNEL_ID = int(os.environ.get("ARCHIVE_CHANNEL_ID", "0"))

bot = bostie.Bot()


@bot.message_command("Archive files")
async def archive(ctx: Context, message: Message | WebhookMessage) -> None:
    await ctx.defer(ephemeral=True)
    files = [await bot.http.to_file(attachment) for attachment in message.attachments]
    if files:
        await bot.rest.messages.create(
            ARCHIVE_CHANNEL_ID, f"From {message.jump_url}", files=files
        )
    await ctx.respond(f"Archived {len(files)} files.")


@bot.command
async def avatar(ctx: Context) -> None:
    """Send your avatar as a file."""
    url = cdn.display_avatar(ctx.user, size=1024, format="png")
    await ctx.respond(files=[await bot.http.to_file(url)])

Без бота

Скрипт или веб-бэкенд, которому нужен только REST, использует RESTClient:

"""A script that uses the REST API without a gateway: list the bot's guilds."""

import asyncio
import logging

import bostie

log = logging.getLogger("rest-example")


async def main() -> None:
    async with bostie.RESTClient() as client:
        me = await client.rest.users.fetch_current()
        async for guild in client.rest.guilds.list():
            log.info("%s is in %s", me.username, guild.name)


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    asyncio.run(main())

async with RESTClient(token) as client: открывает HTTP-клиент; в client.rest есть все группы bot.rest, а client.http — это сырой клиент. Токен по умолчанию берётся из BOSTIE_TOKEN, а клиент принимает api_url, http_settings и allowed_mentions, как и бот. Руководство по OAuth2 описывает BearerClient, который вызывает API с токеном пользователя, а руководство по вебхукам — клиенты, которым токен вообще не нужен.

Группы

bot.rest. Методы
application edit, fetch, fetch_activity_instance, fetch_bot_application, list_role_connection_metadata, update_role_connection_metadata
audit_log list
auto_moderation create, delete, edit, fetch, list
bans bulk_create, create, fetch, list, remove
channels add_recipient, create, create_dm, create_group_dm, delete, delete_permission, edit, edit_permissions, edit_positions, fetch, fetch_member_permissions, fetch_role_permissions, follow, list, remove_recipient, set_voice_status, trigger_typing_indicator
commands bulk_overwrite, bulk_overwrite_guild, create, create_guild, delete, delete_guild, edit, edit_guild, fetch, fetch_guild, fetch_permissions, list, list_guild, list_permissions
emojis create, create_application, delete, delete_application, edit, edit_application, fetch, fetch_application, list, list_application
gateway fetch, fetch_bot
guilds begin_prune, edit, edit_incident_actions, edit_welcome_screen, edit_widget, fetch, fetch_preview, fetch_prune_count, fetch_vanity_url, fetch_welcome_screen, fetch_widget, fetch_widget_image, fetch_widget_settings, leave, list
identities delete, fetch_profile, list, list_by_external_id, update_profile
integrations delete, list
invites add_target_user, bulk_add_target_users, bulk_delete_target_users, create, delete, fetch, fetch_target_users_job_status, list, list_guild, list_target_users, remove_target_user, update_target_users
lobbies add_member, bulk_update_members, create, create_invite_for_user, delete, edit, fetch, remove_member, update_message_moderation_metadata
members add, add_role, edit, edit_current, fetch, fetch_permissions, list, remove, remove_role, search
messages bulk_delete, create, crosspost, delete, edit, fetch, list, list_pins, pin, purge, search, unpin
monetization consume_entitlement, create_test_entitlement, delete_test_entitlement, fetch_entitlement, fetch_subscription, list_entitlements, list_skus, list_subscriptions
onboarding edit, fetch
partner_sdk create_provisional_token, exchange_child_token, exchange_external_token, unmerge_external_account, unmerge_provisional_account, update_dm_moderation_metadata
polls end, list_answer_voters
reactions create, delete, delete_all, delete_all_for_emoji, delete_own, list
roles create, delete, edit, edit_positions, fetch, fetch_member_counts, fetch_permissions, list
scheduled_events create, delete, edit, fetch, list, list_users
soundboard create, delete, edit, fetch, list, list_default, send
stage_instances create, delete, edit, fetch
stickers create, delete, edit, fetch, fetch_guild, fetch_pack, list, list_packs
templates create, delete, edit, fetch, list, sync
threads add_member, fetch_member, join, leave, list_active, list_joined_private_archived, list_members, list_private_archived, list_public_archived, remove_member, start, start_from_message, start_in_forum
users edit_current, fetch, fetch_current
voice edit_current_state, edit_state, fetch_current_state, fetch_state, list_guild_regions, list_regions
webhooks create, delete, delete_message, delete_with_token, edit, edit_message, edit_with_token, execute, execute_github, execute_slack, fetch, fetch_message, fetch_with_token, list, list_guild

Параметры и возвращаемый тип каждого метода приведены в справочнике по REST API. Несколько операций намеренно оставлены для bot.http.request: эндпоинты, которые Discord объявил устаревшими или так и не задокументировал, а также эндпоинты обратного вызова взаимодействия и исходного ответа, которые покрывают контексты взаимодействий.

Некоторые группы обслуживают возможности Discord за пределами ботов на серверах: monetization и application — для монетизации и Активностей, lobbies, identities и partner_sdk — для Social SDK, а application.update_role_connection_metadata — для связанных ролей.