Asana API Webhooksを使ってメッセージ通知を飛ばす

はじめに

Asana API Webhooksの概要を理解し、そのWebhooksをトリガーとしたメッセージ通知の実装方法を整理する。

環境

  • タスク管理ツール:Asana
  • メッセージ:Telegram
  • アプリ:Ruby on Rails 5

Asana Webhooksの概要

公式ドキュメント

冒頭の説明

Webhooks allow an application to be notified of changes. This is in addition to the ability to fetch those changes directly as Events – in fact, Webhooks are just a way to receive Events via HTTP POST at the time they occur instead of polling for them. For services accessible via HTTP this is often vastly more convenient, and if events are not too frequent can be significantly more efficient.

In both cases, however, changes are represented as Event objects – refer to the Events documentation for more information on what data these events contain.

NOTE: While Webhooks send arrays of Event objects to their target, the Event objects themselves contain only IDs, rather than the actual resource they are referencing. So while a normal event you receive via GET /events would look like this:

(和訳)
Webhooksはアプリケーションに変更を知らせることができます。これは、変更を”Events”として直接的に取得できるようにする追加機能です。実際に、WebhooksはEventsが起こったタイミングでHTTP POSTを通じてその情報を受け取る手段に過ぎません。(以下、省略)

取得できるデータ

Webhooksで取得できるデータは以下のような感じで、具体的な情報はあまりない。

{
  "resource": 1337,
  "parent": null,
  "created_at": "2013-08-21T18:20:37.972Z",
  "user": 1123,
  "action": "changed",
  "type": "task"
}

Asana Webhooksの利用手順

Webhookの作成

Webhooksを利用するためには、まずAPI POSTリクエストでWebhookを作成しないといけない。

# Request
curl -H "Authorization: Bearer <personal_access_token>" \
-X POST https://app.asana.com/api/1.0/webhooks \
-d "resource=8675309" \
-d "target=https://example.com/receive-webhook/7654"

resourceにはプロジェクトやタスクのIDを用いる。
targetはWebhooksを受け取るエンドポイント。

X-Hook-Secretのハンドシェイク

先ほどのPOSTリクエストが上手くいけば、ターゲットに “X-Hook-Secret” が送られてくる。

# Handshake sent to https://example.com/
POST /receive-webhook/7654
X-Hook-Secret: b537207f20cbfa02357cf448134da559e8bd39d61597dcd5631b8012eae53e81

この “X-Hook-Secret” をヘッダにセットし、200リクエストを返すことでハンドシェイクが完了する。

# Handshake response sent by example.com
HTTP/1.1 200
X-Hook-Secret: b537207f20cbfa02357cf448134da559e8bd39d61597dcd5631b8012eae53e81

# Response
HTTP/1.1 201
{
  "data": {
    "id": 43214,
    "resource": {
      "id": 8675309,
      "name": "Bugs"
    },
    "target": "https://example.com/receive-webhook/7654",
    "active": false,
    "last_success_at": null,
    "last_failure_at": null,
    "last_failure_content": null
  }
}

ハンドシェイクが適切に完了すると、Asana API Webhookが作成される。

Webhooks受け取り後の処理

前述の通り、Webhooksで飛んでくる情報はめちゃくちゃシンプルなので、それだけでは以下のようなメッセージ通知のトリガーを識別することができない。

  • ユーザーへのタスク割当
  • タスク期日変更
  • コメント投稿
  • タスク完了

したがって、受け取った情報を使ってAPIコールを行い、追加情報を取得してメッセージ通知の要否を判断する必要がある。

Railsアプリへの実装例

APIコール部分(Service)

まずAPIコールを実行できるようにする。

公式のgemライブラリ ruby-asana をインストールしてくる。

gem 'asana'
$ bundle install

Serviceオブジェクトを作成。


class AsanaService
  attr_accessor :client
  def initialize
    @client =
      Asana::Client.new do |c|
        c.authentication :oauth2, bearer_token: Rails.application.secrets.asana_bearer_token
      end
  end
end

このServiceオブジェクトのclientを使えば楽にAPIコールを実行できるようになる。

