跳转至

REST API

Bostie 跃起去接一个球

对于 Discord REST API 中机器人可以调用的每个操作,bot.rest 都提供了一个方法,并按其操作的资源分组:bot.rest.messages.create、bot.rest.members.add_role、bot.rest.guilds.fetch。请求经由 bostie 的 Rust HTTP 客户端发出,它会遵守 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)

约定

  • URL 中的 ID 排在最前面,仅限位置传参,顺序与 URL 中一致:bot.rest.members.add_role(guild_id, user_id, role_id)。
  • 其余参数都是关键字参数,命名与 Discord 文档一致,并按每个操作对应的 TypedDict 检查,因此拼写错误既是类型错误,也会在运行时引发 TypeError。
  • Discord 会记入审核日志的操作接受 reason=,长度为 1 到 512 个字符,会显示给服务器的版主。
  • 图片或声音字段,例如表情符号的 image 或服务器的 icon,接受一个 File;bostie 会把它作为 Discord 期望的 data URI 发送。权限字段接受 Permissions。
  • 表情符号,例如 reactions.create 添加的那个,接受一个 Unicode 表情符号、一个写成 <:name:id>、<a:name:id> 或 name:id 的自定义表情符号,或一个 Emoji 或 PartialEmoji 模型。
  • 名为 fetch 的方法获取单个对象,list 遍历多个对象,edit 修改,create 创建,delete 删除。其他动词沿用文档中的用词,例如 members.add_role 或 guilds.begin_prune。
  • 方法返回模型,当 Discord 回应无内容时返回 None。它们不会修改 bot.cache,唯一的例外是 channels.create_dm 会在 max_dm_channels 允许时保存私信频道;变更之后的网关事件会更新缓存。

分页

对于 Discord 分页的端点,其 list 方法返回一个异步迭代器,会随着遍历自动获取下一页:async for message in bot.rest.messages.list(channel_id, limit=500):,就像上面的 /today 那样。不设置 limit 时,它会遍历所有项目。before、after 和 around 可以从其他位置开始,三者最多用一个;它们接受一个 ID 或一个 datetime,bostie 会把后者转换为该时刻的 ID。消息按从新到旧排列,使用 after 时则按从旧到新。对于 Discord 不分页的端点,其 list 方法是一个返回元组的协程。audit_log.list 产出完整的 AuditLogPage 对象,其中包含条目所引用的用户、Webhook 和其他对象。

批量清除消息

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,成员的 communication_disabled_until 为 datetime,频道的 permission_overwrites 为 Overwrite 元组。Discord 的特殊情况遵循其文档:成员身份组上的 $add 和 $remove 保存 AuditLogRole 对象,Webhook 的 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:400 为 BadRequest,403 为 Forbidden,404 为 NotFound,5xx 为 ServerError。该异常带有 status、以 JSONErrorCode 表示的 Discord code、它的 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 所有请求使用的 HTTP 或 HTTPS 代理 URL,包括 voice.Url 音频和 CDN 下载,凭据写在 URL 中,如 http://user:password@host:3128

429 会在 Discord 给出的延迟之后重试,前提是该延迟在 max_rate_limit_wait 之内。500、502、503、504 或 524,或者连接中断,会对除 POST 外的所有方法重试 retries 次,因为 POST 可能已经生效;从未建立的连接则对所有方法都会重试。

Discord 会封禁在 10 分钟内发出 10 000 个无效请求(401、403、429)的 IP。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=、用于 multipart 上传的 files=、用于具名表单字段的 form=、用于 form-urlencoded 请求体的 urlencoded=、query= 和 reason=,并引发相同的异常。bot.http.send(...) 接受同样的参数,返回回应的原始字节,适用于不以 JSON 回应的端点。

下载文件

附件和图片位于 Discord 的 CDN 上,而不在 API 之后,因此下载既不发送令牌,也不消耗速率限制额度。bot.http 通过同一个 Rust 客户端获取它们,同样使用其超时、重试和 proxy 设置:

方法 作用
read(source) 返回字节
save(source, path) 把它们写入 path 处的文件
to_file(source) 返回一个可以再次上传的 File;来自 Attachment 时保留文件名、描述、标题、剧透标记和语音消息的信息,来自 URL 时以其路径的最后一段命名

source 是一个 Attachment,或者一个 URL,例如 bostie.cdn 辅助函数构建的 URL。文件不存在时引发 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 的脚本或 Web 后端使用 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 指南介绍了用用户令牌调用 API 的 BearerClient,Webhook 指南介绍了完全不需要令牌的客户端。

分组

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 用于变现和 Activity,lobbies、identities 和 partner_sdk 用于 Social SDK,application.update_role_connection_metadata 用于关联身份组。