API REST¶

bot.rest a une méthode pour chaque opération de l'API REST de Discord qu'un bot peut appeler, regroupées selon la ressource sur laquelle elles agissent : bot.rest.messages.create, bot.rest.members.add_role, bot.rest.guilds.fetch. Les requêtes passent par le client HTTP Rust de bostie, qui respecte les limites de débit de 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)
Conventions¶
- Les id de l'URL viennent en premier, positionnels uniquement, dans l'ordre de l'URL :
bot.rest.members.add_role(guild_id, user_id, role_id). - Tout le reste est un argument nommé comme dans la documentation de Discord, vérifié par un
TypedDictpropre à chaque opération : une faute de frappe est donc une erreur de type, et uneTypeErrorà l'exécution. - Les opérations que Discord consigne dans le journal des actions acceptent
reason=, de 1 à 512 caractères, affiché aux modérateurs du serveur. - Un champ d'image ou de son, comme l'
imaged'un émoji ou l'icond'un serveur, accepte unFile; bostie l'envoie sous forme de data URI, comme Discord l'attend. Les champs de permissions acceptent desPermissions. - Un émoji, comme celui qu'ajoute
reactions.create, accepte un émoji Unicode, un émoji personnalisé sous la forme<:name:id>,<a:name:id>ouname:id, ou un modèleEmojiouPartialEmoji. - Les méthodes nommées
fetchrécupèrent un objet,listen parcourt plusieurs,editmodifie,createcrée etdeletesupprime. Les autres verbes gardent le mot de la documentation, commemembers.add_roleouguilds.begin_prune. - Les méthodes renvoient des modèles, ou
Nonequand Discord répond sans contenu. Elles ne modifient pasbot.cache, saufchannels.create_dm, qui conserve le salon de MP quandmax_dm_channelsle permet ; ce sont les événements de la gateway qui suivent un changement qui mettent le cache à jour.
Pagination¶
Les méthodes list des endpoints que Discord pagine renvoient un itérateur asynchrone, qui récupère la page suivante au fur et à mesure : async for message in bot.rest.messages.list(channel_id, limit=500):, comme le fait /today ci-dessus. Sans limit, il parcourt tous les éléments. before, after et around commencent ailleurs, un seul d'entre eux au plus ; ils acceptent un id ou un datetime, que bostie convertit en l'id de ce moment. Les messages arrivent du plus récent au plus ancien, ou du plus ancien au plus récent avec after. La méthode list d'un endpoint que Discord ne pagine pas est une coroutine qui renvoie un tuple. audit_log.list produit des objets AuditLogPage entiers, avec les utilisateurs, les webhooks et les autres objets auxquels les entrées font référence.
Purger des messages¶
messages.purge(channel_id, limit=100, check=None) parcourt les limit derniers messages, du plus récent au plus ancien, et supprime ceux pour lesquels check renvoie vrai, ou tous sans check ; limit=None parcourt tout le salon, et before et after commencent ailleurs, comme dans list. Les messages de moins de 14 jours partent dans des suppressions groupées de 100 au plus, les plus anciens à raison d'une requête chacun, car Discord ne supprime en masse que les messages récents ; un type que Discord ne permet pas de supprimer, comme le message de départ d'un fil, est ignoré. bulk=False supprime un message à la fois, comme dans un MP, où les suppressions groupées n'existent pas. La méthode renvoie les messages supprimés, et reason va dans le journal des actions. Les suppressions groupées et les messages des autres demandent la permission Gérer les messages.
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.")
Le journal des actions¶
audit_log.list(guild_id) produit des objets AuditLogPage, et la gateway envoie chaque nouvelle entrée sous forme d'événement AuditLogEntryCreate. Les changes d'une entrée contiennent un AuditLogChange par champ modifié, dont new_value et old_value ont le type que l'objet modifié donne à ce champ : Permissions pour les permissions d'un rôle, un datetime pour le communication_disabled_until d'un membre, un tuple de Overwrite pour les permission_overwrites d'un salon. Les exceptions de Discord suivent sa documentation : $add et $remove sur les rôles d'un membre contiennent des objets AuditLogRole, l'avatar_hash d'un webhook et le channel_id d'une invitation prennent le type de avatar et d'un id, et un changement de permission de commande a pour clé l'id du rôle, de l'utilisateur ou du salon et contient une CommandPermission. Une clé que bostie ne connaît pas, ou une valeur d'une forme que le champ n'accepte pas, garde sa valeur JSON, et une valeur que Discord omet vaut 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.",
)
Erreurs¶
Une requête à laquelle Discord répond par un statut d'erreur lève une HTTPException : BadRequest pour 400, Forbidden pour 403, NotFound pour 404, ServerError pour 5xx. L'exception porte le status, le code de Discord sous forme de JSONErrorCode, son message, les errors par champ et le body brut ; son texte liste chaque erreur de champ sur sa propre ligne. UnexpectedResponse signifie que Discord a accepté la requête mais que sa réponse n'a pas pu être décodée ; son message indique si le changement a pris effet, et error.raw contient la réponse.
Limites de débit, nouvelles tentatives et délais d'expiration¶
Le client Rust suit le bucket de limite de débit de chaque route ainsi que la limite globale de Discord, et retient une requête jusqu'à ce que son bucket ait de la place. Bot(http_settings=HTTPSettings(...)) change ce comportement :
Champ de HTTPSettings |
Par défaut | Signification |
|---|---|---|
global_rate_limit |
50 | requêtes par seconde, toutes routes confondues ; augmentez-la si Discord a accordé au bot une limite plus élevée |
max_rate_limit_wait |
30.0 | l'attente la plus longue pour une limite de débit ; une requête qui attendrait plus longtemps lève RateLimited sans être envoyée |
retries |
2 | le nombre de nouvelles tentatives pour une requête échouée, avec un backoff exponentiel qui attend au plus max_rate_limit_wait |
connect_timeout |
10.0 | secondes pour ouvrir une connexion |
read_timeout |
60.0 | secondes d'attente d'une réponse |
rate_limiter |
True |
False envoie immédiatement et attend seulement la fin des 429 qui reviennent, pour un usage derrière un proxy de limite de débit |
proxy |
None |
une URL de proxy HTTP ou HTTPS pour chaque requête, audio voice.Url et téléchargements du CDN compris, identifiants inclus comme dans http://user:password@host:3128 |
Un 429 est retenté après le délai que donne Discord, si celui-ci ne dépasse pas max_rate_limit_wait. Un 500, 502, 503, 504 ou 524, ou une connexion coupée, est retenté retries fois pour toutes les méthodes sauf POST, qui a pu prendre effet ; une connexion qui ne s'est jamais ouverte est retentée pour toutes les méthodes.
Discord bannit une IP qui envoie 10 000 requêtes invalides (401, 403, 429) en 10 minutes. bostie les compte et journalise un avertissement à 1000, 5000 et 9000. À 9500, il refuse chaque requête avec RequestBudgetExhausted au lieu de l'envoyer, jusqu'à ce que le compte des 11 dernières minutes repasse sous 9000.
N'importe quel endpoint¶
bot.http.request(method, route, **params) envoie n'importe quelle requête par le même client et renvoie le JSON décodé, ou None pour une réponse vide. La route garde ses espaces réservés, que les arguments nommés remplissent : await bot.http.request("GET", "/channels/{channel_id}/pins", channel_id=123). Elle accepte json=, files= pour un envoi multipart, form= pour des champs de formulaire nommés, urlencoded= pour un corps form-urlencoded, query= et reason=, et lève les mêmes exceptions. bot.http.send(...) accepte les mêmes arguments et renvoie les octets bruts de la réponse, pour un endpoint qui ne répond pas en JSON.
Télécharger des fichiers¶
Les pièces jointes et les images se trouvent sur le CDN de Discord, pas derrière l'API : un téléchargement n'envoie donc aucun token et ne consomme aucune limite de débit. bot.http les récupère par le même client Rust, avec ses délais d'expiration, ses nouvelles tentatives et son proxy :
| Méthode | Effet |
|---|---|
read(source) |
renvoie les octets |
save(source, path) |
les écrit dans le fichier situé à path |
to_file(source) |
renvoie un File à envoyer de nouveau ; depuis une Attachment, il garde le nom du fichier, la description, le titre, le spoiler et les détails de message vocal ; depuis une URL, c'est la dernière partie de son chemin qui le nomme |
source est une Attachment, ou une URL telle que celles que construisent les utilitaires bostie.cdn. Un fichier absent lève 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)])
Sans bot¶
Un script ou un backend web qui n'a besoin que de REST utilise un 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: ouvre le client HTTP ; client.rest a tous les groupes de bot.rest, et client.http le client brut. Le token vaut par défaut BOSTIE_TOKEN, et le client accepte api_url, http_settings et allowed_mentions, comme le bot. Le guide OAuth2 couvre BearerClient, qui appelle l'API avec le token d'un utilisateur, et le guide des webhooks les clients qui n'ont besoin d'aucun token.
Les groupes¶
bot.rest. |
Méthodes |
|---|---|
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 |
Les paramètres et le type de retour de chaque méthode figurent dans la référence de l'API REST. Quelques opérations sont volontairement laissées à bot.http.request : les endpoints que Discord a dépréciés ou n'a jamais documentés, ainsi que les endpoints de callback d'interaction et de réponse originale, que couvrent les contextes d'interaction.
Certains groupes servent des fonctionnalités de Discord qui vont au-delà des bots sur des serveurs : monetization et application pour la monétisation et les Activités, lobbies, identities et partner_sdk pour le Social SDK, et application.update_role_connection_metadata pour les rôles liés.