The goal
I want to build a Teams app, with an Azure bot (built using the Bot Framework Python SDK), added to a meeting.
When the meeting is started, a notification subscription is registered with the Graph API to alert an endpoint that the transcript is available.
The goal is to then automatically process that transcript (using some custom AI steps) to get insights from the meeting.
The problem
Almost all steps have gone well to develop, apart from the step to fetch the actual transcript text. There the Graph API is giving me troubles.
Steps undertaken
Please find a quick summary of the steps I have undertaken so far, hopefully this will give further insights.
App registration
An App registration has been made in our Entra ID tenant, providing a client-id and client-secret to provide to the Bot, and to later fetch a Graph API token.
No additional permissions have been granted to this app registration, since we want to purely use RSC based permissions, to have the smallest possible access scope.
A client-id and client-secret are noted and stored safely somewhere.
Azure bot backend
An Azure bot has been set up with a custom python backend, with the appropriate API endpoint as well to receive notification subscriptions under the /notify
endpoint
Teams app manifest
A teams app manifest is defined to configure the bot access and list the necessary RSC permissions. You can find a redacted version here below:
{
"$schema": "https://developer.microsoft.com/json-schemas/teams/v1.19/MicrosoftTeams.schema.json",
"manifestVersion": "1.19",
"version": "1.0.9",
"id": "<TEAMS-APP-EXTERNAL-ID>",
"showLoadingIndicator": false,
"developer": {
"name": "<DEVNAME>",
"websiteUrl": "https://www.microsoft.com",
"privacyUrl": "https://www.microsoft.com/privacy.html",
"termsOfUseUrl": "https://www.microsoft.com/termsofuse.html"
},
"name": {
"short": "<SHORT-NAME>",
"full": "<FULL-NAME>"
},
"description": {
"short": "<SHORT-DESCRIPTION>",
"full": "<FULL-DESCRIPTION>"
},
"icons": {
"outline": "outline.png",
"color": "color.png"
},
"accentColor": "#ffffff",
"permissions": [
"identity",
"messageTeamMembers"
],
"webApplicationInfo": {
"id": "<AAD-CLIENT-ID>",
"resource": "https://RscPermission"
},
"authorization": {
"permissions": {
"resourceSpecific": [
{
"name": "OnlineMeetingRecording.Read.Chat",
"type": "Application"
},
{
"name": "OnlineMeetingTranscript.Read.Chat",
"type": "Application"
},
{
"name": "OnlineMeeting.ReadBasic.Chat",
"type": "Application"
},
{
"name": "ChannelMeeting.ReadBasic.Group",
"type": "Application"
},
{
"name": "OnlineMeetingParticipant.Read.Chat",
"type": "Application"
},
{
"name": "ChannelMessage.Read.Group",
"type": "Application"
},
{
"name": "ChatMessage.Read.Chat",
"type": "Application"
}
]
}
},
"validDomains": [
"<BOT-BACKEND-URL>",
"token.botframework.com"
],
"bots": [
{
"botId": "<AAD-CLIENT-ID>",
"scopes": [
"personal",
"team",
"groupChat"
],
"needsChannelSelector": false,
"isNotificationOnly": false,
"supportsFiles": false
}
]
}
Publish and approve app
The app as above has been submitted for approval, has been approved and is visible under the "Built for your org" section of custom apps in the Teams desktop client.
Register notification subscription on meeting start
The bot gets a notification under the on_teams_meeting_start_event
that the meeting has started. A piece of code is then executed to register a notification subscription on the Graph API, so that the backend receives a REST call when the transcript is available. This is done as follows:
subscription_url = "https://graph.microsoft.com/beta/subscriptions"
app_id=<TEAMS-APP-INTERNAL-ID>
base64_certificate=<CERTIFICATE-PUBLIC-KEY>
certificate=<CERTIFICATE-ID>
headers = headers = {
"Authorization": f"Bearer <TOKEN FETCHED VIA AAD APP REG CLIENT ID AND SECRET>",
}
subscription_data = {
"changeType": "created",
"notificationUrl": "<BACKEND-URL>/api/notify",
"resource": f"appCatalogs/teamsApps/{app_id}/installedToOnlineMeetings/getAllTranscripts?useResourceSpecificConsentBasedAuthorization=true",
"includeResourceData": True,
"expirationDateTime": "<EXPIRATION-DATETIME>",
"clientState": "<STATE-STRING>",
"encryptionCertificate": base64_certificate,
"encryptionCertificateId": cert_id,
}
response = requests.post(subscription_url, headers=headers, json=subscription_data)
print(response.status)
print(response.json())
This correctly results in a 201
status with following body:
{
"@odata.context": "https://graph.microsoft.com/beta/$metadata#subscriptions/$entity",
"id": "<SUBSCRIPTION-ID>",
"resource": "appCatalogs/teamsApps/<TEAMS-APP-INTERNAL-ID>/installedToOnlineMeetings/getAllTranscripts?useResourceSpecificConsentBasedAuthorization=true",
"applicationId": "<AAD-CLIENT-ID>",
"changeType": "created",
"clientState": "<STATE-STRING>",
"notificationUrl": "<NOTIFICATION-URL-ENDPOINT>",
"notificationQueryOptions": null,
"notificationContentType": null,
"lifecycleNotificationUrl": null,
"expirationDateTime": "<EXPIRATION-DATETIME>",
"creatorId": "CREATOR-ID",
"includeResourceData": true,
"latestSupportedTlsVersion": "v1_2",
"encryptionCertificate": "<CERTIFICATE-PUBLIC-KEY>",
"encryptionCertificateId": "<CERTIFICATE-ID>",
"notificationUrlAppId": null
}
Capture transcript available event
When the meeting is done, I correctly get a notification event, as described int the online documentation.
For example:
{
"tenantId": "<TENANT-ID>",
"changeType": "created",
"subscriptionId": "<SUBSCRIPTION-ID>",
"clientState": "<STATE-STRING>",
"subscriptionExpirationDateTime": "<EXPIRATION-DATETIME>",
"resource": "communications/onlineMeetings('<MEETING-ID>')/transcripts('<TRANSCRIPT-ID>')",
"resourceData": {
"id": "<TRANSCRIPT-ID>",
"@odata.type": "#Microsoft.Graph.callTranscript",
"@odata.id": "communications/onlineMeetings('<MEETING-ID>')/transcripts('TRANSCRIPT-ID')",
},
"encryptedContent": {
"data": "<ENCRYPTED-CONTENT>",
"dataSignature": "<DATA-SIGNATURE>",
"dataKey": "<DATA-KEY>",
"encryptionCertificateId": "<CERTIFICATE-ID>",
"encryptionCertificateThumbprint": "<CERTIFICATE-THUMBRPINT>",
},
}
I was kind of hoping the encrypted content would already contain the transcript, but upon decryption it does not:
{'@odata.context': "https://graph.microsoft.com/$metadata#communications/onlineMeetings('<MEETING-ID>')/transcripts/$entity",
'id': '<TRANSCRIPT-ID>',
'meetingId': '<MEETING-ID>',
'callId': '<CALL-ID>',
'contentCorrelationId': '<CONTENT-CORR-ID>',
'createdDateTime': '...',
'endDateTime': '...',
'transcriptContentUrl': 'communications/onlineMeetings/<MEETING-ID>/transcripts/<TRANSCRIPT-ID>/content',
'meetingOrganizer': {'application': None,
'device': None,
'user': {'userIdentityType': 'aadUser',
'tenantId': '<TENANT-ID>',
'id': '...',
'displayName': None}},
'content': None}
Fetch the transcript content
This is the step where it goes wrong.
Using a GET request to this url
https://graph.microsoft.com/v1.0/<TRANSCRIPT-CONTENT-URL-FROM-ABOVE>
Using the same Bearer token as earlier (so obtained via the AAD app client-id and client-secret) results in a 404
response:
{
"error": {
"code":"UnknownError",
"message":"404 page not found\\n",
"innerError": {
"date":"2025-01-20T09:20:12",
"request-id":"c78ce659-fa44-4a98-bd48-a82ac0447c5f",
"client-request-id": "c78ce659-fa44-4a98-bd48-a82ac0447c5f"
}
}
}
So I am lost as why the content url, that was provided by the notification subscription event, could result in a 404.
Which step am I missing?
I have searched all online docs and threads for answers, but unfortunately cannot find any at the moment.
All help is greatly appreciated.