🗒️

Microsoft Teams の会議終了イベントから文字起こしを任意場所に自動保存する仕組み(Graph API × Webhook)

に公開

AI 活用を前提とした業務効率化を進める中で、
「Teamsオンライン会議の文字起こしファイルを、会議終了後に自動で任意のストレージへ保存したい」
というニーズに直面しました。

しかし実際に構築しようとすると、会議終了イベントの取得方法や主催者の扱い、Microsoft Graph API特有の仕様など、つまずきどころが多く、必要な情報も断片的で分かりづらい状況でした。

同じように Teams を活用している企業でも役に立つ内容だと思うので、今回まとめて整理します。


全体像

社内メンバーがTeamsの会議で録画をします。会議が終了すると、自動的にストレージサービスにトランスクリプトが保存される仕組みです。


前準備

Teams 会議のトランスクリプトにアクセスするには、Azure(Entra ID)側と Teams 側の両方の設定が必要です。ここではアプリ登録からアクセス許可ポリシーの設定までをまとめます。


① Entra ID のアプリ登録

まず Azure Entra ID でアプリ登録を行い、以下の 3 つを取得します。

  • Client ID
  • Client Secret
  • Tenant ID

これらは後続の Graph API 認証(client_credentials)で利用します。

付与するアプリケーション権限

次の 4 つの権限が必要です。

  • User.Read.All

    • 会議の主催者など、対象ユーザーの userId を調べるため
  • CallRecords.Read.All

    • 会議終了イベント(callRecords)を取得するため
  • OnlineMeetings.Read.All

    • joinWebUrl を元に OnlineMeeting 情報を取得するため
  • OnlineMeetingTranscript.Read.All

    • トランスクリプト一覧や本文(VTT/JSON)を取得するため

すべて管理者承認(Admin consent)が必要です。


② Teams アプリケーションアクセスポリシーの設定

Graph API のアプリケーション権限では、Teams 側で「どのアプリがどのユーザーの会議にアクセスできるか」を明示的に許可する必要があります。この設定を行うのが Application Access Policy です。

権限の全体像

Azure Entra ID(権限付与)
    └─ OnlineMeetingTranscript.Read.All など
            │
            ▼
Teams PowerShell
    ├─ New-CsApplicationAccessPolicy(ポリシー作成)
    └─ Grant-CsApplicationAccessPolicy(ユーザーへ適用)
            │
            ▼
Graph API(client_credentials)
    GET /users/{userId}/onlineMeetings/{meetingId}/transcripts

多くの場合、管理者権限が必要です。筆者は情シスに依頼して実行しました。


Application Access Policy の設定手順

  • Teams PowerShell の準備
Install-Module MicrosoftTeams -Scope AllUsers
  • Teams へ接続
Connect-MicrosoftTeams
  • ポリシーを作成
