大ちゃんの駆け出し技術ブログ

RUNTEQ受講生のかわいいといわれるアウトプットブログ

【Slack API】Home Tabの表示

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

今回は前回の記事に引き続きEvent Subscriptionsについて書きたいと思います。

前回は「ユーザがメッセージタブでテキトーなメッセージを送信したイベント」を検知し、それをアプリ側にリクエストを送信、そしてアプリ側がレスポンスをメッセージタブに送信するというものでした。

・ユーザがメッセージタブでテキトーなメッセージを送信(イベント)
↓
・Slackからリクエストを送信
↓
・アプリがレスポンス
↓
・メッセージタブにメッセージが送信される

前回の記事はこちら!

sakitadaiki.hatenablog.com

今回の記事では「Home Tabを開いたイベント」を検知し、それをアプリ側にリクエストを送信、そしてアプリ側がレスポンスし、Home Tabにメッセージを表示する方法を紹介します!

・ユーザがHome Tabを開くイベント)
↓
・Slackからリクエストを送信
↓
・アプリがレスポンス
↓
・Home Tabにメッセージが表示される

対象読者

  • SlackアプリをRailsで開発している人
  • Slack APIをある程度理解している人(scopeやEvent Subscriptionsの説明が必要ない人)

Home Tabとは

Home Tabとは、Slackのアプリ画面でSlackアプリを開いたときに最初に表示されるアプリの用途や使用説明、機能の一部などを表示したタブのことです。下記の例では「Slack Developer Tools」というSlackアプリのHome Tabです。

https://i.gyazo.com/c99c3e09ecc8cee9a04e9d505c00de77.png

補足ですが、このHome Tabは開発する際任意につけられるもので、Home Tabが表示されていないSlack アプリもあります。例えば、有名なアプリ「Google Drive」は表示がされていませんでした。

https://i.gyazo.com/380b4872bf03cc724418aab17dadac59.png

実装に必要なAPI各種

app_home_opened

まずHome Tabを開いたというイベントを検知するためのEvent Subscriptionsです。Home Tabを開いたときのイベントを検知するためにはapp_home_openedをEvent Subscriptionsに設定します。

api.slack.com

views.publish

Home Tabにメッセージを表示させたいときに使用する専用のメソッドとして、views.publishメソッドを使用します。

api.slack.com

このメソッドを使うために必要なscopeはありません。

https://i.gyazo.com/3334d8ae9d7ff43418ea800d112297cb.png

トークン以外の必要なArgumentsとして、user_idとviewが必要になります。

https://i.gyazo.com/41fa08a160ba40a795fb8ba14210af05.png

このviewですが、今まで投稿機能の時に使用していたBlock Kitとほとんど変わりません。異なる点として、今まではblocksの中身の配列を指定していましたが、type":"home"を含めたJSONオブジェクトを指定する必要があります。Home Tabのオブジェクトであると区別するためですかね。

{
   "type":"home",
   "blocks":[
      {
         "type":"section",
         "text":{
            "type":"mrkdwn",
            "text":"A simple stack of blocks for the simple sample Block Kit Home tab."
         }
      },
      {
         "type":"actions",
         "elements":[
            {
               "type":"button",
               "text":{
                  "type":"plain_text",
                  "text":"Action A",
                  "emoji":true
               }
            },
            {
               "type":"button",
               "text":{
                  "type":"plain_text",
                  "text":"Action B",
                  "emoji":true
               }
            }
         ]
      }
   ]
}

api.slack.com

実装手順

それでは実装していきましょう!

※前回の記事で既にEvent Subscriptionsの設定を行いました。今回は同様のリクエストURLに送信するので、まだ行っていない人はこちらの記事を参照してください。

sakitadaiki.hatenablog.com

app_home_openedの設定

まずSlack アプリ開発画面からEvent Subscriptionsを設定します。

https://i.gyazo.com/7e474b42237ad3731d4f104d373616e7.png

Subscribe to bot eventsのタブを開きます。

https://i.gyazo.com/f55b17bd2d03c0beaaeae7c5bd088e09.png

「Add Bot User Event」のボタンをクリックして、app_home_openedを検索。追加したら設定を保存するために「Save Changes」をクリックしてください。

https://i.gyazo.com/7b427baea0ecda8b76609254701126e9.gif

これでアプリのHome Tabを開いたときにイベントが発生し、リクエストURLにリクエストが送信されます。

views.publish

あとはviews.publishを使用してHome Tabに表示させたいメッセージを表示します。

ただここで少しだけ注意が必要です。Event SubscriptionsのリクエストURLは一つしか設定できません。つまり、前回実装したテキトーなメッセージを送信するイベントのリクエストURLがアプリのURL/slack/eventsだったのですが、今回のHome Tabを開いた時のリクエストURLも同様にアプリのURL/slack/eventsとなります。よって、同一のURLの中で条件分岐をする必要があります。

条件分岐をさせる方法として、Slackから送信されるパラメーターを使用します。今回の実装でHome Tabを開いた場合、下記のようなパラーメーターが送信されます。

{
    "type": "app_home_opened",
    "user": "U061F7AUR",
    "channel": "D0LAN2Q65",
    "event_ts": "1515449522000016",
    "tab": "home",
    "view": {
        "id": "VPASKP233",
        "team_id": "T21312902",
        "type": "home",
        "blocks": [
           ...
        ],
        "private_metadata": "",
        "callback_id": "",
        "state":{
            ...
        },
        "hash":"1231232323.12321312",
        "clear_on_close": false,
        "notify_on_close": false,
        "root_view_id": "VPASKP233",
        "app_id": "A21SDS90",
        "external_id": "",
        "app_installed_team_id": "T21312902",
        "bot_id": "BSDKSAO2"
    }
}

"type": "app_home_opened"とあるように、Home Tabが開いたイベントであることがわかります。

逆に、メッセージを送信したというイベントのパラメーターが下記になります。

{
    "token": "one-long-verification-token",
    "team_id": "T061EG9R6",
    "api_app_id": "A0PNCHHK2",
    "event": {
        "type": "message",
        "channel": "D024BE91L",
        "user": "U2147483697",
        "text": "Hello hello can you hear me?",
        "ts": "1355517523.000005",
        "event_ts": "1355517523.000005",
        "channel_type": "im"
    },
    "type": "event_callback",
    "authed_teams": [
        "T061EG9R6"
    ],
    "event_id": "Ev0PV52K21",
    "event_time": 1355517523
}

"type": "message"で、"text"のパラメーターがあれば、それはテキトーに送信したメッセージであることがわかりそうですね。

よって条件分岐をすると下記のようになります。views_publishメソッドにはもちろんHome Tabを表示させるメソッドを後述します。

def respond
  if params[:event][:type] == 'app_home_opened'
    views_publish
  elsif params[:event][:type] == 'message' && params[:event][:text].present?
    send_help_msg
  end
end

条件分岐が完了したところで、いよいよAPIメソッドを使用してメッセージを表示させます。

先にコードを見せておきます。

def views_publish
  team = Team.find_by(workspace_id: params[:team_id])
  user = User.find_by(uid: params[:event][:user])
  return if team.nil? || user.nil? || team.workspace_id != user.team.workspace_id

  access_token = set_access_token(user.authentication.access_token)
  publish_to_home_tab(team, user, access_token)
end

def publish_to_home_tab(team, user, access_token)
  encoded_msg = encoded_home_tab_block_msg(team)
  access_token.post("api/views.publish?user_id=#{user.uid}&view=#{encoded_msg}&pretty=1").parsed
end

def encoded_home_tab_block_msg(team)
  channel_id = team.share_channel_id
  channel_name = team.share_channel_name
    msg = "{~~~~~~~~~~~~~}"
  encoded_msg = ERB::Util.url_encode(msg)
  encoded_msg
end

まず、Slackアプリにユーザとチームが存在し、ユーザがチームに属しているはずですので、そうでない場合はreturnで処理を強制終了させます。

team = Team.find_by(workspace_id: params[:team_id])
user = User.find_by(uid: params[:event][:user])
return if team.nil? || user.nil? || team.workspace_id != user.team.workspace_id

その後access_tokenを取り出すメソッドを使用します。

access_token = set_access_token(user.authentication.access_token)

上述しましたviewですが、msgの箇所は長すぎたので割愛します。自分はかなり複雑なviewを設定しましたが、作り方は簡単です。block-kit-builderを使用して、タブにApp Home Previewを指定すると、Home Tabに表示したいJSONオブジェクトを簡単に作れます。

app.slack.com

https://i.gyazo.com/7b4f93a857d9c8ba4a2b70612cc253d8.png

あとはAPIメソッドを使用してHome Tabにメッセージを表示させます。

access_token.post("api/views.publish?user_id=#{user.uid}&view=#{encoded_msg}&pretty=1").parsed

これで実装は終了です。実際にアプリのHome Tabを開いてみましょう。

https://i.gyazo.com/958e1cad50cfd165c2c059e1fc871d44.png

実際に表示することができました!

【Slack】テキトーなテキストメッセージへの返信方法

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

今回もまた!Slack APIです笑

もうどんだけ書くのよという感じですが、今はこれに奮闘しすぎてSlackのネタがたくさん出てきてしまいます笑

今回はテキトーなメッセージを送信した時にヘルプメッセージを送信する方法を紹介します。基本的にはヘルプメッセージはslashコマンドの一覧を返します。

https://i.gyazo.com/08691868040f2d705457bbe5b8eddb8e.png

読者対象

  • Slack APIをある程度知っている方
  • RubyでSlack アプリを作成している方
  • Slack Appを既に開発している方

メッセージタブでのやりとりのフロー

この記事を深く理解するための事前知識として、「メッセージタブ」の存在に触れておきます。メッセージタブとは、Slack Appのボットとメッセージのやりとりができるスペースのことです。下の図で言うところの「Home」、「Messages」、「About」のMessagesにあたる箇所です。

https://i.gyazo.com/42cf36c25eafade6784ab7b42eda3ddf.png

Slackアプリはこのメッセージタブでイベントを発生させることでユーザとのやりとりを可能にします。メッセージタブでのイベントの種類は下記のような感じです。

  • ユーザがメッセージタブを開いた時
  • ユーザがメッセージタブにメッセージを送信した時

今回は後者のメッセージタブにメッセージを送信した時にアプリ側にリクエストが送られ、それに対してヘルプメッセージを送信するフローになります。


  • ユーザが「hogehoge」とメッセージタブに打ち込む

  • Slackが指定のURLにリクエストを送信

  • アプリ側でヘルプメッセージを送信する処理

  • メッセージタブにヘルプメッセージが送られる

方法がわからなかったのはSlackが指定のURLにリクエストを送信する方法です。「hogehoge」とメッセージタブに送信を行った場合、Slack側ではどのようにそれをアプリ側に送信するのでしょうか。

Event Subscriptions

SlackではEvent SubscriptionsというSlack上でのユーザの動作に対してアプリにリクエストを送ることができる機能があります。

