UI元件是Discord提供的互動性元件
有別於之前所學的,單純只有文字可用,此類別可以讓我們創造圖形化的按鈕和選單等等

此用法非常需要對於物件與類別的熟悉度,忘記的一定要加緊複習

範例資料

請在資料夾內開一個檔案data.json並儲存以下內容
如果你不記得如何讀取json資料,也可以在程式碼內貼上以下內容作為辭典儲存,或參考以下做法

資料

1
2
3
4
5
6
7
8
9
10
{
"fish": {
"amount": 12,
"price": 30
},
"pork": {
"amount": 34,
"price": 50
}
}

讀取資料

1
2
3
4
5
6
import json

with open('data.json', 'r', encoding='utf8') as f:
data = json.load(f)

# data即為該資料集

View

View是乘載所有元件的類別,可以想像為一架飛機
飛機上會乘載許多乘客,乘客沒有飛機就不能去往各地,飛機沒有人駕駛也沒有意義

View通常不需要複雜的設定,所以不需要透過繼承來細部調整內容,簡單的類別實體化成物件就可以了
除了timeout可以設定,timeout是等待時間的意思,Discord會持續接收這個View的任何操作直到timeout的時間
如果沒有設定timeout則不會停止,除非機器人重啟

1
2
3
4
5
6
# 此處叫view1是為了好區別參數名稱跟內容,實際上取作view即可

view1 = discord.ui.View()
button = Button() # 這裡後面會教
view1.add_item(button) # 把做好的物件放入view,這個動作可以做很多次,也可以用for迴圈做一大堆
await interaction.response.send_message(view=view1)

按鈕

按鈕應該不需多說,就字面上的意思
由於互動的各項回應可能較複雜,所以我們用繼承的方式來定義一個按鈕物件,並覆蓋掉原本的內容來自定義

一個按鈕物件有許多屬性可以設定,以下是常用的:

參數 名稱 資料型態
label 標籤 str
style 樣式 點我看列表
emoji 表符 discord.PartialEmojistr

其中style的用法大概像是

1
2
3
discord.ButtonStyle.primary  # 藍色
discord.ButtonStyle.success # 綠色
discord.ButtonStyle.danger # 紅色

在設定的時候,直接在初始化函數使用父類別super()的初始化設定

內容固定版本

1
2
3
4
5
6
7
8
9
10
class Button(discord.ui.Button): # 繼承按鈕的類別
def __init__(self):
super().__init__(label='哈囉', emoji="😁") # 用父類別初始化來設定屬性

# callback 是當使用者點擊按鈕時自動觸發的函數,就像是幫按鈕設定一個功能,一按就會執行裡面的程式碼
async def callback(self, interaction):
await interaction.response.send_message("你按下了哈囉")

# 在指令內
button = Button()

可變版本

利用這種實體化的時候接收參數的方式,可以靈活的變化按鈕內容,同一種類別可以產生很多不同的按鈕
如果是固定版本,一個類別就只能產生一種按鈕,內容跟表符還有回應等等都是固定的

1
2
3
4
5
6
7
8
9
10
class Button(discord.ui.Button):
def __init__(self, label, emoji, response): #這些參數的內容在物件實體化的時候才會決定
super().__init__(label=label, emoji=emoji)
self.response = response # 由於response這個參數只會在這個初始化函數內存在,所以存到self的屬性來讓內容在物件內都可以使用

async def callback(self, interaction):
await interaction.response.send_message(self.response) # 從這邊取用自身response屬性的內容

# 在指令內
button = Button(label="Bye", emoji="👋", response="你說了掰掰")

網址

如果只需要打開一個網址,可以不用另外繼承在設定,Discord提供了一種簡便的做法

1
button = discord.ui.Button(label="點我", url="https://google.com") #一樣可以表情符號

這種按鈕沒有callback,而是由Discord自動處理

練習

