Slackで「今の電車の運行情報」を自分だけに教えてくれるSlash commandsを作った

Slackで「今の電車の運行情報」を自分だけに教えてくれるSlash commandsを作りましたので、詳細な手順をまとめました。
2019.03.12

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

サーバーレス開発部の藤井元貴です。

以前、電車の運行情報を毎朝Slackに通知する仕組みを作りました。

電車の運行情報(遅延・運転見合・運休など)を毎朝Slackに通知してみた

このときは、毎朝8時に通知していましたが、

  • 昼から外出するタイミングの運行情報は?
  • 帰宅時の運行情報は?
  • 休日の運行情報は?

なども知りたくなりました。人間とは欲深いものです。

そこで今回は、「今の運行情報」を自分だけに教えてくれるSlash Commandsを作成しました!!

おすすめの方

  • 今の電車の遅延情報を知りたい
  • AWS SAMで複数のLambda関数を定義したい
  • Slackでアプリを作りたい
  • SlackでSlach commandsを作りたい
  • サーバーレスに興味がある

電車の運行情報

電車の運行情報については、下記のサイトを使用させていただきました。 使用にあたっては、下記サイトの「お約束」をご確認ください。

全体概要

前回の内容も含んでいます。

SlackのSlash comamndsは、特定のURLに対して、「HTTP(POST)」でペイロードを送信します。 そのため、この情報を受け取るWebAPIを作成しています。

2つのLambda関数を作成しています。デプロイにAWS SAMを使用します。

Lambda関数 運行情報を見れる人 やること
定期通知用 チャンネルの全員 毎日、指定時刻に起動し、その時点の運行情報をSlackにPOSTする
Slash commands用 お願いした個人のみ 好きなタイミングで個人が起動し、その時点の運行情報を個人向けに返答する
  • 定期通知用のLambda
  • Slackのincoming webhookに対してメッセージをPOSTします
  • Slash commands用のLmabda
  • Lambdaの戻り値(=API Gatewayの応答)でメッセージを返却します
  • この処理は3000ms以内に実行する必要があります

環境

項目 バージョン
macOS High Sierra 10.13.6
AWS CLI aws-cli/1.16.89 Python/3.6.1 Darwin/17.7.0 botocore/1.12.79
AWS SAM CLI 0.10.0
Python 3.6

AWS側の作成

Lambda関数

2つのLambda関数で共通利用するため、下記の処理を切り出しました。

  • 通知対象の路線情報
  • 共通の処理

Lambda関数は2つデプロイしますが、2つとも同じフォルダ(ファイル)をデプロイし、実行されるhandlerを変えています。 (他の方法としては、イイカンジにフォルダ構成を考える、Lambda Layerを使う、が考えられます。)

ファイル構成

主要なファイルの構成は下記となります。

├── hello_world
│   ├── common_lambda.py
│   ├── periodic.py
│   ├── requirements.txt
│   ├── slash_command.py
│   └── target.json
└── template.yaml

通知対象の路線ファイル(JSON)

この内容は任意にカスタマイズしてください!

target.json

[
  {
    "name": "中央・総武各駅停車",
    "company": "JR東日本",
    "website": "https://traininfo.jreast.co.jp/train_info/kanto.aspx"
  },
  {
    "name": "東西線",
    "company": "東京メトロ",
    "website": "https://www.tokyometro.jp/unkou/history/touzai.html"
  }
]

通知対象の路線を変更する場合は、このJSONファイルを修正すればOKです。

定期通知用

periodic.py

import os
import json
import requests

from common_lambda import get_notify_delays, get_message

SLACK_WEBHOOK_URL = os.environ['SLACK_WEBHOOK_URL']


def lambda_handler(event, context) -> None:

    notify_delays = get_notify_delays()

    if not notify_delays:
        # 遅延が無ければ通知しない
        return

    # Slack用のメッセージを作成して投げる
    (title, detail) = get_message(notify_delays)
    post_slack(title, detail)

    return


def post_slack(title, detail) -> None:
    """SlackにPostする

    Args:
        title: メッセージのタイトル
        detail: メッセージの詳細(遅延情報)

    Returns:

    """
    # https://api.slack.com/incoming-webhooks
    # https://api.slack.com/docs/message-formatting
    # https://api.slack.com/docs/messages/builder
    payload = {
        'attachments': [
            {
                'color': '#36a64f',
                'pretext': title,
                'text': detail
            }
        ]
    }

    # http://requests-docs-ja.readthedocs.io/en/latest/user/quickstart/
    try:
        response = requests.post(SLACK_WEBHOOK_URL, data=json.dumps(payload))
    except requests.exceptions.RequestException as e:
        print(e)
    else:
        print(response.status_code)

Slash command用

slash_command.py

import json

from urllib.parse import unquote
from common_lambda import get_notify_delays, get_message