Using the Slack Events API

どこで設定するのかというと、Slack App開発画面のFeatures > Event Subscriptionsで設定できます。

https://i.gyazo.com/4d73b7818aa9e2632cb0334ccca0c231.png

ここで「ユーザがメッセージタブにメッセージを送った時にアプリにリクエストを送る」と言う設定を行っていきます。ユーザがメッセージタブにメッセージを送るイベントをアプリ側に送るためには、message.im eventをEvent Subscriptionsで設定します。

Event reference: message.im event

少し大雑把に説明しましたが、ある程度の理解で構いません。今はEvent Subscriptionsの設定でmessage.im eventを使用することでメッセージタブのイベントをリクエストとして送信することができるんだなと思っておいてください。

実装手順

それでは実際に設定していきます。

まず、先ほどの設定画面で右上にあるボタンをONにしましょう。

https://i.gyazo.com/87848a945f705d9ee17b188cb3087514.gif

すると、いろいろな設定項目が出てきます。

まずRequest URLから設定していきます。これはEvent Subscriptionsで有効にしたイベントが発生した時に送信するURLのことです。Railsアプリ側でそのURLを受け取るためのルーティングを設定しましょう。下記の例ですとアプリURL/slack/eventsのリクエストを受け取るようにします。

# routes.rb
namespace :slack do
  post 'events', to: 'events#respond'
end

よってRequest URLで設定するURLもこれに合わせて設定します。今回はngrokを使用してHTTPS側のURLを設定します。

すると、下記のようにエラーメッセージ が表示されたはずです。

https://i.gyazo.com/8093306bb9984aa251b45cbe008442a3.png

**URLがchallengeパラメータの値で応答しませんでした。**

Event SubscriptionsのURLを受け取るためにはアプリ側で一度challengeパラメータの値に応答しておく必要があります。これはurl_verificationというイベントが発生していて、Slack側で発行されるchallengeパラメータをRequest URLのレスポンスでJSONでレンダーすることで、アプリのリクエストURLが本人のものということを認証します。

Event reference: url_verification event

このRequest URLはどのようなHTTPSのURLも設定できてしまいます。例えばYouTubeのURLも設定できます。

https://www.youtube.com/

しかし、当然ですがYouTubeは自分が開発したものではないですよね。何の認証もなしにRequest URLの値に好きなURLを入れられたら、たくさんのリクエストが知らないところから飛んできてしまいます。

そのため、Request URLに最初にchallengeパラーメーターを送り、同じ値をレンダーすることでそのアプリが本人のものということを認証するのです。よってchallengeパラメータ をレンダーするようにします。

def respond
  render json: params[:challenge], status: 200
end

これで同じリクエストURLに認証を行うと、下記画面のように認証が通ったというメッセージが表示されます。

https://i.gyazo.com/e29f4217eb28e27ab65f0cd57b3a1c79.png

ちなみにですが、このchallengeパラメーターへの処理は認証が通ればそれ以上は必要ないので削除します。もしこれを残したまま本番環境にデプロイすると、他のslackアプリからのRequest URLにも同じURLが指定された時にchallengeパラメーターを返却してしまいます。あくまで自分のアプリであることを証明するためのフローです。

それでは次にmessage.im eventを追加します。Subscribe to bot eventsの「Add Bot User Event」ボタンからmessage.imを検索しましょう。見つけたら追加して右下にある「Save Changes」ボタンをクリックします。

https://i.gyazo.com/1a59b2eb5d4b1d40571faf19f44a4e2b.gif

補足すると、im:historyというscopeがアプリ側に自動で追加されているかと思います。これはこのイベントを追加するためにはこのim:historyのscopeが必要になるということです。

これでGUI画面での設定は終わりです。実際にパラメータを受け取れるようになったはずです。binding.pryで実際にパラメータが飛んでいるか確認します。

def respond
  binding.pry

アプリ側で一度「hogehoge」というメッセージを送ってみましょう。すると処理が止まるはずですのでそこでparamsを確認します。するとすごく長いパラメーターを取得できたと思います。