請利用範例資料,製作兩個按鈕,對應兩種商品,只需要名稱不用emoji
點擊後會發送該商品的數量與價格
提示:

  • 使用「可變版本的 Button 類別」,並從 data 變數裡取出商品資訊
  • 可以稍微修改可變版本,把不需要的元素捨棄掉,並修改回應內容來取得資料

按鈕練習

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Button(discord.ui.Button):
def __init__(self, label):
super().__init__(label=label)

async def callback(self, interaction):
item = data[self.label]
await interaction.response.send_message(f"數量:{item['amount']}\n價格{item['price']}")

@bot.tree.command()
async def items(interaction:discord.Interaction):
view = discord.ui.View()
# 由於這邊單一個按鈕沒有很複雜,直接塞到add_item也行,不一定要先設一個button變數
view.add_item(Button(label='fish'))
view.add_item(Button(label='pork'))
await interaction.response.send_message(view=view)

單次回覆的方法

有時候我們只希望使用者只使用一次選單後就不再使用,而不是可以持續操作選單
這時我們有幾個方法來達成

關閉

在callback裡面將自己的關閉屬性設為True,然後編輯原訊息,把原本的view改成包含關閉按鈕的view

1
2
3
4
5
async def callback(self, interaction): 
self.disabled = True # 把自己關閉(所屬的view會自動更新)
# 關閉自己後,要更新原本的訊息,所以把原本訊息的view編輯成新的view
await interaction.message.edit(view=self.view) # self.view代表自己所屬的view
await interaction.response.send_message("你按下了哈囉")

有時一個View可能包含多個按鈕或元件,當我們想要關閉全部時,可以使用view.children屬性,這個屬性是一個陣列,包含所有乘載的元件,利用for迴圈一一關閉即可

1
2
3
4
5
async def callback(self, interaction): 
for item in self.view.children: # 遍歷一次所有元件然後關閉他們
item.disabled = True
await interaction.message.edit(view=self.view)
await interaction.response.send_message("你按下了哈囉")

直接覆蓋原訊息

1
2
async def callback(self, interaction): 
await interaction.message.edit(content="你說了掰掰", view=None)

沒有使用者回應

有時候按下按鈕後,我們並不打算傳給使用者什麼回應,有可能是我們只要在後台抓取某些資料或是print一些東西,這時候使用者端會顯示交互失敗
只要加上思考就能解決了

1
await interaction.response.defer()

選單

選單比按鈕複雜一點點,因為一個選單雖然已經是需要被View乘載的元件了,但它自身也可以乘載選項
選項(Option)就是選單內會出現的那些選項內容,你可以自訂每個選項的顯示文字、實際值、提示文字,甚至是表情符號
這種元件在設計上可以讓使用者一次從多個項目中挑一個(或多個),比起按鈕更適合大量資料的選擇

基本版

一個選單必須要有options屬性,代表內部的選項
options是一個由discord.SelectOption物件組成的陣列,如果不想要一直打discord.也可以直接先import

一個選單可以有以下設定

參數名稱 說明 類型
placeholder 選單尚未選擇時的提示文字 str
min_values 最少要選幾個(預設是1) int
max_values 最多可以選幾個(預設是1) int
options 選項清單 list[SelectOption]

而一個SelectOption可以有以下設定

參數名稱 說明 類型
label 顯示在選單上的文字 str
value 真正代表的值(通常會在 callback 裡面使用) str
description 顯示在標題下的說明 str
emoji 可以加個小表情讓選項更生動 strPartialEmoji
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Menu(discord.ui.Select):
def __init__(self):
# 在這裡設定好選項的陣列
options = [
discord.SelectOption(label="魚", description="便宜好吃", value="fish"),
discord.SelectOption(label="豬肉", description="香氣十足", value="pork")
]
# 在這裡初始化這個選單的內容
super().__init__(placeholder="選一樣東西", options=options)

async def callback(self, interaction: discord.Interaction):
# self.values 是一個列表,代表被選擇的選項的value,即使只選一項也是列表,要記得取第0個
selected = self.values[0]
item = data[selected]
await interaction.response.send_message(f"你選的是 {selected},數量:{item['amount']} 價格:{item['price']}")

