# 資訊之芽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分。