params
=> <ActionController::Parameters {"token"=>"xxxxxxx", "team_id"=>"xxxxxx", "api_app_id"=>"xxxxx", "event"=><ActionController::Parameters {"client_msg_id"=>"xxxxx", "type"=>"message", "text"=>"hogehoge", "user"=>"xxxxx",

あとはユーザに対してヘルプメッセージを送信する処理のみです。

自分が作成した処理を貼っておきます。

def respond
  if params[:event][:type] == 'message' && params[:event][:text].present?
    send_help_msg
  end
end

def send_help_msg
  team = Team.find_by(workspace_id: params[:team_id])
  user = User.find_by(uid: params[:event][:user])
  return if team.nil? || user.nil? || team.workspace_id != user.team.workspace_id
  access_token = set_access_token(user.authentication.access_token)
  encoded_text = get_encoded_help_text
  encoded_msg = get_encoded_help_block_msg
  access_token.post("api/chat.postMessage?channel=#{user.uid}&blocks=#{encoded_msg}&text=#{encoded_text}&pretty=1").parsed
end

def get_encoded_help_text
  text = "ヘルプメッセージを送信しました"
  encoded_text = ERB::Util.url_encode(text)
  return encoded_text
end

def get_encoded_help_block_msg
  msg = "[ { 'type': 'divider' }, { 'type': 'section', 'text': { 'type': 'mrkdwn', 'text': '本アプリでは以下のコマンドがSlack内で利用できるよ:hamster:' } }, { 'type': 'section', 'fields': [ { 'type': 'mrkdwn', 'text': ':information_source: `/prof_help` \nDMでヘルプメッセージを送るよ' }, { 'type': 'mrkdwn', 'text': ':postbox: `/prof_random_block` \n DMでランダムにブロックを1つ送るよ' } ] }, { 'type': 'section', 'fields': [ { 'type': 'mrkdwn', 'text': ':ok_hand: `/prof_activate_share` \n毎日18時の投稿を有効するよ' }, { 'type': 'mrkdwn', 'text': ':raised_back_of_hand: `/prof_inactivate_share` \n 毎日18時の投稿を止めるよ' } ] }, { 'type': 'divider' } ]"
  encoded_msg = ERB::Util.url_encode(msg)
  return encoded_msg
end

まずパラメータの種類がmessageであること、textが含まれていることでユーザからのDMだということがわかるので条件分岐を張り付けます。

if params[:event][:type] == 'message' && params[:event][:text].present?

DMであることがわかれば、そのユーザからアクセストークンと取り出します。その手前でuserとteamを取り出している処理ですが、これはアプリにユーザが登録しているかどうかを検証するためです。ユーザでなければ基本的にはヘルプメッセージを送信する必要がないので。

team = Team.find_by(workspace_id: params[:team_id])
user = User.find_by(uid: params[:event][:user])
return if team.nil? || user.nil? || team.workspace_id != user.team.workspace_id
access_token = set_access_token(user.authentication.access_token)

あとはchat.postMessageメソッドでエンコードしたメッセージを送信しています。ここれへんの説明はSlack APIをよく知っていればわかるはずです。

Image from Gyazo

【Rails】本番環境のツール各種

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

ほぼポートフォリオも完成というところまできたので、本番環境に必要な解析ツールやHerokuのAdd-onを導入したのでそれらの導入を記録した記事を残しておきます。各ツールの機能について説明を入れながら導入していきます。

New Relic APM

【概要】

HerokuのAdd-onでアプリのパフォーマンスを監視してくれるサービスです。非常によく使われるAdd-onのようで導入も簡単でした。

【参考】

  • 公式

Elements Marketplace: New Relic APM

  • 導入記事

Heroku上のRailsアプリにNewRelicを導入する - Qiita

【インストール】

① アプリ画面から「Resources」の欄に移動し、Add-on検索からNew Relic APMを検索し、Freeプランで追加します。

https://i.gyazo.com/e93631fa7305ea503acb1b8e0661b5b4.png

ダッシュボード画面に遷移します。下記コマンドをターミナルに打ち込めば遷移できます。

$ heroku addons:open newrelic

③ 「Add an app or servise」をクリック

https://i.gyazo.com/9da77111954924cf6ea248b606dbeb92.png

④ サイドバーが出現するのでそこで言語にRubyを選択

https://i.gyazo.com/643fa75b2a065fdce3453ef16607a4ab.png

⑤ 各項目を入力していきます。

https://i.gyazo.com/e39b558805a50fd66756c0937ab7cb86.png

  • Give your application a name

⇒ アプリ名を入力

  • Are you using Bundler?

⇒ Yesを選択すると、下記gemをインストールするように指示されるのでインストール

# Gemfile
gem 'newrelic_rpm'

# ターミナル
$ bundle install
・
・
・
Installing newrelic_rpm 7.1.0
  • Download your custom configuration file

ファイルをダウンロードしconfig配下に配置します。Licenseが書かれているので環境変数に移します。

# config/newrelic.yml
#
# This file configures the New Relic Agent.  New Relic monitors Ruby, Java,
# .NET, PHP, Python, Node, and Go applications with deep visibility and low
# overhead.  For more information, visit www.newrelic.com.
#
# Generated June 15, 2021
#
# This configuration file is custom generated for NewRelic Administration
#
# For full documentation of agent configuration options, please refer to
# https://docs.newrelic.com/docs/agents/ruby-agent/installation-configuration/ruby-agent-configuration

common: &default_settings
  # Required license key associated with your New Relic account.
  license_key: ENV['LICENSE_KEY']

  # Your application name. Renaming here affects where data displays in New
  # Relic.  For more details, see https://docs.newrelic.com/docs/apm/new-relic-apm/maintenance/renaming-applications
  app_name: prof-chan

  distributed_tracing:
    enabled: true

  # To disable the agent regardless of other settings, uncomment the following:
  # agent_enabled: false

  # Logging level for log/newrelic_agent.log
  log_level: info

# Environment-specific settings are in this section.
# RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment.
# If your application has other named environments, configure them here.
development:
  <<: *default_settings
  app_name: prof-chan (Development)

test:
  <<: *default_settings
  # It doesn't make sense to report to New Relic from automated test runs.
  monitor_mode: false

staging:
  <<: *default_settings
  app_name: prof-chan (Staging)

production:
  <<: *default_settings

④ これでローカルでの設定は完了。デプロイします。

⑤ 実際にアクセスしてデータを取得できているか確認します。

Sentry

HerokuのAdd-onでサイトのエラーなどを監視し、ログとして記録するサービスです。他のAdd-onでもログの検知ツールはありますが、どちらがいいかがあまりわからなかったので脳死でSentryにしました。

【参考】

  • 公式

Rails Error and Performance Monitoring

Rails

  • 導入記事

Kazu Tech Blog

【インストール】

① アプリ画面から「Resources」の欄に移動し、Add-on検索からSentryを検索し、Freeプランで追加します。

https://i.gyazo.com/cbd3d0dfb18d62712aecf90e3958cb21.png

② gemをインストール

# Gemfile
gem 'sentry-rails'
gem 'sentry-ruby'

#ターミナル
$ bundle install
・
・
・
Installing sentry-ruby-core 4.5.1
Fetching sentry-ruby 4.5.1
Fetching sentry-rails 4.5.1
Installing sentry-ruby 4.5.1
Installing sentry-rails 4.5.1

config/initializers/sentry.rbを作成します。

# config/initializers/sentry.rb
Sentry.init do |config|
  config.dsn = 'https://xxxxxxx:xxxxxxx@xxxx.ingest.sentry.io/xxxxxx'
  config.breadcrumbs_logger = [:active_support_logger]

  # To activate performance monitoring, set one of these options.
  # We recommend adjusting the value in production:
  config.traces_sample_rate = 0.5
  # or
  config.traces_sampler = lambda do |context|
    true
  end
end

config.dsnの値はアプリの「Settings」のConfig Varsから「SENTRY_DSN」のキーの値を参照してください。Add-onが追加されているとここに自動的に値が割り振られています。

https://i.gyazo.com/70b2c53c810f422836b25eb0390ca820.png

④Herokuにデプロイしましょう。

⑤動作確認として下記コマンドを実行します。

Sentry.capture_message("test message")

Log Rocket

【概要】

Log Rocketとは自分のサイトでどのようにユーザが動いたのかを動画として記録してくれるツールです。ユーザがどこで迷っているかやどの画面に多くの時間を使っているのかなどがこれをみると一目瞭然。こんなものが今できたんですね。びっくりしました。

【参考】

  • 公式

Modern Frontend Monitoring and Product Analytics

  • 導入記事

ユーザー行動を動画で記録できる「LogRocket」の使い方・設定方法【JSコード1行で簡単導入】 | Tekito style.me

【インストール】

①トップ画面から「Get Started Free」をクリック

https://i.gyazo.com/ae0bffcf7119b4967087ab3a2f9cc7db.png

②認証方法を選択しログインします。(Googleログインなど外部認証で問題ないです。)

https://i.gyazo.com/5dac9db3f597faf7a0402b8b35bd99d6.png

③プロジェクト名を入力します。基本的にはサイトのドメインなのでいいと思います。また、プロジェクト管理用アカウントとしてメールアドレスを指定して他の人などを招待もできます。自分はサイト用のアドレスを追加しました。

https://tekito-style.me/wp-content/uploads/2019/09/th_6-1logrocket.jpg

④インストール方法としてnpmを使用して直接インストールする方法とheadタグにscriptを挿入する方法があります。npmより取り外しが簡単なscriptでも問題ないと思います。

  • npm

https://i.gyazo.com/247909326120e966558130eeb9549c74.png

  • script

https://i.gyazo.com/0f2fa49cfc0c0226b3a14bb3a349f841.png

headタグにscriptを追加(slimの場合)

doctype html
html
  head

/ Log Rocket
    script src="https://cdn.lr-ingest.io/LogRocket.min.js" crossorigin="anonymous"
    javascript:
      window.LogRocket && window.LogRocket.init('9mwyop/f');

以上でLog Rocketの導入は完了です。めちゃ簡単で驚きました。

【Slack API】APIメソッドまとめ

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

今回は自分が今まで使用してきたSlack のAPIのメソッドをまとめてみました!メソッドの使用方法と使用するために必要なscopeなどを載せておきます。1から詳しくは説明しませんがメソッドでできることを大まかにお伝えできたらと思います。

※「scopeとはなんぞ?」と思った方は以前自分が書いた記事のscopeの節を読んでいただければと思います。

【Slack API】user scopeとbot scope - 大ちゃんの駆け出し技術ブログ

users.identity


Sign in with Slack」でトークンを取得した後に、ユーザに関する情報を取得するメソッド。

【概要】

メソッドのURL: https://slack.com/api/users.identity
推奨HTTPメソッド: GET
必要なscope: identity.basic(tokenの種類: user)

取得できる情報

JSON

{
    "ok": true,
    "user": {
        "name": "Sonny Whether",
        "id": "U0G9QF9C6"
    },
    "team": {
        "id": "T0G9PQBBK"
    }
}

【公式サイト】

api.slack.com


以下の3つのscopeを追加で付与することで取得するユーザ情報を増やすことができます。

  • identity.email
  • identity.avatar
  • identity.team

identity.emailを追加した場合のJSON

{
    "ok": true,
    "user": {
        "name": "Sonny Whether",
        "id": "U0G9QF9C6",
        "email": "bobby@example.com" // emailアドレスの追加
    },
    "team": {
        "id": "T0G9PQBBK"
    }
}

identity.avatarを追加した場合のJSON

{
    "ok": true,
    "user": {
        "name": "Sonny Whether",
        "id": "U0G9QF9C6",
                // 各サイズごとの画像の追加(数字はサイズを表している)
        "image_24": "https://cdn.example.com/sonny_24.jpg",
        "image_32": "https://cdn.example.com/sonny_32.jpg",
        "image_48": "https://cdn.example.com/sonny_48.jpg",
        "image_72": "https://cdn.example.com/sonny_72.jpg",
        "image_192": "https://cdn.example.com/sonny_192.jpg"
    },
    "team": {
        "id": "T0G9PQBBK"
    }
}

identity.teamを追加した場合のJSON

{
    "ok": true,
    "user": {
        "name": "Sonny Whether",
        "id": "U0G9QF9C6"
    },
    "team": {
        "name": "Captain Fabian's Naval Supply", // teamの名前の追加
        "id": "T0G9PQBBK"
    }
}

基本的にidentity.basicJSONレスポンスだけだと不十分なので、identity.avatarなどを追加で付与することが多いのかなと思っています。

conversations.list


ワークスペースにあるチャンネルをリストとして返してくれるメソッド。

【概要】

メソッドのURL: https://slack.com/api/conversations.list
推奨HTTPメソッド: GET
必要なscope: channels:read, groups:read, im:read, mpim:read(tokenの種類: bot)

取得できる情報

  • ワークスペースのチャンネルリスト
  • チャンネルごとの名前やID、その他細かい情報

JSON

{
    "ok": true,
    "channels": [
        {
            "id": "C012AB3CD",
            "name": "general",
            "is_channel": true,
            "is_group": false,
            "is_im": false,
            "created": 1449252889,
            "creator": "U012A3CDE",
            "is_archived": false,
            "is_general": true,
            "unlinked": 0,
            "name_normalized": "general",
            "is_shared": false,
            "is_ext_shared": false,
            "is_org_shared": false,
            "pending_shared": [],
            "is_pending_ext_shared": false,
            "is_member": true,
            "is_private": false,
            "is_mpim": false,
            "topic": {
                "value": "Company-wide announcements and work-based matters",
                "creator": "",
                "last_set": 0
            },
            "purpose": {
                "value": "This channel is for team-wide communication and announcements. All team members are in this channel.",
                "creator": "",
                "last_set": 0
            },
            "previous_names": [],
            "num_members": 4
        },
        {
            "id": "C061EG9T2",
            "name": "random",
            "is_channel": true,
            "is_group": false,
            "is_im": false,
            "created": 1449252889,
            "creator": "U061F7AUR",
            "is_archived": false,
            "is_general": false,
            "unlinked": 0,
            "name_normalized": "random",
            "is_shared": false,
            "is_ext_shared": false,
            "is_org_shared": false,
            "pending_shared": [],
            "is_pending_ext_shared": false,
            "is_member": true,
            "is_private": false,
            "is_mpim": false,
            "topic": {
                "value": "Non-work banter and water cooler conversation",
                "creator": "",
                "last_set": 0
            },
            "purpose": {
                "value": "A place for non-work-related flimflam, faffing, hodge-podge or jibber-jabber you'd prefer to keep out of more focused work-related channels.",
                "creator": "",
                "last_set": 0
            },
            "previous_names": [],
            "num_members": 4
        }
    ],
    "response_metadata": {
        "next_cursor": "dGVhbTpDMDYxRkE1UEI="
    }
}

【公式サイト】

api.slack.com


少しわかりづらい値としてis_channelなどがあるかと思いますが、それも公式で説明してくれています。

conversation type

調べてみると同じような情報が乱立しているようです。数が多いので抜粋して説明します。


  • is_channel・・・チャンネルがパブリックチャンネルであればtrue、プライベートであればfalseを返す
  • is_group・・・チャンネルがパブリックチャンネルであればfalse、プライベートであればtrueを返す
  • is_im・・・DMの会話であるかどうか
  • created・・・unixのタイムスタンプ
  • creator・・・チャンネルを作成したユーザのID

ちなみに必要なscopeはchannels:read, groups:read, im:read, mpim:readと書かれていますが、全ての説明が必要と言うわけではありません。users.identityの時と同じようにscopeによって取得できるチャンネルの種類が変わります。

  • channels:read・・・ワークスペース内のパブリックチャンネル
  • groups:read・・・アプリボットが参加しているチャンネル
  • im:read・・・アプリボットがDMを送った会話
  • mpim:read・・・アプリボットがグループにDMを送った会話

conversations.create


ワークスペース内にチャンネルを作成するメソッド。

【概要】

メソッドのURL: https://slack.com/api/conversations.create
推奨HTTPメソッド: POST
必要なscope: channels:manage, groups:write, im:write, mpim:write(tokenの種類: bot)
必要な値: name(作成するチャンネルの名前)

取得できる情報

  • 作成したチャンネル
  • チャンネルの名前やID、その他細かい情報

JSON

{
    "ok": true,
    "channel": {
        "id": "C0EAQDV4Z",
        "name": "endeavor",
        "is_channel": true,
        "is_group": false,
        "is_im": false,
        "created": 1504554479,
        "creator": "U0123456",
        "is_archived": false,
        "is_general": false,
        "unlinked": 0,
        "name_normalized": "endeavor",
        "is_shared": false,
        "is_ext_shared": false,
        "is_org_shared": false,
        "pending_shared": [],
        "is_pending_ext_shared": false,
        "is_member": true,
        "is_private": false,
        "is_mpim": false,
        "last_read": "0000000000.000000",
        "latest": null,
        "unread_count": 0,
        "unread_count_display": 0,
        "topic": {
            "value": "",
            "creator": "",
            "last_set": 0
        },
        "purpose": {
            "value": "",
            "creator": "",
            "last_set": 0
        },
        "previous_names": [],
        "priority": 0
    }

【公式サイト】

api.slack.com


こちらのメソッドではURLに作成するチャンネル名を含める必要があります。

def create_channel_in_team(channel_name, access_token)
  encoded_name = URI.encode_www_form_component(channel_name)
  access_token.post("api/conversations.create?name=#{encoded_name}&pretty=1").parsed
end

こちらのメソッドも複数のscopeを必要としますが、channels:managegroups:writeで使い分ける必要がありそうです。

  • channels:manage・・・新規チャンネルの作成権限とアプリボットが参加しているパブリックチャンネルの管理権限(チャンネル名を変えたり、チャンネルのトピックを追加するなどの権利)
  • groups:write・・・新規チャンネルの作成権限とアプリボットが参加しているプライベートチャンネルの管理権限(チャンネル名を変えたり、チャンネルのトピックを追加するなどの権利)
  • im:write・・・ダイレクトメッセージの開始
  • mpim:write・・・複数人とのダイレクトメッセージの開始

パブリックチャンネルを作成する場合channels:manageを、プライベートチャンネルを作成する場合はgroups:writeを使用しましょう。

conversations.invite


チャンネルにユーザを参加させるメソッド。

【概要】

メソッドのURL: https://slack.com/api/conversations.invite
推奨HTTPメソッド: POST
必要なscope: channels:manage, groups:write, im:write, mpim:write(tokenの種類: bot)
必要な値: channel(招待するチャンネルのID), users(招待するユーザのリスト)

取得できる情報

  • 招待したチャンネル
  • チャンネルの名前やID、その他細かい情報

JSON

{
    "ok": true,
    "channel": {
        "id": "C012AB3CD",
        "name": "general",
        "is_channel": true,
        "is_group": false,
        "is_im": false,
        "created": 1449252889,
        "creator": "W012A3BCD",
        "is_archived": false,
        "is_general": true,
        "unlinked": 0,
        "name_normalized": "general",
        "is_read_only": false,
        "is_shared": false,
        "is_ext_shared": false,
        "is_org_shared": false,
        "pending_shared": [],
        "is_pending_ext_shared": false,
        "is_member": true,
        "is_private": false,
        "is_mpim": false,
        "last_read": "1502126650.228446",
        "topic": {
            "value": "For public discussion of generalities",
            "creator": "W012A3BCD",
            "last_set": 1449709364
        },
        "purpose": {
            "value": "This part of the workspace is for fun. Make fun here.",
            "creator": "W012A3BCD",
            "last_set": 1449709364
        },
        "previous_names": [
            "specifics",
            "abstractions",
            "etc"
        ]
    }
}

【公式サイト】

api.slack.com


こちらのメソッドではURLに招待するチャンネルのIDと招待するユーザのID群を含める必要があります。以下の例は1人のユーザを招待しています。

def try_invite_user(info, channel, access_token)
    channel_id = channel.dig("id")
    user_id = info.dig("user", "id")
    access_token.post("api/conversations.invite?channel=#{channel_id}&users=#{user_id}&pretty=1").parsed
  end

conversations.createでチャンネルを作成し、conversations.inviteでそのチャンネルに招待するという流れになるかと思うので、必要となるscopeについてはconversations.createと同じかと思います。

chat.postMessage


メッセージを送信するためのメソッド。Slack APIに関する記事の例で最も使われるメソッドです。

【概要】

メソッドのURL: https://slack.com/api/chat.postMessage
推奨HTTPメソッド: POST
必要なscope: chat:write(tokenの種類: bot)
必要な値: channel(送信するチャンネルのID)

取得できる情報

  • 送信したしたチャンネルのID
  • 送信したメセージの内容

JSON

{
    "ok": true,
    "channel": "C1H9RESGL",
    "ts": "1503435956.000247",
    "message": {
        "text": "Here's a message for you",
        "username": "ecto1",
        "bot_id": "B19LU7CSY",
        "attachments": [
            {
                "text": "This is an attachment",
                "id": 1,
                "fallback": "This is an attachment's fallback"
            }
        ],
        "type": "message",
        "subtype": "bot_message",
        "ts": "1503435956.000247"
    }
}

【公式サイト】

api.slack.com


こちらのメソッドではURLに招待するチャンネルのIDを含める必要があります。しかし、チャンネルのIDの代わりにユーザのIDを指定するとユーザにDMを送信することができます。

def post_direct_message_to_user(info, access_token)
  user_id = info.dig("user", "id")
  encoded_mgs = get_msg
  text = "こんにちは"
  encoded_text = URI.encode_www_form_component(text)
  access_token.post("api/chat.postMessage?channel=#{user_id}&blocks=#{encoded_mgs}&text=#{encoded_text}&pretty=1").parsed
end

また、送信するメッセージとして、text(テキスト)、blocks(Block Kit)、もしくはattachments(添付)のどれかを必ず値としてURLに含める必要があります。そうでないとno_textというエラーを返します。


必要なscopeは1つのみです。基本的にアプリbotからメッセージに送信をすることはSlack アプリを作成すると必ず必要になると思いますので以下のscopeは覚えておきましょう。

chat:write・・・アプリbotからメッセージを送信する権限

参考

api.slack.com

【Rails】アクセストークンは別テーブルで管理

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

以前Slack API を使用する上でアクセストークンを管理する記事を出しました。

【Rails】Slack認証時のアクセストークンの保存 - 大ちゃんの駆け出し技術ブログ

この記事の中で暗号化をするために以下のメソッドをbefore_saveで使用しました。

before_save :encrypt_access_token

def encrypt_access_token
  key_len = ActiveSupport::MessageEncryptor.key_len
  secret = Rails.application.key_generator.generate_key('salt', key_len)
  crypt = ActiveSupport::MessageEncryptor.new(secret)
  self.access_token = crypt.encrypt_and_sign(access_token)
 end

しかしながら、今回別の機能を実装したことで上記の実装ではできないことに気づきました。その経緯を備忘録として記録します。

ユーザの更新機能

今回追加する機能としては、ユーザごとにアプリからSlackへの投稿機能を1日に一回にするというものです。そのため、enumで投稿を既に行ったかどうかを示すカラムを定義しました。

新規カラムをusersテーブルに追加

t.integer :share_right, null: false, default: 0

user.rbでカラムをenumとして設定

enum share_right: { not_shared_yet: 0, already_shared: 1 }

あとはユーザが投稿機能を使用したらUserに対してalready_shared!メソッドを使用することでユーザの更新を行います。

def update_share_right
  if @user.not_shared_yet? # おそらく必要はないが念のため
    @user.already_shared!
    render json: @user, serializer: UserSerializer
  end
end

そして定期実行で全てのユーザのステータスを元のnot_shared_yetに戻します。

namespace :users do
  desc "全てのユーザのslack共有の権利を更新"
  task update_share_right: :environment do
    User.find_each do |user|
      user.not_shared_yet! if user.already_shared?
    end
  end
end

ところがこのユーザの更新が問題でした。


before_saveの理解不足

しばらく気づかなかったのですが、どうもユーザが一度投稿してenumのメソッドを実行した後にAPIメソッドが使用できなくなっていました。理由はbefore_saveにありました

実はbefore_saveは作成時だけでなく、更新時にも実行されます。

バリデーションに成功し、実際にオブジェクトが保存される直前で実行されます。INSERT される場合も、UPDATE される場合も呼び出されます。INSERT もしくは UPDATE の場合だけ実行したい処理があるときは、後述する before_create / before_update を使用します。

Railsのコールバックまとめ | TECHSCORE BLOG

つまり、ユーザの更新を行ってしまうと暗号化されたアクセストークンが再度暗号化されてしまうのです。

更新前の暗号化済みトーク

RHrIxenifGqbqi4vvi7ybE79cg/AhbQDMiLh

暗号化済みトークンを再度暗号化(値が変わっている!!!)

kXUHH9fVb7ZhrqASKKi+dK2c65YHWKlhw6np

よって一度の復号ではなく二度復号処理を行わないと、復号された値を取り出すことができなくなりました。

def set_access_token
    encrypted_access_token = current_user.access_token
    key_len = ActiveSupport::MessageEncryptor.key_len
    secret = Rails.application.key_generator.generate_key('salt', key_len)
    crypt = ActiveSupport::MessageEncryptor.new(secret)
    decrypted_access_token = crypt.decrypt_and_verify(encrypted_access_token)
        decrypted_access_token = crypt.decrypt_and_verify(decrypted_access_token) # 再復号処理
    access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], decrypted_access_token)
    return access_token
  end

しかし、定期実行でもユーザーの更新を行うため、その時にも復暗号化済みのトークンが再度暗号化されてしまいます。よって、ユーザごとにアクセストークンが暗号化されている回数が違います。

考えられる解決策

解決策について考えたところ、最初はユーザカラムの状態を変えるのが面倒なので、復号化された値が取り出せるまで複合処理を繰り返し行う実装を考えました。

def set_access_token
  encrypted_access_token = current_user.authentication.access_token
  key_len = ActiveSupport::MessageEncryptor.key_len
  secret = Rails.application.key_generator.generate_key('salt', key_len)
  crypt = ActiveSupport::MessageEncryptor.new(secret)
  while encrypted_access_token.kind_of?(String)
    encrypted_access_token = crypt.decrypt_and_verify(decrypted_access_token)
  end
    decrypted_access_token = encrypted_access_token
  access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], decrypted_access_token)
  return access_token
