Saltar a contenido

Componentes

Bostie presiona un botón grande

Los componentes son los botones, menús de selección y bloques de diseño de un mensaje. bostie.ui los construye como diccionarios simples con la forma del JSON de Discord y comprueba los límites de Discord mientras los construye. Un handler registrado con @bot.on_component responde a sus usos.

bostie.ui es la forma de construir componentes. Los modelos de componente, como bostie.Button o bostie.ActionRow, son lo que contiene el campo components de un mensaje decodificado; components= también los acepta, así que los componentes de un mensaje se pueden volver a enviar, editados con msgspec.structs.replace, sin reconstruirlos, pero esos modelos no comprueban los límites de Discord.

Diseño

import asyncio
from pathlib import Path

import bostie
from bostie import ButtonStyle, Context, File, color, ui

bot = bostie.Bot()


@bot.command
async def release(ctx: Context) -> None:
    """Show the release notes."""
    notes = await asyncio.to_thread(Path("notes.pdf").read_bytes)
    await ctx.respond(
        components=[
            ui.container(
                ui.section(
                    ui.text("## Version 2.0"),
                    ui.text("Faster startup and a new voice mixer."),
                    accessory=ui.thumbnail("https://example.com/logo.png"),
                ),
                ui.separator(spacing=2),
                ui.media_gallery(
                    ui.gallery_item("https://example.com/before.png"),
                    ui.gallery_item("https://example.com/after.png", spoiler=True),
                ),
                ui.file("notes.pdf"),
                ui.row(
                    ui.link_button("Changelog", "https://example.com/changelog"),
                    ui.button(
                        "Subscribe", custom_id="subscribe", style=ButtonStyle.SUCCESS
                    ),
                ),
                accent_color=color.BLURPLE,
            )
        ],
        files=[File("notes.pdf", notes)],
    )

Estos son Components V2: los componentes forman el mensaje completo, con ui.text en lugar de content, como explica Contenido de los mensajes.

Builder Qué muestra
ui.text(markdown) texto, con el markdown de Discord
ui.section(*texts, accessory=...) de uno a tres textos con un botón o una miniatura al lado
ui.thumbnail(url, description=None, spoiler=False) una imagen pequeña, solo como accesorio de una sección
ui.media_gallery(*items) de una a diez imágenes o videos, cada uno un ui.gallery_item(url, description=None, spoiler=False)
ui.file(filename, spoiler=False) un archivo subido con el mensaje con ese nombre
ui.separator(divider=True, spacing=1) espacio entre componentes, spacing=2 para uno grande, con una línea salvo que divider=False
ui.container(*components, accent_color=None, spoiler=False) un recuadro alrededor de filas, textos, secciones, galerías, archivos y separadores, con un borde de color
ui.row(*components) hasta cinco botones, o un menú de selección

Una URL puede ser attachment://name para mostrar un archivo subido con el mensaje. Todos los builders aceptan id= para el id numérico opcional del componente, y ui.raw(dict) pasa sin cambios un componente para el que ui todavía no tiene builder. Demasiados elementos, una etiqueta demasiado larga o un menú de selección dentro de una fila con botones lanzan ValueError al construirlos. Cada builder devuelve un diccionario tipado, como ui.ButtonDict, ui.RowDict, ui.TextDict, ui.SectionDict, ui.ThumbnailDict, ui.MediaGalleryDict, ui.GalleryItemDict, ui.FileDict, ui.SeparatorDict, ui.ContainerDict, ui.StringSelectDict, ui.SelectOptionDict, ui.EntitySelectDict y ui.SelectDefaultDict, y ui.raw devuelve un ui.RawComponent; anota tus propias funciones auxiliares con ellos, como hace ballot más abajo.

Botones

  • ui.button(label, custom_id=..., style=ButtonStyle.SECONDARY) le envía custom_id al bot cuando se hace clic. style es PRIMARY, SECONDARY, SUCCESS o DANGER.
  • ui.link_button(label, url) abre una URL y nunca llega al bot.
  • ui.premium_button(sku_id) ofrece a la venta un SKU de la aplicación y nunca llega al bot.

