概要
僕はひょんなことからdiscordのbotを作成しようと思い立ったのですが、その時にかなりたくさんのトラブルに見舞われたので、ここに記録しておきます。
環境はWSLのUbuntuです。
行程
では実際に工程を追っていきます。
序
wsl導入
http://www.atmarkit.co.jp/ait/articles/1806/28/news043.html
まずはこの記事に沿ってすんなりとwslの日本語環境を。通常なら標準で沢山のソフトが内包されているUbuntuですが、このwslではインストールを容易にするためにほぼない状態なので、必要なものは適当にinstallしましょう。とりあえず何らかのEditorくらいは(Emacsやvimなど)。そのeditorの環境設定はご自由に。
https://qiita.com/funafuna/items/c3bb78a546cf2605205d
wslのhome変更。sudo使わないといけない場面が増えますし、他にもリスキーなのでlnを使って簡単にwindows上のwork spaceへ飛ぶ方がいいとは思います。なんにせよ/mnt/c/
という表記やln
は重宝しますね。Tabによる補完はlnにも対応しています。
https://qiita.com/makky0620/items/e31edc90f22340d791ff
windows上でterminalが別windowを作るにはXmingが必要です。また、windows立ち上げごとにXmingの起動が必要なので、キーバインドを設定しておくと楽かもしれません。(今回は必須ではない)
http://wadap.hatenablog.com/entry/20080114/1200288402
terminalにtabを導入し、C-j+<number>
のみでtab移動できるscreenもあれば便利かも。(今回は必須ではない)
python導入
pythonはwslのUbuntuにはdefaultには入っていないので新しいpython3をinstall。(必要なコマンドはterminalにpython3と打つと教えてくれます)
Pythonどころかオブジェクト指向型のプログラム自体初めて触れたのですが、aidemyというページはオンライン上だけで説明を読んだり適宜ソースを書いたりしながらPythonを習得できるのでとても参考になりました。
discord bot準備
https://qiita.com/PinappleHunter/items/af4ccdbb04727437477f
https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f
このあたりのサイトがとても参考になるので見ながら進めていくといいのですが、僕の場合はpipの挙動がおかしく(egg_infoエラー)discord.pyがinstallできなくてかなり苦しめられました。かなりいろんなサイトを見たのですが、最終的に解決してくれたのはこのページでした。discordのbotのscriptについて参考になるページは他にもたくさんあるので適当に検索してもらえばいいですがこのページくらいは。
さて、ここまでですでにbotを動かすこと自体はできているはずですが、PCを立ち上げてterminal上でPythonを動かしているときしかbotを使えません。というわけでさっきのページの後半にもある通りにHerokuを利用します。
Tokenは環境変数で隠す(超重要)
順調に進んでいれば上記の時点でbotのtokenを取得して、localで動かしているはずです。このtokenはいわばpasswordで、決して外部に漏らしてはなりません。仮に知られればbotが乗っ取られ、エログロ画像をbotの活動できる全範囲でばらまかれます。 もちろん普通にしていればtokenを教えるような場面はないのですが、実は1つ大きな罠があります。
以下ではHerokuを用いてbotを自分のPCを用いずとも動かせるようにするのですが、通常のgithubアカウントではすべてのrepository(同期しているフォルダ)は公開されます。そこに含まれるbot用のsourceも公開されるわけで、見る人が見れば最後の列にあるtokenも把握されてしまいます。そう、少なくとも愚直に作業を進めていると(botをはじめて作った僕は実際、思い至りませんでした)必然的にtokenを世界中に公開することになるのです。
そこでos内の環境変数にtokenを覚えさせて、それをbot用のsourceで利用します。herokuへのpushやdeployはPCから(localに)行われるので、tokenを外部に知られることが完全になくなります。
https://note.nkmk.me/python-os-environ-getenv/
osの環境変数の利用について。linuxでの環境変数の設定は~/.bashrc内にexport DISCORD_TOKEN='XXXXX'
と記載してください。これでimport os
とした上でos.environ.get("DISCORD_TOKEN")
をtokenとして使用できます。
Heroku
Heroku導入
ここで新たに問題発生です。なぜかわかりませんがherokuのinstallに用いられるsnapdの挙動が怪しいです。(snapdを入れなおしても同様。snap --version
を使うとsnapdの欄はunavailableになっていました……。)さてsnapdの利用を諦めるまでにまた大量の時間を消費しましたが、結局はOther installation methodsのStandalone installationで解決しました。
また、このときに併せてGitのinstallも必要です。(このページの上部にあります。)後々のことも考えるとGitHubのDesktop appもinstallしておくといいかもしれません。
HerokuでDeploy
先ほどのページに従います。Procileやruntime.txt、requirements.txtはbot用のpythonファイルと同じディレクトリに置きましょう。サイトによってはrequirements.txtにはpip freeze > requirements.txt
とするようにと書いてあることもありますが、herokuが対応していないものもあるらしく(errorの原因になります)、必要最低限のdiscord.py==0.16.12
でいいと思います。(そもそも僕はpipの調子が悪かったせいか、pip freeze
にはdiscord.pyが含まれていませんでした。)
そしてあまり考えずにterminalから指示されてるコマンドを入力します。この段階でerrorが出た場合は設定ファイルかpythonソースに誤りがあります。また、ここは僕が一番ハマったポイントなのですが、僕のやった限りでは一度Herokuのページからボタンをずらしてdynoをonにしないといけないようです。まだHeroku logs --tail
では微妙にerrorが残っているのですが、とりあえずうまくいっています(?)
2018-10-27T13:51:03.820767+00:00 heroku[router]: at=error code=H14 desc="No web processes running" method=GET path="/" host=sino-shinma.herokuapp.com request_id=119b0e08-885c-444b-8c2c-5081975d256a fwd="126.224.83.208" dyno= connect= service= status=503 bytes= protocol=https
2018-10-27T13:51:05.015322+00:00 heroku[router]: at=error code=H14 desc="No web processes running" method=GET path="/favicon.ico" host=sino-shinma.herokuapp.com request_id=abca14b3-1b2f-4826-8e43-d75613c2882a fwd="126.224.83.208" dyno= connect= service= status=503 bytes= protocol=https
実際のbot作成
https://note.nkmk.me/python-pprint-pretty-print/
pprint
は多用します。
https://github.com/Rapptz/discord.py/issues/186
@bot.event
と@bot.command
が共にある場合、@bot.event
内にeventの中にawait bot.process_commands(message)
とないと@bot.command
は無視されます。
https://graffitinote.hatenadiary.jp/entry/2017/12/26/004657
これも見つけるのにかなり苦労したのですが、VCチャンネル内のメンバー一覧リストの取得方法です。
@bot.command(description='「?vc」で「コロシアムVC」の参加メンバーの(ニックネームではない)名前一覧が得られます。')
async def vc():
"「コロシアムVC」の参加メンバーの名前一覧を表示します。"
channel = bot.get_channel("413951021891452932")
if len([member.name for member in channel.voice_members]) == 0:
await bot.say("今、" + channel.name + "には一人もいない……一人も……")
else:
member_list = pprint.pformat(
[member.name for member in channel.voice_members])
await bot.say(channel.name + "にいるのは\n" + member_list.replace(",", "\n") + "\nだよ!") # replaceで改行して見やすく
https://ogapsan.com/archives/1167
pickle
の利用により、プログラムの完全に外側に変数を保管できます。
@bot.command(description='「?note 楽器神魔の場面で魔書ばかり引きました」と記録しても、他の人に読み出されることはありません。')
async def note(ctx: commands.Context,memo: str):
"ユーザーごとにメモを記録します。「?call」で呼び出します。"
f_name = "/tmp/memo_" + ctx.message.server.id + ".pkl"
with open(f_name, 'wb') as f:
pickle.dump(memo,f)
await bot.say("覚えました!!")
https://discordpy.readthedocs.io/ja/latest/faq.html?highlight=ctx#how-do-i-get-the-original-message
command中で入力を伴うようなsub commnadをもつものについては@bot.groupを用いる。ここのQ&Aは必読。
https://discordpy.readthedocs.io/ja/latest/ext/commands/api.html#discord.ext.commands.Context
ctxはよく使う情報を携えているのでとても便利。@boddy.commnad(pass_context=True)
とpass_context=True
を忘れると使えないので注意。
https://discordpy.readthedocs.io/ja/latest/api.html#discord.utils.get
discord内の様々なobjectをidから得ることができる。
http://www.iam4a.ml/141.p
役職もbotで扱うことが可能。その場合、はじめにdiscord側でbot用の上位役職を用意する。
@bot.command(description='',pass_context=True)
async def absent(ctx: commands.Context):
"役職をAbsentに変更して遅刻しそうないし欠席の可能性があることを明確にできます。「?role_reset」で全員のAbsentをもとに戻せます。"
user = ctx.message.author
role = discord.utils.get(user.server.roles, name="Absent")
await bot.add_roles(user, role)
http://ncastar.hatenablog.com/entry/2016/01/20/001858
https://blog.mah-lab.com/2013/05/16/heroku-commons-16/
これはかなり重要。herokuにおいてファイルの保存は/tmp/以下にのみ可能であり、しかしserver再起動時に消滅する。結局はglobal変数と同じだけしか生き延びられない。dynoの停止から一定時間でもtmp内は消えるが、常に動いているdiscord botの場合は関係ない。
S3
S3との連携
特にここでは文字データをS3に保存することでserverの再起動におけるデータの消失を防ぐことを目的とする。
https://devcenter.heroku.com/articles/s3-upload-python#direct-uploading
herokuとawsの設定。
https://boto3.amazonaws.com/v1/documentation/api/latest/guide/quickstart.html
boto3のinstallとsetupについて。~/.aws/credentialsと/.aws/configだけは必要。
https://stackoverflow.com/questions/33388555/unable-to-install-boto3
boto3のinstallにはpipの代わりにpip3を使う必要があることも。
https://www.sejuku.net/blog/41293
json形式の取り扱い。boto3はjsonと親和性が高い。
https://www.yoheim.net/blog.php?q=20160608
dict型から要素を取り出す際にはgetを使うと例外処理もできてよい。
https://dev.classmethod.jp/cloud/aws/upload-json-directry-to-s3-with-python-boto3/
実際にuploadする。ここではjsonデータをファイルとして保存せず、直接s3へアップロードする手法を取る。
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#object
上記でも使われているObjectについて。s3のAPIなので、困ったらここを見る。Object.getやObject.putは最低限使うことに。
@bot.command(description='serverのみんなでmemoを共有できます。', pass_context=True)
async def notes(ctx: commands.Context, label: str, memo: str):
"「?notes secret ギルマスは実は高校生」とすれば、secretラベルで「ギルマスは実は高校生」を記録できます。スペースが区切りとみなされます"
json_key = "memo_" + ctx.message.author.server.id + ".json" # 読み出し
obj = s3.Object(bucket_name, json_key)
if obj.delete_marker == None:
memos = json.loads(obj.get()['Body'].read()) # s3からjson => dict
else:
memos = {}
memos[label] = memo # 追加
obj.put(Body=json.dumps(memos))
await bot.say("覚えました!!")
tmpとs3の併用
s3と直接ファイルをやり取りすることに成功したものの、外部とファイルをやり取りしているために微妙に遅い。応答が遅れるためか、一部では存在するはずのデータがないものとして扱われることも……。実用に耐えるか微妙だったため、方針を変更。通常時は今まで通りtmpにpickle形式で変数を保存して利用。bot起動時にs3からtmpにdownload、定期的にtmpからs3にuploadすることでデータを安全に保持する。これにより従来のスピードを保ったまま、botの再起動を差し支えなく行えることとなった。
https://qiita.com/ikai/items/38c52e0c459792a4da46
list_objectによりs3内のファイルなどの一覧を得ることができる。結果はdict型で帰ってくるので注意。(様々なデータを包含している。)
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.list_objects
list_objectsについて。Client関連では役に立つものも多い。
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Object.upload_file
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Object.download_file
upload_fileとdownload_fileについて。
import discord
from discord.ext import commands
import os
import glob
import boto3
bucket_name = "XXXX"
s3 = boto3.resource('s3')
# s3連携
error_count = 0
# async外で保存するためにGlobal変数を用いる
def func_tmp_up():
"tmpフォルダ内のfileをs3に避難させます(upload)。"
for file_name in glob.glob("/tmp/*.*"):
# await bot.say(file_name)
# "/tmp/"のままではs3においては""(空欄)ディレクトリ内のtmpディレクトリにアクセスしてしまう
s3.Object(bucket_name, file_name[1:]).upload_file(file_name)
# await bot.say("Upload's Finished")
def func_tmp_dl():
"s3からtmpフォルダにfileを復帰させます。(download)"
client = boto3.client('s3')
# "/tmp/"のままではs3においては""(空欄)ディレクトリ内のtmpディレクトリにアクセスしてしまう
response = client.list_objects(Bucket=bucket_name, Prefix="tmp/")
file_list = [content['Key'] for content in response['Contents']]
for file_name in file_list:
# await bot.say(file_name)
s3.Object(bucket_name, file_name).download_file("/"+file_name)
# await bot.say("Download's Finished")
@bot.event # server加入時の処理
async def on_ready():
print('Logged in as')
print(bot.user.name)
print(bot.user.id)
print('------')
func_tmp_dl() # まずdl
@bot.event # error時に定期的にupload
async def on_command_error(exception: Exception, ctx: commands.Context):
global error_count
channel = bot.get_channel("XXXX")
error_count += 1
if error_count % 10 == 1 and ctx.message.author.id == "XXX": # 毎回はさすがに多い。他の人のerrorは無視
func_tmp_up()
await bot.send_message(channel, "up") # 一人serverに報告
また、"/tmp/a.pkl"がheroku内においては~/tmp/a.pkl
を表すのに対し、s3においては~//tmp/a.pkl
(""というディレクトリが存在する)となることに注意。
実際のbotの作成(続)
日本語でのdiscord botに関するページは一見多いが、そのほとんどの内容はたかだかbot導入くらいまでであり、botのまともな内容に言及しているものは少ない。(Bot command frameworkにすら入っていない時点でお察しである。)
ならば英語を読めばいいかというとそうでもない。discord.pyのAPI Referenceを見ればかなり助けになるのは確かだが、それでも実際の使い方については分からないことも多い。英語版の質問サイトの回答を修正して初めてうまくいったことも多い。情報が錯綜しているのだ。
sub command
cog