esaのURLを貼ったときに展開するSlack AppをServerless Frameworkで作り直した
今回の記事はグリモアの天網チーム所属のたけおが担当します。
自己紹介
グリモアができたときからのメンバーで、ゲームのAPIサーバー実装、運用のためのツール開発、サーバーの管理などサーバーまわり全般を担当しています。
経緯
グリモアでは文書の共有にesaが使われており、SlackでesaのURLを共有することがよくありますが、esaの閲覧にはログインが必要なため、デフォルトではSlackはURL展開をしてくれません。
URL展開を行うには自前で実装する必要があり、そのためのSlack AppがAPI GatewayとLambdaで構築されていましたが、手作業で作られたため再現しづらいものになっていました。
このSlack Appは3つ目の記事で書かれている複数AWSアカウントの整備が行われる前に実装されたため、本番プロダクト用のアカウントで動いていました。
その後、社内ツール用のAWSアカウントができたので、移動するついでに再現性を高めるためにServerless Frameworkを使って構築し直すことにしました。
環境構築
Serverless Frameworkを使うにはNode.jsが必要です。
今回はnodenvでバージョン16の最新をインストールして、globalにserverlessをインストールします。
$ nodenv install 16.13.1
$ nodenv global 16.13.1
$ npm install -g serverless
実装
Slackにはunfurlingという仕組みがあり、これを利用してesaのURLが貼られたときにURL展開を行います。
詳しくはUnfurling links in messagesを参照してください。
おおまかな処理の流れは以下の通りです。
Slackからlink_sharedイベントを受け取る
esaのAPIを呼び出し、投稿の本文を取得する
Slackのchat.unfurl APIを呼び出す
実装したコードを下に貼り付けます。
一部のチャンネルでは社外の方が参加しているので、`UNFURL_EXCEPT_CHANNEL`で除外するチャンネルを正規表現で指定できるようにしています。
require "json"
require "net/http"
require "uri"
ESA_TEAM_NAME = "********"
UNFURL_EXCEPT_CHANNEL = /foo|bar/
SUCCESS_RESPONSE = { statusCode: 200, body: "OK" }.freeze
def receive_event(event:, context:)
puts event
event_body = JSON.parse(event["body"])
puts event_body
# verification tokenを確認
if event_body["token"] != ENV["SLACK_VERIFICATION_TOKEN"]
puts "invalid verification token"
return { statusCode: 400, body: "error" }
end
# URL verification
if event_body["type"] == "url_verification"
puts "url_verification"
return { statusCode: 200, body: event_body["challenge"] }
end
# link_sharedイベント以外を無視する
return SUCCESS_RESPONSE if event_body.dig("event", "type") != "link_shared"
# リトライの場合は何もしない
if event.dig("headers", "x-slack-retry-num")
puts "x-slack-retry-num: #{event.dig('headers', 'x-slack-retry-num')}"
puts "x-slack-retry-reason: #{event.dig('headers', 'x-slack-retry-reason')}"
return SUCCESS_RESPONSE
end
channel_id = event_body.dig("event", "channel")
# メッセージ入力中に飛ぶイベントを無視する
return SUCCESS_RESPONSE if channel_id == "COMPOSER"
name = channel_name(channel_id)
# 一部のチャンネルではunfurlしない
return SUCCESS_RESPONSE if !name || name =~ UNFURL_EXCEPT_CHANNEL
unfurls = {}
links = event_body.dig("event", "links")
links.each do |link|
next if link["domain"] != "#{ESA_TEAM_NAME}.esa.io"
url = link["url"]
uri = URI.parse(url)
_, path, post_id = uri.path.split("/")
next if path != "posts" || !post_id
data = fetch_esa_post(post_id)
unfurls[url] = build_block(data)
end
return SUCCESS_RESPONSE if unfurls.empty?
message_ts = event_body.dig("event", "message_ts")
payload = {
channel: channel_id,
ts: message_ts,
unfurls: unfurls,
}
unfurl_links(payload)
SUCCESS_RESPONSE
end
def channel_name(channel_id)
uri = URI.parse("https://slack.com/api/conversations.info")
payload = {
token: ENV["SLACK_OAUTH_TOKEN"],
channel: channel_id,
}
response = Net::HTTP.post_form(uri, payload)
data = JSON.parse(response.body)
data.dig("channel", "is_channel") && data.dig("channel", "name")
end
def build_block(data)
url = data["url"]
title = data["full_name"]
summary = data["body_md"].slice(0, 50)
{
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: "*<#{url}|#{title}>*\n#{summary}",
},
},
]
}
end
def unfurl_links(payload)
uri = URI.parse("https://slack.com/api/chat.unfurl")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
token = ENV["SLACK_OAUTH_TOKEN"]
headers = {
"Content-Type" => "application/json; charset=utf-8",
"Authorization" => "Bearer #{token}",
}
response = http.post(uri.path, payload.to_json, headers)
puts response.body
end
def fetch_esa_post(post_id)
uri = URI.parse("https://api.esa.io/v1/teams/#{ESA_TEAM_NAME}/posts/#{post_id}")
token = ENV["ESA_TOKEN"]
headers = { "Authorization" => "Bearer #{token}" }
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
response = http.get(uri, headers)
JSON.parse(response.body)
end
serverlessの設定ファイルは以下のとおりです。
esaやSlackのトークンをパラメータストアから取得し、環境変数でセットするようにしています。
service: slackapp-esa
frameworkVersion: '2'
provider:
name: aws
runtime: ruby2.7
lambdaHashingVersion: 20201221
region: ap-northeast-1
environment:
ESA_TOKEN: ${ssm:/slackapp-esa/esa_token}
SLACK_OAUTH_TOKEN: ${ssm:/slackapp-esa/slack_oauth_token}
SLACK_VERIFICATION_TOKEN: ${ssm:/slackapp-esa/slack_verification_token}
package:
patterns:
- "src/**"
functions:
receive_event:
handler: src/handler.receive_event
events:
- httpApi:
method: POST
path: /events
Slack App作成
続いて、Slack Appを作ります。
https://api.slack.com/apps にアクセスし、`Create New App`をクリックします。
表示されたダイアログで`From an app manifest`を選択します。
workspaceを選択し、`Next`をクリックします。
manifestを入力して`Next`をクリックします。これはOAuth tokenを生成するための仮設定で、後の手順で正式なものに更新します。
_metadata:
major_version: 1
display_information:
name: esa
features:
bot_user:
display_name: esa
always_online: false
oauth_config:
scopes:
bot:
- links:read
- links:write
- channels:read
settings:
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
ymlに記述した権限が表示されていることを確認し、`Create`をクリックするとAppが作られます。
トークンの準備
デプロイする前に3つのトークンを準備します。
Slack AppのOAuth Token
OAuth & Permissionsを開いて、`Install to Workspace`をクリックします。
次のページで許可し、戻ってくるとBot User OAuth Tokenが生成されています。
Slack AppのVerification Token
Basic Informationページの中ほどのApp Credentialsに記載されています。
今回の対応後に気がついたのですが、このトークンはdeprecatedになっていて、今は署名を検証する方法が推奨されているようです。
esaのトークン
SETTING → ユーザー設定 → Applicationsページ内のPersonal access tokensのところの`Generate new token`をクリックすると生成されます。
パラメータストアに保存
トークンが用意できたら、パラメータストアに保存します。
serverless.yml内の設定と名前を合わせる必要があります。
デプロイ
Serverlessを使ってデプロイします。
3つ目の記事で触れたとおり、aws-vaultを使って認証情報をslsコマンドに渡します。
$ aws-vault exec backyard -- sls deploy
Opening the SSO authorization page in your default browser (use Ctrl-C to abort)
https://device.sso.ap-northeast-1.amazonaws.com/?user_code=********
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Ensuring that deployment bucket exists
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service slackapp-esa.zip file to S3 (3.12 kB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
.........
Serverless: Stack update finished...
Service Information
service: slackapp-esa
stage: dev
region: ap-northeast-1
stack: slackapp-esa-dev
resources: 11
api keys:
None
endpoints:
POST - https://********.execute-api.ap-northeast-1.amazonaws.com/events
functions:
receive_event: slackapp-esa-dev-receive_event
layers:
None
endpointができたので、manifestを更新します。
_metadata:
major_version: 1
minor_version: 1
display_information:
name: esa
features:
bot_user:
display_name: esa
always_online: false
unfurl_domains:
- ********.esa.io
oauth_config:
scopes:
bot:
- links:read
- links:write
- channels:read
settings:
event_subscriptions:
request_url: https://********.execute-api.ap-northeast-1.amazonaws.com/events
bot_events:
- link_shared
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
manifestを更新するとページ上部に"URL isn't verified"と出てくるので、`Click here to verify`をクリックしてエラーが起きなければ成功です。
これでSlack上でesaのURLが展開されるようになったはずです。
(下のスクショは加工して一部伏せ字にしています)
まとめ
Serverless Frameworkを使ってesaのURL展開が行われるようにSlack Appを作成しました。
unfurlingを使えばesaだけでなく他のサービスでも同様に対応することができます。
最後に
ここまでお付き合いいただき、本当にありがとうございます。
グリモアは一緒に【中二病を救う】側になってくれる仲間を大大大募集中です。
少しでも当社に興味を持って頂けましたら、是非とも下記の採用サイトを御覧ください。
この記事が参加している募集
読んでくださりありがとうございま――…… え?さぽーと…?いやいやいや!そんな恐れ多いですよ!でも、サポートいただけると、ゲーム開発が少しだけ楽になるかも…… あ!ごめんなさい、独り言ですっ!えへへへ……