Featured image of post 第1回:まずはSlackメッセージを受け取ろう【PerlでSlackボット指令センターを作る】

第1回:まずはSlackメッセージを受け取ろう【PerlでSlackボット指令センターを作る】

こんにちは、技術のスリリングな部分に触れるのが大好きなnobuです。

今回から新しい連載「PerlでSlackボット指令センターを作る」(全9回)が始まります! このシリーズでは、Slack(またはDiscordなどのチャットツール)から、デプロイやサーバー再起動、メトリクス確認といったDevOpsタスクを一元管理する「指令センター」を構築していきます。

単なる「便利なスクリプト」で終わらせません。Mediator、Command、Observerという3つのデザインパターンを駆使して、マイクロサービス顔負けのイベント駆動アーキテクチャをPerlとMoo実装します。

第1回は、すべての始まりとなる「Slackからのメッセージ受信」です。

Webhook受信

なぜ「指令センター」なのか?

DevOpsの現場では、タスクが散らばりがちです。

  • デプロイはGitHub Actionsの画面で
  • ログ確認はSSHしてtail -f
  • アラート対応はDatadogで
  • データベースの簡易調査は踏み台サーバーで…

これらをSlackという一つのインターフェースに集約するのがChatOpsの考え方です。「指令センター」ボットがいれば、スマホからでもコマンド一つでこれらの操作が可能になります。

でも、機能が増えるにつれてボットのコードはスパゲッティになりがち。そこでデザインパターンの出番です。この連載で、「拡張に強く、変更に閉じている(Open/Closed Principle)」ボットの作り方をマスターしましょう。

ステップ1:Webhookを受け取るサーバー

Slackボットの方式には、WebSocket(Socket Mode)とWebhook(Event Subscriptions)の2つがありますが、今回はサーバーレス関数などへのデプロイも視野に入れやすいWebhook方式を採用します。

PerlでWebアプリといえばPSGI/Plackですね。まずは極限までシンプルに、SlackからのPOSTリクエストを受け取って内容をログに出すだけのサーバーを書いてみます。

app.psgi という名前で保存してください。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
use strict;
use warnings;
use Plack::Request;
use JSON::PP;
use Data::Dumper;

my $app = sub {
    my $env = shift;
    my $req = Plack::Request->new($env);

    if ($req->method eq 'POST') {
        # JSONボディをデコード
        my $json = $req->content;
        my $payload = decode_json($json);
        
        # SlackのURL検証用チャレンジレスポンス
        if ($payload->{type} && $payload->{type} eq 'url_verification') {
            return [
                200, 
                ['Content-Type' => 'text/plain'], 
                [ $payload->{challenge} ]
            ];
        }

        # メッセージ受信ログ
        warn Dumper($payload);

        return [200, [], ['OK']];
    }

    return [404, [], ['Not Found']];
};

これを plackup app.psgi で起動し、ngrokなどで外部公開したURLをSlack App設定画面の「Event Subscriptions」に登録します。

ステップ2:メッセージに返信する

受け取るだけでは会話になりません。受け取ったメッセージに対して、Slack APIを使って返信してみましょう。Perl標準ライブラリ(v5.14以降)である HTTP::Tiny を使います。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
use HTTP::Tiny;

# ... (前略)

        # ユーザーからのメッセージイベントのみ処理
        if ($payload->{event} && $payload->{event}->{type} eq 'message' && !$payload->{event}->{bot_id}) {
            my $channel = $payload->{event}->{channel};
            my $text    = $payload->{event}->{text};

            # オウム返し
            send_slack_message($channel, "受け取りました: $text");
        }

# ... (中略)

sub send_slack_message {
    my ($channel, $text) = @_;
    my $token = $ENV{SLACK_BOT_TOKEN};
    
    my $ua = HTTP::Tiny->new;
    my $res = $ua->post(
        'https://slack.com/api/chat.postMessage',
        {
            headers => { 
                'Authorization' => "Bearer $token",
                'Content-Type'  => 'application/json; charset=utf-8',
            },
            content => encode_json({
                channel => $channel,
                text    => $text,
            }),
        }
    );
}