練習題

請把前面的按鈕練習改寫成使用選單的版本
提示:

  • 使用 discord.ui.Select 來定義一個選單類別
  • 每一個商品都要變成一個選項
  • callback 裡面使用 self.values[0] 抓到選到的項目
  • 記得一樣要搭配 View 使用!
1
2
3
4
5
6
7
8
9
10
11
12
class Menu(discord.ui.Select):
def __init__(self):
options = [
discord.SelectOption(label=item, value=item)
for item in data.keys()
]
super().__init__(placeholder="請選擇商品", options=options)

async def callback(self, interaction: discord.Interaction):
selected = self.values[0]
item = data[selected]
await interaction.response.send_message(f"你選的是 {selected},數量:{item['amount']} 價格:{item['price']}")
1
2
3
4
5
@bot.tree.command()
async def items_menu(interaction: discord.Interaction):
view = discord.ui.View()
view.add_item(Menu())
await interaction.response.send_message("請從下拉選單中選擇商品", view=view)

補充: 另一種加入選項的方法

前面我們在 __init__ 裡直接把所有選項打包成一個 options 陣列,再傳給 super().__init__()
但其實還有另一種作法:可以先建立空的選單,然後用 add_option() 一個一個加入選項

這種方式特別適合在某些選項是根據使用者或資料狀況動態生成的情況下使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Menu(discord.ui.Select):
def __init__(self):
super().__init__(placeholder="請選擇商品") # 這裡已經初始化過了

# 這裡還能再新增選項
for item in data.keys():
self.add_option(
label=item,
value=item,
description=f"{item} 的庫存與價格"
)

async def callback(self, interaction: discord.Interaction):
selected = self.values[0]
item = data[selected]
await interaction.response.send_message(f"你選的是 {selected},數量:{item['amount']} 價格:{item['price']}")

就連在指令的函數裡面也可以
這樣方便我們呼叫API之後動態更改選單的內容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@bot.tree.command()
async def items_menu(interaction: discord.Interaction):
# 模擬呼叫 API 的動作,實際上你可以從資料庫或API抓資料
items = {
"apple": {"amount": 10, "price": 15},
"banana": {"amount": 6, "price": 20},
"peach": {"amount": 12, "price": 25}
}

menu = Menu()
for name in items:
menu.add_option(label=name, value=name, description=f"{name} 的價格資訊")

view = discord.ui.View()
view.add_item(menu)
await interaction.response.send_message("請從選單中選擇商品", view=view)

表單

表單是一個比較特殊的UI,他不適用View,也不能同時塞在一個訊息裡面
相反的,他會彈出一個視窗輸入

選單的載體類別跟問題類別可以寫在一起,不需要分開定義再用add_item()

問題的類別是discord.ui.TextInput,可先使用import避免太長
一個問題可以包含以下屬性

參數名稱 說明 類型
label 問題的名稱 str
min_length/max_length 最短和最長文字數 int
placeholder 尚未輸入時顯示的文字 str
required 是否必填 bool
style 輸入欄的格式 discord.TextStyle
default 預設內容 str

style的部分可參考這裡

使用以下方法就可以建立一個問題表單

1
2
3
4
5
6
7
8
9
10
class Questions(discord.ui.Modal, title="test"):

# 選單比較特殊,不需要使用__init__()
class_ = TextInput(label="班級", max_length=4, min_length=4, required=True)
number = TextInput(label="座號", min_length=2, max_length=2, required=True)
name = TextInput(label="姓名", min_length=2, max_length=4, required=True)
info = TextInput(label="想說的話", style=discord.TextStyle.paragraph, required=False)

async def on_submit(self, interaction:discord.Interaction):
await interaction.response.send_message(f"{self.class_} {self.number} {self.name},你想說的話是:{self.info}")

標題與問題

關於標題的部分,可以直接寫在繼承的地方,這是discord自己的特殊設計,一般狀況下是不合法的

1
class Questions(discord.ui.Modal, title="收集訊息"):

