API REST¶

bot.rest tem um método para cada operação da API REST do Discord que um bot pode chamar, agrupados pelo recurso sobre o qual agem: bot.rest.messages.create, bot.rest.members.add_role, bot.rest.guilds.fetch. As requisições passam pelo cliente HTTP em Rust do bostie, que segue os rate limits do 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)
Convenções¶
- Os IDs da URL vêm primeiro, somente posicionais, na ordem da URL:
bot.rest.members.add_role(guild_id, user_id, role_id). - Todo o resto é um argumento nomeado como na documentação do Discord, verificado contra um
TypedDictpor operação, então um erro de digitação é um erro de tipo e umTypeErrorem tempo de execução. - As operações que o Discord registra no registro de auditoria aceitam
reason=, de 1 a 512 caracteres, mostrado aos moderadores do servidor. - Um campo de imagem ou de som, como o
imagede um emoji ou oiconde um servidor, aceita umFile; o bostie o envia como o data URI que o Discord espera. Os campos de permissão aceitamPermissions. - Um emoji, como o que
reactions.createadiciona, aceita um emoji Unicode, um emoji personalizado como<:name:id>,<a:name:id>ouname:id, ou um modeloEmojiouPartialEmoji. - Os métodos chamados
fetchbuscam um objeto,listpercorre vários,editmodifica,createcria edeleteexclui. Os outros verbos mantêm a palavra da documentação, comomembers.add_roleouguilds.begin_prune. - Os métodos retornam modelos, ou
Nonequando o Discord responde sem conteúdo. Eles não mudambot.cache, exceto quechannels.create_dmguarda o canal de mensagem direta quandomax_dm_channelspermite; os eventos do gateway que seguem uma mudança atualizam o cache.
Paginação¶
Os métodos list dos endpoints que o Discord pagina retornam um iterador assíncrono, que busca a página seguinte conforme você avança: async for message in bot.rest.messages.list(channel_id, limit=500):, como /today faz acima. Sem limit, ele percorre todos os itens. before, after e around começam em outro ponto, no máximo um deles; eles aceitam um ID ou um datetime, que o bostie transforma no ID daquele momento. As mensagens vêm das mais novas para as mais antigas, ou das mais antigas para as mais novas com after. Um método list de um endpoint que o Discord não pagina é uma corrotina que retorna uma tupla. audit_log.list produz objetos AuditLogPage inteiros, com os usuários, webhooks e outros objetos aos quais as entradas se referem.
Limpando mensagens¶
messages.purge(channel_id, limit=100, check=None) percorre as últimas limit mensagens, das mais novas para as mais antigas, e exclui aquelas para as quais check retorna verdadeiro, ou todas, quando ele não é passado; limit=None percorre o canal inteiro, e before e after começam em outro ponto, como em list. Mensagens com menos de 14 dias vão em exclusões em massa de até 100, as mais antigas em uma requisição cada, já que o Discord só exclui em massa mensagens recentes; um tipo que o Discord não deixa excluir, como a mensagem inicial de um tópico, é pulado. bulk=False exclui uma de cada vez, como em uma mensagem direta, onde exclusões em massa não existem. Ele retorna as mensagens excluídas, e reason vai para o registro de auditoria. As exclusões em massa e as mensagens de outras pessoas precisam de Gerenciar mensagens.
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.")
O registro de auditoria¶
audit_log.list(guild_id) produz objetos AuditLogPage, e o gateway envia cada nova entrada como um evento AuditLogEntryCreate. As changes de uma entrada guardam um AuditLogChange por campo alterado, cujos new_value e old_value têm o tipo que o objeto alterado dá àquele campo: Permissions para o permissions de um cargo, um datetime para o communication_disabled_until de um membro, uma tupla de Overwrite para o permission_overwrites de um canal. As exceções do Discord seguem a documentação dele: $add e $remove nos cargos de um membro guardam objetos AuditLogRole, o avatar_hash de um webhook e o channel_id de um convite têm o tipo de avatar e de um ID, e uma mudança de permissão de comando tem como chave o ID do cargo, do usuário ou do canal e guarda um CommandPermission. Uma chave que o bostie não conhece, ou um valor num formato que o campo não aceita, mantém o seu valor JSON, e um valor que o Discord omite é 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.",
)
Erros¶
Uma requisição que o Discord responde com um status de erro levanta uma HTTPException: BadRequest para 400, Forbidden para 403, NotFound para 404, ServerError para 5xx. A exceção tem o status, o code do Discord como um JSONErrorCode, a message dele, os errors por campo e o body bruto; o texto dela lista cada erro de campo em uma linha própria. UnexpectedResponse significa que o Discord aceitou a requisição, mas a resposta dele não decodificou; a mensagem diz se a mudança teve efeito, e error.raw guarda a resposta.
Rate limits, novas tentativas e tempos limite¶
O cliente em Rust acompanha o bucket de rate limit de cada rota e o limite global do Discord, e segura uma requisição até que o bucket dela tenha espaço. Bot(http_settings=HTTPSettings(...)) muda como:
Campo de HTTPSettings |
Padrão | Significado |
|---|---|---|
global_rate_limit |
50 | requisições por segundo somando todas as rotas; aumente-o se o Discord deu ao bot um limite maior |
max_rate_limit_wait |
30.0 | a espera mais longa por um rate limit; uma requisição que esperaria mais levanta RateLimited sem ser enviada |
retries |
2 | quantas vezes uma requisição que falhou é repetida, com backoff exponencial que espera no máximo max_rate_limit_wait |
connect_timeout |
10.0 | segundos para abrir uma conexão |
read_timeout |
60.0 | segundos para esperar uma resposta |
rate_limiter |
True |
False envia na hora e só espera passar os 429 que voltam, para uso atrás de um proxy de rate limit |
proxy |
None |
uma URL de proxy HTTP ou HTTPS para todas as requisições, incluindo o áudio de voice.Url e os downloads da CDN, com credenciais incluídas como em http://user:password@host:3128 |
Um 429 é repetido depois do atraso que o Discord indica, se ele estiver dentro de max_rate_limit_wait. Um 500, 502, 503, 504 ou 524, ou uma conexão que caiu, é repetido retries vezes em todos os métodos menos POST, que pode ter tido efeito; uma conexão que nunca abriu é repetida em todos os métodos.
O Discord bane um IP que envia 10 000 requisições inválidas (401, 403, 429) em 10 minutos. O bostie as conta e escreve um aviso no log em 1000, 5000 e 9000. Em 9500, ele recusa toda requisição com RequestBudgetExhausted em vez de enviá-la, até que a contagem dos últimos 11 minutos caia abaixo de 9000.
Qualquer endpoint¶
bot.http.request(method, route, **params) envia qualquer requisição pelo mesmo cliente e retorna o JSON decodificado, ou None para uma resposta vazia. A rota mantém os seus marcadores, que os argumentos nomeados preenchem: await bot.http.request("GET", "/channels/{channel_id}/pins", channel_id=123). Ele aceita json=, files= para um upload multipart, form= para campos de formulário nomeados, urlencoded= para um corpo form-urlencoded, query= e reason=, e levanta as mesmas exceções. bot.http.send(...) aceita os mesmos argumentos e retorna os bytes brutos da resposta, para um endpoint que não responde com JSON.
Baixando arquivos¶
Os anexos e as imagens ficam na CDN do Discord, não atrás da API, então um download não envia token nem gasta rate limit. bot.http os busca pelo mesmo cliente em Rust, com os seus tempos limite, novas tentativas e proxy:
| Método | Faz |
|---|---|
read(source) |
retorna os bytes |
save(source, path) |
grava-os no arquivo em path |
to_file(source) |
retorna um File para enviar de novo; a partir de um Attachment, ele mantém o nome do arquivo, a descrição, o título, o spoiler e os detalhes de mensagem de voz, e a partir de uma URL, a última parte do caminho dá o nome |
source é um Attachment, ou uma URL como as que os helpers de bostie.cdn montam. Um arquivo ausente levanta 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)])
Sem um bot¶
Um script ou um backend web que só precisa de REST usa um 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: abre o cliente HTTP; client.rest tem todos os grupos de bot.rest, e client.http o cliente bruto. O token tem como padrão BOSTIE_TOKEN, e o cliente aceita api_url, http_settings e allowed_mentions como o bot. O guia de OAuth2 cobre o BearerClient, que chama a API com o token de um usuário, e o guia de webhooks os clientes que não precisam de token nenhum.
Os grupos¶
bot.rest. |
Métodos |
|---|---|
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 |
Os parâmetros e o tipo de retorno de cada método estão na referência da API REST. Algumas operações ficam de propósito para bot.http.request: endpoints que o Discord descontinuou ou nunca documentou, e os endpoints de callback de interação e de resposta original, que os contextos de interação cobrem.
Alguns grupos atendem a recursos do Discord além dos bots em servidores: monetization e application para monetização e Atividades, lobbies, identities e partner_sdk para o Social SDK, e application.update_role_connection_metadata para os cargos vinculados.