Los botones aceptan emoji=: un emoji Unicode, un emoji personalizado escrito como lo muestra Discord (<:name:id>, o <a:name:id> si es animado, como lo genera fmt.emoji), o un modelo Emoji o PartialEmoji. PartialEmoji.parse(text) lee una cadena así. Los botones también aceptan disabled=True. Una etiqueta tiene como máximo 80 caracteres y un custom id, de 1 a 100.

Rutas

Una ruta es un handler para cada custom id que encaja con una plantilla:

import bostie
from bostie import ButtonStyle, ComponentContext, Context, Range, ui

bot = bostie.Bot()
votes: dict[str, list[int]] = {}


def ballot(topic: str) -> list[ui.TextDict | ui.RowDict]:
    yes, no = votes.setdefault(topic, [0, 0])
    return [
        ui.text(f"**{topic}**: {yes} for, {no} against"),
        ui.row(
            ui.button("For", custom_id=vote.id(side=0, topic=topic)),
            ui.button(
                "Against",
                custom_id=vote.id(side=1, topic=topic),
                style=ButtonStyle.DANGER,
            ),
        ),
    ]


@bot.command
async def propose(ctx: Context, topic: Range[str, 1, 80]) -> None:
    """Start a vote."""
    await ctx.respond(components=ballot(topic))


@bot.on_component("vote:{side:int}:{topic}")
async def vote(ctx: ComponentContext, side: int, topic: str) -> None:
    votes.setdefault(topic, [0, 0])[side] += 1
    await ctx.update(components=ballot(topic))

"vote:{side:int}:{topic}" coincide con vote:0:Lunch at noon. {name} captura uno o más caracteres sin salto de línea, y {name:int} un entero, incluidos los negativos. Los valores llegan al handler como argumentos por palabra clave, convertidos en el caso de int. Las plantillas necesitan texto literal entre dos marcadores de posición, y una plantilla sin marcadores de posición coincide exactamente con su custom id.

vote.id(side=0, topic=topic) construye el custom id a partir de la propia ruta, así que la plantilla se escribe una sola vez. Lanza ValueError si el id superaría los 100 caracteres de Discord o si al analizarlo no devolvería los mismos valores.

El estado vive en el custom id, no en un objeto de Python, así que un botón enviado la semana pasada sigue funcionando después de un reinicio, siempre que su ruta esté registrada. @bot.on_component acepta ephemeral, auto_defer y checks, como un comando, y llamar a la ruta, como en await vote(ctx, side=0, topic="x"), llama al handler.

Un uso de componente que no coincide con ninguna ruta no recibe respuesta: Discord muestra que la interacción falló y bostie deja una advertencia en el log, salvo que escuches InteractionCreate.

Responder a un componente

Un ComponentContext tiene los métodos para responder del contexto de un comando, y además:

  • await ctx.update(**parts) edita el mensaje en el que está el componente. Las partes que no pasas se quedan, y None borra una.
  • await ctx.defer_update() confirma el uso sin una respuesta visible; update puede editar el mensaje dentro de los 15 minutos.
  • await ctx.modal(ui.modal(...)) abre un modal.

respond envía un mensaje nuevo, como en un comando. Un uso que queda sin respuesta durante auto_defer segundos se difiere con defer_update.

Menús de selección

import bostie
from bostie import ChannelType, ComponentContext, Context, ui

bot = bostie.Bot()

LANGUAGES = {"py": "Python", "rs": "Rust", "ts": "TypeScript"}