可以注意到選單收集問題的方法是用類別的屬性,而不是物件的屬性
由於類別屬性比較少用到,可以簡單理解為屬於類別所以每一個實體化的物件都一樣
只要像這樣直接定義,你的問題就會自動被收集了
定義的順序就會是問題的順序

1
2
3
4
5
6
class Questions(discord.ui.Modal, title="收集訊息"):

class_ = TextInput(label="班級", max_length=4, min_length=4, required=True)
number = TextInput(label="座號", min_length=2, max_length=2, required=True)
name = TextInput(label="姓名", min_length=2, max_length=4, required=True)
info = TextInput(label="想說的話", style=discord.TextStyle.paragraph, required=False)

這裡的class_是變數命名的常用做法,因為這麼名字跟命名類別的class重複到了,有可能Python會視為此處要定義一個類別,後面又不是類別的語法,會發生語法錯誤,所以慣例上來說會在後面加上一個底線來避免被辨識成錯誤的語法

收集答案

答案會自動被分配到各自的屬性裡面,一樣使用self的屬性就可以引用了
要注意這裡是on_submit而不是callback

1
2
async def on_submit(self, interaction:discord.Interaction):
await interaction.response.send_message(f"{self.class_} {self.number} {self.name},你想說的話是:{self.info}")

送出

表單的送出也比較特別,因為表單不是連同訊息一起的,所以有自己的函數

1
await interaction.response.send_modal(Questions())

總練習

請做出以下流程要求的功能
建立一個角色卡

  • 按鈕: 開始建立角色
  • 選單: 可以從3種職業中選擇一個
  • 表單: 可以填寫名稱、年齡和個性
  • 輸出角色卡

可使用小社課幫手的/demo來查看效果(placeholder、預設跟按鈕顏色不是必須,可做可不做)

思路:
建立三種不同的UI
按鈕按下之後關閉自己,並傳送一個選單
選單選擇以後關閉自己,並開啟表單
表單送出以後把資訊印出來

因為送出的資訊需要包含職業,但是職業選擇的結果留在選單了
可以設計表單可以傳入職業做初始化,這樣在選單的結尾送出表單時傳進去,就可以在表單內存取職業了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class Start(ui.Button):
def __init__(self):
super().__init__(label="開始建立角色", style=discord.ButtonStyle.primary)
async def callback(self, interaction:discord.Interaction):
self.disabled = True
await interaction.message.edit(view=self.view)
view = ui.View()
view.add_item(Jobs())
await interaction.response.send_message(view=view)

class Jobs(ui.Select):
def __init__(self):
super().__init__(placeholder="請選擇職業", min_values=1, max_values=1)
jobs = [
{
"name": "劍士",
"description": "近戰專家,擅長突進砍擊"
},
{
"name": "魔法師",
"description": "魔法專家,擅長範圍遠程攻擊與治療"
},
{
"name": "弓箭手",
"description": "遠程專家,擅長遠程單點擊破"
}
]
for job in jobs:
self.add_option(label=job["name"], value=job["name"], description=job["description"])

async def callback(self, interaction:discord.Interaction):
self.disabled = True
await interaction.message.edit(view=self.view)

await interaction.response.send_modal(Info(job=self.values[0]))


class Info(ui.Modal, title="角色資訊"):
def __init__(self, job):
super().__init__()
self.job = job

name = ui.TextInput(label="角色名稱", placeholder="阿姆斯特朗炫風阿姆斯特朗炮")
age = ui.TextInput(label="年齡", placeholder="未知")
personality = ui.TextInput(label="個性", default="沈著冷靜,擅長分析情勢")

async def on_submit(self, interaction:discord.Interaction):
message = f"""
> 角色卡
* 名稱:{self.name}
* 年齡:{self.age}
* 個性:{self.personality}
* 職業:{self.job}
"""
await interaction.response.send_message(message)


@bot.tree.command()
async def demo(interaction:discord.Interaction):
view = ui.View()
view.add_item(Start())
await interaction.response.send_message(view=view)