catch-img

Zoomの投票作成をAPIで秒処理

こんにちは。経営企画室 プロトタイプビルダーの山下です。
ラーニングサービス部 AWS認定インストラクターの傍ら、主に自分の作業まわりを改善するプロトタイプビルダーを兼任しています。

今日は今年作って、運用しているツールを一つ紹介します。
読んでいただいた方の何かのヒントになれば幸いです。

課題

トレノケートではオンライントレーニングを行う上での配信サービスとして、Zoomミーティングを利用しています。
Zoomミーティングには、投票機能があり、受講者の皆様にアンケートを取ることができます。
ミーティングにはテンプレート機能があるので、ミーティングの設定とあわせて作成しておいた投票を使い回せます。
ですが、トレノケートではミーティングを作成する部門があり、開催するインストラクターが作成するわけではありません。
そして、コース数も約1,400コースあり、それぞれで個別管理をすることが現実的ではないため、テンプレート機能は使いません。
なので、投票などの個別設定はミーティングが作成された後にインストラクターがそれぞれ行います。

そこで例えば10問ほどある投票を作成しようと思うと、毎回同じ質問と選択肢をコピー、ペーストするだけでも、10分以上の時間がかかります。

私はこの作業が面倒で面倒で苦手でした。
この作業が待っていると思うと少しばかり憂鬱になってしまい、美味しいお酒も100%楽しむことができませんでした。

そこで、まずはZoomのAPIのpollsへPostmanを使って、投票内容を書いておいたJsonをPOSTする運用を始めました。

これで毎回、秒で投票作成が完了するようになりました。

すごく便利ですごく嬉しかったので、他のインストラクターにも共有しようと思いましたが、そのためにはZoomのAPI認証情報を共有しなければなりません。
トレノケートではユーザーごとにはAPI認証情報の発行は制御されていて、管理部門から発行してもらったAPI認証情報を使っています。

そこで、以下の要件を満たすツールを作りました。

  • インストラクターはログイン認証して利用する。
  • 投票内容はゼロから書かなくても過去のミーティングから取得可能。
  • 一度使った投票は何度でも呼び出し可能。編集削除も可能。
  • 他のインストラクターの投票は見ない。
  • ZoomのAPI認証情報は安全な場所で管理して使用する。


作ったもの

ログイン

[UserName]と[Password] を入力して[Login]ボタンからログインします。

投票作成までの流れ

(1) 投票内容登録
データベースに投票内容を登録します。

(2) 投票作成
データベースに登録した投票を、指定したミーティングIDに作成します。

(1) 投票内容登録

[投票内容]をJson形式で入力して、[Add]ボタンで登録します。

[名前]はこのツールで投票内容を見分けやすくするためのものです。いわば目印です。わかりやすい名前をつけてください。(オプション機能参照)

過去のミーティングから投票内容を呼び出すこともできます。(オプション機能参照)

* Jsonの例
2つの投票で、1つめの投票に2つの質問、2つめの投票は1つの質問の例です。
「好きなメニューは何ですか?」の質問は、複数選択("type": "multiple")です。

{
    "polls": [
        {
            "title": "おはようございます!",
            "questions": [
                {
                    "name": "どこから接続されていますか?",
                    "type": "single",
                    "answers": [
                        "自宅から",
                        "会議室から",
                        "オフィスから",
                        "その他"
                    ]
                },
                {
                    "name": "好きなメニューは何ですか?",
                    "type": "multiple",
                    "answers": [
                        "和食",
                        "中華",
                        "フレンチ",
                        "イタリアン",
                        "ファーストフード",
                        "その他"
                    ]
                }
            ]
        },
        {
            "title": "午後もがんばっていきましょう!",
            "questions": [
                {
                    "name": "運動は何かされてますか?",
                    "type": "single",
                    "answers": [
                        "ランニング",
                        "筋トレ",
                        "ヨガ",
                        "その他"
                    ]
                }
            ]
        }
    ]
}

オプション機能 - 名前

名前をつけて一覧で見やすくすることができます。

既存の投票内容にも名前を入力して[Update]できます。

オプション機能 - 呼び出し

過去に使用したミーティングから投票内容を呼び出せます。

過去のZoomのミーティングIDを入力して、[呼び出し]ボタンを押下します。

投票内容に反映されます。
このまま[add]ボタンから新規追加して、[投票作成]することもできます。
もちろん修正して再利用ができます。


