[Day 28] 開發實戰 (二):專案架構與主程式

今天要來開始寫程式了!

進度

有了昨天列出來的使用者故事 (需求),接下來就是把它們實作出來了。今天會依序介紹專案架構、主程式,最後會開始實作一點簡單的功能。

使用者故事 (User Story)

複習一下昨天整理出來的使用者故事。

1. 線索管理功能

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

2. 計算最佳做法功能

  • 作為頻道成員,我可以下達指令要求開始計算。
  • 作為頻道成員,當我要求開始計算時,Discord BOT 會檢查是否所有成員當天都已提交線索,若不是,則會彈出提示訊息詢問是否確定要進行計算。
  • 作為頻道成員,我可以在計算開始前查看本次計算的組合數量。
  • 作為頻道成員,在 Discord BOT 計算時,我會看到「正在輸入…」的提示。
  • 作為頻道成員,計算完成後,我可以在特定頻道中看到 Discord BOT 提供的結果。
  • 作為頻道成員,我可以下達指令,查看當前最新的計算結果。

3. 其他功能

  • 作為頻道成員,我可以享有 Discord BOT 定時檢查所有人是否已報線索的功能,若有未報者,該成員會被 Tag 提醒。
  • 作為頻道成員,我可以開啟或關閉 Discord BOT 的定時檢查功能。
  • 作為頻道成員,我可以查看 Discord BOT 的使用指南或教學。

4. 使用限制

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

補充:「在頻道內輸入線索」簡稱為「報線索」

開始之前…

除了上面比較偏向功能面的需求之外,在實作上也希望有以下幾個要求:

  1. 使用 bot 指令框架
  2. 使用 Cog 把指令們從主程式拆出來,並進行適當分類
  3. 各個指令中與 Discord 比較無關的部分,拆出來移到另一個資料夾

專案架構

以下是我的專案架構:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Repo
├─cogs
│ ├─__init__.py
│ ├─clue.py # 線索管理
│ ├─exchange.py # 計算最佳做法功能
│ ├─remind.py # 定時提醒
│ └─tutorial.py # 使用指南與教學
├─utils
│ ├─__init__.py
│ ├─crud_clues.py # 線索資訊的增刪查改
│ ├─calculate.py # 計算最佳化做法
│ ├─optimize.exe # 計算最佳化做法的主程式
│ ├─clues.txt # 儲存線索資訊
│ └─result.txt # 儲存計算結果
├─.env
├─example.env
├─main.py # 主程式
├─players.json # 紀錄 Discord ID 與遊戲暱稱
└─requirements.txt

其中,cogs資料夾內放的是個各類分類的指令。分成幾類:

  • 線索
  • 計算最佳化交換做法
  • 定時提醒
  • 使用教學

utils 資料夾內放的則是其他與 discord 本身無關的,例如計算最佳化結果的相關程式。

.envexample.env 就會是存放環境變數,例如伺服器 ID、報線索頻道的 ID 等。
player.json 紀錄的是 Discord ID 與暱稱的對照表,在結果呈現上會使用到。

主程式

接下來,來看一下主程式。

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
# main.py

from typing import Optional

import discord
from discord.ext import commands
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
token: str
guild_id: Optional[int] = None

class MyBot(commands.Bot):
def __init__(
self,
*args,
initial_extensions: list[str],
guild_id: Optional[int] = None,
**kwargs,
):
super().__init__(*args, **kwargs)
self.guild_id = guild_id
self.initial_extensions = initial_extensions

async def setup_hook(self) -> None:
for extension in self.initial_extensions:
await self.load_extension(extension)

if self.guild_id:
guild = discord.Object(self.guild_id)
self.tree.copy_global_to(guild=guild)
await self.tree.sync(guild=guild)

settings = Settings()
exts = [
"cogs.clues",
"cogs.exchange",
"cogs.remind",
"cogs.tutorial",
]
intents = discord.Intents.default()
intents.message_content = True