end

暗号化されている値はStringクラスなのでencrypted_access_token.kind_of?(String)access_tokenがStringクラスの間復号処理を行うというものです。正直、これによって暗号化された回数に関わらず全てのアクセストークンが復号できるのでこれでもいいかと思いました。


しかし、少し適当すぎるかなと思い別の解決策を考えたところ、before_createを使用することを考えました。

before_save の後に実行されます。オブジェクトが登録されるとき (new_record? が true のとき) は before_create が実行されます。

Railsのコールバックまとめ | TECHSCORE BLOG

before_createであれば更新時には実行されることはないので安心だと思いました。

before_create :encrypt_access_token

def encrypt_access_token
  key_len = ActiveSupport::MessageEncryptor.key_len
  secret = Rails.application.key_generator.generate_key('salt', key_len)
  crypt = ActiveSupport::MessageEncryptor.new(secret)
  self.access_token = crypt.encrypt_and_sign(access_token)
 end

しかし、userカラムにaccess_tokenを残しておくこと自体が少し危険だと思い始めました。今回のように後々ユーザのカラムに今回のように更新したい値が出てきた時に、毎度before_createを使用して退避しなければなりません。加えて、誤って気づかないうちにユーザカラムを更新する処理を行っていたら、いつのまにかバグになってしまいそうです。


そのため、少々面倒ですがaccess_tokenを別で管理するテーブルをhas_oneの関係で用意することにしました。

class User < ApplicationRecord
    has_one :authentication, dependent: :destroy

    def check_authentication_existence(hash_token)
      if self.authentication.present?
        self.authentication.update!(access_token: hash_token)
      else
        @authentication = self.build_authentication(access_token: hash_token)
        @authentication.save!
      end
    end
end
class Authentication < ApplicationRecord
  # before
  before_save :encrypt_access_token

  belongs_to :user

  def encrypt_access_token
    key_len = ActiveSupport::MessageEncryptor.key_len
    secret = Rails.application.key_generator.generate_key('salt', key_len)
    crypt = ActiveSupport::MessageEncryptor.new(secret)
    self.access_token = crypt.encrypt_and_sign(access_token)
  end
end