@bot.command
async def setup(ctx: Context) -> None:
    """Pick your languages and a channel for news."""
    languages = [ui.select_option(name, code) for code, name in LANGUAGES.items()]
    await ctx.respond(
        components=[
            ui.row(
                ui.string_select(
                    "setup:languages",
                    languages,
                    placeholder="Languages you use",
                    min_values=1,
                    max_values=len(languages),
                )
            ),
            ui.row(
                ui.channel_select(
                    "setup:news",
                    channel_types=[ChannelType.GUILD_TEXT],
                    placeholder="Where to post news",
                )
            ),
            ui.row(ui.role_select("setup:roles", max_values=3)),
        ],
        ephemeral=True,
    )


@bot.on_component("setup:languages")
async def languages_picked(ctx: ComponentContext) -> None:
    names = ", ".join(LANGUAGES[code] for code in ctx.values)
    await ctx.respond(f"You use {names}.", ephemeral=True)


@bot.on_component("setup:news")
async def news_picked(ctx: ComponentContext) -> None:
    await ctx.respond(f"News goes to {ctx.channels[0].mention}.", ephemeral=True)


@bot.on_component("setup:roles")
async def roles_picked(ctx: ComponentContext) -> None:
    picked = " ".join(role.mention for role in ctx.roles) or "no roles"
    await ctx.respond(f"You picked {picked}.", ephemeral=True)
Builder El usuario elige Lee lo elegido con
ui.string_select(custom_id, options) opciones construidas con ui.select_option(label, value) ctx.values, los valores
ui.user_select(custom_id) usuarios ctx.users, y ctx.members en un servidor
ui.role_select(custom_id) roles ctx.roles
ui.mentionable_select(custom_id) usuarios y roles ctx.users, ctx.members, ctx.roles
ui.channel_select(custom_id, channel_types=...) canales, solo de los tipos indicados ctx.channels

ctx.ids contiene los ids elegidos en cualquier menú de selección de entidades. Leer el tipo equivocado, como ctx.values en un menú de selección de usuarios, lanza TypeError.

Todos los menús de selección aceptan placeholder (hasta 150 caracteres), min_values y max_values (1 por defecto, como máximo 25) y disabled. Un menú de selección de cadenas tiene de 1 a 25 opciones; select_option acepta description, emoji como los botones, y default=True para mostrarla como elegida. Los menús de selección de entidades aceptan defaults, los ids que se muestran como elegidos, y el menú de selección de mencionables acepta default_users y default_roles. required solo importa en un modal.

Páginas

ui.pager(route, page, count) devuelve una fila de tres botones para una ruta con un marcador de posición {page:int}: anterior, una etiqueta deshabilitada page / count y siguiente. La primera página es la 0, y los argumentos por palabra clave adicionales completan los otros marcadores de posición de la ruta. Como el número de página está en el custom id, la paginación sobrevive a los reinicios. El comando /tour del ejemplo de componentes lo usa.

Esperar un clic dentro de un handler

Un comando puede esperar un uso de un componente sin una ruta:

import asyncio

import bostie
from bostie import ButtonStyle, Context, ui

bot = bostie.Bot()


@bot.command
async def reset(ctx: Context) -> None:
    """Reset your settings after a confirmation."""
    yes = f"reset:{ctx.interaction.id}"
    button = ui.button("Reset", custom_id=yes, style=ButtonStyle.DANGER)
    await ctx.respond("Reset all your settings?", components=[ui.row(button)])
    try:
        async with asyncio.timeout(30):
            press = await bot.wait_for_component(
                yes, check=lambda press: press.user.id == ctx.user.id
            )
    except TimeoutError:
        await ctx.edit(content="Nothing was reset.", components=[])
        return
    await press.update(content="Your settings are reset.", components=[])

await bot.wait_for_component(*custom_ids, check=None) devuelve el ComponentContext del siguiente uso cuyo custom id sea uno de custom_ids, o de cualquier componente si no se indica ninguno, y para el que check devuelva verdadero. Para un uso que devuelve, no se ejecuta ninguna ruta. Espera para siempre, así que ponle un límite con asyncio.timeout(); una excepción lanzada por check se lanza aquí. Acepta ephemeral y auto_defer para el contexto que devuelve.