Azure FunctionsとGoでZoom会議参加者を取得する

# はじめに
リモートワークによりZoom会議が増え、参加者を記録して残すことが増えてきました。
今までは会議にAcceptしたりした人をカウントしたり、会議参加者リストをスクリーンショットしたりして参加者を取得していましたが、書き漏らしたり、スクリーンショットを忘れていたりといったことが起きていました。

Zoom自体に会議参加者をエクスポートする機能はあるのですが、せっかくなのでAPIを叩いて取得できるツールを作ってみようと思い、作り始めました。

# 構成
ツールを作るに当たり、必要な/やってみたい技術、どんな形にしたいかをまとめてみました。

- 完成形のツールイメージ
→ Meeting UUIDをチャットツールから送信するだけで参加者一覧の名前が返ってくる

- 使用する技術と理由
 - Slack（チャットツールなら何でも良かったのですが、手元にSlackがあったので）
 - Azure Functions（会社でAzureを使っているため個人でも使ってみたかった）
 - Go（未体験の言語学習）
 - Zoom（参加者取得）

この技術を使ってイメージ図を作成しました。SlackからAzure Functionsへリクエストを飛ばし、カスタムハンドラーのGoへリクエストを渡します。カスタムハンドラー側でZoom APIにMeeting UUIDをつけたリクエストを飛ばし、レスポンスから参加者を取得してSlackに返します。

<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/3df0356d-c34b-c9a1-83a0-9966e2bc7d52.png">

