---
# System prepended metadata

title: 資訊之芽2024 二階段大作業Report
tags: [Python, 資訊之芽, Discord.py]

---

# 資訊之芽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輸出使用者的所有提醒事件。
![Pasted image 20240621211828](https://hackmd.io/_uploads/r1TuPWHLC.png)
#### 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本身包含以下資訊：
![Pasted image 20240621223946](https://hackmd.io/_uploads/rJqcvZBUA.png)

##### 展示
![Pasted image 20240621231553](https://hackmd.io/_uploads/SJMsvWHU0.png)
### $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的結算畫面中有這樣的戰績顯示：
![Pasted image 20240622111154](https://hackmd.io/_uploads/rJCowWS8R.png)

一場Wordle玩完如果沒有留下任何紀錄也感覺有點空虛，於是就想要嘗試模仿這個畫面。

但這張表主要的問題是Discord終究是個純文字聊天軟體，不太能夠做出完全精準的長條圖。這時我剛好想到之前在[這部影片（傳送門已設置）](https://youtu.be/DQ0lCm0J3PM?si=5uWbizsGWP2dEEni&t=789)看到過類似的需求，不過是在Minecraft中實現長條圖。計算長條圖的長度的公式如下：
$$
f(x) = \lfloor\dfrac{length\times(x - min)}{max - min}\rfloor
$$
其實是一個滿直觀的公式，只是另外加上高斯符號取整而已，實作的結果如下：
![Pasted image 20240622111559](https://hackmd.io/_uploads/r12nPbS8A.png)
### 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. 這部分在測驗規範中沒有明文規定，儘管實際上測驗內容有一些潛規則會對位數做額外的限制，因此無法達到完全相同。
#### 一些展示
~~真的太久沒有練了~~
![Pasted image 20240622152507](https://hackmd.io/_uploads/HkleRvWS8R.png)
![Pasted image 20240622152701](https://hackmd.io/_uploads/B1w0PWrUA.png)
![Pasted image 20240622152530](https://hackmd.io/_uploads/BJkJubH8R.png)
![Pasted image 20240622152600](https://hackmd.io/_uploads/r1mkd-H80.png)

### a
進行回答。
### abacus_end
結束當前正在進行中的測驗。
### abacus_session
和`$abacus`大致相同，但只進行一部份的測驗（加減算、乘算、除算）。此時每題10分，滿分為100分。
#### 一些展示
![Pasted image 20240622153108](https://hackmd.io/_uploads/S1R1dZrI0.png)
![Pasted image 20240622153122](https://hackmd.io/_uploads/HJ4edbBIA.png)
### 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分。