AtCoder精進用discord botを作った話

##はじめに
みなさん、精進する際にどの問題を解くか迷ったことはありませんか？
自分はまだ解いていない問題を見てしまうと、あれもこれも解かねば...と考え、無駄に労力を使ってしまうことがあります。そんな時、今から解く1問を無作為に決めてくれるbotがあれば、解く問題を決める時間を省けるのではないかと思い、botの作成に取り組むことにしました。
本記事では、作成したbotの仕様と開発した際の流れについて、まとめるものとします。
なお、執筆者は開発経験が浅く、また記事の初投稿でもありますので、拙い箇所がある可能性がありますが、その点ご了承いただければと思います。

## 目次
1. botの仕様
2. 開発環境
3. 開発の流れ

##1. botの仕様
今回作成したものは「AtCoderの問題を1問無作為に返すdiscord bot」です。
仕様は以下のように設定しました。

* `@` でメンションすると、問題を1問選び、そのURLを返す
* 返す問題はABC,ARC,AGCの中から選ばれる
* 引数で問題の難易度を指定できる
    * 数値指定(下限, 上限の順 / 指定しない時はdef)
        * 例. `@bot 300 800`
    * 色指定(灰, 茶, 緑, 水,... / GRY, BWN, GRN, AQU, ... などの色の3文字略称も可)
        * 例. `@bot 茶`
    * 指定しない場合は全範囲から選ばれる 

Discord上で動かすと以下のようになります。
<img width="500" alt="bot-example.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/885966/d943ef4a-bdc4-0b4e-3273-d68143e898b4.png">

##2. 開発環境
Ubuntu 18.04 (VirtualBox, ホストOSはWindows10)
Python 3.7.5
discord.py-1.5.1

##3. 開発の流れ
開発は大まかに以下の流れで進めました。
1. discord botの設定
2. discord.pyのインストール
3. 各種機能の実装
4. herokuへのデプロイ

###1. discord botの設定
はじめにDiscord側でbotアカウントを作成しました。
流れは以下のページの手順に沿っています。
https://qiita.com/1ntegrale9/items/cb285053f2fa5d0cccdf

簡単にまとめると、

