Seina-18147

7/30/2021 projects學習歷程們

# 自訂義聊天機器人

Github (opens new window)

Blog Doc (opens new window)

hackmd (opens new window)

Seina-18147 (opens new window)

# 目錄

  • 前言
  • 理想
  • 過程
    • 基本架構
    • 自定義聊天主功能
    • 其他功能
    • 選定伺服器架設
  • 心得

# 前言

從會寫程式開始的第一個side project

便是discord bot

還記得最開始的discord bot 便是最樸實的純聊天機器人

無限的往程式碼加入if 來加入回復

半年過去

回去看自己的程式碼只能說頭真的很痛

但看到這又想看看自己到底提升了多少

所以我決定來重現一下這個功能

並把它做得更好 更有趣

# 理想

還記得看到之前的程式碼

添加的回覆全部都在主程式裡瘋狂添加if

所以這次希望至少可以使用json來儲存回復

並希望可以有明確的專案結構

不要再同一個檔案寫到底

導致程式碼可讀性差

# 過程

# 基本架構

# 建立基本檔案

首先要決定整個專案的結構

因為discord bot需調用本身的api的關係

專案結構必須根據官方的cog方法來建置

├── functions(cog資料夾)
│   ├── 功能分類一.py
│   └── 功能分類二.py
├── 機器人主程式.py
└── setting.json

之後去discord developers申請一隻discord bot

點application

之後新增一個application

點bot

在application中新增機器人使用者

之後複製token放入設定檔

{
    "token" : "複製過來的token"
}

再來導入discord的函式庫

pip install discord

在主程式中加入機器人基本設定

import discord
from discord.ext import commands

import json

import os


with open('setting.json','r',encoding='utf8') as file:  # 開啟設定檔
    setting = json.load(file)                           #

bot = commands.Bot(command_prefix='&')  # 建立機器人物件


for file in os.listdir('./functions'):               # 逐一讀取cog功能         
    if file.endswith('.py'):                         #
        bot.load_extension(f'functions.{file[:-3]}') #

if __name__ == '__main__':  
    bot.run(setting["token"]) # 讀入設定檔中機器人金鑰

# 建立其他功能(cog) 檔案

cog架構是以物件的方式分開功能

一功能分類用一類別撰寫

範例如下

import discord
from discord.ext import commands

class 功能分類一(commands.Cog):
    def __init__(self,bot):
        self.bot = bot
        
def setup(bot):
    bot.add_cog(功能分類一(bot))

# 功能加載

用cog架構的好處

就是能夠透過指令開關加載、卸載分類

從而達到不關掉機器人

且可以更新程式碼的效果

接下來將在主程式中加入加載、卸載功能

@commands.command()
@commands.is_owner()                                         # 機器人擁有者才可使用                               
async def load(self,ctx,extention):                          # 加載功能
    self.bot.load_extension(f'functions.{extention}')
    await ctx.message.delete()
    await ctx.send(f'Load function {extention} successfully')
@commands.command()
@commands.is_owner() 
async def unload(self,ctx,extention):                        # 卸載功能
    self.bot.unload_extension(f'functions.{extention}')
    await ctx.message.delete()
    await ctx.send(f'Un - Load function {extention} successfully')
@commands.command()
@commands.is_owner()
async def reload(self,ctx,extention):                        # 重新載入功能
    self.bot.reload_extension(f'functions.{extention}')
    await ctx.message.delete()
    await ctx.send(f'Re - Load function {extention} successfully')

# 自定義聊天主功能

完成基本架構後

便開始製作主要的自訂聊天功能

# 決定json

首先要決定json的格式

因為考慮要在複數伺服器使用

所以json最外層需使用伺服器id

內層分別為四種觸發模式

  • contain 包含
  • equal 等於
  • startwith 開頭為
  • endwith 結尾為

範例如下