bot = MyBot(
initial_extensions=exts,
guild_id=settings.guild_id,
command_prefix="",
intents=intents,
)

bot.run(settings.token)

相較於之前的範例,這個稍微複雜了一些,讓我們來看一下這兩個部分:

  1. 環境變數
  2. 載入 Extension 與 sync 應用指令

環境變數

這邊我選擇使用 Pydantic 的 Pydantic Settings 來取得 .env 中的環境變數 (預設路徑就是 .env)。

1
2
3
4
5
6
7
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
token: str
guild_id: Optional[int] = None

settings = Settings()

之後就可以輕鬆地從 settings 中取得 .env 中的環境變數了,例如:settings.token

我個人蠻喜歡用 Pydantic 來取得環境變數,因為它可以幫忙做型別轉換、驗證、設定預設值。如果各位對於取得環境變數有其他比較習慣的做法,都可以進行替換。

載入 Extension 與 sync 應用指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyBot(commands.Bot):
def __init__(
self,
*args,
initial_extensions: list[str],
guild_id: Optional[int] = None,
**kwargs,
):
super().__init__(*args, **kwargs)
self.guild_id = guild_id
self.initial_extensions = initial_extensions

async def setup_hook(self) -> None:
for extension in self.initial_extensions:
await self.load_extension(extension)

if self.guild_id:
guild = discord.Object(self.guild_id)
self.tree.copy_global_to(guild=guild)
await self.tree.sync(guild=guild)

由於指令都是放在 cogs 資料夾內,所以找個時機點使用 load_extension,那些指令才會生效。另外,我偏好還是要逐一列出要載入的 extension (要稱為 cog 也可以),而不是整包資料夾都送進去。

應用指令也有類似的狀況,為了避免等太久才生效,需要找個時機 sync 到指定的伺服器。不過,還是有保留一點彈性,把它設為 Optional 的參數。

而上面這兩個步驟的最佳時機點就是 setup_hook (再次強調,絕對不是 on_ready)。

第一個功能

今天先從簡單的功能開始做,讓大家熟悉這個專案架構。而最簡單的功能就是:

  • 作為頻道成員,我可以查看 Discord BOT 的使用指南或教學。

這個比較簡單,就直接來看程式碼。

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

from discord.ext import commands


class TutorialCog(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot

@commands.hybrid_command()
async def tutorial(self, ctx: commands.Context):
await ctx.send("使用教學")


async def setup(bot: commands.Bot):
await bot.add_cog(TutorialCog(bot))

為了節省版面,之後就只會呈現中間 Cog 的部分

這個功能的第一直覺應該是使用 help 來觸發指令,但是 help 是預設指令,所以這邊改成選擇使用 tutorial 來當作觸發的指令。

首先,要思考的是,要用什麼關鍵字來當作指令?由於 help 是預設指令,所以這邊選擇使用 tutorial 來當作觸發的指令。

可以看到目前只有一個指令:tutorial

使用教學的內容?

至於「使用教學」的內容… 我目前還沒寫XD

考量到內容應該頗長,這時候可以考慮直接放一個網址就好 (e.g. HackMD 筆記),或者是使用嵌入式內容 (請參考 Day 13) 做一個重點說明,並附上詳細說明的連結。

1
2
3
4
5
6
7
8
9
@commands.hybrid_command()
async def tutorial(self, ctx: commands.Context):
embed = discord.Embed(
title="使用教學",
url="https://ithelp.ithome.com.tw/users/20162280/ironman/7781",
)
embed.add_field(name="tutorial", value="查看使用教學", inline=False)
embed.add_field(name="exchange", value="開始計算最佳做法", inline=False)
await ctx.send(embed=embed)

效果如下:

因為是使用 Hybrid Command,所以會同時設定兩種指令。

小結

今天介紹了專案架構和主程式,並開始實作功能。明天會繼續實作其他功能~