* [DEVELOPER PORTAL](https://discord.com/developers/applications/)にログイン
* botアカウントを作成
* botの初期設定
* アクセストークンの取得

のような流れで進めました。

###2. discord.pyのインストール
ここからはPython側でbotの中身を実装していくことになります。
以降の流れは以下のページに沿って行いました。
https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f

discord.pyのインストールは以下のように行いました。

```console:discord.pyのインストール
$ python3 -m pip install -U "discord.py[voice]"
```
そして、原形となるコードを作成し、discordbot.pyという名前で保存しました。

```Python:discordbot.py
import discord

TOKEN = '作成したBotのアクセストークン'

client = discord.Client()

# 起動時に動作する処理
@client.event
async def on_ready():
    print('login')

# メッセージ受信時に動作する処理
@client.event
async def on_message(message):
    # メッセージ送信者がBotだった場合は無視する
    if message.author.bot:
        return
    # 「/neko」と発言したら「にゃーん」が返る処理
    if message.content == '/neko':
        await message.channel.send('にゃーん')

# Botの起動とDiscordサーバーへの接続
client.run(TOKEN)
```
Botの起動確認は以下のようにして行います。

```console:Botの起動確認
$ python3 discordbot.py
```

###3. 各種機能の実装

* 問題選定

問題選定は、コンテストの種類(ABC, ARC, AGC)の決定、コンテスト番号(第何回か)の決定、問題番号(A～F問題)の決定、URLの生成の順に処理を行い、選定しています。
それぞれ対応する関数は以下のように作成しました。

```python:コンテストの種類決定
def get_contest_kind():
  n = random.randint(1,10000)
  div = 6
  brg = n%div
  log(brg)

  contest_kind = 'a'
  if brg == 0:
    contest_kind += 'g'
  elif brg == 1 or brg == 2 :
    contest_kind += 'r'
  else:
    contest_kind += 'b'
  contest_kind += 'c'
  return contest_kind
```

```python:コンテスト番号の決定
def get_contest_number(kind):
  contest_num = 0
  if kind == 'abc':
    contest_num = random.randint(1,contest_max_abc)
  elif kind == 'arc':
    contest_num = random.randint(1,contest_max_arc)
  else:
    contest_num = random.randint(1,contest_max_agc)
  contest_num_string = str(contest_num)
  contest_num_string = contest_num_string.zfill(3)
  return contest_num_string
```

```python:問題番号(A～F問題)の決定
def get_problem_number():
  n = random.randint(0,5)
  problem_number = chr(ord('a')+n)
  return problem_number
```

```python:問題URLの生成
def get_url(c_str,p_str):
  url = 'https://atcoder.jp/contests/' + c_str + '/tasks/' + p_str
  return url
```

コンテストの種類やコンテスト番号、問題番号は乱数で選択するようにしています。
コンテストの種類は0から10000の間の乱数を生成し、その数を6で割った余りによって場合分けをして決定しています。ABC, ARC, AGCのコンテスト数の比はおおよそ5:3.5:1.5であったため、このようにしています(もっと良い方法はあると思います)。
問題URLの生成では、AtCoderのURLの命名規則に沿って文字列を連結させています。

最終的にはこれらの処理をまとめて、generate関数にて問題選定を行っています。

```python:generate関数
def generate(message):
  json_load = data

  print(message.content)
  args = message.content.split()
  
  lower = -10000
  upper = 10000

  if len(args) >= 2:
    if args[1] == 'help' or args[1] == 'ヘルプ':
      return help

    if args[1] != 'def':
      if args[1] == 'GRY' or args[1] == '灰':
        upper = 399
      elif args[1] == 'BRN' or args[1] == '茶':
        lower = 400
        upper = 799
      elif args[1] == 'GRN' or args[1] == '緑':
        lower = 800
        upper = 1199
      elif args[1] == 'AQU' or args[1] == '水':
        lower = 1200
        upper = 1599
      elif args[1] == 'BLU' or args[1] == '青':
        lower = 1600
        upper = 1999
      elif args[1] == 'YEL' or args[1] == '黃':
        lower = 2000
        upper = 2399
      elif args[1] == 'ORN' or args[1] == '橙':
        lower = 2400
        upper = 2799
      elif args[1] == 'RED' or args[1] == '赤':
        lower = 2800
        upper = 3199
      elif args[1] == 'SIL' or args[1] == '銀':
        lower = 3200
        upper = 3599
      elif args[1] == 'GLD' or args[1] == '金':
        lower = 3600
      else:
        if args[1].isdecimal():
          lower = int(args[1])
    if len(args) >= 3:
      if args[2] != 'def':
        if args[2].isdecimal():
          upper = int(args[2])

  if lower > upper:
    error('difficulty setting error')
    return

  log('lower limit:'+str(lower))
  log('upper limit:'+str(upper))

  s = ''
  sc = ''
  search_count = 0
  search_limit = 1000
  search_flag = False
  while search_count < search_limit:
    sc = get_contest_kind()
    sc += get_contest_number(sc)
    sn = get_problem_number()
    s = sc + '_' + sn
    if s in json_load:
      difficulty = json_load[s].get('difficulty','not found')
      if difficulty == 'not found':
        log('difficulty not found')
        continue
      if lower <= difficulty and difficulty <= upper:
        print(json_load[s]['difficulty'])
        break
    else:
      log('problem not found')
    search_count += 1
    if search_count == search_limit:
      search_flag = True

  log('')

  if search_flag:
    return ''

  url = get_url(sc,s)
  print(url)

  return url
```

generate関数ではおおよそ、メッセージの内容に応じて選ぶ問題難易度の上限・下限を設定し、その後条件を満たす問題を乱択で探すという動作をしています。問題を探す回数は上限を決めており、上限数だけ探しても問題が見つからない場合は、何も返さないという動作をします。

* 入力受付

botがメッセージを受け取った際の処理は以下の関数で対応するようにしています。

```python:メッセージ受信時の処理
# メッセージ受信時に動作する処理
@client.event
async def on_message(message):
    if client.user in message.mentions:
        await reply(message)
```

```python:reply関数
async def reply(message):
    url = generate(message)
    reply = url
    if reply != '':
      await message.channel.send(reply)
```

メッセージを受け取った際には、その内容を引数として自作のreply関数を呼び出しています。
reply関数内ではgenerate関数によってメッセージの内容に対応した文字列を生成し、その文字列が空文字列でなければチャンネルに送信するといった動作を設定しています。

* AtCoder Problems APIの取得

APIの取得は以下の自作関数により行いました。

```python:APIの取得関数
def get_atcoder_problems_api():
  global data
  time.sleep(1)
  resp = requests.get('https://kenkoooo.com/atcoder/resources/problem-models.json')
  json_load = resp.json()
  data = resp.json()
```
この関数では、HTTPのGETメソッドによりAPIを取得し、取得したjson形式のデータをPythonの辞書型としてグローバル変数dataに格納しています。

###4. herokuへのデプロイ
デプロイについても、上記のページに引き続き行いました。
該当箇所は以下の所からになります。
[Botを24時間365日稼働させる](https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f#bot%E3%82%9224%E6%99%82%E9%96%93365%E6%97%A5%E7%A8%BC%E5%83%8D%E3%81%95%E3%81%9B%E3%82%8B)

なお、Procfileは以下のように設定しています。

```sh:Procfile
worker: python3.7 discordbot.py
```

Procfileはサーバが実行するプロセス名と実行されるコマンドの対を記すファイルです。このBotでは起動時にAtCoder ProblemsのAPIを読み込むため、プロセス名をworkerとする必要があります。プロセス名をwebとしても動作しましたが、アプリが頻繁にクラッシュしてしまうため、プロセス名をworkerとしています。
参考：https://stackoverflow.com/questions/51984638/discord-app-error-r10-when-deploying-with-heroku

##終わりに
今回作成したbotは以下のリンクから招待することができます。
[Discord Bot: AtCoder Random Provider](https://discord.com/api/oauth2/authorize?client_id=783046640973512705&permissions=223232&scope=bot)
ご希望の方はぜひご利用ください。
また、詳細なソースコードはGitHubにありますので、興味のある方はご覧ください。
[GitHub: AtCoder Random Provider](https://github.com/Kalancs/AtCoder-Random-Provider)

このbotにはまだまだ改善の余地があると思われるため、今後も改良を進めていく予定です。何かご意見等のある方がいましたら、ご連絡ください。
本記事を最後までご精読いただき、ありがとうございました。

##参考文献
Discord上でのbot作成
https://note.com/exteoi/n/nf1c37cb26c41#s8rK8
https://qiita.com/1ntegrale9/items/cb285053f2fa5d0cccdf
各種機能の実装、デプロイ周り
https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f
AtCoder Problemsのドキュメント
https://github.com/kenkoooo/AtCoderProblems/blob/master/doc/api.md