サーバー内の会話から学習して発言するDiscord botを作成したら思った以上に遊べた話

## 概要と経緯
数ヶ月前の話ですがDiscordでyoutubeの音を流すことのできるRythm Botがyoutubeからのサービス停止を求められサービスを終了してしまいました。
これを期にbotを自作してみようと思いまずはテキストベースのものを作成した次第です。
また、作成するにあたりDiscord bot周りの文献を色々と調べたのですが、細かい部分で散らばっていたりあまり親切ではなかったりと苦労したのでそこらへんのメモも兼ねています。

## 環境
 - Windows10
 - python3

## 機能
本記事では以下の機能を搭載したDiscord botの解説をしていきます。かなり初歩的な部分も拾っていきたいので不明点、指摘等ありましたらコメントお願いします。

 - botの起動中サーバー内で発言されたテキストを記憶する
 - 記憶した文章の中からマルコフ連鎖を用いて生成した文章を特定のチャンネルで発言する

## 実装
### DBの準備
記憶しておく場所はテキストファイルでもいいのですがせっかくなのでDBで管理したいと思います。
~~楽をしたいので~~ローカルな環境で完結したいためsqliteを利用します。  
実際は後述の通りコード内で作成部分も自己完結してしまうので事前に用意しておく必要もないのですが、今回必要なデータを格納するテーブルについてのみ整理しておきます。

 - 発言内容をまるごと格納するテーブル(1投稿1データ)
 - botが投稿するチャンネルを記憶するテーブル

### botの基本的な構造  
Discord botの基本的な(最低限の機能を有した)構造は以下のような形になります。  

```
# アクセストークン
TOKEN = 'アクセストークン'

# 接続に必要なオブジェクトを生成
client = discord.Client()

# 起動時に動作する処理
@client.event
async def on_ready():
    # 起動したらターミナルにログイン通知をするとか

# メッセージ受信時に動作する処理
@client.event
async def on_message(message):
    # メッセージ受信時に動作する処理を書き込んでいく

# Botの起動とDiscordサーバーへの接続
client.run(TOKEN)
```

### メッセージ受信時の動作に肉付けしていく
on_message関数の中身を肉付けしていきます。

まずはサーバー内の人間による発言だけ拾いたい、また自身の発言は無視したいため以下でbotによる発言を無視します。

```
# メッセージ送信者がBotだった場合は無視する
if message.author.bot:
    return
```

次にDB周りの設定を行います。

```
# 発言されたサーバーのIDを取得する
serverId = message.guild.id
# サーバーIDを名前とするsqlファイルを指定し、アクセスする(なければ作成する)。
filename = str(serverId) + ".sq3"
conn = sqlite3.connect(filename)
cur = conn.cursor()

# テーブルがない場合作成する
cur.execute('CREATE TABLE IF NOT EXISTS sentence(id INTEGER PRIMARY KEY AUTOINCREMENT, sentence text)')
cur.execute('CREATE TABLE IF NOT EXISTS commentChannel(name PRIMARY KEY)')
```
これで初回発言時にはsqlファイルとテーブルを作成、2回目以降は発言されたサーバー用のsqlファイルにアクセスできるようになりました。
サンプルなのでテーブル設定がひどいのは見逃してください。


次にコマンドとして受け付ける文字列を指定します。Rythmで言うところの「!」です。本記事では「<」を指定していますが環境に合わせて他のbotと競合しない文字列を指定してください。

```
# 1文字目が「<」の時のみコマンドとして入力を受け付ける
if  message.content[0] == "<":
    # この中で更にコマンドを細分化していく
    message_2 = message.content[1:8]
    
    # 喋るチャンネルを固定するコマンド
    if message_2 == "cmthere":
        sql = 'INSERT OR REPLACE INTO commentChannel(name) VALUES(?)'
        print(message.channel.id)
        cur.execute(
            sql, (message.channel.id,)
        )
        conn.commit()

    # マルコフ連鎖を用いて生成した文章を発言するコマンド
    elif message_2 == "comment":
        # (詳しい内容は後述)

    # その他任意でコマンドを記述していく
    elif message_2 == "select ":
             ・
             ・
             ・
# コマンドでない時の処理
else:
    # 文章をまんまDBにIN
    sql = 'INSERT INTO sentence(sentence) VALUES(?)'
    cur.execute(
        sql, (message.content,)
    )
    conn.commit()

conn.close()
```
別になくてもいいんですが特定のチャンネルに投稿してくれたほうが見栄えがいいので喋るチャンネルを固定する仕様にします。

これで大体の流れは完了です。DBは適宜_commit()_と使い終わった際の_close()_を忘れないように。
私のbotではselectやdelete等の簡単なsql文をコマンドとして設定しています。