別テーブルで管理することでユーザの更新を行っても、access_tokenが更新されることはありません。加えて、check_authentication_existenceメソッドをslack認証時に使用することで、アクセストークンの更新も行えるようにしておきます。

これでなんとかaccess_tokenの再暗号化を防ぐことができました!

【Rails】Slack認証時のアクセストークンの保存

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

今日はslack認証時に得られるアクセストークンをDBに保存する方法です。(立て続けにslackについての記事を書いておりますが、最近ようやく実装できたことなのでたくさんアウトプットの題材が残っているのでアウトプットしておきます。)アクセストークンを保存することで 、下記メソッドを使用することで認証時以外でもslack APIのメソッドを使用することができます。

access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], hash_token)

それでは記事内容を見ていきましょう。

JSON型で保存

外部認証時の返却値request.env['omniauth.strategy']からアクセストークンを生成するメソッドにつなげることができます。

bot_token = request.env['omniauth.strategy'].access_token
hash_token = bot_token.to_hash
access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], hash_token)

繰り返しになりますが、保存すべき値はhash_tokenの値になります。

このhash_tokenの値をDBに保存するのですがhash_tokenはJSONです。

hash_token
=> {"ok"=>true,
 "app_id"=>"xxxxxxxxxx",
 "authed_user"=>
  {"id"=>"xxxxxxxxxx",
   "scope"=>"identity.basic,identity.email,identity.avatar,identity.team",
   "access_token"=>"xoxp-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx",
   "token_type"=>"user"},
 "scope"=>"chat:write,groups:write,im:write,channels:manage,channels:read,groups:read",
 "token_type"=>"bot",
 "bot_user_id"=>"xxxxxxxxxx",
 "team"=>{"id"=>"xxxxxxxxxx", "name"=>"prof"},
 "enterprise"=>nil,
 "is_enterprise_install"=>false,
 :access_token=>"xoxb-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx",
 :refresh_token=>nil,
 :expires_at=>nil}

よってDBに保存する型はJSON型を使用します。(はじめてJSON型というものを使用しました、、、。)基本的にhashは全てJSON型の方が良さそうだと思いました。

ActiveModelのバリデータでJSON型のカラムを検証する

したがってUserカラムに追加するaccess_tokenはjson型で作成しましょう。

class DeviseTokenAuthCreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table(:users) do |t|
      ## User Info
            ・
            ・
            ・
      t.json    :access_token
    end
  end
end

あとは保存時にhash_tokenを引数として指定して、保存をしてあげるだけです。

@user = User.from_omniauth(request.env['omniauth.auth'], user_info, hash_token, channel)
# app/models/user.rb
class User < ApplicationRecord

  def self.from_omniauth(auth, user_info, hash_token, channel)
    user = find_or_initialize_by(provider: auth.provider, uid: auth.uid)
    user.password = Devise.friendly_token[0, 20] # ランダムなパスワードを作成
    user.name = user_info.dig('user', 'name')
    user.email = user_info.dig('user', 'email')
    user.access_token = hash_token
    user.remote_image_url = user_info.dig('user', 'image_192')
    user.check_team_existence(user_info.dig('team'), channel)
    user.save!
    user
  end
end

暗号化

slack APIについては今回生成したアクセストークンの値などが記されたhash_tokenを直接盗み見られてもそこまで問題ではありません。繰り返しアクセストークンを生成するメソッドを例にしますが、slackアプリのIDとなるSLACK_CLIENT_IDとSLACK_CLIENT_SECRETが知られない限り、アクセストークンを知られたとしても基本的に何もできません。

access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], hash_token)

SLACK_CLIENT_IDとSLACK_CLIENT_SECRETがあることでsecureな状態を保ってくれています。

しかし、アクセストークンを直接保存することはあまり良くないので、暗号化してから保存しましょう。

# app/models/user.rb
class User < ApplicationRecord

  # before
  before_save :encrypt_access_token, :if => Proc.new {|u|  u.access_token.present? }

  def encrypt_access_token
    key_len = ActiveSupport::MessageEncryptor.key_len
    secret = Rails.application.key_generator.generate_key('salt', key_len)
    crypt = ActiveSupport::MessageEncryptor.new(secret)
    self.access_token = crypt.encrypt_and_sign(access_token)
  end

  def self.from_omniauth(auth, user_info, hash_token, channel)
    user = find_or_initialize_by(provider: auth.provider, uid: auth.uid)
    user.password = Devise.friendly_token[0, 20] # ランダムなパスワードを作成
    user.name = user_info.dig('user', 'name')
    user.email = user_info.dig('user', 'email')
    user.access_token = hash_token
    user.remote_image_url = user_info.dig('user', 'image_192')
    user.check_team_existence(user_info.dig('team'), channel)
    user.save!
    user
  end
end

before_save :encrypt_access_tokenで保存する前にencrypt_access_tokenを実行するようにします。そのUserがアクセストークンを持っていない場合は実行しないように:ifオプションをつけています。

  before_save :encrypt_access_token, :if => Proc.new {|u|  u.access_token.present? }

暗号化方法にはいくつかの方法がありますが、上述したようにアクセストークンを暗号化する重要性がそこまで高くはないため、少し簡易的な方法をとっています。

  def encrypt_access_token
    key_len = ActiveSupport::MessageEncryptor.key_len
    secret = Rails.application.key_generator.generate_key('salt', key_len)
    crypt = ActiveSupport::MessageEncryptor.new(secret)
    self.access_token = crypt.encrypt_and_sign(access_token)
  end

これを復号するときは以下のようにするば復号できます。

def set_access_token
    encrypted_access_token = current_user.access_token
    key_len = ActiveSupport::MessageEncryptor.key_len
    secret = Rails.application.key_generator.generate_key('salt', key_len)
    crypt = ActiveSupport::MessageEncryptor.new(secret)
    decrypted_access_token = crypt.decrypt_and_verify(encrypted_access_token)
    access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], decrypted_access_token)
    return access_token
  end

Slack認証時に保存するべき値

以上までの説明でhash_tokenを保存することで再度slack用のアクセストークンを作成することができます。しかし、アプリによってはアクセストークン以外にも認証時にカラムに保存しておくべき値があります。例えば、DMをuserに送信するアプリの場合、ユーザのIDがわかっていないと送ることができません。

chat.postMessage Slack API Method

chat.postMessageメソッドはアクセストークンの値に加えて、channelのIDを必要とします。(DMを送る場合はuserのIDを必要とする)

https://i.gyazo.com/71e198f2409cfb142112c5206a70b412.png

よって認証時にuserのIDは控えておけば、のちのちDMを認証時以外で送る場合により簡単にDMを送ることができるでしょう。

アプリの用途によって必要なAPIメソッドが変わるのでそれに合わせて必要な値を認証時に保存しておきましょう。

今回は以上です。

【Slack API】user scopeとbot scope

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

最近になってようやく「Sign in with Slack」を実装できるようになったのですが、そこまで到達するのに必要なSlack API特有の知識があったのでそれについて紹介していこうかと思います。今回はその第一弾ということで、user scopeとbot scopeについて書いていきます!

今回の記事は基本的にはコードを書くということはありません。基本的には説明オンリーで話を進めていきます。

scopeとは

scopeというのは「範囲」という意味で理解している人が多そうですが、その意味を使うとSlack APIでのscopeは「権限の範囲」という意味になります。APIにどの程度アクセスすることができるかを決定するのがscopeです。

scope一覧から少し抜粋し説明します。

Scopes and permissions

例えば、チャンネルに投稿するというAPIの権限が欲しいのであれば、chat:writeという権限が必要になります。

https://i.gyazo.com/b14bf4397e70154f446ea445bbfe58b5.png

このscopeがあることで、チャンネルに投稿するためのAPIメソッドが利用可能になります。chat.postMessageメソッドはSlackに投稿するために必要なメソッドです。chat:writeの権限が付与されていることで初めて利用可能になります。

chat.postMessage Slack API Method

ちなみに、scopeによる権限は投稿に関わる権限となるため、投稿を更新するメソッドや削除するメソッドも利用可能になります。

chat.updateメソッド

chat.update Slack API Method

chat.deleteメソッド

chat.delete Slack API Method

このようにscopeがAPIの利用範囲を決定づけています。scopeの理解はSlack APIを利用する上でとても重要ですので、他の権限でもどういったメソッドが利用可能なのか今後紹介していけたらと思います。

User Scope

Slackはこのscopeの権限を付与する対象としてUserとBotを明確に区別しています。Userについての理解は簡単です。Slackを利用する私たちの情報にどれほどアクセスすることができるかという権限です。例えば、identity.basicというscopeはユーザのIDの情報を得ることができるusers.identityメソッドが利用可能になります。

users.identity Slack API Method

レスポンスとして返却されるJSON

{
    "ok": true,
    "user": {
        "name": "大ちゃん",
        "id": "U0G9QF9C6"
    },
    "team": {
        "id": "T0G9PQBBK"
    }
}

名前とユーザIDが返却されていますね。他にもユーザのアイコン画像へのアクセスやemailアドレスへのアクセスをするためのscopeがあります。

ユーザのscopeはユーザ情報を得るためのもの以外にもchat:writeのような投稿する権限なども一応設定できますが、基本的に利用頻度は少ないです。理由は権限を使わなくてもユーザがチャンネルに参加して投稿すれば言い訳ですので。ですので、基本的にはSlack アプリを作る上では、ユーザのscopeはユーザ情報へのアクセスをどの程度行うことができるかという理解で大丈夫だと思います。

Bot Scope

BotとはSlackアプリのBotのことです。Slack Botと言えば下記のアイコンをイメージする人が多いと思います。

https://livedoor.sp.blogimg.jp/joesbar/imgs/6/3/63f21d24.png

しかし、Botはこれだけでなくインストールしたアプリは全てBotになります。インストールしたアプリは下の画像のようにslack UIの左下に表示されると思います。

https://i.gyazo.com/a7fe0a1215c56dc49b62175d28aff59f.png

このアプリはメッセージを#generarlに投稿したり、他のチャンネルに投稿したりするかと思いますが、それはBotにscopeが付与されているためです。上述したようにchat:writeのような投稿する権限はユーザに必要ないということを説明しましたが、それはユーザが生きている人間で参加したり投稿したりを自由意志で行えるからです。アプリのBotにはそのような自由意志はなく、権限を与えなければ投稿などをさせることができません。このような違いからBotとUserで権限を分けているのです。

ちなみに2つの種類のscopeの見分け方ですがscope一覧の右列のtokenから確認できます。一覧にあるworkspaceとlegacyは昔使われていたらしいですが、今はほとんどは非推奨です。

https://i.gyazo.com/81e5cc2d1172fee6e7a160b1fc5954a0.gif

Scopes and permissions

【Slack API】Block Kitで投稿する方法

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

slackのBlock Kitを知っていますか?

Block Kit

Block KitはJSON形式でメッセージの装飾を表したものになります。

slackのメッセージでは他のチャットツールよりもかなり複雑なメッセージを送ることができます。

