見出し画像

esaのURLを貼ったときに展開するSlack AppをServerless Frameworkで作り直した

この記事は「GRIMOIRE アドベントカレンダー2021 ODD」の10個目の記事です

今回の記事はグリモアの天網チーム所属のたけおが担当します。

自己紹介

グリモアができたときからのメンバーで、ゲームの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を参照してください。

おおまかな処理の流れは以下の通りです。

  1. Slackからlink_sharedイベントを受け取る

  2. esaのAPIを呼び出し、投稿の本文を取得する

  3. 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だけでなく他のサービスでも同様に対応することができます。

最後に

ここまでお付き合いいただき、本当にありがとうございます。
グリモアは一緒に【中二病を救う】側になってくれる仲間を大大大募集中です。
少しでも当社に興味を持って頂けましたら、是非とも下記の採用サイトを御覧ください。

※各社の会社名、製品名、サービス名は各社の商標または登録商標です。


読んでくださりありがとうございま――…… え?さぽーと…?いやいやいや!そんな恐れ多いですよ!でも、サポートいただけると、ゲーム開発が少しだけ楽になるかも…… あ!ごめんなさい、独り言ですっ!えへへへ……