[Day 29] 開發實戰 (三):功能實作 (上)

今天要來開發主要功能了!

進度

進度圖沒有變化。

昨天介紹了專案架構與主程式,今天要來繼續開發主要功能。不過礙於版面有限,今天只會介紹到線索管理功能,其他剩餘部分就留到明天吧!

使用者故事 (User Story)

複習一下使用者故事。

1. 線索管理功能

  • 作為頻道成員,我可以輸入線索,讓 Discord BOT 取得並儲存相關資訊。
  • 作為頻道成員,若我輸入的線索格式不正確,會收到錯誤提示,且該線索不會被記錄。
  • 作為頻道成員,我可以編輯線索,並且 Discord BOT 會更新該線索資訊並檢查其格式。
  • 作為頻道成員,我可以刪除線索,Discord BOT 會相應更新線索資訊。
  • 作為頻道成員,我可以下達指令,要求根據歷史訊息來更新已儲存的線索資訊。
  • 作為頻道成員,我可以下達指令,要求查看目前儲存的線索資訊。
  • 作為頻道成員,我可以為其他頻道成員設定線索。

4. 使用限制

  • 線索只能在特定頻道中提交。
  • 只有特定的頻道成員可以提交線索。

1. 線索管理功能

(1) 設定線索

  • 作為頻道成員,我可以輸入線索,讓 Discord BOT 取得並儲存相關資訊。
  • 作為頻道成員,若我輸入的線索格式不正確,會收到錯誤提示,且該線索不會被記錄。
  • 線索只能在特定頻道中提交。

這個應該是數一數二重要的功能了。

照理來說,應該要使用指令來觸發報線索相關功能 (不論是最基本的 command 或是 slash command),但由於大家已經習慣只在某個特定頻道報線索了 (這是最一開始的作法),所以希望可以維持不變,直接輸入線索就好。

因此,報線索比較適合透過監聽 on_message 事件來觸發。

1
2
3
4
5
6
@bot.event
async def on_message(message: discord.Message):
if message.author == bot.user:
return

# do something here

接下來,還要驗證是否是在「報線索頻道」、線索格式是否正確,都符合才會更新線索資訊。如果不符合,立刻在下方留言告知格式不正確。不過,可惜的是,這不是互動性的指令,無法使用 empheral 屬性,大家都會知道有人輸入的線索格式錯誤了。

判斷「是否為可以提交線索的特定成員」的部分在 update_clue 內才進行。

1
2
3
4
5
6
7
8
9
10
11
12
@bot.event
async def on_message(message: discord.Message):
if message.author == bot.user:
return

if message.channel.id != CLUE_CHANNEL_ID:
return

if validate_clue(message.content):
update_clue(message.author.id, message.content)
else:
await message.channel.send("線索格式不正確!")

在錯誤提示的地方還有一些地方可以微調,例如:改成用回覆的方式,讓大家可以比較清楚知道是哪一則留言格式錯誤,避免同時有兩人報線索,卻無法判斷到底是哪則留言格式不對。

1
await message.reply("線索格式不正確!")

或者,覺得這樣公開讓大家知道會讓留言的人很尷尬,改成讓這個錯誤訊息只出現 10 秒鐘,時間到就自動刪除。

1
2
response = await message.reply("線索格式不正確!")
await response.delete(delay=10)

