컴포넌트¶

컴포넌트는 메시지에 들어가는 버튼, 선택 메뉴, 레이아웃 블록입니다. 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입니다. 메시지 내용에서 설명하듯 컴포넌트가 메시지 전체를 이루며, content 대신 ui.text를 씁니다.
| 빌더 | 표시하는 것 |
|---|---|
ui.text(markdown) |
Discord 마크다운을 쓸 수 있는 텍스트 |
ui.section(*texts, accessory=...) |
텍스트 1~3개와 그 옆의 버튼 또는 썸네일 |
ui.thumbnail(url, description=None, spoiler=False) |
작은 이미지. 섹션의 accessory로만 사용 |
ui.media_gallery(*items) |
이미지나 동영상 1~10개. 각각 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) |
행, 텍스트, 섹션, 갤러리, 파일, 구분선을 감싸는 상자. 테두리에 색이 들어감 |
ui.row(*components) |
버튼 최대 5개, 또는 선택 메뉴 1개 |
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=를 받습니다. 유니코드 이모지, 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를 발생시킵니다.
상태는 Python 객체가 아니라 커스텀 ID에 들어 있으므로, 지난주에 보낸 버튼도 라우트가 등록되어 있는 한 재시작 뒤에도 동작합니다. @bot.on_component는 명령어처럼 ephemeral, auto_defer, checks를 받으며, await vote(ctx, side=0, topic="x")처럼 라우트를 호출하면 핸들러가 호출됩니다.
어떤 라우트와도 일치하지 않는 컴포넌트 사용에는 응답하지 않습니다. InteractionCreate를 수신하지 않는 한, Discord는 "This interaction failed"를 표시하고 bostie는 경고를 로그에 남깁니다.
컴포넌트에 응답하기¶
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를 받습니다. 엔티티 선택 메뉴는 선택된 상태로 표시할 ID인 defaults를 받고, 멘션 가능 선택 메뉴는 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)은 커스텀 ID가 custom_ids 중 하나이고(아무것도 주지 않으면 모든 컴포넌트) check가 참을 반환하는 다음 사용의 ComponentContext를 반환합니다. 이렇게 반환된 사용에 대해서는 라우트가 실행되지 않습니다. 무한정 기다리므로 asyncio.timeout()으로 시간을 제한하세요. check에서 발생한 예외는 여기서 다시 발생합니다. 반환하는 컨텍스트에 쓸 ephemeral과 auto_defer도 받습니다.