(2) 投票作成

登録した投票が上部一覧に表示されますので選択します。
[投票内容]フィールドに選択した投票内容が表示されます。
ZoomのミーティングIDを[ミーティングID]フィールドに入力して、[投票作成]ボタンを押下します。
[ミーティングID]フィールドが空(ブランク)になったら処理終了です。
Zoomの管理画面を確認してください。

次回以降は、(2) 投票作成の操作のみで、投票作成ができます。

投票内容の編集

登録した投票が上部一覧に表示されますので選択します。
[投票内容]フィールドに選択した投票内容が表示されますので修正します。
[Update]ボタンを押下します。

投票内容の削除

登録した投票の右のゴミ箱アイコンから削除します。

構成サービス

Amazon S3

静的なHTML, CSS, JavaScript、画像を配置して配信しています。

Amazon Cognito

ユーザープールを使って、ユーザーの登録、ログイン認証を行っています。
API GatewayのCognitoオーサライザーを使用してログイン認証していないとAPIが実行できないようにしています。

Amazon DynamoDB

投票内容をユーザーごとに保存しています。

Amazon API Gateway

JavaScriptから呼び出すREST APIをセットアップしています。
それぞれLambdaを呼び出しています。
設定はこちらのSwaggerを参考にしていただけますと幸いです。
(長くなるのでOPTIONSは割愛しています)

swagger: "2.0"
info:
  version: "2020-07-12T12:12:55Z"
  title: "ZoomPollsAPI"
host: "abcdefghi.execute-api.us-east-2.amazonaws.com"
basePath: "/v1"
schemes:
- "https"
paths:
  /polls:
    get:
      consumes:
      - "application/json"
      produces:
      - "application/json"
      responses:
        200:
          description: "200 response"
          schema:
            $ref: "#/definitions/Empty"
          headers:
            Access-Control-Allow-Origin:
              type: "string"
      security:
      - ZoomTool: []
      x-amazon-apigateway-integration:
        uri: "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:123456789012:function:list_poll/invocations"
        responses:
          default:
            statusCode: "200"
            responseParameters:
              method.response.header.Access-Control-Allow-Origin: "'*'"
        requestTemplates:
          application/json: "{\n    \"userId\": \"$context.authorizer.claims.sub\"\
            \ \n}"
        passthroughBehavior: "when_no_templates"
        httpMethod: "POST"
        contentHandling: "CONVERT_TO_TEXT"
        type: "aws"
    post:
      consumes:
      - "application/json"
      produces:
      - "application/json"
      parameters:
      - in: "body"
        name: "NoteCreateModel"
        required: true
        schema:
          $ref: "#/definitions/NoteCreateModel"
      responses:
        200:
          description: "200 response"
          schema:
            $ref: "#/definitions/Empty"
          headers:
            Access-Control-Allow-Origin:
              type: "string"
      security:
      - ZoomTool: []
      x-amazon-apigateway-request-validator: "Validate body"
      x-amazon-apigateway-integration:
        uri: "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:123456789012:function:create_update_poll/invocations"
        responses:
          default:
            statusCode: "200"
            responseParameters:
              method.response.header.Access-Control-Allow-Origin: "'*'"
        requestTemplates:
          application/json: "{\r\n    \"userId\": \"$context.authorizer.claims.sub\"\
            ,\r\n    \"noteId\": $input.json('$.noteId'),\r\n    \"note\": $input.json('$.note'),\r\
            \n    \"pollname\": $input.json('$.pollname')\r\n}"
        passthroughBehavior: "when_no_templates"
        httpMethod: "POST"
        contentHandling: "CONVERT_TO_TEXT"
        type: "aws"
  /polls/addpoll:
    post:
      consumes:
      - "application/json"
      produces:
      - "application/json"
      responses:
        200:
          description: "200 response"
          schema:
            $ref: "#/definitions/Empty"
          headers:
            Access-Control-Allow-Origin:
              type: "string"
      security:
      - ZoomTool: []
      x-amazon-apigateway-integration:
        uri: "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:123456789012:function:add_poll/invocations"
        responses:
          default:
            statusCode: "200"
            responseParameters:
              method.response.header.Access-Control-Allow-Origin: "'*'"
        requestTemplates:
          application/json: "{\r\n    \"note\": $input.json('$.note'),\r\n    \"mtgid\"\
            : $input.json('$.mtgid'),\r\n    \"company\": \"trainocate\"\r\n}"
        passthroughBehavior: "when_no_templates"
        httpMethod: "POST"
        contentHandling: "CONVERT_TO_TEXT"
        type: "aws"
  /polls/zoompolls:
    get:
      consumes:
      - "application/json"
      produces:
      - "application/json"
      parameters:
      - name: "mtgid"
        in: "query"
        required: false
        type: "string"
      responses:
        200:
          description: "200 response"
          schema:
            $ref: "#/definitions/Empty"
          headers:
            Access-Control-Allow-Origin:
              type: "string"
      security:
      - ZoomTool: []
      x-amazon-apigateway-integration:
        uri: "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:123456789012:function:get_poll/invocations"
        responses:
          default:
            statusCode: "200"
            responseParameters:
              method.response.header.Access-Control-Allow-Origin: "'*'"
        requestTemplates:
          application/json: "{\r\n    \"mtgid\": \"$input.params(\"mtgid\")\",\r\n\
            \    \"company\": \"trainocate\"\r\n}"
        passthroughBehavior: "when_no_templates"
        httpMethod: "POST"
        contentHandling: "CONVERT_TO_TEXT"
        type: "aws"
  /polls/{id}:
    delete:
      consumes:
      - "application/json"
      produces:
      - "application/json"
      parameters:
      - name: "id"
        in: "path"
        required: true
        type: "string"
      responses:
        200:
          description: "200 response"
          schema:
            $ref: "#/definitions/Empty"
          headers:
            Access-Control-Allow-Origin:
              type: "string"
      security:
      - ZoomTool: []
      x-amazon-apigateway-integration:
        uri: "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:123456789012:function:delete_poll/invocations"
        responses:
          default:
            statusCode: "200"
            responseParameters:
              method.response.header.Access-Control-Allow-Origin: "'*'"
        requestTemplates:
          application/json: "{\r\n    \"userId\": \"$context.authorizer.claims.sub\"\
            ,\r\n    \"noteId\": \"$input.params('id')\"\r\n}"
        passthroughBehavior: "when_no_templates"
        httpMethod: "POST"
        contentHandling: "CONVERT_TO_TEXT"
        type: "aws"