{
    "伺服器id 1": {
        "contain": {
            "觸發字串一": [
                "回復字串一",
                "回復字串二"
            ],
            "觸發字串二": [
                "回復字串三",
                "回復字串四"
            ]
        },
        "equal": {
            "觸發字串一": [
                "回復字串一",
                "回復字串二"
            ],
            "觸發字串二": [
                "回復字串三",
                "回復字串四"
            ]
        },
        "startwith": {
            "觸發字串一": [
                "回復字串一",
                "回復字串二"
            ],
            "觸發字串二": [
                "回復字串三",
                "回復字串四"
            ]
        },
        "endwith":  {
            "觸發字串一": [
                "回復字串一",
                "回復字串二"
            ],
            "觸發字串二": [
                "回復字串三",
                "回復字串四"
            ]
        }
    },
    "伺服器id 2": {
        "contain": {
            "觸發字串一": [
                "回復字串一",
                "回復字串二"
            ],
            "觸發字串二": [
                "回復字串三",
                "回復字串四"
            ]
        },
        "equal": {
            "觸發字串一": [
                "回復字串一",
                "回復字串二"
            ],
            "觸發字串二": [
                "回復字串三",
                "回復字串四"
            ]
        },
        "startwith": {
            "觸發字串一": [
                "回復字串一",
                "回復字串二"
            ],
            "觸發字串二": [
                "回復字串三",
                "回復字串四"
            ]
        },
        "endwith":  {
            "觸發字串一": [
                "回復字串一",
                "回復字串二"
            ],
            "觸發字串二": [
                "回復字串三",
                "回復字串四"
            ]
        }
    }
}

# 撰寫新增函式

def update_reply(mode,context,reply,server_id):
    jsonFile = open("reply.json", "r",encoding='utf8')   	# 開啟json
    data = json.load(jsonFile)                           	# 存取暫存
    jsonFile.close()                                     	# 關閉json

    if server_id not in data.keys():                     	# 若server未曾存入 初始化 json
        data[server_id] = {								 	#
            "contain":{}, 								 	#                   
            "equal":{},							         	#   	
            "startwith":{},								 	#
            "endwith":{}						         	#       		
        }								                 	#
        
    if context not in data[server_id][mode].keys():      	# 若觸發字串未曾存入 初始化 json
        data[server_id][mode][context] = []              	#
        
    if reply not in data[server_id][mode][context]:      	# 若回復字串未曾新增 加入回復
        data[server_id][mode][context].append(reply)     	#

    jsonFile = open("reply.json", "w+",encoding='utf8')     # 開啟json
    json.dump(data,jsonFile, indent=4,ensure_ascii=False)   # 更新資料
    jsonFile.close()                                        # 關閉json

    embed=discord.Embed(title="reply message", description="the reply you just add", color=0xa7f21c)  # 生成discord embed訊息
    embed.add_field(name=mode, value=f"`{context}`", inline=True)                                     #
    embed.add_field(name="reply", value=f"`{reply}`", inline=True)                                    #
    return embed # 回傳提示訊息至主程式

# 撰寫新增指令

因為功能相近

這裡使用了discord特殊的子命令結構

使用方法須先撰寫主命令名稱

@commands.group()
async def define_message(self,ctx):
    pass

之後於主命令下新增四個主命令

直接對應到剛剛json的四個觸發模式

指令裡直接呼叫剛剛的新增函式

@define_message.command(
    help="add reply message when message contain the context",
    brief="add reply message when message contain the context"
)
async def contain(self,ctx,context,reply_message):
    embed = update_reply("contain",context,reply_message,ctx.message.guild.id)
    await ctx.send(embed=embed)
@define_message.command(
    help="add reply message when message equal the context",
    brief="add reply message when message equal the context"
)
async def equal(self,ctx,context,reply_message):
    embed = update_reply("equal",context,reply_message,ctx.message.guild.id)
    await ctx.send(embed=embed)