$appId = "{ClientID}"  # Entra ID アプリケーション ID
New-CsApplicationAccessPolicy `
  -Identity "AiDriven-Allow-Graph-MeetingAccess" `
  -AppIds $appId `
  -Description "Allow Graph app to access meeting transcripts"
  • ユーザーへポリシー適用
Grant-CsApplicationAccessPolicy `
  -PolicyName "AiDriven-Allow-Graph-MeetingAccess" `
  -Identity "{システムアカウント or 個人アカウント}"

通常は会議を主催するユーザーに対して設定します。

  • 設定確認
Get-CsApplicationAccessPolicy -Identity "AiDriven-Allow-Graph-MeetingAccess"

ここまで設定すれば、Graph API の client_credentials フローで取得したトークンを使って、callRecords・onlineMeetings・transcripts など必要な API にアクセスできます。


イベント終了イベントを飛ばす設定

まずはTeams会議が終了したら、イベントを飛ばす設定を行っていきます。
Microsoft Graph APIのサブスクリプションを作成していきます。

① トークン取得

まずGraph APIのトークンを取得します。

curl -X POST "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=${CLIENT_ID}" \
  -d "client_secret=${CLIENT_SECRET}" \
  -d "grant_type=client_credentials" \
  -d "scope=https://graph.microsoft.com/.default"

今後のリクエストで毎回使うので、変数にいれておきます。

TOKEN={取得したtoken}

② userId を調べる(サブスクリプション登録前に必須)

Teamsのユーザーごとにイベントを受け取るため、まずuserId(Graph の内部ユーザーID)を取得します。

MAIL="taro.yamada@hoge.co.jp"

curl -sS -X GET "https://graph.microsoft.com/v1.0/users?\$filter=mail eq '${MAIL}'&\$select=id,displayName,mail" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json"

サンプルレスポンス:

{
  "value": [
    {
      "id": "00000000-1111-2222-3333-444444444444",
      "displayName": "Taro Yamada",
      "mail": "taro.yamada@hoge.co.jp"
    }
  ]
}

この id をサブスクリプションで利用します。


③ イベント終了トリガーの設定

さきほど取得したuserIdをコマンドに書いて、リクエストをします。

notificationUrlにはイベント送信先のURLを記載します。
筆者は今回AzureFunctionsにしました。

curl -X POST "https://graph.microsoft.com/v1.0/subscriptions" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "changeType": "created",
    "notificationUrl": "{イベント送信先URL}",
    "resource": "/communications/callRecords?$filter=participants/any(p:p/id eq '\''{USER_ID}'\'')",
    "expirationDateTime": "2025-11-30T13:00:00Z",
    "clientState": "callrecords-hook"
  }'

通知先の注意点としては、初回認証があるため、受け取る側は初回認証に対応できるようにする必要があります。

token = req.params.get("validationtoken") or req.params.get("validationToken")
if token:
    return func.HttpResponse(token, mimetype="text/plain", status_code=200)

※ 会議終了後、イベントが届くまで最大30分ほどかかります

実際にこのようなリクエストがきます。

{
  "value": [
    {
      "subscriptionId": "36073a2c-8310-4645-b5c1-0a95c3b042e9",
      "changeType": "created",
      "resource": "communications/callRecords/95e20f79-89dd-48fc-b0d6-c29e2a60a6d0",
      "resourceData": {
        "@odata.type": "#microsoft.graph.callRecord",
        "id": "95e20f79-89dd-48fc-b0d6-c29e2a60a6d0",
        "version": 4
      }
    }
  ]
}

こちらのresourceData.idを使います。

📝注意点

  • 会議の主催者(Outlook の Organizer)=オンライン会議の所有者です。
    録画を開始したユーザーとは関係ありません。
    Graph API で onlineMeetings やトランスクリプトを取得する場合は、
    必ず主催者の userId を使用します。

  • callRecords サブスクリプションの有効期限は最大 2 日間です。
    長期運用する場合は、有効期限前に定期的に PATCH で延長してください。
    期限切れになると通知は停止し、再作成しない限り復旧しません。

    • 延長: PATCH https://graph.microsoft.com/v1.0/subscriptions/{subscriptionId}
  • サブスクリプションの resource は更新できません(Graph の仕様)。
    監視対象ユーザーを変更したい場合は、既存サブスクリプションを削除 → 新規作成が必要です。

  • resource を PATCH すると 200 OK が返ることがありますが、実際は更新されません。
    Graph API は不正なフィールドをエラーにせず無視するため、
    GET /subscriptions を確認すると元の resource のままです。
    監視対象を変えたい場合は、必ず DELETE → POST で再作成してください。


文字起こしファイル取得

次に実際に受け取ったイベントから文字起こしファイルまでの取得手順をまとめます。
筆者はAzureFunctionsでシステム化しましたが、今回は手順をまとめることが目的の為、curlコマンドでまとめます。

① Call Record(会議メタ情報)を取得

最初に受け取ったイベントから会議のメタ情報を取り出します。
CALL_RECORD_IDにはサブスクリプションから受け取ったresourceData.idを使います。

curl -X GET "https://graph.microsoft.com/v1.0/communications/callRecords/${CALL_RECORD_ID}" \
  -H "Authorization: Bearer $TOKEN"

サンプルレスポンス
ここからjoinWebUrlを使います

{
  "id": "abc12345-6789-0000-1111-222233334444",
  "joinWebUrl": "https://teams.microsoft.com/l/meetup-join/...",
  "participants": [
    {
      "identity": {
        "user": {
          "id": "00000000-1111-2222-3333-444444444444"
        }
      }
    }
  ]
}

② OnlineMeeting を取得

joinWebUrlをリクエスト時に使いますが、ODataの構文エラーになるため、URLエンコードする必要があります。

JOIN_URL="https://teams.microsoft.com/l/meetup-join/..."   # callRecord の joinWebUrl
ENCODED=$(printf "%s" "$JOIN_URL" | jq -sRr @uri)

会議の詳細情報を取得します。

curl -X GET \
  "https://graph.microsoft.com/v1.0/users/${USER_ID}/onlineMeetings?\$filter=joinWebUrl%20eq%20'${ENCODED}'" \
  -H "Authorization: Bearer $TOKEN"

サンプルレスポンス
ここからidを取り出します。

{
  "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('bc081f53-d77c-48f1-b809-94d9bd0dabed')/onlineMeetings",
  "value": [
    {
      "id": "MCMxOTptZWV0aW5nOjEwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA",
      "subject": "定例ミーティング(Weekly Sync)",
      "joinUrl": "https://teams.microsoft.com/l/meetup-join/19%3ameeting_xxxxxxxxxxxx%40thread.v2/0?context=%7b%22Tid%22%3a%22xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx%22%2c%22Oid%22%3a%22bc081f53-d77c-48f1-b809-94d9bd0dabed%22%7d",
      "joinWebUrl": "https://teams.microsoft.com/l/meetup-join/19:meeting_xxxxxxxxxxxx@thread.v2/0?context={\"Tid\":\"xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\",\"Oid\":\"bc081f53-d77c-48f1-b809-94d9bd0dabed\"}",
      "startDateTime": "2025-11-28T01:00:00Z",
      "endDateTime": "2025-11-28T02:00:00Z",
      "creationDateTime": "2025-11-27T23:30:00Z",
      "audioConferencing": null,
      "chatInfo": {
        "threadId": "19:meeting_xxxxxxxxxxxx@thread.v2",
        "messageId": null
      },
      "participants": {
        "organizer": {
          "identity": {
            "user": {
              "id": "bc081f53-d77c-48f1-b809-94d9bd0dabed",
              "displayName": "山田 太郎"
            }
          }
        },
        "attendees": [
          {
            "identity": {
              "user": {
                "id": "12345678-abcd-4ef0-9999-aaaaaaaaaaaa",
                "displayName": "佐藤 花子"
              }
            },
            "upn": "hanako.sato@hoge.co.jp",
            "role": "attendee"
          },
          {
            "identity": {
              "user": {
                "id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
                "displayName": "田中 三郎"
              }
            },
            "upn": "saburo.tanaka@hoge.co.jp",
            "role": "attendee"
          }
        ]
      },
      "videoTeleconferenceId": "123456789",
      "isEntryExitAnnounced": false,
      "allowedPresenters": "everyone",
      "isLobbyBypassEnabled": true,
      "recordAutomatically": false,
      "isEndToEndEncryptionEnabled": false
    }
  ]
}

③ Transcript一覧を取得

curl -X GET \
  "https://graph.microsoft.com/v1.0/users/${USER_ID}/onlineMeetings/${MEETING_ID}/transcripts" \
  -H "Authorization: Bearer $TOKEN"

サンプルレスポンスボディ
contentUrlを取り出します

{
  "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('bc081f53-d77c-48f1-b809-94d9bd0dabed')/onlineMeetings('MCMxOTptZWV0aW5nOjEwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA')/transcripts",
  "value": [
    {
      "id": "a1b2c3d4-e5f6-47ab-8899-112233445566",
      "createdDateTime": "2025-11-28T02:03:00Z",
      "meetingOrganizerId": "bc081f53-d77c-48f1-b809-94d9bd0dabed",
      "content": {
        "contentUrl": "/users/bc081f53-d77c-48f1-b809-94d9bd0dabed/onlineMeetings/MCMxOTptZWV0aW5nOjEwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA/transcripts/a1b2c3d4-e5f6-47ab-8899-112233445566/content"
      },
      "transcriptContentType": "text/vtt",
      "status": "completed",
      "metadata": {
        "locale": "ja-JP",
        "duration": "PT3600S"
      }
    }
  ]
}
---

### ④ トランスクリプト本体(VTT)を取得
Transcript API の `contentUrl` は、そのまま VTT ファイルを返します。

```sh
curl -X GET \
  "https://graph.microsoft.com/v1.0/users/${USER_ID}/onlineMeetings/${MEETING_ID}/transcripts/${TRANSCRIPT_ID}/content" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: text/vtt" \
  --output transcript.vtt

これを任意のストレージ(Azure Blob、S3、GCS など)に保存すれば、一連の処理は完了です。


さいごに

今回は、会議終了後にトランスクリプトが自動保存される仕組みについて整理しました。

この仕組みを活用すれば、社内用語を学習させたLLMで内容を解析して任意のフォーマットで議事録を作成したり、資料作成を自動化するなど多くの選択肢ができるかと思います。

Teams側でもAI要約機能などのアップデートが進んでおり、単発の業務効率化であればそれらで十分かと思います。しかし、より広い範囲で業務全体の効率化を行いたい場合には、1つの選択肢になりそうです。

Microsoft Graph API権限がややこしいですが、MS365やTeamsを利用している組織は仲良くなれると色んなことができそうです!

Discussion