https://i.gyazo.com/2103ba7cc9c94e7371741e4d0576b1c6.png

これをJSONで表すと、、、

{
    "blocks": [
        {
            "type": "header",
            "text": {
                "type": "plain_text",
                "text": "New request",
                "emoji": true
            }
        },
        {
            "type": "section",
            "fields": [
                {
                    "type": "mrkdwn",
                    "text": "*Type:*\nPaid Time Off"
                },
                {
                    "type": "mrkdwn",
                    "text": "*Created by:*\n<example.com|Fred Enriquez>"
                }
            ]
        },
        {
            "type": "section",
            "fields": [
                {
                    "type": "mrkdwn",
                    "text": "*When:*\nAug 10 - Aug 13"
                },
                {
                    "type": "mrkdwn",
                    "text": "*Type:*\nPaid time off"
                }
            ]
        },
        {
            "type": "section",
            "fields": [
                {
                    "type": "mrkdwn",
                    "text": "*Hours:*\n16.0 (2 days)"
                },
                {
                    "type": "mrkdwn",
                    "text": "*Remaining balance:*\n32.0 hours (4 days)"
                }
            ]
        },
        {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": "<https://example.com|View request>"
            }
        }
    ]
}

このようにかなり複雑な見た目となっています。しかし、自分で装飾するのはとても簡単です!

slackが専用で直感的に作れるようにBlock Kit Builderというものが用意されています。

https://i.gyazo.com/ceecefc671b7453802a8e2870d200142.png

Sign in | Slack

これを使えば、めちゃくちゃ簡単に装飾できます。しかし、実はこれを自分のslackチャンネルにRailsアプリを使用して投稿するのに少し苦労したのでここに記録しておきます。

読者対象

  • omniauthを使用してsign in with slackが実装できているアプリをお持ちの方

以下のgemを使用すればできます。

ginjo/omniauth-slack

star数すごく少ないですが、omniauth-slackで1番使用されています。ちなみにTwitterのomniauthのstar数は569でした。悲しい、、、

※ omniauth-slackを使用した認証方法は結構時間かかると思いますので、別記事にて今度紹介したいと思います!

実装方法

①アクセストークンの取得

ではまず認証が通ってリダイレクトされる時に呼び出されるslackメソッドを空にした状態から始めます。

module Users
  class OmniauthCallbacksController < Devise::OmniauthCallbacksController
    skip_before_action :authenticate_user!, only: %i[slack failure]

    def slack

    end

まず、slack認証が通るとrequest.env['omniauth.strategy']というものが返ってきます。これを使用してslack用のアクセストークンを作ります。request.env['omniauth.strategy'].access_tokenbot_tokenというものを取得できます。

bot_token = request.env['omniauth.strategy'].access_token

bot_tokenは簡単に説明すると、slackアプリのBot専用のアクセストークンになります。ユーザー専用のトークンはbot_token.user_tokenで取り出すことができます。(bot_token, user_tokenの説明に関してはomniauth-slackを理解していることが前提ですので大幅に省きます。詳しくは今後の記事で。)

user_token = bot_token.user_token

その後、slackアプリを作る上で定番となる「SLACK CLIENT ID」、「SLACK CLIENT SECRET」を使用してBotのアクセストークンを生成することができます。第三引数にはbot_tokenをハッシュ化したものを指定します。

access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], bot_token.to_hash

使用しているメソッド(OmniAuth::Slack.build_access_token)については下記リンクを参照ください。

ginjo/omniauth-slack

access_tokenを使用することでslackのAPIを叩くことができます。ちなみに、機能するかどうかを確認する方法があります。access_token.get("/api/auth.test")を使用し、以下のようなレスポンスが返ってこれば正常に動作しています。

{
    "ok": true,
    "url": "https://subarachnoid.slack.com/",
    "team": "Subarachnoid Workspace",
    "user": "grace",
    "team_id": "T12345678",
    "user_id": "W12345678"
}

auth.testメソッドについてはこちら

auth.test Slack API Method

これまでのコード

module Users
  class OmniauthCallbacksController < Devise::OmniauthCallbacksController
    skip_before_action :authenticate_user!, only: %i[slack failure]

    def slack
      bot_token = request.env['omniauth.strategy'].access_token
            user_token = bot_token.user_token
      access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], bot_token.to_hash
    end

②ユーザ情報の取得

現状user_tokenを取得できているのでscopeを設定できていれば情報にアクセスすることができます。といっても今回はユーザーのID情報を取得できればいいのでidentity.basicのscopeだけあれば問題ないです。

https://i.gyazo.com/71eb89bfcbd81727c22ca85b5a49383d.png

ちなみにuser_token.scopesを使用することでscopeの中身を確認できます。identity.basicがあればokです!

user_token.scopes
=> {"classic"=>["identity.basic", "identity.email", "identity.avatar", "identity.team"]}

scopeを確認したらusers.identityメソッドを使用してユーザ情報を取得します。

user_info = user_token.get('/api/users.identity').parsed

返却値の例としては下記のような見た目です。

{
    "ok": true,
    "user": {
        "name": "Sonny Whether",
        "id": "U0G9QF9C6"
    },
    "team": {
        "id": "T0G9PQBBK"
    }
}

user_info.dig("user", "id")でidを取得することができます。

user_id = user_info.dig("user", "id")

users.identityメソッドについてはこちら

users.identity Slack API Method

これまでのコード

module Users
  class OmniauthCallbacksController < Devise::OmniauthCallbacksController
    skip_before_action :authenticate_user!, only: %i[slack failure]

    def slack
      bot_token = request.env['omniauth.strategy'].access_token
            user_token = bot_token.user_token
      access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], bot_token.to_hash
            user_info = user_token.get('/api/users.identity').parsed
            user_id = user_info.dig("user", "id")
    end

③ブロックメッセージの準備

それでは実際にBolt Kitのメッセージを変数に代入しておきます。代入するメッセージは最初にお見せしたJSONをそのまま使用してみます。

[再掲]

{
    "blocks": [
        {
            "type": "header",
            "text": {
                "type": "plain_text",
                "text": "New request",
                "emoji": true
            }
        },
        {
            "type": "section",
            "fields": [
                {
                    "type": "mrkdwn",
                    "text": "*Type:*\nPaid Time Off"
                },
                {
                    "type": "mrkdwn",
                    "text": "*Created by:*\n<example.com|Fred Enriquez>"
                }
            ]
        },
        {
            "type": "section",
            "fields": [
                {
                    "type": "mrkdwn",
                    "text": "*When:*\nAug 10 - Aug 13"
                },
                {
                    "type": "mrkdwn",
                    "text": "*Type:*\nPaid time off"
                }
            ]
        },
        {
            "type": "section",
            "fields": [
                {
                    "type": "mrkdwn",
                    "text": "*Hours:*\n16.0 (2 days)"
                },
                {
                    "type": "mrkdwn",
                    "text": "*Remaining balance:*\n32.0 hours (4 days)"
                }
            ]
        },
        {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": "<https://example.com|View request>"
            }
        }
    ]
}

しかし、ここで問題点がありまして、それがBolt KitのJSONが長すぎ問題です。これをコントローラに置いておいたらとんでもなくFat controllerになってしまいます。そのためBolt Kit用のmoduleを作成し、そこにメソッドとしてBolt KitのJSONを返すメソッドを定義しておきます。

SlackApiBlockKitをモジュールとして作成し、それを認証コントローラでincludeします。メッセージを返すメソッドはget_block_kit_msgとしましょう。

module SlackApiBlockKit

    def get_block_kit_msg
      message = '[        {           "type": "header",           "text": {                 "type": "plain_text",               "text": "New request",              "emoji": true             }       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*Type:*\nPaid Time Off"                },              {                   "type": "mrkdwn",                   "text": "*Created by:*\n<example.com|Fred Enriquez>"              }           ]       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*When:*\nAug 10 - Aug 13"              },              {                   "type": "mrkdwn",                   "text": "*Type:*\nPaid time off"                }           ]       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*Hours:*\n16.0 (2 days)"               },              {                   "type": "mrkdwn",                   "text": "*Remaining balance:*\n32.0 hours (4 days)"                 }           ]       },      {           "type": "section",          "text": {                 "type": "mrkdwn",               "text": "<https://example.com|View request>"          }       }   ]'
      return ERB::Util.url_encode(message)
    end
end
module Users
  class OmniauthCallbacksController < Devise::OmniauthCallbacksController
    skip_before_action :authenticate_user!, only: %i[slack failure]
    include SlackApiBlockKit

それではメソッドの中にメッセージを仕込みます。注意点としてはJSONの"blocks"の中の配列を使用してください。また、その配列を文字列として認識するために、``で囲いましょう。

message = '[         {           "type": "header",           "text": {                 "type": "plain_text",               "text": "New request",              "emoji": true             }       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*Type:*\nPaid Time Off"                },              {                   "type": "mrkdwn",                   "text": "*Created by:*\n<example.com|Fred Enriquez>"              }           ]       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*When:*\nAug 10 - Aug 13"              },              {                   "type": "mrkdwn",                   "text": "*Type:*\nPaid time off"                }           ]       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*Hours:*\n16.0 (2 days)"               },              {                   "type": "mrkdwn",                   "text": "*Remaining balance:*\n32.0 hours (4 days)"                 }           ]       },      {           "type": "section",          "text": {                 "type": "mrkdwn",               "text": "<https://example.com|View request>"          }       }   ]'

APIを叩くためにencodeする必要があるため、このメッセージをencodeします。slackのencode方法に対応したERB::Util.url_encodeメソッドを選定しました。他のencode方法も試しましたがうまく動かなかったため、Block Kitをencodeする際はERB::Util.url_encodeを使用しましょう。

return ERB::Util.url_encode(message)

よってメッセージを取得するメソッドは以下のようになります。

def get_block_kit_msg
  message = '[        {           "type": "header",           "text": {                 "type": "plain_text",               "text": "New request",              "emoji": true             }       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*Type:*\nPaid Time Off"                },              {                   "type": "mrkdwn",                   "text": "*Created by:*\n<example.com|Fred Enriquez>"              }           ]       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*When:*\nAug 10 - Aug 13"              },              {                   "type": "mrkdwn",                   "text": "*Type:*\nPaid time off"                }           ]       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*Hours:*\n16.0 (2 days)"               },              {                   "type": "mrkdwn",                   "text": "*Remaining balance:*\n32.0 hours (4 days)"                 }           ]       },      {           "type": "section",          "text": {                 "type": "mrkdwn",               "text": "<https://example.com|View request>"          }       }   ]'
  return ERB::Util.url_encode(message)
end

これをコントローラ側で取り出します。

encoded_mgs = get_block_kit_msg

これまでのコード

module Users
  class OmniauthCallbacksController < Devise::OmniauthCallbacksController
    skip_before_action :authenticate_user!, only: %i[slack failure]
        include SlackApiBlockKit

    def slack
      bot_token = request.env['omniauth.strategy'].access_token
            user_token = bot_token.user_token
      access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], bot_token.to_hash
            user_info = user_token.get('/api/users.identity').parsed
            user_id = user_info.dig("user", "id")
            encoded_mgs = get_block_kit_msg
    end
