こんにちは、技術のスリリングな部分に触れるのが大好きなnobuです。
今回から新しい連載「PerlでSlackボット指令センターを作る」(全9回)が始まります!
このシリーズでは、Slack(またはDiscordなどのチャットツール)から、デプロイやサーバー再起動、メトリクス確認といったDevOpsタスクを一元管理する「指令センター」を構築していきます。
単なる「便利なスクリプト」で終わらせません。Mediator、Command、Observerという3つのデザインパターンを駆使して、マイクロサービス顔負けのイベント駆動アーキテクチャをPerlとMoo実装します。
第1回は、すべての始まりとなる「Slackからのメッセージ受信」です。

なぜ「指令センター」なのか?
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']];
};
|