最後,一些重複性的判斷可以獨立出來變成一個函數,或甚至改成 Decorator 也可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class ClueCog(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot

def is_valid_message(self, message: discord.Message) -> bool:
if message.author == self.bot.user:
return False
if message.channel.id != settings.clue_channel_id:
return False
return True

@commands.Cog.listener()
async def on_message(self, message: discord.Message):
if not self.is_valid_message(message):
return

if validate_clue(message.content):
update_clue(message.author.id, message.content)
else:
response = await message.reply("線索格式不正確!")
await response.delete(delay=10)

(2) 編輯與刪除線索

  • 作為頻道成員,我可以編輯線索,並且 Discord BOT 會更新該線索資訊並檢查其格式。
  • 作為頻道成員,我可以刪除線索,Discord BOT 會相應更新線索資訊。

這兩個需求就比較直觀了,使用 on_message_editon_message_delete 就可以。其他檢查的部分就沿用前面「設定線索」的需求。

1
2
3
4
5
6
7
8
9
10
@commands.Cog.listener()
async def on_message_edit(self, before: discord.Message, after: discord.Message):
if not self.is_valid_message(after):
return

if validate_clue(after.content):
update_clue(after.author.id, after.content)
else:
response = await after.reply("線索格式不正確!")
await response.delete(delay=10)
1
2
3
4
5
6
@commands.Cog.listener()
async def on_message_delete(self, message: discord.Message):
if not self.is_valid_message(message):
return

update_clue(message.author.id, "")

補充說明:編輯訊息不適合使用 message command,因為 message command 不能再額外提供參數。

(3) 查看目前的線索資訊

  • 作為頻道成員,我可以下達指令,要求查看目前儲存的線索資訊。

有的時候,會想要確認一下 Discord BOT 到底有沒有成功紀錄到線索 (在早期開發時格外需要),因此就有設計這項功能。另外,也希望這項功能不要影響到原本報線索的畫面,所以會加上 empheral=True 的設定。

1
2
3
4
5
@app_commands.command()
async def clues(self, interaction: discord.Interaction):
"""取得所有人的線索資訊"""
clues = get_clues()
await interaction.response.send_message(clues)

(4) 替別人更新線索

  • 作為頻道成員,我可以為其他頻道成員設定線索。

在早期開發時,錯誤處理做的還不是很完善,導致有時候會出現「明明有在頻道報線索,雀沒有被正確儲存」的狀況。因此,特別設計一個可以幫忙別人報線索的功能,讓發生上述情況時,其他人可以幫忙設定線索。現在,功能完善很多了,這個功能就幾乎不會用到了,但為了保險起見,還是把這個功能保留下來。

幫別人設定線索時,需要使用者自行帶入兩個參數,一個是要設定的對象,另一個是線索。因此,這邊最適合的做法是使用 slash command。

1
2
3
4
5
6
7
8
9
10
11
12
@app_commands.command()
async def clue(
self, interaction: discord.Interaction, member: discord.Member, clue: str
):
"""更新某人的線索資訊"""
if validate_clue(clue):
update_clue(member.id, clue)
await interaction.response.send_message(
f"{member} 線索已更新!", ephemeral=True
)
else:
await interaction.response.send_message("線索格式不正確!", ephemeral=True)

有一個很重要的點,一定要記得加上 type hint,這樣就可以讓 discord.py 自行幫忙轉換成頻道成員。而且,如此一來,使用者在輸入參數的時候,就會自動出現選項,非常方便。

(5) 用歷史訊息更新線索

  • 作為頻道成員,我可以下達指令,要求根據歷史訊息來更新已儲存的線索資訊。

跟上面的狀況有點類似,早期開發時,還不太熟悉部屬的部分,所以 Discord BOT 只是單純地在我的筆電上執行。這樣造成的問題是,一旦我的筆電休眠了,Discord BOT 也會跟著下線,進而導致後面的其他成員即使報線索,也無法更新線索資訊。雖然可以使用上面的方法,一個一個慢慢加,但一直複製貼上也是頗麻煩的,因此就設計了一個「使用歷史訊息更新線索」的功能。有了這項功能,即使 Discord BOT 曾經下線一段時間導致線索沒有更新到,也可以很快速地把線索都更新完。這個功能現在也不常用了,同樣也只是為了保險起見才留下來。

如果想要讀取頻道的歷史訊息,可以使用 channel.history

1
2
3
4
counter = 0
async for message in channel.history(limit=200):
if message.author == client.user:
counter += 1

或是

1
messages = [message async for message in channel.history(limit=123)]

其他參數可以參考文件

需要注意的是,有些時候,有些人報完線索發現有打錯,想要進行修改,就會再報一次 (那時沒有監聽編輯事件的功能)。而使用上面的方法取得到的歷史訊息是由最新的訊息開始的,這樣會導致使用歷史訊息更新時,舊的線索反而會蓋掉後來修正的線索 (新留言的)。因此,在開始更新之前,要記得把順序反過來。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@app_commands.command()
async def update(self, interaction: discord.Interaction):
"""用歷史訊息更新所有人的線索資訊"""
channel = self.bot.get_channel(settings.clue_channel_id)
messages = [message async for message in channel.history(limit=10)]
reversed_messages = messages[::-1]

for message in reversed_messages:
if message.author == self.bot.user:
continue
if validate_clue(message.content):
update_clue(message.author.id, message.content)

await interaction.response.send_message("線索已更新!", ephemeral=True)

小結

今天介紹了有關線索的功能的實作,剩下的部分 (計算與定時提醒) 明天會繼續介紹。

2. 計算最佳做法功能

(1) 開始計算

(2) 察看結果

3. 定時提醒

有時候大家忙著上班 (?),會一個不小心忘記報線索,這時候如果有一個定時提醒的功能就很棒。這個功能是每天下午 1 點檢查一次,如果當天還有人沒有報線索,就會發訊息 Tag 對方。

(1) 定時提醒

既然是「定時」提醒,就需要使用到 Day 12 所介紹到的 Task 功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# cogs/remind.py

# Set time at 13:00 in GMT+8 (which is 05:00 UTC)
alert_time = [
datetime.time(hour=5),
]

class RemindCog(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.daily_task.start()

@tasks.loop(time=alert_time)
async def daily_task(self):
members_to_remind = daily_check_clues()
channel = self.bot.get_channel(settings.clue_channel_id)

for member in members_to_remind:
await channel.send(f"請 {member.mention} 提供線索!")

為了 Demo,只好調整一下時間。

(2) 開啟/關閉定時提醒

但這個功能,我其實有點擔心會不會大家覺得太吵,所以想說還是要預留一個開關。

1
2
3
4
5
6
7
8
9
@app_commands.command(name="toggle")
async def toggle_remind(self, interaction: discord.Interaction):
"""開啟或關閉每日提醒"""
if self.daily_task.is_running():
self.daily_task.cancel()
await interaction.response.send_message("已停止每日提醒")
else:
self.daily_task.start()
await interaction.response.send_message("已開始每日提醒")

這邊使用 .is_running() 來判斷是否正在運行,如果是,就使用 cancel() 立刻中止。反之,就使用 start() 再次啟動。

要終止 task 除了可以使用 cancel(),也可以使用 stop()。差別是,stop() 還會執行完當下的這一次,也就是說,還會再提醒一次。所以,這邊我選擇使用 cancel()

第二點,由於 slash command 是使用空格隔開不同參數,但在報線索時,大家也是習慣使用空格格開「擁有的線索」與「一定要換的線索」,這樣會導致平常的寫法只會讀取第一個部分 (也就是「擁有的線索」)

不正確的範例

所以,這邊應該要使用 *,把參數隔開 (文件中有蠻詳盡的範例與說明)

當然,這邊直接要求大家更換一個報線索的規則也是一個可行的做法。只是我個人傾向盡量不去改變大家原本的習慣,所以才會這樣進行開發