ChatOpsのススメ

Slack の Chatbot を作ってみたいなぁ・・・と、なんとなく思いながらも、でも、会話ロボットがほしいわけでもないので、手を出してなかったのですが、 AWS Chatbot で ChatOps の一端に触れる機会があり、少し興味が出てきたので、どんなものなのかを調べてみることにしました。
うまくいけば、社内専用の Chatbot を育てながら、普段の仕事の中で ChatOps が実践できるとよいなと思ってます。
この記事では、サンプルを動かしたところまでを順を追ってご紹介します。

Chatbot のフレームワーク

普段一番使っているのが Slack なので、Slack の Bot を作ります。
できるだけ仕事で使える ChatOps にしたいので、最初から Chatbot のフレームワークを使って、先々の高機能化に耐えられるものに目指します。

そんなわけで、npm trends で比較検索してみました。

npm trends

Hubot というのを見聞きしたことがありましたが Bot Builder の Download 数が一番多いようです。
Bot Builder は Microsoft の OSS で、開発が止まってしまう心配もないので、これを使うことにします。

Bot Builder は、対応している Chat が 2020年11月時点で、21 種類もあって、SDKが4つの言語用(C# / JavaScript / Python / Java)に用意されています。
そして、サンプル実装が、大量にあります。

今回は、この中から Python SDK を使って Slack のサンプルを動かすことにします。

他にも、 Slack Bot の仕組みも、読んでおいた方がよいでしょう。
こちらの記事が、判りやすかったように思います。

Slack の API は、 Slack Adapter が吸収してくれるので、当面は、Slack API の仕様は不要かと思いますが、こちらで参照することができます。

上記の Slack AdapterのサンプルAzure Bot Service を使って Slack に接続してメッセージに応答するシンプルなエコーボットです。

でも仕事では、AWS を使うことが多いので、 Lambda にデプロイして Slack Bot をサーバーレスで運用 できるようにします。

ローカル環境を作成する

開発は、Dockerコンテナで行いますが、環境を作る前に、メッセージが、どんな感じで流れるのかを説明します。
サンプルの Bot は、Slack の Event API を使っています。

📝前提

  • コンテナ内の Bot は、Webサーバー機能が動いていて Slack の Event を受信する API が実装されてある
  • ngrok でパブリックな URL を取得して ローカルPC で動く Bot の Event 受信API にフォワードされる
  • Slack アプリの Event Subscriptions の Request URL に ngrok の URL を設定する
  • Slack アプリをチャンネルに追加する

📝メッセージの流れ

  • チャンネルでメッセージを投稿する
  • Slack が ngrok の URL にメッセージを POST する
  • ngrok がコンテナ内の Bot にメッセージをフォワードする
  • コンテナ内の Bot は POST されたメッセージを読んで、Slack に同じ文言でメッセージ送信する
  • チャンネルに投稿したものと同じメッセージがエコーされる

サンプルをコピーして作業開始

それでは、ローカルに環境を作成していきます。

# サンプルを clone
git clone https://github.com/microsoft/BotBuilder-Samples.git

# botディレクトリを作成して、Slack Adapterのサンプルだけコピーする
mkdir bot
cd bot
cp -r ../BotBuilder-Samples/samples/python/60.slack-adapter/* .

開発用の requirements.txt

開発時にのみ使うモジュールを requirements-dev.txt に追加します。

# ランタイムに必要なパッケージ
-r requirements.txt
# 開発環境にのみ必要なパッケージ
awscli
ptvsd
pylint

Dockerイメージ

Lambdaで実行するので、 Lambda の docker イメージを使います。
BotBuilder-python が Python 3.8 前提なので、Dockerイメージも Python3.8 にします。

https://github.com/microsoft/BotBuilder-python/#prerequisites

ngrok と、あとで使用する Severless Framework もインストールしておきます。

docker/Dockerfile

FROM lambci/lambda:build-python3.8

# python を lambda にデプロイするときには、 requirements.txt から依存モジュールのインストールをするが、
# slsでそれをするのは、plugin が必要で、その plugin が Docker を必要とする
COPY --from=docker:19.03 /usr/local/bin/docker /usr/local/bin/

# serverless framework
## serverless-python-requirements は最新の 5.1.0 では、
## deploy時にエラーが出るので、4.3.0 を使う
RUN curl -sL https://rpm.nodesource.com/setup_10.x | bash \
    && yum install -y nodejs \
    && npm install -g serverless \
    && npm install -g serverless-python-requirements@4.3.0

# ngrok
RUN curl -sSL -o ngrok.zip https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-amd64.zip \
    && unzip ngrok.zip \
    && mv ngrok /usr/local/bin

# このイメージは、ビルドの段階で、requirements-dev.txt を pip install します。
# `requirements*.txt ` が変更されたときには、ビルドし直してください。
COPY ./requirements*.txt ./
RUN pip install -r requirements-dev.txt

docker の起動オプションがいろいろあるので docker-compose にしておきます。

docker-compose.yml

version: '3'
services:

  bot:
    build:
      context: .
      dockerfile: docker/Dockerfile
    volumes:
      - '.:/opt/bot'
      # aws cofigure のディレクトリをマウント
      - '.aws:/opt/.aws'
      # serverless-python-requirements が Docker を使うので、
      # dood (Docker outside of Docker) でコンテナ内からホストのDockerを使う
      - /var/run/docker.sock:/var/run/docker.sock
    working_dir: '/opt/bot'
    environment:
      # aws cofigure の配置場所を設定
      AWS_CONFIG_FILE: /opt/.aws/config
      AWS_SHARED_CREDENTIALS_FILE: /opt/.aws/credentials
    ports:
      # ngrok 用
      - 3978:3978
      # ptvsd 用
      - 5678:5678
    command: 'sleep infinity'

ビルドとコンテナの起動

docker-compose build

# コンテナを起動
docker-compose up -d

Bot 用の Slack Application を作成する

  1. Slackにログインして、アプリ一覧 ページを開きます。

  2. 'Create new app' ボタンをクリック

  3. "App Name" を入力し "Development Slack Workspace" を選択

  4. "Create App" ボタンをクリック

サンプルを動かすために必要な設定は、 Basic InformationOAuth & Permissions にあります。

  1. Basic Information は、すでに設定されていますが、 "Display Information" のところを、任意に変更してください。

  2. OAuth & Permissions は、"Bot Token Scopes" で "Add an OAuth Scope" ボタンをクリックして chat:write, im:history, im:read を追加してください。

  3. 上の方にスクロールすると、"Install App to Workspace" がクリックできるようになっているので、クリックします。

  4. OAuth の権限の確認画面が表示されるので、"許可"をクリックします。

Slack Application の設定は、まだやることがあるのですが、いったん、次に進みます。

環境変数を設定する

環境変数に、Slack のアプリ情報を設定します。

# Basic Information の Verification Token
export SlackVerificationToken="xxxxxxxxxxx"

# Basic Information の Signing Secret
export SlackClientSigningSecret="xxxxxxxxxxx"

# OAuth & Permissions の Bot User OAuth Access Token
export SlackBotToken="xxxxxxxxxxx"

Ngrok でテスト用のURLを準備する

Ngrok を使ったことがない場合には、下記の記事を読んでおいてください。

Dockerイメージには、ngrok のコマンドはインストール済です。 特に、Sign in しなくても、使えますが、長時間のテストをする場合には、Sign In するとよいと思います。どっちでも、無料プランでテストできます。

botコンテナに入ります。
docker-compose exec bot bash

コンテナ内で ngrok を起動します。
ngrok http 3978 -host-header="127.0.0.1:3978"

テスト中は、起動したままにします。

Bot を起動

上記とは異なるターミナルから、コンテナ内に入ります。
docker-compose exec bot bash

botを起動します。
python app.py

この時点では、WEBサーバーが起動しただけなので、まだ何も動きはないです。

Slack Application の Event Subscriptions を有効にする

サンプルの Bot は、Slack の Event API を使っているので、そのための設定を行います。

Slack api dashboard から作成した Slack Application を選択します。

  1. Event Subscriptions を選択

  2. Enable Events を On にする

  3. 'Request URL' に ngrok で Forwarding される https で始まる URL を設定する
    ただし、ngrok の URL は、ホスト名だけなので、サンプルがルーティング実装している /api/messages をパスに加えます。
    (例)https://xxxxxxxxxx.ngrok.io/api/messages

  4. 'Subscribe to bot events' を展開して、'Add Bot User Event' ボタンをクリックして、message.im を追加する

動かしてみる(1)

Slack のチャンネルに作成したSlackアプリを招待します。
そして、以下を試してみてください。

  • チャンネルにメッセージを書き込む
    😄同じメッセージがエコーされる

  • 添付ファイルを付けてメッセージを書き込む
    😄メッセージのエコーと、添付ファイルを受信したことがエコーされる

  • メッセージにURLを含めて送信する
    😄メッセージのエコーと、URLを受信したことがエコーされる

動かしてみる(2)

/test コマンドもサンプルに実装されています。
Slack Application の設定にコマンドを追加します。

Slack Application にコマンドを追加設定

  1. アプリ一覧 からアプリを選択

  2. "Slash Commands" を選択

  3. "Create New Command" ボタンをクリック

  4. 以下の内容を入力

    • Command: /test
    • Request URL: https://xxxxxxxxxx.ngrok.io/api/slack
      (xxxxxxxxxx 部分は、ngrok の Forwarding のURLを参照)
    • Short Description: テスト
  5. ”Save” ボタンをクリック

コマンド実行

コマンドは、Slack の Workspace 全体で使われます。

  • 任意のチャンネルで /test を送信する
    😄処理されて、ディナーのレストラン選択のメッセージが返信されます。

Lambda 用の変更

さて、Ngrok で動くことを確認したら、次には、Lambda にデプロイして、サーバーレスで動かしてみます。

LambdaのeventオブジェクトからRequestオブジェクトへの変換

Bot Builder は、WEB APIで動くことが前提になっているようで、Slack Adapter とのインターフェースで aiohttp.web_request.Request が使われています。

Lambda の handler の引数の event オブジェクトとは、違うものなので、変換してから渡すことにします。

app.py を修正します。

import sys
import traceback
import logging
import json
import base64
import asyncio
from datetime import datetime
from urllib.parse import parse_qs
from aiohttp.web_request import Request
from aiohttp.web_response import Response
from aiohttp import test_utils
from aiohttp.streams import StreamReader
from aiohttp.test_utils import make_mocked_request
from unittest import mock

from botbuilder.adapters.slack import SlackAdapterOptions
from botbuilder.adapters.slack import SlackAdapter
from botbuilder.adapters.slack import SlackClient
from botbuilder.core import TurnContext
from botbuilder.core.integration import aiohttp_error_middleware
from botbuilder.schema import Activity, ActivityTypes

from bots import EchoBot
from config import DefaultConfig

CONFIG = DefaultConfig()

# Create adapter.
SLACK_OPTIONS = SlackAdapterOptions(
    CONFIG.SLACK_VERIFICATION_TOKEN,
    CONFIG.SLACK_BOT_TOKEN,
    CONFIG.SLACK_CLIENT_SIGNING_SECRET,
)
SLACK_CLIENT = SlackClient(SLACK_OPTIONS)
ADAPTER = SlackAdapter(SLACK_CLIENT)

_logger = logging.getLogger(__name__)

# Catch-all for errors.
async def on_error(context: TurnContext, error: Exception):
    _logger.error(f"\n [on_turn_error] unhandled error: {error}", exc_info=True)

ADAPTER.on_turn_error = on_error

# Create the Bot
echo_bot = EchoBot()


# Listen for incoming requests on /api/messages
async def messages(req: Request) -> Response:
    return await ADAPTER.process(req, echo_bot.on_turn)


# Listen for incoming slack events on /api/slack
async def slack(req: Request) -> Response:
    return await ADAPTER.process(req, echo_bot.on_turn)


def handler(event, context):
    if _logger.level <= logging.DEBUG:
        _logger.debug(f'event={event}')
    try:
        result = asyncio.get_event_loop().run_until_complete(__async_handler(event, context))
        return __respond(None, result)
    except Exception as e:
        _logger.error('エラー発生', exc_info=True)
        return __respond(e)


async def __async_handler(event, context):
    request = await __conv_lambda_event_to_request(event, context)
    response = await messages(request)
    return response


def __respond(err, res=None):
    if err:
        lamda_res = {
            'statusCode': '400',
            'body': type(err),
            'headers': {
                'Content-Type': 'text/plain'
            },
        }
    else:
        lamda_res = {
            'statusCode': res.status,
            'body': res.text,
            'headers': {},
        }
        for k, v in res.headers.items():
            lamda_res['headers'][k] = v
    if _logger.level <= logging.DEBUG:
        _logger.debug(f'__respond={lamda_res}')
    return lamda_res


async def __conv_lambda_event_to_request(event, context):
    ''' lambda の event から aiohttp の Request に変換する

    この変換処理は、 aiohttp のユニットテスト用のユーティリティ
    test_utils を使っています。
    '''

    body = event.get('body', '')
    if not event.get('isBase64Encoded', False):
        data = body.encode()
    else:
        data = base64.b64decode(body)

    protocol = mock.Mock(_reading_paused=False)
    payload = StreamReader(protocol=protocol, limit=2 ** 16, loop=asyncio.get_event_loop())
    payload.feed_data(data)
    payload.feed_eof()

    req = make_mocked_request(
        method=event['httpMethod'],
        path=event['path'],
        headers=event['headers'],
        payload=payload)
    return req

Serverless Framework

デプロイは、Serverless Framework で行います。

serverless.yml

service: bot
frameworkVersion: '2'

provider:
  name: aws
  runtime: python3.8
  stage: ${opt:stage, self:custom.defaultStage}
  region: ${opt:stage, self:custom.defaultRegion}
  # ログの保存期間
  logRetentionInDays: 5

  iamRoleStatements:
    - Effect: Allow
      Action:
        - "logs:CreateLogGroup"
        - "logs:CreateLogStream"
        - "logs:PutLogEvents"
      Resource:
        - "arn:aws:logs:::"
  environment: ${self:custom.environment.${self:provider.stage}}

plugins:
  - serverless-python-requirements

custom:
  pythonRequirements:
    layer: true
  defaultStage: dev
  defaultProfile: default
  defaultRegion: ap-northeast-1
  environment:
    dev: ${file(./conf/dev.yml)}
    prod: ${file(./conf/prod.yml)}

package:
  exclude:
    - .aws/**

functions:
  handler:
    handler: app.handler
    events:
      - http:
          path: api/messages
          method: post
          cors: true
      - http:
          path: api/slack
          method: post
          cors: true
    layers:
      - {Ref: PythonRequirementsLambdaLayer}

環境変数は yml にします。

conf/dev.yml

# Basic Information の Verification Token
SlackVerificationToken: "xxxxxxxxxxx"

# Basic Information の Signing Secret
SlackClientSigningSecret: "xxxxxxxxxxx"

# OAuth & Permissions の Bot User OAuth Access Token
SlackBotToken: "xxxxxxxxxxx"

デプロイ

AWSのアカウントは .aws/ に作成しておいてください。

sls deploy

Slack Application の URL を API Gateway のものに変える

deploy コマンドが、作成された API Gateway の URL を出力するので、それをコピーして、 Slack Application の設定画面で、URLを置き換えます。

Slack api dashboard から作成した Slack Application を選択します。

  1. Event Subscriptions を選択

  2. 'Request URL' に API Gateway の URL を設定する

動かしてみる

  • チャンネルにメッセージを書き込む
    😄同じメッセージがエコーされる

まとめ

Slack 起点で、いろんなことができそうな気がしてきました。

例えば

  • ちょっとしたデプロイのコマンド
  • メンテナンス画面への切替コマンド
  • 常時起動してない ec2 の起動
  • 社内 wiki の横断検索
  • GitHub への Issue 投稿コマンド
  • リアクションを集計して感謝ネットワークとして可視化
  • 社内図書(自炊PDF)の検索
  • チーム内の作業時間の回収と集計

・・・。

いろいろ試してみようと思います。

みなさんも、社内にお助け Chatbot を作ってみてはいかがでしょうか?

最後に、実際に chatbot を作ってみようと思ったら、こちらを読んでみることをオススメします。

最近の記事タグ

\(^▽^*) 私たちと一緒に働いてみませんか? (*^▽^)/

少しでも興味をお持ちいただけたら、お気軽に、お問い合わせください。

採用応募受付へ

(採用応募じゃなく、ただ、会ってみたいという方も、大歓迎です。)