@define_message.command(
    help="add reply message when message startwith the context",
    brief="add reply message when message startwith the context"
)
async def startwith(self,ctx,context,reply_message):
    embed = update_reply("startwith",context,reply_message,ctx.message.guild.id)
    await ctx.send(embed=embed)

@define_message.command(
    help="add reply message when message endwith the context",
    brief="add reply message when message endwith the context"
)
async def endwith(self,ctx,context,reply_message):
    embed = update_reply("endwith",context,reply_message,ctx.message.guild.id)
    await ctx.send(embed=embed)

# 撰寫偵測事件

最後一步就是在訊息發送事件中加入偵測

@commands.Cog.listener()        # cog內event特殊寫法 
async def on_message(self, message):
    if not message.author.bot:  # 避免偵測機器人訊息 
        if not message.content.startswith("http"): # 過濾網址
            with open('reply.json','r',encoding='utf8') as file:  # 開啟json
                reply = json.load(file)                           # 存入暫存

            server_id = str(message.guild.id)  # 暫存伺服器id
            if server_id in reply.keys():      # 偵測伺服器是否有加入回復訊息
                msg = []                       # 吻合回復字串庫
                for i in reply[server_id]["contain"].keys():  # 搜尋並加入回復字串庫
                    if i.lower() in message.content.lower():  #
                        msg += reply[server_id]["contain"][i] #

                for i in reply[server_id]["equal"].keys():    # 搜尋並加入回復字串庫
                    if i.lower() == message.content.lower():  #
                        msg += reply[server_id]["equal"][i]   #

                for i in reply[server_id]["startwith"]:               # 搜尋並加入回復字串庫
                    if message.content.lower().startswith(i.lower()): #
                        msg += reply[server_id]["startwith"][i]       #

                for i in reply[server_id]["endwith"]:                 # 搜尋並加入回復字串庫
                    if message.content.lower().endswith(i.lower()):   #
                        msg += reply[server_id]["endwith"][i]         #

                if(len(msg) > 0):
                    await message.reply(choice(msg))  # 從字串庫中隨機挑選字串回復

此功能變大工告成

# 效果

使用指令新增回復

機器人回復剛剛新增的訊息

# 其他功能

為了讓機器人變得更有趣

我還加入了很多奇怪、沒有用

但很有趣的功能

因為有點多 而且真的不太有用

所以將用程式碼加上註解的方式來呈現

# 隨機顏色產生

@commands.command()
async def color(self,ctx):

    from random import randint          # 導入隨機函示庫
    from PIL import Image, ImageDraw    # 導入圖片函示庫

    r,g,b = [randint(0,255) for i in range(3)]  # 隨機產生 rgb 三數值

    color_str = "#"                       # 將 rgb 數值轉換成 16 進制字串
    for i in [r,g,b]:                     #
        color_str += hex(i)[2:].upper()   #


    img = Image.new("RGB", (300,300) ,(r,g,b))   # 用函示庫產生純色色塊
    img.save("color.jpg")                        #


    embed = discord.Embed(title="Seina pick a color for you",  description = color_str, color=discord.Color.from_rgb(r,g,b)) # 生成及傳送discord embed訊息
    file = discord.File("color.jpg", filename="color.jpg")                                                                   # 
    embed.set_image(url=f"attachment://color.jpg")                                                                           # 
    await ctx.send(embed=embed , file = file)                                                                                #

# 天氣查詢功能