module SlackApiBlockKit
    def get_block_kit_msg
      message = '[        {           "type": "header",           "text": {                 "type": "plain_text",               "text": "New request",              "emoji": true             }       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*Type:*\nPaid Time Off"                },              {                   "type": "mrkdwn",                   "text": "*Created by:*\n<example.com|Fred Enriquez>"              }           ]       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*When:*\nAug 10 - Aug 13"              },              {                   "type": "mrkdwn",                   "text": "*Type:*\nPaid time off"                }           ]       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*Hours:*\n16.0 (2 days)"               },              {                   "type": "mrkdwn",                   "text": "*Remaining balance:*\n32.0 hours (4 days)"                 }           ]       },      {           "type": "section",          "text": {                 "type": "mrkdwn",               "text": "<https://example.com|View request>"          }       }   ]'
      return ERB::Util.url_encode(message)
    end
end

④メッセージを投稿

最後にメッセージを投稿するメソッドとして、chat.postMessageメソッドを使用します。本来であればチャンネルに投稿するためのメソッドですが、userのIDを使用することでDMを送ることができます。

以下公式の内容です。

If you simply want your app's bot user to start a 1:1 conversation with another user in a workspace, provide the user's user ID as the channel value and a direct message conversation will be opened, if it hasn't already. Resultant messages and associated direct message objects will have a direct message ID you can use from that point forward, if you'd rather.

ここではaccess_tokenを使用するため、access_tokenにchat:writeのscopeがあることも確認してください。user_tokenと同様にaccess_token.scopesで確認できます。

access_token.scopes
=> {"classic"=>["chat:write"]}

それでは実際にメソッドを使用しAPI を叩きます。

access_token.post("api/chat.postMessage?channel=#{user_id}&blocks=#{encoded_mgs}").parsed

するとDMが実際に飛んできたかと思います。

https://i.gyazo.com/d93b9840d06217f36970b114064f91f2.png

chat.postMessageメソッドについてはこちら

chat.postMessage Slack API Method

最終コード

module Users
  class OmniauthCallbacksController < Devise::OmniauthCallbacksController
    skip_before_action :authenticate_user!, only: %i[slack failure]
        include SlackApiBlockKit

    def slack
      bot_token = request.env['omniauth.strategy'].access_token
            user_token = bot_token.user_token
      access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], bot_token.to_hash
            user_info = user_token.get('/api/users.identity').parsed
            user_id = user_info.dig("user", "id")
            encoded_mgs = get_block_kit_msg
            access_token.post("api/chat.postMessage?channel=#{user_id}&blocks=#{encoded_mgs}").parsed
    end
module SlackApiBlockKit
    def get_block_kit_msg
      message = '[        {           "type": "header",           "text": {                 "type": "plain_text",               "text": "New request",              "emoji": true             }       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*Type:*\nPaid Time Off"                },              {                   "type": "mrkdwn",                   "text": "*Created by:*\n<example.com|Fred Enriquez>"              }           ]       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*When:*\nAug 10 - Aug 13"              },              {                   "type": "mrkdwn",                   "text": "*Type:*\nPaid time off"                }           ]       },      {           "type": "section",          "fields": [               {                   "type": "mrkdwn",                   "text": "*Hours:*\n16.0 (2 days)"               },              {                   "type": "mrkdwn",                   "text": "*Remaining balance:*\n32.0 hours (4 days)"                 }           ]       },      {           "type": "section",          "text": {                 "type": "mrkdwn",               "text": "<https://example.com|View request>"          }       }   ]'
      return ERB::Util.url_encode(message)
    end
end

注意点としては、Block Kitのメッセージは必ずERB::Util.url_encodeメソッドを使用してください。ここでだいぶ時間がかかりました。一応公式にもSlackが使用しているencode方法が載っているのですが、実際にRubyでBlock Kitをアクセストークンから飛ばしている記事はなかったので注意が必要です。

【Gem】fog-aws

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

ついにPFを昨日アップロードしたのですが、案の定複数の不具合が起きてしまいました。その一つにcarriwaveの画像が読み込まれないというものがありました。これはよくよく調べるとherokuではcarriwaveをそのまま使用できないという記事が複数あったので、自分の調査不足が原因でもあります。

解決策としてはfog-awsを使用して画像をAWS上にアップロードすることだそうですので、こちらで備忘録として残しておきます。まだAWSの登録をしていない方は登録を済ませてください。

AWSでS3の設定

まずはAWSの設定から。AWSはほとんど触ったことがないので疑問点が多いですが、一つ一つ調べていきます。

まずそもそもS3というのはなんなのかと思って調べてみました。S3は「データを保存したり取得できるオブジェクトストレージ」というのが公式の記述で書かれています。データをAWS上に保存するという比較的シンプルでよく使うようなサービスなのかなと思います。

それではAWSの設定を始めていきます。 「S3」のダッシュボード画面に遷移しバケットを作成をクリックしてください。

https://i.gyazo.com/5ce86d84d4d33c576c37f99e31e44b1b.png

一般的な設定の画面ではバケット名に任意の名前と、リージョンにアジアパジフィック(東京)を選択します。

https://i.gyazo.com/d6dc13588976b34dadd5e2c2d7070f3a.png

※ 画像のバケット名だと保存できません!バケット命名規則_は使えなかったみたいですね、、、。

Bucket naming rules

このバケットのブロックパブリックアクセス設定では以下のように項目を設定します。

https://i.gyazo.com/7a042206d4dc764ac74bfe388d09c729.png

これで「バケットを作成」をクリックしてください。これでバケットが作成されたと思いまうs。

次にS3のポリシーを編集していきます。作成したバケットの管理画面に遷移します。アクセス許可のタブを開き下にスクロールすると、バケットポリシーという画面があると思います。

https://i.gyazo.com/a99e7411228a3f7fae0ccd93ed657d00.gif

編集ボタンをクリックすると下記画面が表示されます。

https://i.gyazo.com/911c73aa22d680cd8baa9fb8a9092a4f.png

ここでポリシーを作成するのですが、このポリシーを作成する上で「ポリシージェネレーター」を使用します。

入力方法としては下記記事を参考にするといいと思います。

AWS のポリシーを楽に作成する方法(ポリシージェネレーター) - Qiita

ポリシーを無事に生成したら生成されたJSONバケットポリシーにそのまま貼り付け保存します。

これでバケットポリシーの設定は完了です。基本的なAWSの設定は完了となります。

fog-awsの導入

AWSの設定が長かったですが、いよいよ実際のアプリにS3を反映させていきます。

fog-awsをインストールします。

※ 意外と依存関係で新しくインストールされるgemがかなりありました。

gem 'fog-aws'

bundle install
Fetching formatador 0.2.5
Installing formatador 0.2.5
Fetching mime-types-data 3.2021.0225
Installing mime-types-data 3.2021.0225
Fetching mime-types 3.3.1
Installing mime-types 3.3.1
Fetching fog-core 2.2.4
Installing fog-core 2.2.4
Using multi_json 1.15.0
Fetching fog-json 1.2.0
Installing fog-json 1.2.0
Fetching fog-xml 0.1.3
Installing fog-xml 0.1.3
Fetching ipaddress 0.8.3
Installing ipaddress 0.8.3
Fetching fog-aws 3.10.0
Installing fog-aws 3.10.0

carriwaveの導入時に設定したapp/uploaders/image_uploader.rbを編集します。storage :fileコメントアウトstorage :fogコメントアウトから外します。

# app/uploaders/image_uploader.rb
# Choose what kind of storage to use for this uploader:
# storage :file
storage :fog

config/initializers/carrierwave.rbを新規で作成します。ファイルを以下のように記述します。

require 'carrierwave/storage/abstract'
require 'carrierwave/storage/file'
require 'carrierwave/storage/fog'

CarrierWave.configure do |config|
  config.storage :fog
  config.fog_provider = 'fog/aws'
  config.fog_directory  = 'prof-chan-carriwave' # AWSで作成したバケット名
  config.fog_credentials = {
    provider: 'AWS',
    aws_access_key_id: ENV['AWS_ACCESS_KEY_ID'],
    aws_secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'],
    region: ENV['AWS_DEFAULT_REGION'],
    path_style: true
  }

end

環境変数の設定には定番のgemであるdotenvを使用します。導入はすごく簡単ですので自分の下記記事を参照してください。

【Gem】dotenv - 大ちゃんの駆け出し技術ブログ

.envファイルに書き込むものは以下の3つです。これはバケット作成時にダウンロードしたcsvファイルから参照できます。

AWS_ACCESS_KEY_ID='AWSで作成したAccess key ID'
AWS_SECRET_ACCESS_KEY='AWSで作成したSecret access key'
AWS_DEFAULT_REGION='ap-northeast-1'

この環境変数をherokuでも設定します。

ブラウザから直接入力してもコンソールから直接入力しても大丈夫です。

自分はherokuから設定しました。環境変数の設定ですので画面はお見せできないですが、下記の項目から環境変数を設定できます。

https://i.gyazo.com/095fe74f6c012a3dd875c77266e610d5.png

じつはこれで設定は完了しています。実際にcarriwaveに画像を投稿すると作成したバケットの配下に画像が格納されているのがわかるかと思います。

https://i.gyazo.com/77518266ed135f0a457787bc531df99c.png

AWSの設定の複雑さに比べるとこちらは断然導入がしやすかったですね。gem最高。

参考記事

Rails, Laravel(画像アップロード)向けAWS(IAM:ユーザ, S3:バケット)の設定 - Qiita

Rails+CarrierWave+heroku環境でAWS S3へ画像をアップロードする - Qiita

ams_lazy_relationships

はじめに

Active Model Serializersというgemを使用していたんですけど、N + 1問題が多く生じるという問題を抱えています。mapメソッドを使用して自分でロジックを組んで解消していくような実装が多く見受けられてたのですが、自分のポートフォリオはモデル数が15を超えているため、あまり自分でロジックを組んで時間をかけたくありませんでした。gemでどうにかしてよしなにやってくれないかと探していたところ、下記のgemを発見しました。

ams_lazy_relationships

Bajena/ams_lazy_relationships

star数自体はそこまで多くないですが、自作で新しいロジックを組むことなく各シリアライザーを編集するだけで済むらしく、興味本位で使ってみました。すると一瞬でN + 1問題が解決できて驚き。仕組みは全く分かりませんがありがたく使わせてもらいました。

そこで勉強しようとQiitaで探そうと思ったところ、、、

https://i.gyazo.com/949b0c07a8338131abc8c908f9df2a08.png

なんとわずか1記事のみ。しかも、この方の記事はams_lazy_relationshipsについて触れているだけであって、gemについては書いていません。つまり0記事。これは記事を書くチャンスと思い本記事を書くこととしました。