## Azure FunctionsでGoを使う
去年の12月に、Azure Functionsがアップデートされ、 どんな言語でもCustom handlerで使用できるようになりました。これまではAzure Functionsで使用できる言語は限られていたので、このアップデートはすごく助かりました。
[Azure Functions in Any Language with Custom Handlers](https://techcommunity.microsoft.com/t5/apps-on-azure/azure-functions-in-any-language-with-custom-handlers/ba-p/1942744)

また、別ブログにて、このCustom HandlerにGoを使ったサンプルも投稿されていました。この記事が私のやりたいことと似ていたので、この記事を基にしてZoomを組み合わせてみることにしました。
[How to build a serverless app using Go and Azure Functions](https://acloudguru.com/blog/engineering/how-to-build-a-serverless-app-using-go-and-azure-functions)

# 開発環境
Go: go1.15.2 darwin/amd64
Visual Studio Code: 1.52.1

# ツールの制限事項
今回作成したツールでは、過去の会議参加者のAPI（[GET
/past_meetings/{meetingUUID}/participants](https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/pastmeetingparticipants)）を使用しています。このAPIは有料プランのPro以上でしか呼べないので、本ツールを使う場合、Zoomの料金プラン（[Choose a plan](https://zoom.us/pricing)）で登録しておく必要があります。

# 作成手順
## ZoomのAPI使用設定
ZoomのApp Marketplaceで、JWT認証によるアプリを作成します。このアプリ作成には以下記事を基に作成しました。
[Zoom App Marketplace](https://marketplace.zoom.us/)
[Zoom APIの使い方：OAuth・JWT による認証方法・Postman での実行方法](https://www.cdatablog.jp/entry/2020/08/17/085924)
[Zoom APIを試してみる JWT編](https://qiita.com/nanbuwks/items/ed74a76a0f294c0bf4ed)

作成した結果がこちらです。今回はここに記載されているAPI KeyとAPI Secretを、後述する環境変数にセットして使用します。
<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/02e2b964-99dd-b6d5-3384-44cfc7c87445.png">

## コード
コードを書く準備として、ローカル環境構築をしておきます。この構築では、MicrosoftがGoのクイックスタートガイドを公開しているので、それに沿って構築してください。
[Quickstart: Create a Go or Rust function in Azure using Visual Studio Code](https://docs.microsoft.com/ja-jp/azure/azure-functions/create-first-function-vs-code-other?tabs=go%2Cmacos)

今回作成したコードは[MeetingParticipantsExporter](https://github.com/M-Yamashita01/MeetingPariticipantsExporter)です。以降、このコードについて説明します。

### HTTPServerの起動
mainでHTTP Serverを起動させておきます。これでFunctions HostからCustom Handlerに対して、`/api/zoom`でリクエストを送信できるようになります。

```go
func main() {
	port, exists := os.LookupEnv("FUNCTIONS_CUSTOMHANDLER_PORT")
	if !exists {
		port = "7071"
	}
	http.HandleFunc("/api/zoom", function.ExportParticipants)
	fmt.Println("Go server listening on port", port)
	log.Fatal(http.ListenAndServe(":"+port, nil))
}
```
### Slackからのリクエストを検証
[Verifying requests from Slack](https://api.slack.com/authentication/verifying-requests-from-slack)にて、Slackからのリクエスト検証方法が記載されています。この検証法と、参考にした[How to build a serverless app using Go and Azure Functions](https://acloudguru.com/blog/engineering/how-to-build-a-serverless-app-using-go-and-azure-functions)を基に、ヘッダのシグネチャやタイムスタンプを使用して、Slackからのリクエストかどうかを判定します。

### Zoom APIの使用
#### JWT認証用のトークン作成
Zoom APIを使用するにはJWT認証用のトークンを作成する必要があります。そのトークンをを作成するために、goで書かれている[jwt](https://github.com/cristalhq/jwt)を使用することにしました。このjwtでは、API KeyやSecret Keyが必要だったので、
「ZoomのAPI使用設定」項目で取得したAPI Key、API Secretを、それぞれAPIKEY、SECRETKEYとして環境変数にセットして使用しています。
以下、トークン作成コードです。

```go
import (
・・・
	"github.com/cristalhq/jwt"
・・・
)

type Env struct {
	SecretKey string `required:"true"`
	ApiKey    string `required:"true"`
}

・・・

func ExportParticipants(w http.ResponseWriter, r *http.Request) {

・・・

	key := []byte(goenv.SecretKey)
	signer, _ := jwt.NewSignerHS(jwt.HS256, key)

	// tokenは作成後1分間有効
	now := time.Now()
	after := now.Add(time.Minute)
	claims := jwt.RegisteredClaims{
		Issuer:    goenv.ApiKey,
		ExpiresAt: jwt.NewNumericDate(after),
	}

	builder := jwt.NewBuilder(signer)
	token, _ := builder.Build(claims)
```

これでZoom APIに使用するトークンを作成できました。

#### 会議参加者のAPIへリクエスト・レスポンス

会議参加者のAPIへリクエストを送ります。使用するAPIの仕様([GET /past_meetings/{meetingUUID}/participants](https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/pastmeetingparticipants))を見ると、
URL: `https://api.zoom.us/v2/past_meetings/{meetingUUID}/participants`
Header: `Authorization: "Bearer " + JWTアクセストークン`
で送る必要があります。
また、レスポンスは以下のような形式となっているため、`participants`内の`name`を取得するようにします。

```json
{
  "page_count": 1,
  "page_size": 30,
  "total_records": 1,
  "next_page_token": "aliqua",
  "participants": [
    {
      "id": "8b29rgg4bb",
      "name": "Ram Shekhar",
      "user_email": "ram.shekhar.123@fkdngfjg.fdghdfgj"
    }
  ]
}
```

このURL生成、リクエスト、レスポンスからの参加者取得は以下のとおりです。

```go
	var tokenStr string
	tokenStr = token.String()

	meetingParticipantsUrl := "https://api.zoom.us/v2/past_meetings/"
	url := meetingParticipantsUrl + meetingUUID + "/participants"
	req, _ := http.NewRequest("GET", url, nil)
	bearerAccessToken := "Bearer " + tokenStr
	req.Header.Set("Authorization", bearerAccessToken)

	client := new(http.Client)
	resp, _ := client.Do(req)

	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	fmt.Printf("Response body: %s", body)
	if err != nil {
		fmt.Printf("Error ocurred while executing ReadAll. %s", err)
		log.Fatal(err)
		return
	}

	//Unmarshall json
	var meetingParticipantsResponseBody MeetingParticipantsResponseBody
	if err := json.Unmarshal(body, &meetingParticipantsResponseBody); err != nil {
		fmt.Printf("Error ocurred while unmarshal body. %s", err)
		log.Fatal(err)
		return
	}

	fmt.Println("Participant")
	participantNames := ""
	for _, participant := range meetingParticipantsResponseBody.Participants {
		fmt.Printf("%s\n", participant.Name)
		participantNames += participant.Name
		participantNames += "\n"
	}

```

### Slackへレスポンス
Slackへは参加者のテキストのみを返すようにします。

```go
type SlackResponse struct {
	Text string `json:"text"`
}

・・・

	slackResponse := SlackResponse{Text: participantNames}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(slackResponse)
```

## Azure上での動作
### Azure Functionsでアプリ作成
[Azure Portal](https://portal.azure.com/#home)上で関数アプリを選択し作成していきます。

<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/3c886505-2c3e-9afa-da90-0d0b11e8efb5.png">

リソースグループ、関数アプリ名には適当な名前をセットします。ランタイムスタックやバージョンでは、Custom Handlerを使うようにしておきます。
<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/362783dc-3af3-e2ae-a229-49c89ac5446e.png">

OSはLinuxを使うようにしておき、プランにはサーバーレスを選択します。
<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/c160e62f-ac1c-5d7b-b80c-8f147624778c.png">

その後の監視やタグは必要に応じて設定してください。
<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/3acc873f-de93-cf2b-83a6-39c563c1a431.png">

<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/9bf88d8f-97ac-beee-bd11-ee04f31b982a.png">

ここまでの設定に問題がなければ、エラーメッセージが出ず、作成ボタンを押すことができます。
<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/7fd4d579-2a5f-2d74-3478-2a2c23157066.png">

作成ボタンを押して数分すると作成完了の画面となります。
<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/e0883edf-f083-2fa6-5ff1-da15614e24c1.png">

### Azure Functionsにコードをアップロード
ローカル環境のコードをLinux/x64環境用にビルドしておきます。

```
> GOOS=linux GOARCH=amd64 go build -o go_main cmd/main.go
```

このコードをVSCodeからアップする方法もありますが、ここではコマンドでアップする方法を実行します。Microsoftの[Azure Functions Core Tools の操作](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-run-local?tabs=macos%2Ccsharp%2Cbash)を参照して、Azure Functions Core Toolsをインストールします。
インストール完了後、`func azure functionapp publish アプリ名 --custom`を実行することでAzure Functionsにコードをアップロードできます。
ここでのアプリ名はAzure Functionsの関数アプリで作成したアプリ名となります。

```
❯ func azure functionapp publish MeetingParticipantsExporter --custom
Getting site publishing info...
Uploading package...
Uploading 7.72 MB [###########################################################]
Upload completed successfully.
Deployment completed successfully.
Syncing triggers...
Functions in MeetingParticipantsExporter:
    zoom - [httpTrigger]
        Invoke url: https://meetingparticipantsexporter.azurewebsites.net/api/zoom?code=xxxxx
```

これでAzure Functionsへのコードアップロードは完了です。
アップロード後の```Invoke url: https://meetingparticipantsexporter.azurewebsites.net/api/zoom?code=xxxxx
```は、後述するSlackのAPI使用設定で使用するため、控えておいてください。

### SlackのAPI使用設定
[slack api](https://api.slack.com/)のサイトから、Create New Appsを選択し、任意のアプリ名を入力します。
<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/9c092378-7aca-133c-1d9d-dd12c2e7321d.png">

アプリ作成後、Slash Commandsを選択し、新しいコマンドを作成します。
<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/9c9f71cc-a03f-724a-2861-024426473e30.png">

コマンドの作成ではSlackからどのように呼び出すかを入力していきます。
`/zoom meetinguuid`と呼び出したいので、コマンドには`/zoom`とします。またRequest URLは、先程控えていたURLを入力します。
<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/405a70f1-2eea-2039-6061-28e6327c8c16.png">

コマンドの作成が終わったら、Install to Workspaceボタンを押して、作成したアプリをワークスペースにインストールします。
<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/0869847a-de69-6daf-42b9-af3b6d32c252.png">

インストールが終わったら、App Credentialsにある、Sigining Secretを控えておきます。
<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/496d48d2-40f5-a849-bcfe-f2640aee0f2e.png">

### 環境変数設定
Azure Functionsの構成項目で、ZoomのAPI Key、Secret Key、SlackのSigining SecretをそれぞれAPIKEY、SECRETKEY、SLACK_SIGNING_SECRETの環境変数としてセットします。
<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/95174438-86c1-f7b9-d7d5-5823d6f7c049.png">

環境変数セット後、Azure Functionsのアプリを再起動しておきます。
これで準備は整いました。

# 結果
Slackから/zoom meetinguuidを送ると、参加者のリストが返されます。
<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/73e31a8f-8857-91ab-5819-526e5125a4bd.png">

<img width="600" height="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406773/c45a042e-c6e8-dfb7-1509-8c9a4ff77b51.png">

# おわりに
各技術を使って、Zoomの参加者を取得するツールを作成しました。触ったことのない技術を独学で使っていくのは大変でしたが、一歩ずつ前に進める経験は結構楽しいですね。
ツールを作るにあたり、本記事に掲載した記事の作者の方々、Twitterでアドバイスを頂いた、[@nthonyChu](https://twitter.com/nthonyChu)さん、[@u_phy](https://twitter.com/u_phy)さん、ありがとうございました。

この記事が誰かのお役に立てれば幸いです。