pry(main)> service = AsanaService.new
=> #<AsanaService:0x007xxxxxxxxx
 @client=
  #<Asana::Client:0x007xxxxxxxxx
   @http_client=
    #<Asana::HttpClient:0x007xxxxxxxxx
     @adapter=:net_http,
     @authentication=#<Asana::Authentication::OAuth2::BearerTokenAuthentication:0x007xxxxxxxxx @token="xxxxxxxxxxxxxxxxxxxxxxxxxx">,
     @config=nil,
     @debug_mode=nil,
     @environment_info=#<Asana::HttpClient::EnvironmentInfo:0x007xxxxxxxxx @client_version="0.6.3", @openssl_version="OpenSSL 1.0.2l  25 May 2017", @os="darwin", @user_agent="ruby-asana v0.6.3">>>>
pry(main)> client = service.client
=> #<Asana::Client:0x007xxxxxxxxx
 @http_client=
  #<Asana::HttpClient:0x007xxxxxxxxx
   @adapter=:net_http,
   @authentication=#<Asana::Authentication::OAuth2::BearerTokenAuthentication:0x007xxxxxxxxx @token="xxxxxxxxxxxxxxxxxxxxxxxxxx">,
   @config=nil,
   @debug_mode=nil,
   @environment_info=#<Asana::HttpClient::EnvironmentInfo:0x007xxxxxxxxx @client_version="0.6.3", @openssl_version="OpenSSL 1.0.2l  25 May 2017", @os="darwin", @user_agent="ruby-asana v0.6.3">>>

workspaces

pry(main)> client.workspaces.find_all
=> #<Asana::Collection<Asana::Resources::Workspace> [#<Asana::Workspace id: 1111111111111, name: "Workspace 1">, #<Asana::Workspace id: 1111111111222, name: "Workspace 2">]>

projects

pry(main)> client.projects.find_all(workspace: 1111111111111)
=> #<Asana::Collection<Asana::Resources::Project> [#<Asana::Project id: 6666666666661, name: "Purchase">, #<Asana::Project id: 6666666666662, name: "Marketing">, #<Asana::Project id: 6666666666663, name: "CRM">]>

tasks

pry(main)> client.tasks.find_all(project: 6666666666661)
=> #<Asana::Collection<Asana::Resources::Task> [#<Asana::Task id: 86652013161884, name: "レザージャケット">, #<Asana::Task id: 86648690737551, name: "柄ものインナー">, #<Asana::Task id: 86648716135169, name: "カーディガン">]>

stories

pry(main)> client.stories.find_by_task(task: 86652013161884)
=> #<Asana::Collection<Asana::Resources::Story> [#<Asana::Story id: 86652013161885, created_at: "2016-02-02T18:54:29.029Z", created_by: {"id"=>46458593978690, "name"=>"Private User"}, text: "added to Purchase", type: "system">, #<Asana::Story id: 91036510856970, created_at: "2016-02-17T14:58:30.220Z", created_by: {"id"=>46458593978690, "name"=>"Private User"}, text: "コメントのテスト投稿", type: "comment">, #<Asana::Story id: 274661526838795, created_at: "2017-02-16T17:40:42.546Z", created_by: {"id"=>215997355802301, "name"=>"test user"}, text: "marked this task complete", type: "system">]>
pry(main)> client.stories.find_by_id(86652013161885)
=> #<Asana::Story id: 86652013161885, created_at: "2016-02-02T18:54:29.029Z", created_by: {"id"=>46458593978690, "name"=>"Private User"}, source: "web", target: {"id"=>86652013161884, "name"=>"レザージャケット"}, text: "added to Purchase", type: "system">

Webhooks受取部分(Controller)

Webhooksの作成

GETリクエストからAsanaプロジェクトIDごとにWebhookを作成できるようにする。


# routes.rb
Rails.application.routes.draw do
  get  'asana_webhooks/create/:project_id'  => 'asana_webhooks#create'
end

class AsanaWebhooksController < ApplicationController
  def create
    project_id = params[:project_id]
    return if project_id.blank?
    service = AsanaService.new
    client = service.client
    args = {
      resource: project_id,
      target: 'https://example.com/asana_webhooks/receive_payload/' + project_id
    }
    asana_webhook = AsanaWebhook.find_or_create_by(project_id: project_id)
    return if asana_webhook.x_hook_secret.present?
    res = client.webhooks.create(args)
    render json: { res: res }
  rescue Asana::Errors::InvalidRequest => e
    render json: { asana_error: e }
  end