これで「オウム返しボット」の完成です。ここまではよくある入門記事と同じですね。

ステップ3:セキュリティを確保する(署名検証)

Webhookはインターネットに公開されるため、誰でもリクエストを送れてしまいます。送信元が本当にSlackであることを証明するために、署名検証 (Signature Verification) が必須です。

Slackは X-Slack-Signature ヘッダーを使ってリクエストの正当性を保証しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
use Digest::SHA qw(hmac_sha256_hex);

sub verify_signature {
    my ($req, $secret) = @_;
    
    my $signature = $req->header('X-Slack-Signature');
    my $timestamp = $req->header('X-Slack-Request-Timestamp');
    my $body      = $req->content;

    # リプレイ攻撃防止(5分以上前のリクエストは拒否)
    if (abs(time - $timestamp) > 300) {
        return 0;
    }

    my $basestring = "v0:$timestamp:$body";
    my $my_signature = 'v0=' . hmac_sha256_hex($basestring, $secret);

    return $signature eq $my_signature; # 厳密にはタイミング攻撃対策で定数時間比較すべき
}

この検証ロジックをリクエスト処理の冒頭に入れることで、なりすましを防ぐことができます。

次回予告

これで入り口と出口は確保できました。しかし、今のままでは「特定のコマンドが来たら処理をする」という分岐を if 文で書くしかありません。コマンドが10個、20個と増えたら…想像するだけで恐ろしいコードになりそうですね。

次回は、受け取ったメッセージを解析して「コマンド」として認識させる部分を作ります。まだデザインパターンは登場しませんが、「なぜパターンが必要になるのか」という混沌への第一歩を踏み出します。

お楽しみに!

今回の完成コード

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# app.psgi
use strict;
use warnings;
use Plack::Request;
use JSON::PP;
use HTTP::Tiny;
use Data::Dumper;
use Digest::SHA qw(hmac_sha256_hex);

my $SLACK_BOT_TOKEN = $ENV{SLACK_BOT_TOKEN} // 'xoxb-dummy-token';
my $SLACK_SIGNING_SECRET = $ENV{SLACK_SIGNING_SECRET} // 'dummy-secret';

sub verify_signature {
    my ($req, $secret) = @_;
    my $signature = $req->header('X-Slack-Signature') || '';
    my $timestamp = $req->header('X-Slack-Request-Timestamp') || 0;
    my $body      = $req->content;

    if (abs(time - $timestamp) > 300) {
        return 0;
    }

    my $basestring = "v0:$timestamp:$body";
    my $my_signature = 'v0=' . hmac_sha256_hex($basestring, $secret);

    return $signature eq $my_signature;
}

sub send_slack_message {
    my ($channel, $text) = @_;
    my $ua = HTTP::Tiny->new;
    $ua->post(
        'https://slack.com/api/chat.postMessage',
        {
            headers => { 
                'Authorization' => "Bearer $SLACK_BOT_TOKEN",
                'Content-Type'  => 'application/json; charset=utf-8',
            },
            content => encode_json({
                channel => $channel,
                text    => $text,
            }),
        }
    );
}

my $app = sub {
    my $env = shift;
    my $req = Plack::Request->new($env);

    if ($req->method eq 'POST') {
        # 署名検証(本番では有効にする)
        # return [401, [], ['Unauthorized']] unless verify_signature($req, $SLACK_SIGNING_SECRET);

        my $json = $req->content;
        my $payload = eval { decode_json($json) };
        return [400, [], ['Bad Request']] unless $payload;

        # URL検証
        if ($payload->{type} && $payload->{type} eq 'url_verification') {
            return [200, ['Content-Type' => 'text/plain'], [ $payload->{challenge} ]];
        }

        # メッセージイベント処理
        if ($payload->{event} && $payload->{event}->{type} eq 'message' && !$payload->{event}->{bot_id}) {
            my $channel = $payload->{event}->{channel};
            my $text    = $payload->{event}->{text};
            send_slack_message($channel, "受け取りました: $text");
        }

        return [200, [], ['OK']];
    }

    return [404, [], ['Not Found']];
};
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。