### マルコフ連鎖を用いて文章を生成
記事のタイトル的にはここが本題なのですが、ほとんど [こちら](https://qiita.com/Cherno/items/ac9ab5a53baa2cacec31)からの流用になります。さらに遡ると[ここ](https://qiita.com/kakakaya/items/38042e807f3410b88b2d)が一次ソースということになりますね。

```
tokenizer = Tokenizer()

cur.execute(
    'SELECT name FROM commentChannel'
)
channelName = cur.fetchone()

if (channelName):
    channel = client.get_channel(channelName[0])

    # DBに蓄積された文章を抽出してリストに格納する
    cur.execute('SELECT * FROM sentence')
    sentenceList = []
    for row in cur:
        sentenceList.append(row[0])

    # -------------以下マルコフ連鎖の処理-------------
    table = str.maketrans({
        '\n': '',
        '\r': '',
        '(': '（',
        ')': '）',
        '[': '［',
        ']': '］',
        '"':'”',
        "'":"’",
        "@":"",
    })
    text = ' '.join(sentenceList)
    result = list(tokenizer.tokenize(text, wakati=True))

    # 1形態素ずつ見ていって、間に半角スペース、文末の場合は改行を挿入
    splitted_text = ""
    for i in range(len(result)):
        splitted_text += result[i]
        if result[i] != '。' and result[i] != '！' and result[i] != '？':
            splitted_text += ' '
        if result[i] == '。' or result[i] == '！' or result[i] == '？':
            splitted_text += '\n'
    text_model = markovify.NewlineText(splitted_text, state_size=1)

    # -------------ここまで-------------

    if text_model.make_sentence() == None:
        await channel.send("情報が足りなくて文章を作れません。") 
    else:
        await channel.send(text_model.make_sentence().replace(' ', '')) 

else:
    await message.channel.send("<cmthere コマンドで喋るチャンネルを指定してください。")
```
マルコフ連鎖的な処理の部分は上述の参考URLに任せて、Discord botとしての動作部分の説明をしていきます。

```
cur.execute(
    'SELECT name FROM commentChannel'
)
channelName = cur.fetchone()

if (channelName):
    channel = client.get_channel(channelName[0])
             ・
             ・
             ・
else:
    await message.channel.send("<cmthere コマンドで喋るチャンネルを指定してください。")
```
「喋るチャンネルを固定するコマンド」で設定したチャンネルを引っ張ってきます。
存在する場合はそのチャンネルを_client.get_channel()_で指定、存在しない場合はコマンドで指定するように促します。その際botが発言するのはコマンドが入力されたチャンネルになります。

```
if text_model.make_sentence() == None:
        await channel.send("情報が足りなくて文章を作れません。") 
    else:
        await channel.send(text_model.make_sentence().replace(' ', '')) 
```
説明を省略しているマルコフ連鎖的な処理の後、_text_model.make_sentence()_で文章を生成できますがデータが足りない等の理由で生成できなかった際にはNoneが返ってくるためその時のための処理です。


他、コマンドのあとに数値を指定させてマルコフ連鎖に使う単語の連結を設定させたりしても面白いと思います。

## 余談(と言い訳)
今更ながら根本的な話としてDiscord botの登録ですが、[公式](https://discordpy.readthedocs.io/ja/latest/discord.html)をご確認ください。

当初のゴールとしてはyoutubeの音を流すbotの作成だったのですがそもそもDiscordで用意されていたAPIが止められているのでどうしようもない気がします。
当然ですがDBへの登録件数が多くなればなるほど返答までの時間は伸びます。本botを利用しているサーバーでsqlファイルが1000KBを超えているところがありますがこのくらいだと返答に5秒程度かかっています。
また、記事内でも述べてますがローカルで完結したかったためDBのセキュリティや全体的な処理には不安な部分が多いです。あくまで身内で楽しむ用ということで、botとして公開するのには向いてないのでご注意ください。


## 展望
今後やりたいことやこの記事のbotを拡張するなら、という夢です。
 - ローカルの音楽ファイルを再生させるbotの作成(次回記事を作ることがあったら)
 - サーバー内の発言を形態素解析して感情分析、使用ワードの傾向を調べる等

## あとがき
ちょっとタイトル詐欺のような気がしなくもないですが、テキスト主体の応答をするDiscord botの作成手順の備忘録ということで。
ある程度の人数がいてテキストチャンネルが活発なサーバーでは結構楽しめると思います。
意図的に単語や歌詞を教えてしまうと面白い文章も生まれやすいですがサーバーにいる人間の個性は出ないので一長一短だったり。
最後に実際運用していた中で面白かった発言をいくつかピックアップして終わりにします。

### bot迷言例
 - うーん、肉！！！！！！！！！！！！！！！！！！！！！！！！！！！！
 - 俺は手動。
 - サーバーの終了さ。
 - トーイレトーイレ居場所はお部屋に負けねえ！
 - あとは近いけどエンターキーは無性別だよ。
 - まだ温かいお茶ももろく砕け散る。
 - マヨネーズで壊れたアゲハ蝶
 - 友達としてカズレーザーができたあ、自分の意味なくない。
 - 自分は確かに興味が滴り落ちる。
 - JR新宿駅の私の末路が見上げた。

インパクトがあるのを選ぶと短文になりがちなので連鎖ができてるのかわかりにくいですね。

## 参考
https://atmarkit.itmedia.co.jp/ait/articles/2102/19/news026.html
https://qiita.com/Cherno/items/ac9ab5a53baa2cacec31
https://qiita.com/Cherno/items/ac9ab5a53baa2cacec31