ZoomAPI（Server-to-server OAuth）でミーティング作成の自動化

[バヅクリ](https://buzzkuri.com)CTOの合原です。
ひさびさにqiita投稿です。

いいタイミングでこちらのキャンペーンも開催していたので、相乗りさせていただきました :sweat_smile: 
https://qiita.com/official-campaigns/engineer-festa/2022


# やったこと
今回は、Railsアプリケーションからの利用を想定していたため、
Server-to-server OAuthアプリにて、実装Tryしてみました。
※Server-to-server OAuthの事例があまり見つからなかったのもあったので　w



--

# 背景（こちらは読み飛ばしていただいても:smiley_cat::ok_hand: ）

当社の主力事業[バヅクリ](buzzkuri.com)では、オンラインでのチームビルディングや研修をアソビを取り入れた、非常にユニークなプログラムを用意して、サービスとして提供しています。その際、Zoomミーティング機能を主に活用しています。

詳細は割愛しますが、これまで、イベント開催が確定したタイミングで
イベント運営担当者が、
* 手動でZoom ミーティングを作成
* 作成ZoommミーティングURLを社内管理サイトへ登録

ということが`手動で`行われていましたが、

* 効率が悪い
* ヒューマンエラーも起きやすい :cry: 

ので、

1. zoomミーティングの作成の自動化
1. 当社DBへ上記ミーティング情報の登録

することで、リスクをZEROにすることができないかと。。。
つまり、zoom APIを利用して、ZoomMeetingの作成を自動化できないか？と検証してみました。


# 調査
何はともあれ、まずはAPIについて調査です。
ググってみると、まさに、
https://qiita.com/yosuke-sawamura/items/fa036273a161b190478b
こちらの神がかった記事があり、参考にさせていただきました。

あとは、セオリー通り[公式ドキュメント](https://marketplace.zoom.us/docs/guides/build/oauth-app/#create-an-oauth-app--omit-in-toc-)を読み読み :pencil:

* Oauthを使うこと（access_token発行） 
* access_tokenを用いて、APIリクエスト

と、ざっくり概要を理解。

## Server-to-server OAuth作成
基本的には、[こちら](https://marketplace.zoom.us/docs/guides/build/server-to-server-oauth-app/)を参考に。。。

使用するための設定をポチポチと :mouse:


※scopeについても、[ドキュメント](https://marketplace.zoom.us/docs/api-reference/zoom-api/methods/#operation/meetingCreate)を参考に設定します。

> Scopes: meeting:write:admin, meeting:write

## まずは、curlでaccess_token取得試す
とても[ドキュメント](https://marketplace.zoom.us/docs/guides/build/server-to-server-oauth-app/#use-account-credentials-to-get-an-access-token)がわかりやすく、こちらを参考に。

credentials情報を元に、
curlコマンドでaccess_token取得、

```sh
$ curl -X POST -H 'Authorization: Basic Base64Encoder(clientId:clientSecret)' https://zoom.us/oauth/token?grant_type=account_credentials&account_id={accountId}
```
下記は、[こちら](https://marketplace.zoom.us/docs/guides/build/server-to-server-oauth-app/)の通り、アプリを作成すれば取得可能です。

* clientId
* clientSecret
* account_id

※秘匿情報なので然るべき管理をお忘れなく　:sweat_smile: :star2: 

あとは、このaccess_tokenを用いて、APIリクエストをすればいいので、
実装しつつ確認へ。

# 設計
さて、Railsでどうやるか。まずは設計です。
moduleを適切に用いて、わかりやすく。。。
かつ、ZoomAPIは、豊富にあるので、各種APIごとに使い分けられるように。。。

ということを踏まえ。
zoomsという名前空間に下記のような継承関係のある形で設計。

:::note info
名前空間の命名は複数形にしておくと、衝突が防げます:star2: 
:::

```sh
$ ~/work/buzzkuri (feat_zooms_modules*) » tree -L 2 app/models/zooms                                                           
app/models/zooms
├── app.rb  # access_token 管理 ※ApplicationRecordを継承
├── base.rb # access_token の（再）発行、API周りの情報を一元化
├── meeting.rb # 下記eventモデルをこちらで、対象アカウント分キックする
└── meetings
    └── event.rb # ZoomMeetingAPIでMeeting作成し、DBへの保存までこちらで。
```

技術検証用にと、、、若干設計が荒削りですが、、まぁご勘弁ください :sweat: 

# 実装
```rb:app/models/zooms/base.rb
class Zooms::Base
  class GetAccessTokenApitError < StandardError; end
  TIME_ZONE = 'Asia/Tokyo'.freeze

  require "net/http"
  require "base64"

  def access_token
    @access_token ||= if token_available?
                        latest_app.access_token
                      else
                        create_access_token!
                        ::Zooms::App.last.access_token
                      end
  end

  private

  def create_access_token!
    response = token_request

    body = JSON.parse(response.body)
    if response.code == "200"
      ::Zooms::App.create!(body)
    else
      #FIXME: Errorの場合のresponseが不明
      raise GetAccessTokenApitError, body.message
    end
  end

  def token_available?
    latest_app && latest_app.created_at > Time.current.ago(1.hour)
  end

  def latest_app
    @latest_app ||= ::Zooms::App.last
  end

  def token_request
    uri = URI.parse("https://zoom.us/oauth/token")
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = uri.scheme === "https"

    http.post(uri.path, token_request_query.to_param, token_request_headers)
  end

  def token_request_query
    {
      grant_type: "account_credentials",
      account_id: account_id,
    }
  end

  def token_request_headers
    { "authorization" => "Basic #{credentials}" }
  end

  def credentials
    Base64.strict_encode64("#{client_id}:#{client_secret}")
  end


  def account_id
    Rails.application.credentials.zoom[Rails.env.to_sym][:api][:account_id]
  end

  def client_id
    Rails.application.credentials.zoom[Rails.env.to_sym][:api][:client_id]
  end

  def client_secret
    Rails.application.credentials.zoom[Rails.env.to_sym][:api][:client_secret]
  end

end
```

```rb:app/models/zooms/meeting.rb
class Zooms::Meeting < Zooms::Base
  class CreateApiError < StandardError; end

  private

    def headers
      {
        Authorization: "Bearer #{access_token}",
        "Content-Type": "application/json"
      }
    end

    def api_request_url
      raise NotImplementedError, "You must implement #{self.class}##{__method__}"
    end

    def post_api_request
      uri = URI.parse(api_request_url)
      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = uri.scheme == "https"

      http.post(uri.path, request_body.to_json, headers)
    end

    def request_body
      raise NotImplementedError, "You must implement #{self.class}##{__method__}"
    end
end


```

```rb:app/models/zooms/meetings/event.rb
class Zooms::Meetings::Event < ::Zooms::Meeting
  include ActiveModel::Model

  ATTRS = %i[
    event
    zoom_account
  ].freeze

  ATTRS.each do |a|
    attr_accessor a
    validates a, presence: true
  end

  validate :ensure_unready_zoom
  validate :ensure_zoom_account

  def create!
    return raise ArgumentError, errors.full_messages.join(", ") if invalid?

    response = post_api_request
    body = JSON.parse(response.body)

    raise CreateApiError, "#{body['code']}: #{body['message']}" unless response.code == "201"

    ActiveRecord::Base.transaction do
      event.zoom_url = body["join_url"].split("?").first
      event.zoom_meeting_id = body["id"]
      event.zoom_passcode = body["password"]
      event.event_zoom_accounts.build(zoom_account: zoom_account)
      event.save!
    end
  end

  private

    def api_request_url
      "https://api.zoom.us/v2/users/#{zoom_account.name}/meetings"
    end

    def request_body
      # https://marketplace.zoom.us/docs/api-reference/zoom-api/methods/#operation/meetingCreate
      {
        topic: topic,
        type: "2",
        start_time: start_time, # yyyy-MM-ddTHH:mm:ss
        timezone: ::Zooms::Base::TIME_ZONE,
        duration: duration, # 所要時間
        password: password,
        settings: {
          alternative_hosts: alternative_hosts
        }
      }
    end

    def start_time
      event.start_time.strftime("%Y-%m-%dT%H:%M:%S")
    end

    def duration
      e = event
      Integer(e.end_time - e.start_time) / 60
    end

    def password
      random = Random.new
      random.rand(9_999_999_999)
    end

    def ensure_unready_zoom
      errors.add :event, " DONT need settings zoom" if event.use_zoom?
    end

    def topic
      "#{!Rails.env.production? ? "[#{Rails.env}]" : ''}【バヅクリ】#{event.membership_business.name}様 #{event.name}"
    end

    def alternative_hosts
      service_managers.join(";")
    end

    def service_managers
      %w[
        rintaro.shimoyoshi@buzzkuri.co.jp
        miku.araya@buzzkuri.co.jp
        yumi.aihara@buzzkuri.co.jp
      ]
    end

    def ensure_zoom_account
      errors.add :zoom_account, :invalid unless ::ZoomAccount.exists? id: zoom_account&.id
    end
end


```
※injectしているeventオブジェクトは当社オリジナルのものです :sweat: 




```rb:app/models/zooms/app.rb
class Zooms::App < ApplicationRecord
  validates :access_token, :token_type, :expires_in, :scope, presence: true
end
```

```rb:app/models/events/zoom.rb
class Events::Zoom
  include ActiveModel::Model

  ATTRS = %i[
    event
    zoom_accounts
  ].freeze

  ATTRS.each do |a|
    attr_accessor a
    validates a, presence: true
  end

  def create!
    return Rails.logger.info "#{self.class.name}: Not found available zoom account!" if creatable_accounts.empty?

    creatable_accounts.each do |a|
      Zooms::Meetings::Event.new(
        event: event,
        zoom_account: a
      ).create!
    end
  rescue StandardError => e
    Rails.logger.info "#{self.class.name}: #{e.message}!"
  end


     :
end

```

↑雑ですが :sweat:

# 実行
こうして、あとは、Rails consoleで、

```rb
$ Events::Zoom.new(event: e).create! 
```
※実際には、active jobを使ってバックグランド実行しています

として、無事、対象のzoomアカウントでリクエストした内容で、ミーティングができました :sunglasses:

![スクリーンショット 2022-07-07 12.28.01 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64537/94f91536-d5f6-8d56-f953-5b49f1fafba2.png)

あとは、現行のイベント確定時の処理に組み込めば、
イベント開催確定時のZoomミーティングの作成の自動化が、できそうなことが十分検証できました。

# 所感

ZoomAPIの利用は初めてでしたが、とてもドキュメントがわかりやすく、充実していて、
詰まる点がなく、検証ができました。

今回利用したAPIでは、Zoomミーティング作成時にレコーディングONなど、さまざまなオプションも設定できるようなので、うちのプログラムに応じて自動で設定することもでき、より、人的作業の効率化ができそうです。

今後は、ミーティング作成に限らず、他のAPIやSDKも使いつつ、自動化したり、面白いZoomの使い方をビジネスサイドに提案していきたいと思います :sunglasses: 

# 恒例のやつですが :sweat: 
当社では、バックエンドサーバーとして主にRails、フロントエンドでは、Nextjsを利用しています。
新しい物（コト）好きの当社では、絶賛、エンジニアを募集中です！

気になる方はお気軽にご連絡いただけましたらと思います！

![スクリーンショット 2022-08-02 13.25.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64537/eb151153-a52a-64c8-99a5-06149b64e66a.png)

https://www.wantedly.com/projects/1050133

https://buzzkuri.co.jp/recruit/engineer