securityDefinitions:
  ZoomTool:
    type: "apiKey"
    name: "Authorization"
    in: "header"
    x-amazon-apigateway-authtype: "cognito_user_pools"
    x-amazon-apigateway-authorizer:
      providerARNs:
      - "arn:aws:cognito-idp:us-east-2:123456789012:userpool/us-east-2_niM1Ff0ZV"
      type: "cognito_user_pools"
definitions:
  Empty:
    type: "object"
    title: "Empty Schema"
  NoteCreateModel:
    type: "object"
    required:
    - "note"
    properties:
      note:
        type: "string"
      noteId:
        type: "string"
    title: "Note Create Model"
x-amazon-apigateway-request-validators:
  Validate body:
    validateRequestParameters: false
    validateRequestBody: true


AWS Lambda

ランタイムはPython3.8です。
コードを記載します。
ロギング、デバッグコードは割愛します。

DynamoDB PUT

import boto3
from botocore.exceptions import ClientError

def lambda_handler(event, context):
    dynamodb = boto3.resource('dynamodb')
    table = dynamodb.Table('zoompoll')

    try:
        response = table.put_item(
            Item={
                'userId': event["userId"],
                'pollId': event["pollId"],
                'poll': event["poll"],
                'pollname': event.get('pollname', '')
            }
        )
    except ClientError as e:
       print(e.response['Error']['Message'])
    else:
        if response['ResponseMetadata']['HTTPStatusCode'] == 200:
            return event["pollId"]
        else:
            return ""

DynamoDB Query

import boto3
from boto3.dynamodb.conditions import Key
from botocore.exceptions import ClientError


def lambda_handler(event, context):
    dynamodb = boto3.resource('dynamodb')
    table = dynamodb.Table('zoompoll')

    try:
        response = table.query(KeyConditionExpression=Key("userId").eq(event["userId"]))
    except ClientError as e:
        print(e.response['Error']['Message'])
    else:
        return response["Items"]

DynamoDB Delete

import boto3
from botocore.exceptions import ClientError