def lambda_handler(event, context) -> dict:
    # 受信したパラメータを解析する
    request_param = parse_slash_commands(event['body'])
    print(json.dumps(request_param))

    if request_param['command'] != '/train':
        # 想定コマンドと異なるため何もしない
        return {
            "statusCode": 200,
        }

    notify_delays = get_notify_delays()

    # Slack用のメッセージを作成して返却する
    (title, detail) = get_message(notify_delays)

    # https://api.slack.com/incoming-webhooks
    # https://api.slack.com/docs/message-formatting
    # https://api.slack.com/docs/messages/builder
    # https://api.slack.com/slash-commands
    payload = {
        'response_type': 'ephemeral',    # コマンドを起動したユーザのみに返答する
        'attachments': [
            {
                'color': '#36a64f',
                'pretext': title,
                'text': detail
            }
        ]
    }

    return {
        "statusCode": 200,
        "body": json.dumps(payload)
    }


def parse_slash_commands(payload) -> dict:
    """Slash commandsのパラメータを解析する

    Args:
        payload: 受信したSlash commandsのパラメータ

    Returns:
        dict: 解析したパラメータとその内容
    """
    params = {}
    key_value_list = unquote(payload).split("&")
    for item in key_value_list:
        (key, value) = item.split("=")
        params[key] = value
    return params

共通処理用

common_lambda.py

import json
import requests

JSON_ADDR = 'https://rti-giken.jp/fhc/api/train_tetsudo/delay.json'


def get_notify_delays() -> list:
    """通知すべき遅延情報を取得する

    Returns:
        list: 通知すべき遅延情報
    """

    current_delays = get_current_delays()

    target_list = get_target_list()

    notify_delays = []

    for delay_item in current_delays:
        for check_item in target_list:
            if delay_item['name'] == check_item['name'] and delay_item['company'] == check_item['company']:
                notify_delays.append(check_item)

    return notify_delays


def get_target_list() -> list:
    """判定対象の路線情報を取得する

    Returns:
        list: 判定対象の路線情報
    """
    with open('target.json') as f:
        return json.load(f)


def get_current_delays() -> list:
    """
    現在の遅延情報を外部サイトから取得する
    Returns:
        list: 現在の遅延情報
    """
    try:
        res = requests.get(JSON_ADDR)
    except requests.RequestException as e:
        print(e)
        raise e

    if res.status_code == 200:
        return json.loads(res.text)
    return []


def get_message(delays) -> tuple:
    """Slackに通知するメッセージを作成する

    Args:
        delays: 通知すべき遅延情報

    Returns:
        str: メッセージのタイトル
        str: メッセージの詳細(遅延情報)
    """
    if not delays:
        return "電車の遅延はありません。", ""

    title = "電車の遅延があります。"

    details = []

    for item in delays:
        company = item['company']
        name = item['name']
        website = item['website']
        details.append(f'・{company}: {name}: <{website}|こちら>')

    return title, '\n'.join(details)

デプロイまで実行する

S3バケットの作成

コード等を格納するためのS3バケットを作成します。作成済みの場合は飛ばします。

aws s3 mb s3://cm-fujii.genki-deploy

ビルド

下記コマンドでビルドします。

sam build

package

続いてコード一式をS3バケットにアップロードします。

sam package \
    --output-template-file packaged.yaml \
    --s3-bucket cm-fujii.genki-deploy

deploy

最後にデプロイします。template.yamlの環境変数をオーバーライドし、ここでSlackのWebhook URLを設定します。 (詳細は前回を参照してください)

sam deploy \
    --template-file packaged.yaml \
    --stack-name NotifyTrainDelayToSlack \
    --capabilities CAPABILITY_IAM \
    --parameter-overrides SlackWebhookUrl=https://hooks.slack.com/services/xxxxxxxxxxxxx

WebAPIの確認

WebAPIのURLを確認します。

$ aws cloudformation describe-stacks --stack-name NotifyTrainDelayToSlack --query 'Stacks[].Outputs'
[
    [
        {
            "OutputKey": "SlashCommandApi",
            "OutputValue": "https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/train/notification",
            "Description": "Slash command API"
        }
    ]
]

このURL(OutputValue)は、SlackのSlash commandsの設定で使用します。 (Slash commandsがこのURLを叩くようにする)

Slackの準備

アプリの作成

Slackのアプリを作成します。

作成画面

任意のチャンネルを開き、「アプリを追加する」を選択します。

ブラウザが開くので、右上の「ビルド」を選択します。

左側にある「Building Slack apps」を選択します。

少し下側にある「Create a Slack app」を選択します。

もしくは、下記にアクセスすればOKです。

  • <https://api.slack.com/apps>

新規作成

表示された画面の「Create New App」を選択します。

「App Name」を入力し、「Development Slack Workspace」を選択し、「Create App」を選択します。

これでアプリの作成まで完了しました。

アプリの設定

Slash Commandsの設定を行い、アプリをワークスペースにインストールします。

Slash Commands

「Slash Commands」を選択し、設定を行います。

「Create New Command」を選択します。

Commandに「/train」と入力し、Request URLに「さきほど確認したAPI GatewayのURL」を入力し、Short Descriptionに「簡単な説明」を入力します。

入力後は「Save」を選択します。

Basic Information

「Basic Information」を選択し、作成したアプリをワークスペースにインストールします。

「Install your app to your workspace」の「Install App to Workspace」を選択します。

「許可する」を選択します。

これで完了です!

使ってみる

Slackで/trainと入力します。

遅延があるとき

遅延がないとき

さいごに

EC2などのサーバーを立てなくても、簡単に実行できる環境が作れました。

サーバーレスは楽しいですね!!

参考