[fondesk用bot]SlashCommandで表記ゆれに抗う

# はじめに
皆さんリモートワークしていますか？慣れてきましたか？
[前回の記事](https://qiita.com/myonoym/items/0541c0e74e0006a71870)で弊社が[fondesk](https://www.fondesk.jp/)を導入し、メンションをつけてくれるbotを開発しましたよというお話をしました。(無事にfondeskは正式採用されたようです。)

今回は、メンションをつけるために必要になる、表記ゆれ対策用の辞書を更新するSlash Commandを開発した話です。

# 関連ツール&技術
今回紹介する機能の関連項目です。

- Slack
- AWS
  - API Gateway
  - Lambda
  - S3
- Serverless Framework
- Python3.7

# 事の発端
前回紹介のbotを導入してから、良い感じにメンションがついているのを喜んでいたのですが、（見ないふりをしていた）事態が勃発しました。そう、表記ゆれです。。

田が「た」なのか「だ」なのか、漢字で投稿されるのかカタカナで投稿されるのか等々…少しずれた表記で投稿されたことで飛ぶ`@here`メンション...この段階では、Lambdaの中に直に辞書を持っており、修正後にはLambdaをデプロイし直さなければいけませんでした。なので発見したら辞書に追加してデプロイ！を都度やっていました。。。

# つらい
何度かはfondesk用チャンネルで`@here`が出ると飛んでいき、辞書を修正して、デプロイ！とやっていたのですが、、、
思ったより、揺れてました。主だった人はすでに登録してたのですが、全員分やるのだるい…（あと`@here`が無闇に飛んでしまうのも心苦しい）ということで、辞書登録を各自に委ねることにしました。（自然言語処理と戦うという選択肢も有りましたが、今回は避けています。）

公式を見てみると「[なまえ辞書の使い方](https://help.fondesk.jp/ja/articles/2800124-%E3%81%AA%E3%81%BE%E3%81%88%E8%BE%9E%E6%9B%B8%E3%81%AE%E4%BD%BF%E3%81%84%E6%96%B9)」なるものがあり、これである程度は防げているようでした。（漢字で来る投稿は登録済みということなのかな？）


# 要件
1. slackから辞書の操作ができる。
1. 各ユーザーがそれぞれ自分の辞書の[登録, 確認, 削除]ができる。

これでやるなら、Slash Command + API Gateway + Lambdaですな、ということで実装します。

# 設定&実装
## 設定
### slack側
[前回作成したApps](https://qiita.com/myonoym/items/0541c0e74e0006a71870#slack%E5%81%B4)にcommandsを追加しておきます。
Features→Slash Commands→Create New Commandと進み、必要事項を埋めて保存します。
<img width="100%" alt="e1Jt1NlCYsc0IF61584523142_1584523165.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/575891/28ef8633-41e3-fb2a-796a-dc790cef14f6.png">

### AWS側
今回も[Serverless](https://serverless.com/)でズドンとやります。
ここで、timeoutを10秒に設定していますが、どのみちslack側は3000msしか待ってくれないので、あまり意味はありません。（将来的に3秒以上処理に時間がかかるようだと、Lambdaの非同期化を考えないといけないですね。）

```yml:serverless.yml
slash-command:
    handler: slashcommand.handler
    name: fondesker-slashcommand-${self:provider.stage}
    timeout: 10
    events:
      - http:
          path: fondesker/slashcommand
          method: post
          integration: lambda
          request:
            passThrough: WHEN_NO_TEMPLATES
            template:
              application/octet-stream:
                '{"headers":{
                  #foreach($key in $input.params().header.keySet())
                  "$key": "$input.params().header.get($key)"#if($foreach.hasNext),#end
                  #end
                  },
                  "body": "$util.base64Encode($input.json(''$''))"
                  }'
              application/x-www-form-urlencoded:
                '{"body": $input.json(''$'')}'
          response:
            headers:
              Content-Type: "'application/json'"
            template: $input.path('$')
            statusCodes:
                200:
                   pattern: ''
                401:
                    pattern: '.*"statusCode": 401,.*'
                    template: $input.path("$.errorMessage")
                    headers:
                      Content-Type: "'application/json'"
    environment:
      FONDESK_BOT_ID: ${env:FONDESK_BOT_ID}
      SLACK_TOKEN: ${self:custom.token.${self:provider.stage}}
      S3BUCKET: ${self:custom.S3Bucket.name}
      S3KEY: ${self:custom.S3Bucket.key}
```
deploy成功後に表示されるAPI Gatewayのエンドポイントを控えておき、Slack側に記入しておきます。


## 実装
Slash Command側からは`application/x-www-form-urlencoded`形式でリクエストが飛んできます。
parse_qsでDict形式に変換しておきます。

```python:slashcommand.py
import os
import json
import re
from typing import List, Dict, Any
from urllib.parse import parse_qs

import requests

from utils.Commands import Commands


HELP = """
            このスラッシュコマンドは次の3つのコマンドが実行できます。\n
        1. /fondesker list\n
        2. /fondesker add <name> <NAME> (スペース区切りで複数指定可)\n
        3. /fondesker remove <name> <NAME> (スペース区切りで複数指定可)
"""


def handler(event: Dict[str, str], context: Any) -> str:
    body = event["body"]
    params = parse_qs(body)

    user_id = params["user_id"][0]

    # 引数がなかった場合
    if params.get("text") is None:
        return HELP

    args = re.split(r"\s", params["text"][0])

    commands = Commands(bucket=os.environ["S3BUCKET"], key=os.environ["S3KEY"])

    if args[0] == "add":
        res = commands.add_list(user_id, args[1:])
    elif args[0] == "list":
        res = commands.get_list(user_id)
    elif args[0] == "remove":
        res = commands.remove_list(user_id, args[1:])
    else:
        res = HELP

    if type(res) is list:
        res = list2str(res)

    return res


def list2str(res: List[str]) -> str:
    if len(res) == 0:
        response_sentence = "現在登録されていません。"
    else:
        response_sentence = "現在名簿に登録されている名前は、\n"
        response_sentence += " ".join(res)
        response_sentence += "\nです。"

    return response_sentence

```
textキー以下に、/commandの後に打った文字列が格納されています。
今回のケースだと
1. add
2. list
3. remove
を待ち構えておき、実行する処理を切り替えていきます。

実行する処理側の実装は↓のような感じです。基本的にはS3から読み込んで、JSONファイルの中身を変更し、その後S3にアップロードするという流れです。

```python:Commands.py
import os
import json
from io import BytesIO
from typing import List
from dataclasses import dataclass
from utils.s3_bucket import S3Bucket


@dataclass
class Commands:
    """
    Slash Commandで実行したい処理を書く。
    名簿はS3に格納してある想定なので、随時S3へのアクセス権限は付与しておく。
    params:
        bucket: 名簿が保存されているバケット名
        key: 名簿へのS3 Key
        region: S3バケットのリージョン（default: ap-northeast-1）
    """
    bucket: str
    key: str
    region: str = "ap-northeast-1"
    def __post_init__(self,):
        self.s3_bucket = S3Bucket(self.region, self.bucket)

    def _get_name_list(self,):
        return json.load(self.s3_bucket.get_object_body(self.key))

    def get_list(self, user_id: str) -> List[str]:
        name_list = self._get_name_list()

        return name_list.get(user_id, [])

    def add_list(self, user_id: str, names: List[str]) -> List[str]:
        name_list = self._get_name_list()

        if user_id in name_list.keys():
            name_list[user_id].extend(names)
            name_list[user_id] = list(set(name_list[user_id]))
        else:
            name_list[user_id] = names

        self.s3_bucket.put_object(key=self.key, body=json.dumps(name_list), content_type="text/json")

        return name_list[user_id]

    def remove_list(self, user_id: str, names: List[str]) -> List[str]:
        name_list = self._get_name_list()

        if user_id in name_list.keys():
            name_list[user_id] = [synonym for synonym in name_list[user_id] if synonym not in names]
            self.s3_bucket.put_object(key=self.key, body=json.dumps(name_list), content_type="text/json")

        return name_list.get(user_id, [])
```

# 結果
### List
<img width="1104" alt="スクリーンショット 2020-03-18 18.09.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/575891/829ceb9b-0044-cd88-4831-f249f5cb4d6a.png">
<img width="232" alt="スクリーンショット 2020-03-18 18.09.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/575891/27bfaddc-05b0-7a31-f560-068e5d6701bc.png">
### Add
<img width="1103" alt="スクリーンショット 2020-03-18 18.10.08.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/575891/dfbabd62-7f5d-266e-7430-115e0edbd378.png">
<img width="306" alt="スクリーンショット 2020-03-18 18.10.19.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/575891/cbacf132-2456-4662-eca6-35f6c59f1447.png">
### Remove
<img width="1100" alt="スクリーンショット 2020-03-18 18.10.45.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/575891/99979e69-6765-9909-82bc-d4ac5f33d86b.png">
<img width="291" alt="スクリーンショット 2020-03-18 18.10.53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/575891/c73322e3-e3fc-d193-4491-1efb79d3b222.png">

# まとめ
今回はSlash Commandを使って、fondeskメンション用の辞書の更新ができるようにしました。これで一人で表記ゆれと格闘する日々は終わり、作業が各自に委ねられることで安心してfondeskチャンネルを眺められるようになりました。めでたし、めでたし。