Componentes¶

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íacustom_idal bot cuando se hace clic.styleesPRIMARY,SECONDARY,SUCCESSoDANGER.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, yNoneborra una.await ctx.defer_update()confirma el uso sin una respuesta visible;updatepuede 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.