導入が相変わらず長くすみません。記事を書いていきます。構成としては、まずgemのドキュメントを訳しつつ概要を把握し、実際に導入する中で使用方法を明確にさせていきます。

ドキュメントの訳

Eliminates N+1 queries problem in Active Model Serializers gem thanks to batch loading provided by a great BatchLoader gem.

Active Model Serializers gemのN+1クエリの問題を解消するgemということで、Active Model Serializers専用のgemであることがわかります。BatchLoaderは依存gemですね。こちらもN + 1問題を解決するために使われていますが、Active Model Serializerのためのgemというわけではなく、より広義のgemみたいですね。ちなみにstar数は800を超えているのでかなり使われているgemかと思います。

exAspArk/batch-loader

次にams_lazy_relationshipsの特徴についてです。これは公式gemに載っていなかったので、別記事を参照しています。


  • バッチローディングを使用して、複雑なオブジェクトトレスをシリアライズする際のN+1クエリを防止する。
  • 過剰なデータをロードしない(Railsが不適切に使用された場合に発生するような)
  • すべてのリレーションシップがActiveRecordモデルではない場合でも、N+1を取り除くことができる(例えば、一部のレコードはMySQL DBに、他のモデルはCassandraに保存されている場合など

Use lazy relationships to eliminate N+1 queries in your Rails apps

全て詳しく説明すると長文になるため、ここではかなりよしなにN + 1問題を解決してくれるgemという理解だけに留めておいてください。正直このgemはかなりよしなにやってくれるgemなので仕組みはほとんど書かれていなかったので、、


次に具体的な使用方法についてですが、これが恐ろしく簡単に記載されています。下記のようにserializerでアソシエーションをしている場合、N + 1問題が発生します。

class BlogPostSerializer < BaseSerializer
  has_many :comments
end

しかし、上記の記述を以下のように変更するだけでN + 1問題は解決できるとのことです。

class BlogPostSerializer < BaseSerializer
  lazy_has_many :comments
end

よしなにやりすぎてませんか??笑ただ接頭辞にlazyを付け足すだけなんて、、

もう少し詳しく見ていきます。公式のgemでは以下のように複数の使用例が紹介されていましたのでそおれぞれ見ていきます。

class UserSerializer < BaseSerializer
  # Short version - preloads a specified ActiveRecord relationship by default
  lazy_has_many :blog_posts
  
  # Works same as the previous one, but the loader option is specified explicitly
  lazy_has_many :blog_posts,
                serializer: BlogPostSerializer,
                loader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts)
  
  # The previous one is a shorthand for the following lines:
  lazy_relationship :blog_posts, loader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts)
  has_many :blog_posts, serializer: BlogPostSerializer do |serializer|
    # non-proc custom finder will work as well, but it can produce redundant sql
    # queries, please see [Example 2: Modifying the relationship before rendering](#example-2-modifying-the-relationship-before-rendering)
    -> { serializer.lazy_blog_posts }
  end
   
  lazy_has_one :poro_model, loader: AmsLazyRelationships::Loaders::Direct.new(:poro_model) { |object| PoroModel.new(object) }
  
  lazy_belongs_to :account, loader: AmsLazyRelationships::Loaders::SimpleBelongsTo.new("Account")
  
  lazy_has_many :comment, loader: AmsLazyRelationships::Loaders::SimpleHasMany.new("Comment", foreign_key: :user_id)
end

まず上から1つ目と2つ目ですがどちらも同じ意味だそうです。

  # Short version - preloads a specified ActiveRecord relationship by default
  lazy_has_many :blog_posts
  
  # Works same as the previous one, but the loader option is specified explicitly
  lazy_has_many :blog_posts,
                serializer: BlogPostSerializer,
                loader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts)

上の方は全てよしなにやっている場合で、下はよしなにやってくれるが一応設定を明示していますね。serializer: BlogPostSerializerでどのserializerを使用するか明示しています。目新しい部分はloaderオプションの箇所であるloader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts)です。

複数の使用例で記載したコードを見るにams_lazy_relationshipsには複数のLoadersクラスがあるようです。


  • AmsLazyRelationships::Loaders::Association

ActiveRecordのアソシエーション(has_one/has_many/has_many-through/belongs_to)をバッチでロードする。特にloaderオプションで明示がない場合、こちらのオプションがデフォルトとして使用される。

  • AmsLazyRelationships::Loaders::SimpleBelongsTo

シリアル化されたオブジェクトの外部キーを使用して、ActiveRecordモデルを一括してロードする。SimpleBelongsToとあるようにbelongs_to用に使われる。

  • AmsLazyRelationships::Loaders::SimpleHasMany

シリアル化されたオブジェクトの外部キーを使用して、ActiveRecordモデルを一括してロードする。SimpleHasManyとあるようにhas_many用に使われる。

  • AmsLazyRelationships::Loaders::Direct

必要な時にだけよしなに実行されるらしい。あまり詳しくは記載なし。


loadersオプションはデフォルトでAssociationですが、基本的にこれだけで完結するのかなと。ただ、オプションの明示が必要な時もあります。例えばシリアライザーの名前と実際のモデル名が異なる場合です。下記の例の場合、シリアライザーの名前はPostであるが、実際のモデルはBlogPostモデルですので、loaderオプションで明示してあげます。

class PostSerializer < BaseSerializer
  lazy_has_many :comments, serializer: CommentSerializer,
    loader: AmsLazyRelationships::Loaders::Association.new(
              "BlogPost", :comments
            )
end

導入手順

公式の流れに沿って、実際に導入していきます。

# Gemfile
gem "ams_lazy_relationships"
$ bundle
Fetching batch-loader 2.0.1
Installing batch-loader 2.0.1
Fetching ams_lazy_relationships 0.3.2
Installing ams_lazy_relationships 0.3.2

各自シリアライザーのベースとなるシリアライザーにinclude AmsLazyRelationships::Coreを記載します。

class ApplicationSerializer < ActiveModel::Serializer
  include AmsLazyRelationships::Core
end

また、注意点として、上述したようにこのgemはBatchLoaderを多用しています。gemの仕様上HTTPリクエストの間にバッチローダのキャッシュをクリアすることを推奨しています。方法はconfig配下のapplication.rbに以下を記載します。

# application.rb

config.middleware.use BatchLoader::Middleware

準備はこれだけです。

では導入前のN + 1問題が起きている状態を見てみます。修正前のシリアライザーの状態です。

# app/serializers/question_block_serializer.rb
class QuestionBlockSerializer < ApplicationSerializer
  attributes :id, :title
  has_many   :question_items,  serializer: QuestionItemSerializer
  has_many :question_block_likes
  has_many :users, through: :question_block_likes
end

質問ブロックみたいなモデルがあってそこに質問項目であるquestion_itemsがhas_manyの関係で表示、また、質問ブロックに対して言い値機能を実装しているので、usershas_many :users, throughの関係でアソシエーションが結ばれています。

ここで実際にリクエストを送ります。N + 1問題検出用のgemであるbulletを使用していますので、bulletのalertがターミナル上に表示されます。

GET /api/v1/question_blocks
USE eager loading detected
  QuestionBlock => [:question_items]
  Add to your query: .includes([:question_items])
Call stack

GET /api/v1/question_blocks
USE eager loading detected
  QuestionBlock => [:question_block_likes]
  Add to your query: .includes([:question_block_likes])
Call stack

GET /api/v1/question_blocks
USE eager loading detected
  QuestionBlock => [:users]
  Add to your query: .includes([:users])
Call stack

GET /api/v1/question_blocks
USE eager loading detected
  QuestionBlockLike => [:user]
  Add to your query: .includes([:user])
Call stack

これだけで4つものN + 1問題が検出されました。長すぎるのでここには少ししか載せませんが、ターミナル上にはたくさんのSQL文が飛んでいます。

  User Load (1.0ms)  SELECT `users`.* FROM `users` INNER JOIN `question_block_likes` ON `users`.`id` = `question_block_likes`.`user_id` WHERE `question_block_likes`.`question_block_id` = 66
  ↳ app/controllers/api/v1/question_blocks_controller.rb:12:in `index'
  QuestionItem Load (0.9ms)  SELECT `question_items`.* FROM `question_items` WHERE `question_items`.`question_block_id` = 67
  ↳ app/controllers/api/v1/question_blocks_controller.rb:12:in `index'
  QuestionBlockLike Load (1.5ms)  SELECT `question_block_likes`.* FROM `question_block_likes` WHERE `question_block_likes`.`question_block_id` = 67
  ↳ app/controllers/api/v1/question_blocks_controller.rb:12:in `index'
  ・
    ・
    ・

ここでlazy_has_manyを使用してみます。面倒なので全てにつけてみましょう。

# app/serializers/question_block_serializer.rb
class QuestionBlockSerializer < ApplicationSerializer
  attributes :id, :title, :owing_user
  lazy_has_many   :question_items,  serializer: QuestionItemSerializer
  belongs_to :profile_block,   serializer: ProfileBlockSerializer
  lazy_has_many :question_block_likes
  lazy_has_many :users, through: :question_block_likes
end

実際にQuestionBlockについて発行されたSQLは下記の部分のみでした。


SQL (191.2ms) SELECT question_blocks.id AS t0_r0, question_blocks.title AS t0_r1, question_blocks.profile_block_id AS t0_r2, question_blocks.created_at AS t0_r3, question_blocks.updated_at AS t0_r4, profile_blocks.id AS t1_r0, profile_blocks.user_id AS t1_r1, profile_blocks.created_at AS t1_r2, profile_blocks.updated_at AS t1_r3, users.id AS t2_r0, users.provider AS t2_r1, users.uid AS t2_r2, users.encrypted_password AS t2_r3, users.reset_password_token AS t2_r4, users.reset_password_sent_at AS t2_r5, users.allow_password_change AS t2_r6, users.remember_created_at AS t2_r7, users.name AS t2_r8, users.image AS t2_r9, users.email AS t2_r10, users.role AS t2_r11, users.team_id AS t2_r12, users.created_at AS t2_r13, users.updated_at AS t2_r14, teams.id AS t3_r0, teams.name AS t3_r1, teams.workspace_id AS t3_r2, teams.image AS t3_r3, teams.created_at AS t3_r4, teams.updated_at AS t3_r5 FROM question_blocks LEFT OUTER JOIN profile_blocks ON profile_blocks.id = question_blocks.profile_block_id LEFT OUTER JOIN users ON users.id = profile_blocks.user_id LEFT OUTER JOIN teams ON teams.id = users.team_id WHERE teams.workspace_id = '852021469278993732'


bulletのalertも表示されていません。本当に簡単に導入できてびっくりしました。。

しかし、実際にはこのまま脳死で使用していくのはブラックボックスすぎるのでさらに理解を深められたら後述で本gemについて加筆していきたいと思います。

参考記事

Use lazy relationships to eliminate N+1 queries in your Rails apps

Bajena/ams_lazy_relationships

exAspArk/batch-loader