@commands.command()
async def weather(self,ctx,location):

    import requests # 導入api爬取所需的request庫

    await ctx.message.delete()  # 刪除訊息
    url = 'https://opendata.cwb.gov.tw/fileapi/v1/opendataapi/F-D0047-091?Authorization=CWB-97AF1200-D64F-4BD7-BFBA-C8E9892538FC&downloadType=WEB&format=JSON' # 政府公開天氣資料api的url

    res = requests.get(url) # 爬取資料

    location_to_code = {            # 將文字轉換成資料裡城市的編號
        '連江': 0,                  # 
        '金門': 1,                  # 
        '宜蘭': 2,                  # 
        '新竹': 3,                  # 
        '苗栗': 4,                  # 
        '彰化': 5,                  # 
        '南投': 6,                  # 
        '雲林': 7,                  # 
        '嘉義': 8,                  # 
        '屏東': 9,                  # 
        '臺東': 10, '台東': 10,     #
        '花蓮': 11,                 #
        '澎湖': 12,                 #
        '基隆': 13,                 #
        '新竹': 14,                 #
        '嘉義': 15,                 #
        '臺北': 16, '台北': 16,     #
        '高雄': 17,                 #
        '新北': 18,                 #
        '臺中': 19, '台中': 19,     #
        '臺南': 20, '台南': 20,     #
        '桃園': 21                  #
    }

    data_we = res.json()  
    description = data_we['cwbopendata']['dataset']['locations']['location'][location_to_code[location]]['weatherElement'][14]['time'][0]['elementValue']['value'] # 氣象描述

    location = data_we['cwbopendata']['dataset']['locations']['location'][location_to_code[location]]['locationName']

    await ctx.send(location+"天氣:\n"+"```\n"+description+"```\n")

# 泡泡紙功能

@commands.command()
async def pop(self,ctx,word='pop',width=10,height=10):

    if (len(word) + 4)*width*height > 1000:        # 檢測泡泡紙大小是否超過discord傳送訊息限制
        await ctx.send("oops, the pop is too big") # 送出錯提示訊息
     else:
        await ctx.message.delete() # 刪除訊息

        embed = discord.Embed(title="Seina give you a bubble paper",  description = (('||'+word+'||')*width+'\n')*height, color = 0xffffff)  # 送出泡泡紙
        await ctx.send(embed = embed) #送出泡泡紙

# 踢出伺服器成員

@commands.command()
@commands.is_owner()    # 機器人擁有者才可使用功能
@commands.has_permissions(kick_members=True)  # 檢測機器人是否有踢人權限
async def kick(self, ctx, member : discord.Member, *, reason=None):
    await member.kick(reason=reason)   # 踢出
    await ctx.send(f'Banned {member.mention}') # 傳送提示訊息

這個就不測試了 傷感情

# 選定伺服器架設

discord bot是以客戶端(client) 的方式在discord中活動的

所以只需要對主程式進行偵錯(debug)

便可以運行機器人

但一直運行在家中的電腦肯定不是長久之計

後來多方考慮下還是選擇架設在heroku

# 加入heroku必要設定檔

  • Procfile

設定heroku伺服器運行模式之檔案

worker python 主程式.py
  • runtime.txt

設定伺服器運行主環境

python-3.8.5
  • requirements.txt

伺服器必要下載套件

須將機器人所用到的函示庫加入其中

discord
requests
Pillow
discord-together

# 創建heroku專案

接下來在heroku中加入新專案

之後使用終端機指令將專案推上heroku

需安裝git工具 及 heroku cli

heroku login                      // 登入heroku
cd 專案名稱                        // 專換路徑至專案
git init                          // 初始化git
heroku git:remote -a heroku專案名稱// 增加heroku推送路徑
git add .                         // 推送至heroku
git commit -m "推送描述"           //
git push heroku master            //

# 推送完成

完成這些後

discord bot就會風雨無阻的工作了喔

# 心得

discord bot 真的是從開始寫程式以來我就很喜歡的介面

每次有想到甚麼功能都會想要用這種表現形式來呈現

這次除了讓我更能夠明確的規劃專案

更多的是讓我熟悉了自己的開發過程跟節奏

也完成了看到自己以前程式碼之後的小測試

看到一年內就算沒有多少 自己也一定有進步

專案的結構、程式碼可讀性都比以前高上很多

不知道明年的自己是不是還會持續進步呢