组件¶

组件是消息中的按钮、选择菜单和布局块。bostie.ui 会把它们构建成 Discord JSON 结构的普通字典,并在构建时检查 Discord 的各项限制。用 @bot.on_component 注册的处理函数负责回应对它们的使用。
bostie.ui 是构建组件的方式。组件模型,例如 bostie.Button 或 bostie.ActionRow,是解码后的消息中 components 所保存的内容;components= 也接受它们,因此可以把消息的组件原样发回,或用 msgspec.structs.replace 修改后再发,无需重新构建,但它们不会检查 Discord 的限制。
布局¶
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)],
)
这些是 Components V2:组件构成整条消息,用 ui.text 代替 content,消息内容中有说明。
| 构建器 | 显示内容 |
|---|---|
ui.text(markdown) |
文本,支持 Discord 的 Markdown |
ui.section(*texts, accessory=...) |
一到三段文本,旁边配一个按钮或缩略图 |
ui.thumbnail(url, description=None, spoiler=False) |
一张小图,只能作为 section 的附属组件 |
ui.media_gallery(*items) |
一到十张图片或视频,每项是一个 ui.gallery_item(url, description=None, spoiler=False) |
ui.file(filename, spoiler=False) |
随消息以该文件名上传的文件 |
ui.separator(divider=True, spacing=1) |
组件之间的间距,spacing=2 为大间距,除非 divider=False,否则带一条分隔线 |
ui.container(*components, accent_color=None, spoiler=False) |
包裹行、文本、section、图库、文件和分隔线的方框,带一条彩色边 |
ui.row(*components) |
最多五个按钮,或一个选择菜单 |
URL 可以写成 attachment://name,以显示随消息上传的文件。每个构建器都接受 id=,作为可选的数字组件 ID,ui.raw(dict) 可以原样传入一个 ui 尚未提供构建器的组件。项目过多、标签过长,或在带按钮的行中放入选择菜单,都会在构建时引发 ValueError。每个构建器都返回一个带类型的字典,例如 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 和 ui.SelectDefaultDict,ui.raw 则返回 ui.RawComponent;可以用它们来标注你自己的辅助函数,就像下面的 ballot 那样。
按钮¶
ui.button(label, custom_id=..., style=ButtonStyle.SECONDARY)被点击时会把custom_id发送给机器人。style可以是PRIMARY、SECONDARY、SUCCESS或DANGER。ui.link_button(label, url)打开一个 URL,永远不会触达机器人。ui.premium_button(sku_id)用于出售应用的某个 SKU,永远不会触达机器人。
按钮接受 emoji=:一个 Unicode 表情符号、一个按 Discord 显示格式书写的自定义表情符号(<:name:id>,动态表情为 <a:name:id>,与 fmt.emoji 生成的一致),或一个 Emoji 或 PartialEmoji 模型。PartialEmoji.parse(text) 可以读取这样的字符串。按钮还接受 disabled=True。标签最多 80 个字符,自定义 ID 为 1 到 100 个字符。
路由¶
路由是一个处理函数,负责处理所有符合某个模板的自定义 ID:
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}" 可以匹配 vote:0:Lunch at noon。{name} 捕获一个或多个不含换行的字符,{name:int} 捕获一个整数,包括负数。这些值以关键字参数的形式传给处理函数,int 类型会被转换。模板中两个占位符之间必须有字面文本,没有占位符的模板会精确匹配其自定义 ID。
vote.id(side=0, topic=topic) 直接从路由本身构建自定义 ID,因此模板只需写一次。如果 ID 会超过 Discord 的 100 个字符,或无法解析回相同的值,它会引发 ValueError。
状态保存在自定义 ID 中,而不是 Python 对象中,因此只要路由已注册,上周发出的按钮在重启后依然有效。@bot.on_component 和命令一样接受 ephemeral、auto_defer 和 checks,调用路由本身,如 await vote(ctx, side=0, topic="x"),就会调用其处理函数。
没有任何路由匹配的组件使用不会得到回应:Discord 会显示“This interaction failed”,bostie 会记录一条警告,除非你监听了 InteractionCreate。
回应组件¶
ComponentContext 拥有命令上下文的所有回应方法,另外还有:
await ctx.update(**parts)编辑组件所在的消息。省略的部分保持不变,None会清除某个部分。await ctx.defer_update()确认这次使用但不显示任何回应;之后 15 分钟内可以用update编辑消息。await ctx.modal(ui.modal(...))打开一个模态框。
respond 会发送一条新消息,与命令中相同。超过 auto_defer 秒仍未回应的使用会用 defer_update 延迟回应。
选择菜单¶
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)
| 构建器 | 用户选择的是 | 用以下方式读取所选内容 |
|---|---|---|
ui.string_select(custom_id, options) |
用 ui.select_option(label, value) 构建的选项 |
ctx.values,即所选的值 |
ui.user_select(custom_id) |
用户 | ctx.users,在服务器中还有 ctx.members |
ui.role_select(custom_id) |
身份组 | ctx.roles |
ui.mentionable_select(custom_id) |
用户和身份组 | ctx.users、ctx.members、ctx.roles |
ui.channel_select(custom_id, channel_types=...) |
频道,仅限给定类型 | ctx.channels |
ctx.ids 保存任何实体选择菜单中所选的 ID。读取错误的种类,例如在用户选择菜单上读取 ctx.values,会引发 TypeError。
每个选择菜单都接受 placeholder(最多 150 个字符)、min_values 和 max_values(默认为 1,最多 25)以及 disabled。字符串选择菜单有 1 到 25 个选项;select_option 接受 description、与按钮相同的 emoji,以及 default=True 以将其显示为已选中。实体选择菜单接受 defaults,即显示为已选中的 ID,可提及对象选择菜单接受 default_users 和 default_roles。required 只在模态框中有意义。
分页¶
ui.pager(route, page, count) 为带有 {page:int} 占位符的路由返回一行三个按钮:上一页、一个已禁用的 page / count 标签,以及下一页。第一页是 0,额外的关键字参数用于填充路由的其他占位符。因为页码保存在自定义 ID 中,所以分页在重启后依然有效。组件示例中的 /tour 命令就用到了它。
在处理函数中等待点击¶
命令可以在不使用路由的情况下等待某个组件的一次使用:
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) 返回下一次使用的 ComponentContext,要求其自定义 ID 属于 custom_ids(未提供时为任意组件),并且 check 对其返回真值。被它返回的使用不会运行任何路由。它会一直等待下去,所以请用 asyncio.timeout() 限定等待时间;check 抛出的异常会在这里抛出。它接受 ephemeral 和 auto_defer,作用于它返回的上下文。