end

ブラウザから “https://example.com/asana_webhooks/create/[project_id]” と叩けば、そのプロジェクトのWebhookを生成できる。

Webhooksの受取

Asanaから飛んでくるデータは以下の2種類。

  • asana_webhooks#createの実行(client.webhooks.createの実行)により返って来るハンドシェイク用のデータ
  • 通常のeventsデータ

したがって、この2種類で処理を分ける必要がある。実装例は以下の通り。


# routes.rb
Rails.application.routes.draw do
  post 'asana_webhooks/receive/:project_id' => 'asana_webhooks#receive'
end

class AsanaWebhooksController < ApplicationController
  protect_from_forgery except: %i[receive]
  skip_before_action :authenticate_user!

  ASANA_BASE_URL = 'https://app.asana.com/0/'.freeze

  # Asana Webhooksの受取口
  def receive
    project_id = params[:project_id]
    asana_webhook = AsanaWebhook.find_by!(project_id: project_id)
    # 初回handshake
    if asana_webhook.x_hook_secret.blank?
      x_hook_secret = request.headers['HTTP_X_HOOK_SECRET']
      asana_webhook.update(x_hook_secret: x_hook_secret)
      response.headers['X-HOOK-SECRET'] = x_hook_secret
    # 初回handshake以降
    else
      # POST data
      data = params.to_unsafe_hash

      # Signatureの検証
      # HMAC SHA256でハッシュ化してもSignatureと合わないため、一旦コメントアウト
      # セキュリティを高めたい場合、以下の実装が必要
      # match_x_hook_signature(asana_webhook.x_hook_secret, data)

      # TelegramチャットIDセット
      asana_project =
        Settings.asana.projects.select { |_k, v| v == project_id }
      chat_id =
        eval('Settings.telegram.chats.' + asana_project.keys.first) if asana_project.present?
      # events parmasセット
      events = data.dig('events')
      events.each do |event|
        break if chat_id.blank?
        begin
          if event['type'] == 'story'
            next if asana_event_exists?(asana_webhook, event)
            # 追加情報取得
            story = asana_story(event['resource'])
            task  = asana_task(event['parent'])
            uam = UserAccountMap.find_by(asana_id: task.assignee['id']) if task.assignee.present?
            # 取得情報に応じてmsgセット
            msg = message_by_story(story, task, project_id, uam.try(:telegram_id))
            # Telegram通知
            msg_sent = send_telegram_message(chat_id, msg) if msg.present?
            create_asana_event(asana_webhook, event) if msg_sent
          end
        rescue Asana::Errors::NotFound => e
          next
        rescue StandardError => e
          logger.error("\n>>> AsanaWebhooks#receive StandardError <<<\n#{e}\n")
          next
        end
      end
    end
    head 200
  rescue Asana::Errors::InvalidRequest => e
    render status: 400, json: { asana_error: e }
  end
  
  ...

end

(ポイント)

  • 外部からPOSTリクエストを受けるように “protect_from_forgery except:” でアクション名を指定
  • ユーザ認証も不要なので “skip_before_action :authenticate_user!” を記載
  • 初回は受け取った “request.headers[‘HTTP_X_HOOK_SECRET’]” をレスポンスヘッダ情報に入れて ステータス200で返す(ステータス204だと正常にWebhookが作成されない)
  • 通常のPOSTデータのSignature検証は、どの値を暗号化すべきか不明なため、一旦実装を保留している(セキュリティを高めたい場合のみ実装が必要)
  • 通常のPOSTデータのevents(配列)の中のデータのうち、Telegram通知が必要なのは「type == ‘story’」の場合のみ
  • eventsデータだけでは情報が足りないので、task情報とstory情報を追加取得する(client.tasks.find_by_id と client.stories.find_by_id)
  • Asana project_id と Telegram chad_idのマッピングは gem settingslogic を利用して、config/application.yml に記載
