# 資訊之芽2024 二階段大作業Report
## 組員
張一中、辜名緯。
## 這次大作業的repo
[Link](https://github.com/sprout-py-discord-bot/discord-bot)
# 環境
## 使用到的函式庫
### 內建
- datetime
- json
- os
- random
- re
- sqlite3
### 第三方
- discord
- discord.ext.commands
- requests
- beautifulsoup4
## 檔案結構
```
DISCORD_BOT_TUTORIAL/
├── cmds/
│ ├── abacus.py # abacus
│ ├── event.py
│ ├── main.py # todolist
│ ├── meme.py # gallery(原本想作梗圖倉庫,後來發現可以不限於梗圖)
│ ├── music.py # music
│ ├── task.py
│ └── wordle.py # wordle
├── image/ # gallery圖片儲存
│ ├── # lots of image files
├── .env
├── .env.defaults
├── .gitignore
├── bot.py
├── config.py
├── core.py
├── data.json
├── ffmpeg.exe
├── ffprobe.exe
├── README.md
├── user_data.db
└── yt-dlp.exe
```
---
# 功能
> [!note]
> 除了Music Bot、原本跟cog相關的指令(以及commmand tree的同步`$sync`)以外,絕大多數指令皆使用 [@commands.hybrid_command](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html?highlight=hybrid#discord.ext.commands.hybrid_command)來實現,因此指令也支援使用Slash Commands呼叫(有少數例外)。但下方的指令格式仍然使用前綴`$`。
## Todolist
### 建構子
```python
class Main(Cog_Extension):
def __init__(self, bot):
super().__init__(bot)
self.to_do_list = {}
```
註:此部分的code放在`cmds/main.py`中
**to_do_list**(dict\[key = discord.User.id: *int*, value: *dict*]): **key**為每個使用者本身的ID,使每個使用者的todolist皆為獨立的。**value**為每個使用者本身的todolist(dict),此字典的**key**為提醒事項的名稱(*str*),**value**為各自的時間(*list\[str]*,`["YYYY", "MM", "DD"]`)。
### 指令
> \$todolist \[action] *\[item = None] \[time = None]*
> [!note]
> 註:所有optional的參數皆以斜體表示
所有指令皆透過`$todolist`或是`/todolist`存取,透過不同的`action`來進行不同操作。
**參數:**
**action**(str): 要進行的操作(add、print、remove、sort、time)。
**item**(str|optional): 提醒事件的名稱,僅在`action`為`[add|remove]`時需要傳入。
**time**(str|optional): 提醒事件的時間,格式為`YYYY/MM/DD`,僅在`action`為`add`時需要傳入。
### action
#### add
判斷`item`和`time`是否滿足以下條件,如果都滿足就會新增到`self.to_do_list[ctx.author.id]`裡面:
1. `item`名稱是否和`self.to_do_list[ctx.author.id]`中已存在的事件衝突。
2. `time`的日期格式是否為`YYYY/MM/DD`。
#### print
使用Code Block和Embeds輸出使用者的所有提醒事件。

#### remove
刪除todolist中指定的事件,若傳入的`item`為`"all"`或`item`為空(`None`)則會將所有事件移除。
#### sort&time
分別藉由**字典序**和**時間**排序所有事件,排序完後會輸出排序好的結果(以print的形式輸出)。下為這兩段的Code。
```python
self.to_do_list[ctx.author.id] = dict(sorted(self.to_do_list[ctx.author.id].items()))
await self.todolist(ctx, 'print')
```
```python
sorted_keys = sorted(self.to_do_list[ctx.author.id], key=lambda x: self.sort_by_date(ctx, x))
self.to_do_list[ctx.author.id] = {key: self.to_do_list[ctx.author.id][key] for key in sorted_keys}
await self.todolist(ctx, 'print')
def sort_by_date(self, ctx, key):
year, month, day = map(int, self.to_do_list[ctx.author.id][key])
return datetime(year, month, day)
```
## Gallery
### 指令
> \$gallery \[action] *\[name]* \[savekeyword]*
**參數:**
**action**(str): 要進行的操作(download、remove、search、take、view)。
**name**(str|optional): 圖片的名稱或是要搜尋的內容,僅在`action`不為`view`時需要傳入。註:`action`為`download`時這個是圖片儲存的名稱。
**savekeyword**(str|optional): 圖片要以什麼名稱儲存,僅在`action`為`[search]`時需要傳入。
### action
#### download
`註:此動作需要同時傳送附件,所以不支援使用Slash Commands呼叫。`
透過`ctx.message.attachments[0].save()`存取並儲存使用者傳送的圖片(若有複數則為第一張),檔名儲存為`{name}.jpg`。所有圖片皆儲存在`./image`中。
#### remove
刪除`./image`中指定的圖片。
#### search
```python
image_url = image_element['src']
image_response = rq.get(image_url)
image_path = os.path.join('image', f'{savekeyword}.jpg')
with open(image_path, 'wb') as file:
file.write(image_response.content)
```
在Google搜尋`name`,並將搜尋到的第一張圖片儲存,檔名為`{savekeyword}.jpg`(`savekeyword`為`None`會用`name`來作為儲存的檔名)。
#### take
傳送`./image/{name}.jpg`,若找不到圖片則回傳錯誤訊息。
#### view
透過`os.listdir()`將`./image`中的所有圖片列出來以便查詢。
## Music
`註:我們測試的伺服器中使用的語音頻道是中文的"一般"而非"general"。`
### 指令
> \$play \[url]
> \$playlist \[action] *\[url] \[way]*
> \$search \[name] *\[way]*
**參數:**
> **\$play**:
> > **url**(str): YouTube影片的網址。
>
> **\$playlist**:
> > **action**(str): 要執行的動作(add、insert、play、remove)。
> > **url**(str|optional): YouTube影片的網址,只在`action`為`[add|insert|remove]`時需要傳入。
> > **way**(str|optional): 播放清單的位置,只在`action`為`[insert]`時需要傳入。
>
> **\$search**:
> >**name**(str): 搜尋的關鍵字。
> >**way**(str|optional): 要插入到播放清單的位置。
### $play
```python
async def play_next():
if self.is_next and self.play_list:
next_url = self.play_list.pop(0)
await self.play(ctx, next_url)
def after_playing():
coro = play_next()
self.bot.loop.create_task(coro)
voice.play(discord.FFmpegPCMAudio(
executable='ffmpeg.exe', source="song.mp3"), after=after_playing())
await ctx.send(embed=discord.Embed(title="Now Playing", description=url, color=discord.Color.blue()), view=self.create_controls(ctx))
```
播放提供的YouTube連結的影片。此外,透過`bot.loop.create_task()`以及上面定義的兩個函數達成自動播放下一首的功能。
### $playlist
#### add&insert
將指定的影片加入當前播放清單的queue中。若`action`為`insert`的話,則可透過額外傳入`way`來指定要加入的位置。
#### play
開始播放歌曲。
#### remove
移除播放清單中第一首網址為`url`的元素。
### search
從YouTube搜尋`name`後將第一個搜尋結果加入當前的播放清單,也可以額外使用`way`指定要加入的位置。
## Wordle
### 建構子
```python
class Wordle(Cog_Extension):
def __init__(self, bot):
# Call the parent's constructor
super().__init__(bot)
# Load the word list
raw_html = rq.get("https://www.wordunscrambler.net/word-list/wordle-word-list").text
word_doc = BeautifulSoup(raw_html, "html.parser")
word_list = word_doc.find_all(class_="invert light")
Wordle.word_list = list()
for item in word_list:
Wordle.word_list.append(item.text[1:-1])
# The dictionary for games ongoing
self.instances = dict()
```
**Wordle.word_list**(list): 所有可能的單字列表。
**instances**(dict\[key = discord.User.id: *int*, value: *WordleGame*])
使用bs4抓取網站上的單字,由於所有單字皆具有名稱為`invert light`的CSS class,所以直接透過這個class抓取所有單字,再利用slicing做簡單的字串處理之後放入`self.word_list`之中。
### 指令
在此先簡單介紹Wordle這個module本身的指令,另外定義的class(WordlGame)會在[後面](#WordleGame)另行說明。
> \$wordle
> \$guess \[guesses]
> \$wordle_end
> \$wordle_info
這邊的功能分成四個部分:
1. **$wordle**: 開啟一場新的Wordle遊戲。
2. **$guess [guesses]**: 用來傳入答案。
3. **$wordle_end**: 強制結束當前進行中的Wordle(自己的)。~~爆氣猜不到的時候很方便~~。
4. **$wordle_info**: 顯示遊玩紀錄,包含遊玩次數、勝率、猜測次數分布表。
**參數:**
> $guess
> > **guesses**(str): 猜測的答案。
### $wordle
確認當前該使用者沒有進行中的WordleGame實例,沒有的話就開啟一場新的遊戲,將開啟的實例儲存於`self.instances`中。
> [!note]
> 這部分下方的code有些在排版上不太好看(尤其是初始化`discord.Embed`的部分),會直接以註解作為pseudo code代替。
### $guess
```python
@commands.hybrid_command()
async def guess(self, ctx, guesses: str):
user_id = ctx.author.id
if not user_id in self.instances:
# send a message that the user should start a new instance first and return.
instance = self.instances[user_id]
result = instance.guess(guesses)
# there would be no message to delete if called by slash commands.
try:
await ctx.message.delete()
except discord.NotFound:
pass
if result[0] == ISSUE:
# send a message that the input is illegal.
elif instance.count == 1:
# initialize things that have to be done after the first guess.
else:
# see below
if result[0] == GAME_STATE:
# do something according to the current game state (which is returned by instance.guess()).
# GAME_STATE is some constants defined in config.py.
```
先確認當前使用者是否有正在進行的實例,若無則回傳錯誤訊息。若有的話,呼叫當前進行中的實例並將答案傳入`WordleGame.guess`做處理。考慮洗板問題,將使用者使用指令的訊息刪除,並根據得到的`GAME_STATE`以及猜測結果作處理。
`GAME_STATE`有4個,依序如下:
```python
CONTINUE = 0
GAME_OVER = 1
ISSUE = 2
CORRECT = 3
```
若當前的`GAME_STATE`為`CONTINUE`或是`ISSUE`的話,代表遊戲仍未結束;若為`GAME_OVER`或`CORRECT`的話,代表遊戲已結束,呼叫`self.updateInfo()`後將當前實例pop掉。
```python
def updateInfo(self, user_id, instance, result):
con = sqlite3.connect("user_data.db")
cur = con.cursor()
res = cur.execute(f"SELECT * FROM wordle_data WHERE user_id = {user_id}")
user_data = res.fetchone()
if user_data == None:
if result == CORRECT:
# see below
else:
# initialize user data, value will be determined by the game result.
else:
if result == CORRECT:
# see below
else:
# update user data according to the result
con.commit()
con.close()
```
將遊戲結果儲存於SQLite資料庫`user_data.db`中,並將資料儲存在`wordle_data`這個table。同樣使用Discord本身的使用者ID作為index。
`wordel_data`這個table本身包含以下資訊:

##### 展示

### $wordle_end
pop掉當前實例並回傳訊息。
### $wordle_info
```python
@commands.hybrid_command()
async def wordle_info(self, ctx):
user_id = ctx.author.id
con = sqlite3.connect("user_data.db")
cur = con.cursor()
res = cur.execute(f"SELECT * FROM wordle_data WHERE user_id = {user_id}")
user_data = res.fetchone()
if user_data == None:
# send a message indicate that data is not found.
else:
count = user_data[3:]
minimum = min(count)
delta = max(count) - min(count)
proportion = [floor(6 * (x - minimum) / delta) for x in count]
# send a message that contains all the information.
con.close()
```
從`user_data.db`中取得使用者資料,在進行簡單的資料處理後回傳。
在原版的Wordle的結算畫面中有這樣的戰績顯示:

一場Wordle玩完如果沒有留下任何紀錄也感覺有點空虛,於是就想要嘗試模仿這個畫面。
但這張表主要的問題是Discord終究是個純文字聊天軟體,不太能夠做出完全精準的長條圖。這時我剛好想到之前在[這部影片(傳送門已設置)](https://youtu.be/DQ0lCm0J3PM?si=5uWbizsGWP2dEEni&t=789)看到過類似的需求,不過是在Minecraft中實現長條圖。計算長條圖的長度的公式如下:
$$
f(x) = \lfloor\dfrac{length\times(x - min)}{max - min}\rfloor
$$
其實是一個滿直觀的公式,只是另外加上高斯符號取整而已,實作的結果如下:

### WordleGame
#### 建構子
```python
class WordleGame():
def __init__(self) -> None:
# Attributes for the game itself
self.word_count = len(Wordle.word_list) - 1
self.count = 0
self.answer = Wordle.word_list[randint(0, self.word_count)]
self.history = list()
self.message = None
```
從`Wordle.word_list`中隨機抓取一個單字作為答案。
#### WordleGame.guess
```python
def guess(self, guesses):
# Check illegal input
if len(guesses) != 5:
return (ISSUE, # error message)
elif guesses not in Wordle.word_list:
return (ISSUE, # error message)
output = [":black_large_square:" for _ in range(5)]
answer = [letter for letter in self.answer]
# Compare the guess to the answer, the result is stored in the output
output = " ".join(output) + "\n"
self.count += 1
if guesses == self.answer:
return (CORRECT, output)
if self.count == 6:
return (GAME_OVER, output)
return (CONTINUE, output)
```
答案跟猜測比對的流程基本上分成三個部分:
1. 確認字母正確且位置正確(綠色)。
2. 確認字母正確但位置不正確(黃色)。
3. 剩餘的皆為灰色。
於是比對答案部分的code長這樣,在初始化`output`這個list時,所有元素原本就是灰色,所以實際上只要處理前兩個部份就好:
```python
for i in range(0, 5):
if guesses[i] == answer[i]:
output[i] = ":green_square:"
answer[i] = ""
for i in range(0, 5):
if output[i] == ":green_square:":
continue
if guesses[i] in answer:
output[i] = ":yellow_square:"
answer.remove(guesses[i])
```
## Abacus
### 指令
> \$abacus \[level]
> \$a \[answer]
> \$abacus_end
> \$abacus_session \[level] \[category]
**參數:**
> **\$abacus**:
> > **level**(int): 要進行的心算測驗的等級(等級參照[此處](https://namuec.org/Abacus%20Mental%20Arithmetic%20Test%20Method.pdf)),支援的等級包含段位(0)至十級心算(10)。
> **\$a**:
> > **answer**(float): 算出來的答案。
>
> **$abacus_session**:
> > **level**(int): 要進行的測驗的等級,支援的等級包含段位(0)至十級心算(10)。
> > **category**(int): 要進行的測驗內容(0:加減算、1:乘算、2:除算)。
這邊會額外進行一些心算測驗的說明:
心算的級數是由高至低,最低階為十五級、十四級,最後到一級。之後是段位,自一段繼續往上。
通常來說測驗時間為3分鐘,根據等級會有加減算、乘算和除算[^1]各十題。3分鐘內若能達到70分以上(滿分為200分)者及格。另外:加減算1題10分,乘除算1題5分。
### 建構子
```python
class Abacus(Cog_Extension):
def __init__(self, bot):
super().__init__(bot)
with open("data.json", "r", encoding = "utf-8") as f:
self.context= json.load(f)
self.instances = dict()
```
自`data.json`讀取心算測驗的規範,並儲存於`self.context`中。
`註:data.json是人工根據試題最終呈現的結果輸入的資料。`
**context**(list\[dict]): 心算測驗的規範,每一級都是一個字典,這個字典的**key**是`["加減算"|"乘算"|"除算"]`,各自的**value**包含了測驗的內容,包含口數、法商數的規範等資訊。
**instances**(dict\[key = discord.User.id: *int*, value: *AbacusInstance* or *SepInstance*]): 儲存當前進行中的測驗。
### abacus
根據選擇的等級進行心算測驗,根據等級產生`AbacusInstance`或是`SepInstance`,並進行計時。儘管大致上符合測驗的規範,但仍會有一些部分和實際上的測驗不太一樣:
1. 加減計算過程中**可能會出現負數**(在正式測驗中是不會出現的)。
2. 乘除算儘管被乘數、乘數&除數、商數的規範和測驗規定相同,實際上仍未有些許差異。
這兩個問題主要來自於以下問題:
1. `random.randint()`這個函數本身是**time-based**的隨機亂數產生器,在生成減法的數字時,經常因為是依序生成的,導致連續扣除大量的數字,進而產生負數的過程。在測試過程中曾經嘗試過若過程有負數就重新生成,但最後因為常常都會達到recursive limit而作罷。最後只有答案為負數會重新生成。
2. 這部分在測驗規範中沒有明文規定,儘管實際上測驗內容有一些潛規則會對位數做額外的限制,因此無法達到完全相同。
#### 一些展示
~~真的太久沒有練了~~




### a
進行回答。
### abacus_end
結束當前正在進行中的測驗。
### abacus_session
和`$abacus`大致相同,但只進行一部份的測驗(加減算、乘算、除算)。此時每題10分,滿分為100分。
#### 一些展示


### AbacusInstance&SepInstance
和[[#WordleGame|WordleGame]]類似,同樣是用來Handle每場測驗的class。**AbacusInstance**和**SepInstance**大致相同,最大的差別是:AbacusInstance是完整的30題測驗,SepInstance只包含其中一部份(10題)。在使用`$abacus`時,若`level`為六級至十級,會自動使用SepInstance,而非AbacusInstance。
### AbacusProblem
負責處理問題生成的class,當[兩種Instance](#AbacusInstance&SepInstance)生成問題時,每一題都是一個新的**AbacusProblem**物件。
# 工作分配
**張一中**:wordle、abacus、report排版、wordle、abacus的report和簡報
貢獻度:5
**辜名緯**:todolist、gallery、music、todolist、gallery、music的report和簡報
貢獻度:5
[^1]:五級之後的測驗才包含乘算和除算,這之前的測驗滿分為100分。