slackの絵文字登録を楽にするslash-command実装

# はじめに

slackの絵文字登録、面倒ですよね。
画像幅やサイズの制限もあり、毎回ローカルでリサイズしてWebフォームからアップロードしていました。

現状、slackのカスタム絵文字登録はAPIが存在しませんが、
ブラウザcookieをそのまま利用するアップロードスクリプトが公開されていました。

https://github.com/slackhq/slack-api-docs/issues/28
https://github.com/smashwilson/slack-emojinator

せっかくなのでSlash Command化して、誰でも叩けるようにしてしまいましょう。

# 構成

```bash
    (Slack) #Slash Command
       ↓
     POST
       ↓
 (API Gateway)
       ↓
RequestResponse
       ↓
   (Lambda1) #コマンド名のチェックと分岐のみ
       ↓
     Event
       ↓
   (Lambda2) #画像のGET, リサイズ, 絵文字アップロード
       ↓
     POST
       ↓
    (Slack) #結果の通知
```

* SlackAppsのSlash Commandを使います
* AWSのAPI Gatewayを介して、コマンド分岐用のLambda1を呼び出します
* 絵文字アップロード用のLambda2をEventモードで呼び出します

ポイントはAPI-Gatewayから起動したLambda1から別の絵文字アップロード用のLambda2を呼び出しているところです。
Slash Commandには、レスポンスタイム3000ms以内という制限があり、
画像をGETしてリサイズして絵文字アップロードする処理はその制限を超えてしまいます。

Lambda1で最小限のコマンド名のチェックをした後、
呼び出しモードをEvent(非同期)にして絵文字アップロードのLambda2を呼び出します。

# 実装

## Lambda1

```python
# -*- coding: utf-8 -*-

import urlparse
import boto3
import json

def lambda_handler(event, context):
    parameters = parse_parameters(event["body"])
    payload = command(parameters)
    return { "statusCode": 200, "body": json.dumps(payload) }

def parse_parameters(token):
    parsed = urlparse.parse_qs(token)
    args = parsed["text"][0].split(" ")
    return {
        "user_id": parsed["user_id"][0],
        "channel_id": parsed["channel_id"][0],
        "image_url": args[0],
        "emoji_name": args[1],
        "response_url": parsed["response_url"][0],
        "team_id": parsed["team_id"][0],
        "channel_name": parsed["channel_name"][0],
        "token": parsed["token"][0],
        "command": parsed["command"][0],
        "team_domain": parsed["team_domain"][0],
        "user_name": parsed["user_name"][0],
    }

def command(parameters):
    if parameters["command"] == "/emoji-san":
        response = boto3.client("lambda").invoke(
            FunctionName="emoji-san",
            InvocationType="Event",
            Payload=json.dumps(parameters)
        )
        return {
            "text": "Thanks %s! Your emoji is now uploading." % parameters["user_name"]
        }
    else:
        return {
            "text": "Not supported command: %s" % parameters["command"]
        }
```

ざっくり説明するとSlackからWebhookされたデータから、
``command``を取り出してboto3を使って別のLambdaを呼び出しています。
Eventモードで呼び出しているので、結果を待たずにレスポンスを返しています。

引数は``text``に空白区切りで入っているためそのまま取り出します。
引数チェックしてヒントなど返してあげるとさらに良いですね。

後はデプロイ(自分はlamveryを使っています)してAPI-Gatewayからトリガーします。
また、このLambdaは別のLambdaを呼び出す権限が必要ですので設定しましょう(``lambda:InvokeFunction``)。
この辺りは調べると記事が充実しているので割愛します。

## Lambda2

以下の.envファイルをスクリプトから読んでいます。
Cookieの取得方法は、[smashwilson/slack-emojinator](https://github.com/smashwilson/slack-emojinator)のREADMEに書かれています。

```bash
SLACK_TEAM=xxxxx
SLACK_COOKIE="..."
SLACK_API_TOKEN=xxxxxxxxxxxxx
```

```python
# -*- coding: utf-8 -*-

import os
import json
import upload
import urlparse
import requests
import commands
import threading
from PIL import Image
from StringIO import StringIO
from os.path import join, dirname
from dotenv import load_dotenv
from bs4 import BeautifulSoup

URL = "https://{team_name}.slack.com/customize/emoji"

def lambda_handler(event, context):
    load_dotenv(join(dirname(__file__), ".env"))
    command_emojisan(event)

def command_emojisan(parameters):
    image = download_image(parameters["image_url"])
    image = resize_image(image)
    image.save("/tmp/temp.jpg", "JPEG")
    session = requests.session()
    session.headers = {"Cookie": os.environ["SLACK_COOKIE"]}
    session.url = URL.format(team_name=os.environ["SLACK_TEAM"])
    upload_emoji(session, parameters["emoji_name"], "/tmp/temp.jpg")
    notify_slack(parameters)

def download_image(url):
    response = requests.get(url)
    return Image.open(StringIO(response.content))

def resize_image(image):
    image.thumbnail((128, 128), Image.ANTIALIAS)
    return image

def upload_emoji(session, emoji_name, filename):
    # Fetch the form first, to generate a crumb.
    r = session.get(session.url)
    r.raise_for_status()
    soup = BeautifulSoup(r.text, "html.parser")
    crumb = soup.find("input", attrs={"name": "crumb"})["value"]

    data = {
        'add': 1,
        'crumb': crumb,
        'name': emoji_name,
        'mode': 'data',
    }
    files = {'img': open(filename, 'rb')}
    return session.post(session.url, data=data, files=files, allow_redirects=False)

def notify_slack(parameters):
    payload = {
        "text": "Successfully upload: [:%s:]" % parameters["emoji_name"]
    }
    requests.post(parameters["response_url"], data=json.dumps(payload))
```

こちらもざっくり説明すると、
引数で受け取った画像URLから画像を取得し、Pillowを利用してリサイズしています。
リサイズされた画像はupload_emoji関数を通ってSlackにアップロードされます。
アップロードされたら``response_url``に対して、完了の通知を行います。

※現状、絵文字のアップロードが成功でも失敗(例えば認証失敗や無効な絵文字名など)でも200が返ってくるため、簡単に失敗判定はできません。
その為、最後のslack通知に絵文字名を含んで、正しく表示される事を確認できるようにします。

後はこちらもデプロイしたら完了です。

# Slash Commandの設定

ほぼ割愛です。
SlackAppsからSlash Commandを追加します。
WebhookのURLにAPI-Gatewayで作られたURLを設定します。
コマンド名やアイコンなどはご自由に(当記事では``/emoji-san [Image URL] [Emoji Name]``)

# まとめとハマりどころ

後はslackで``/emoji-san https://example.com/image.jpg emoji1``等すると、絵文字が登録されていきます。

<img src="https://i.gyazo.com/ab12566cd3e1737b32036a4fd3790c5c.png" width=50%>

* いざ記事にすると手順が多かったのと、コードも直しつつ書いたのでそのまま動く保証はできません・・
* Slackのレスポンスタイム制限に気づかずに途中で構造を変える事になり時間がかかった  
  (複数Lambda以外に方法はないのでしょうか？)
* コマンド名での分岐をLambda1で行えるので、新しいコマンドを作りたい時にはこっちの方が便利かも
* Pillowは``pip install``した環境に依存してしまいます  
長くなるので省きますが、amazon-linuxのDockerコンテナを立ち上げてその中でインストールしたパッケージ一式をLambdaパッケージに含みました。  
CircleCIを通してデプロイするなど回避方法は色々あります。