# config/application.yml
# AsanaプロジェクトIDとTelegramチャットIDのマッピング
defaults: &defaults
  asana:
    projects:
      purchase: '6666666666661'
      marketing: '6666666666662'
      crm: '6666666666663'
  telegram:
    chats:
      purchase: '-1111111'
      marketing: '-1111222'
      crm: '-1111333'

gem settingslogicの実装例は『Rails – Settingslogicで定数管理を行う』を参照。

メッセージ通知(Telegram)

asana_webhooks#receiveの「message_by_story」の部分。

追加取得したstory情報を用いて、以下の場合分けを行う。

ユーザーへのタスク割当
story.type == ‘system’ かつ story.text に ‘assigned to’ が含まれる場合

タスク期日変更
story.type == ‘system’ かつ story.text に ‘changed the due date’ が含まれる場合

コメント投稿
story.type == ‘comment’ の場合

タスク完了
story.type == ‘system’ かつ story.text に ‘completed this task’ || ‘marked this task complete’ が含まれる場合

実装例は以下の通り。


class AsanaWebhooksController < ApplicationController

  ...

  private

  def message_by_story(story, task, project_id, telegram_user)
    task_url = ASANA_BASE_URL + project_id.to_s + '/' + task.id.to_s
    task_tags = task.tags.pluck(:name).join(',')
    if story.type == 'system'
      # 新規タスク担当
      if story.text.include?('assigned to')
        "◇◇◇ 新規タスク割当 ◇◇◇ #{task_tags}\nタスク: #{task.name}\n担当者: #{telegram_user} 期日: #{task.due_on}\n#{telegram_user} に新たにタスクが割当てられました!"
      # タスク期日変更
      elsif story.text.include?('changed the due date')
        "▲▲▲ タスク期日変更 ▲▲▲ #{task_tags}\nタスク: #{task.name}\n担当者: #{telegram_user} 期日: #{task.due_on}\nタスクの期日が変更されました"
      # タスク完了
      elsif story.text.include?('completed this task') || story.text.include?('marked this task complete')
        "◆◆◆ タスク完了 ◆◆◆ #{task_tags}\nタスク: #{task.name}\n担当者: #{telegram_user} 期日: #{task.due_on}\nタスクが完了しました!"
      end
    elsif story.type == 'comment'
      uam = UserAccountMap.find_by(asana_id: story.created_by['id'])
      # コメント
      "タスク: #{task.name} へのコメント\n担当者: #{telegram_user}\n[#{uam.try(:telegram_id)}]:\n #{story.text}"
    end
  end

  ...

end

補足:無限Webhooks問題への対応

同じWebhooksが何回も飛んでくることがあり(異常かどうかは不明)、無限にメッセージ通知が行われるというバグが発生した。

開発段階でWebhooksへのレスポンスステータスを204にしてしまっていたことや、本番で何回もエラー出してしまったことが原因かもしれないので、Webhooksを一旦全削除してもう一度作成し直した。

Webhooksの削除は以下のAPIリソースで実行できる。

DELETE https://app.asana.com/api/1.0/webhooks/[webhook-id]

webhook-idは「client.webhooks.get_all」で確認できる。

さらに、今後同じWebhooksが飛んできた場合に備えて、通知済みのWebhooks情報をDBに保存するようにした。

新規Asanaプロジェクトの追加設定

以下の手順で新規Asanaプロジェクトの追加設定ができる。

config/application.yml

config/application.ymlに、AsanaプロジェクトIDとTelegramチャットIDを追加。

asana_webhooks#createの実行

ブラウザから “https://example.com/asana_webhooks/create/[project_id]” を叩く。

レスポンスにエラーがなければ、Webhookの正常に生成されている。

所感

gem ruby-asanaは使いやすいですね。Serviceオブジェクトにしちゃえば、どこからでも簡単にAPIコールを呼び出せます。一方で、Webhooksの部分はハンドシェイクさせるとこらへんがややこしい。

Sources

兵庫県西宮市生まれのフリーランスRailsエンジニア。海外を拠点にデジタルノマド生活中。/ 前職・資格:公認会計士 / プログラミング言語:Ruby, JavaScript, HTML, CSS / 日本語・英語
コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です