def lambda_handler(event, context):
    dynamodb = boto3.resource('dynamodb')
    table = dynamodb.Table('zoompoll')

    try:
        response = table.delete_item(
            Key={
                'userId': event["userId"],
                'pollId': event["pollId"]
            }
        )
    except ClientError as e:
        print(e.response['Error']['Message'])
    else:
        if response['ResponseMetadata']['HTTPStatusCode'] == 200:
            return event["pollId"]
        else:
            return ""


Zoom GET

Zoom APIの認証情報は、Secrets Managerで安全に管理しています。

import os
import json
import requests
import boto3
import base64
from botocore.exceptions import ClientError

secret_name = os.environ.get('SECRET_NAME')


def get_secret():
    session = boto3.session.Session()
    client = session.client(
        service_name='secretsmanager'
    )

    try:
        secret_id = secret_name
        get_secret_value_response = client.get_secret_value(
            SecretId=secret_id
        )
    except ClientError as e:
        raise e
    else:
        if 'SecretString' in get_secret_value_response:
            secret = get_secret_value_response['SecretString']
        else:
            secret = base64.b64decode(get_secret_value_response['SecretBinary'])
    return secret


def lambda_handler(event, context):
    try:
        secret = json.loads(get_secret())
        mtg_id = event['mtgid'].replace('-', '').replace(' ', '')
        url = 'https://api.zoom.us/v2/meetings/{meetingId}/polls'.format(
            meetingId=mtg_id
        )
        headers = {
            'Authorization': 'Bearer {token}'.format(
                token=secret['token']
            ),
            'Accept': 'application/json, application/xml',
            'Content-Type': 'application/json'
        }

        response = requests.get(
            url,
            headers=headers
        )
        if response.status_code == 200:
            polls = json.loads(response.text)
            if ('total_records' in polls):
                del polls['total_records']
            for poll in polls['polls']:
                if ('id' in poll):
                    del poll['id']
                if ('status' in poll):
                    del poll['status']
            return polls
        else:
            return ''

    except Exception as e:
        raise e

Zoom POST

import os
import json
import requests
import boto3
import base64
from botocore.exceptions import ClientError

secret_name = os.environ.get('SECRET_NAME')


def get_secret():
    session = boto3.session.Session()
    client = session.client(
        service_name='secretsmanager'
    )

    try:
        get_secret_value_response = client.get_secret_value(
            SecretId=secret_name
        )
    except ClientError as e:
        raise e
    else:
        if 'SecretString' in get_secret_value_response:
            secret = get_secret_value_response['SecretString']
        else:
            secret = base64.b64decode(get_secret_value_response['SecretBinary'])

    return secret


def lambda_handler(event, context):
    try:
        secret = json.loads(get_secret())

        mtg_id = event['mtgid'].replace('-', '').replace(' ', '')
        polls = json.loads(event['poll'].replace('\n', ''))

        url = 'https://api.zoom.us/v2/meetings/{meetingId}/polls'.format(
            meetingId=mtg_id
        )
        headers = {
            'Authorization': 'Bearer {token}'.format(
                token=secret['token']
            ),
            'Accept': 'application/json, application/xml',
            'Content-Type': 'application/json'
        }

        for poll in polls['polls']:
            response = requests.post(
                url,
                data=json.dumps(poll),
                headers=headers
            )

    except Exception as e:
        raise e


山下 光洋(やました みつひろ)

山下 光洋(やました みつひろ)

トレノケート株式会社 講師。AWS Authorized Instructor Champion / AWS認定インストラクター(AAI) / AWS 認定ソリューションアーキテクト - プロフェッショナル /AWS認定DevOpsエンジニア - プロフェッショナル / AWS 認定デベロッパー - アソシエイト / AWS 認定 SysOps アドミニストレーター - アソシエイト / AWS 認定クラウドプラクティショナー / kintone認定 カスタマイズスペシャリスト他。AWS認定インストラクターとしてAWS認定コースを実施。毎年1,500名以上に受講いただいている。AWS 認定インストラクターアワード2018, 2019を日本で唯一受賞。著書『AWSではじめるLinux入門ガイド』(マイナビ出版社)。共著書『AWS認定試験対策 AWS クラウドプラクティショナー』(SBクリエイティブ社)。前職では2016年にAWS Summitにパネラーとして参加。その前はLotus Technical Award 2009 for Best Architectとして表彰されている。また、各コミュニティの運営にも個人的に関わり、勉強会にてスピーカーや参加をしている。

© Trainocate Japan, Ltd. 2008-2021