Graph CallReport Webhook
Wer eine CallID hat, kann per Graph weitere Details zu dem Anruf ermitteln. Im Nov 2021 konnte ich aber nur für "Telefonanrufe" (DirectRouting/Dialplan) eine Liste der CallIDs gebekommen aber nicht für Meetings, Teams-Anrufe und Federation Anrufe. Diese Seite beschreibt, wie ich mit Azure Functions einen Webhook für Graph bereitstelle, um CallReport Benachrichtigungen zu erhalten und weiter zu bearbeiten.
Beachten Sie dazu auch die Seite Teams Verbindungsdaten, Graph Get-Callrecord und Teams Graph und CallIDs
Streaming, Pull, Graph
Der Abruf von Daten in Graph ist im Prinzip in Polling. Ich melde mich an oder nutze ein früheres Token und greife auf eine Funktion zu, deren Antwort ich dann weiter verarbeite. Es gibt aber Aktionen, die Graph auslösen soll. Als klassischer Entwickler würde ich einfach einen Webrequest aufrufen und Graph könnte die Antwort einfach langeverzögern, bis der Event passiert. So funktioniert z.B. das "Push"-Prinzip in ActiveSync, welches eigentlich ein "Lazy-Pull" ist und auch EWS kennt Streaming- und Pull Notifications. Diese Verfahren haben alle den Nachteil, dass der Client eine Verbindung offenhalten muss und ich auch an einen Server gebunden bin.
Webhooks oder Push-Benachrichtigungen funktionieren anders. Mein Code stellt selbst einen von der Gegenseite erreichbaren Webservice bereit, der eingehende Anfragen annimmt und damit arbeitet. Das eigentliche Programm hinterlegt nun über die bekannte API beim Services diese URL und wartet dann auf die Rückmeldung.
Diese Art der Kommunikation ist natürlich primär für eine Server zu Server-Kommunikation gedacht, denn ein Client wird in der Regel keinen per HTTPS mit Zertifikat und DNS und öffentlicher IP-Adresse erreichbaren Dienste bereitstellen.
- Use the Microsoft Graph API to get
change notification
https://docs.microsoft.com/en-us/graph/api/resources/webhooks - Set up notifications for changes in user
data
https://docs.microsoft.com/en-us/graph/webhooks - Notification subscriptions, mailbox
events, and EWS in Exchange
https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/notification-subscriptions-mailbox-events-and-ews-in-exchange
Schaubild
Für die Nutzung mit Graph brauche ich aber einen im Internet auflösbaren und per HTTPS erreichbaren Service, der von Microsoft Graph angesprochen werden kann. Die Plattform dahinter ist dabei egal. Sie können eine PHP-Seite auf Apache bei einem Webhoster ihrer Wahl ebenso nutzen, wie ein IIS im eigenen Netzwerk oder eine Azure Function. Mein Beispiel basiert auf Azure Function über die ich Meldungen von Teams Calls einsammle und mir per Mail zusenden lassen.
Folgende Schritte soll die Lösung später ausführen:
- Anmelden an Graph und Webhook erstellen
Dazu muss natürlich der Webservice (4) in Azure schon angelegt und erreichbar sein. Daher mache ich das gleich zuerst - Teams meldet neuen Call
Nun sollte jede Call-Änderung von Teams an Graph gemeldet werden. Dazu gehört "Start" eines Anruf und "Ende" eines Anruf - Graph triggert dann den hinterlegten
Webhook
Graph informiert meinen Webhook über einen HTTP-Request auf die hinterlegte Adresse - Ausgabe im Debug-Log
Für den Anfang reicht es mir zu sehen, welche IDs denn wie gemeldet werden. Ich gebe daher die Daten per "Write-Host" erst einmal auf die Konsole aus. Eine "Lösung" müsste dann natürlich die Call-IDs z.B. irgendwie weiter verarbeiten, - Einsicht ins Log
Über das Azure Portal kann ich direkt in die Debug-Ausgabe der Azure Function schauen und mit in einem eigenen Prozess dann die Details zu den Calls anschauen
Wer solche Daten natürlich produktiv nutzt, sollte die Daten natürlich z.B. in einer Datenbank (CosmosDB, Azure Table o.ä.) ablegen. Die Azure Function könnte auch gleich die Änderungen einsammeln, über die der Webhook informiert hat. Eine Azure Function muss sich dazu natürlich auch anmelden und Sie sollten schon das OAUTH-Token dann puffern und solange nutzen, wie es gültig ist oder ein eigener Prozess kümmert sich um den "Renew" des Access-Tokens aber auch des WebHook. Das würde aber den Umfang der Seite sprengen.
- Azure Functions
- Azure Functions developer guide
https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference
Einrichtung
Entsprechend führe ich nun die folgenden Schritte aus:
- Azure Function anlegen
In einem beliebigen Azure Subscription lege ich eine Azure Function an - Azure Function Code hinterlegen
Ich habe mich für PowerShell entschieden, welche die Details des Requests einfach per Azure Logging ausgibt. - "App" registrieren
Damit ich einen Webhook anfordern kann, muss ich die Rechte haben. Dazu lege ich mir eine Azure Application an, die die erforderliche Graph-Rechte zum Lesen von "CallReport" bekommt. - PowerShell zu Anlegen der Subscription
Per PowerShell und der AppID und dem ClientSecret der vorher registrierten App fordere ich per PowerShell nun einen Webhook an.
Azure Function anlegen
Die grundlegenden Schritte zum Anlegen einer funktionierenden Azure Function mit PowerShell habe ich auf der Seite Azure Funtion beschrieben. Wenn Sie diese Schritte durchlaufen haben, dann sollten Sie ein Fenster zum Editieren des Code vor sich haben. Ersetzen Sie den Code einfach durch folgenden Code
Hier als Text, wenn Sie nicht abtippen wollen. Der Code enthält mittlerweile auch die Ausgabe in eine CosmosDB, die erst später angelegt wird.
using namespace System.Net param($Request, $TriggerMetadata) Write-Host "Graph_Webhook: Start" Write-Host "Graph_Webhook: ---------------- Headers -----------------" foreach ($header in $Request.Headers.Keys){ Write-Host "$($header) = $($Request.Headers.($header))" } Write-Host "Graph_Webhook: ---------------- RawBody -----------------" Write-Host $Request.RawBody Write-Host "Graph_Webhook: ---------------- Body -----------------" Write-Host "CallID1=$($Request.body.value.resourceData.id)" Write-Host "Graph_Webhook: ---------------- Query -----------------" Write-Host "Query validationToken = $($Request.Query[""validationToken""])" Write-Host "Query Type: $($Request.Query.gettype().name)" foreach ($query in $Request.Query.keys){ Write-Host "Querykey:$($query) = $($Request.Query.$($query))" } Write-Host "Graph_Webhook: ---------------- Processing -----------------" $Body = "MSXFAQ Graph_Webook Test. Normal Processing" if ($Request.Query["validationToken"]) { Write-Host "validationToken found. - Someone registered a Subscription" $Body = $Request.Query["validationToken"] } else { Write-Host "Writing to CosmosDB" Push-OutputBinding -Name CallIDDocument -Value @{ timestamp = (get-date) Callid = $Request.body.value.resourceData.id changeType = $Request.body.value.changeType xforwardedfor = $Request.Headers.("x-forwarded-for") } Write-Host "Writing to Azure Table" Push-OutputBinding -Name AzureTableCallID -ErrorAction SilentlyContinue -Value @{ PartitionKey = (get-date -Format "yyyyMMddHH") RowKey = $Request.body.value.resourceData.id + "-" + $Request.body.value.changeType } } Write-Host "Sending Body: $($Body)" Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK ContentType = 'text/plain' Body = $body }) Write-Host "Graph_Webhook: End"
Im Azure Portal habe ich mich dann mit dem Logspace verbunden um die "Write-Host" Ausgaben zu sehen.
Auf der Seite Azure Functions finden Sie eine grundlegende Einleitung in Azure Functions
App anlegen
Der nächste Schritt ist nun die Registrierung einer Subscription per Skript. Das geht am besten über eine App mit entsprechenden Berechtigungen. Die einzelnen Schritte zum Anlegen einer App im AzureAD habe ich schon auf den Seiten OAUTH2 / Modern Authentication, Graph Token, Graph Berechtigungen und die Beispiele Graph und Benutzer und Graph und Kennworte thematisiert. Daher hier die Kurzfassung:
- Azure App anlegen
Die App zur Anlage des Webhook muss in den zu überwachenden Tenant die entsprechenden Berechtigungen haben. Die Azure Function muss also nicht im gleichen Tenant laufen, wie Teams. Damit si eine übergreifende App funktioniert, muss ich aber ein "Verified publisher" sein.
Wenn ich also einen "SaaS"-Ansatz machen möchte, dass ich als Anbieter die CallID für den Kunden auswerte, dann muss ich hier weitere Schritte durchlaufen. Für den Test gehe ich daher in den Teams-Tenant und lege dort die App an.
- App-Secret anlegen
Ich nutze für den Test die Kombination aus AppID und Kennwort. Alternativ wäre natürlich ein App-Zertifikat möglich. - App-Berechtigungen
Meiner App, die später den Webhook anfordert und die Details anhand der CallID abfragt, braucht entsprechende Berechtigungen. Ich vergeben "CallRecords.Read.All" und "CallRecord-PstnCalls.Read.All" und erteile im nächsten Dialog noch den erforderlichen "Admin Consent"
In einer SaaS-Umgebung würde ich als Dienstleister meinem Kunden einfach eine URL geben, über die er als Admin meine App berechtigen kann.
Webhook anlegen
Auf den Seiten Graph Token und anderen Seite im Bereich Graph API habe ich schon vorarbeiten geleistet, so dass der Code zur Registrierung schnell zusammengestrickt war. Die ganze Parameter habe ich in einem eigenen "PARAM"-Abschnitt angegeben. Alternativ könnte ich diese in einer XML-Datei auslagern, denn "Credentials im Code" ist immer eine schlechte Idee. Siehe auch PowerShell Parameter.
param ( $LoginUrl="https://login.microsoftonline.com", $AppID="xxxxxxxxxxxxxxxxxxx", $AppSecret= "xxxxxxxxxxxxxxxxxxx", $TenantName="xxxxxxx.onmicrosoft.com" )
Die nächsten Zeilen sorgen für ein Token für die nächsten Zugriffe:
$authresponse=Invoke-RestMethod ` -Uri "$($LoginUrl)/$($TenantName)/oauth2/v2.0/token" ` -Method POST ` -ContentType "application/x-www-form-urlencoded" ` -body "client_id=$($AppID) &scope=https%3A%2F%2Fgraph.microsoft.com%2F.default &client_secret=$($AppSecret) &grant_type=client_credentials &api-version=1.0" $accesstoken= $authresponse.access_token
Mit dem Token kann ich dann die Subscription anfordern:
[string]$expirationDateTime = (get-date).adddays(1).tostring("yyyy-MM-ddThh:mm:ss.FFFFFFFZ") $webhookresponse= Invoke-RestMethod ` -Uri "https://graph.microsoft.com/v1.0/subscriptions" ` -Method POST ` -ContentType "application/json" ` -Header @{ 'Authorization' = "Bearer $($accesstoken)" } ` -Body "{ ""changeType"": ""created,updated"", ""notificationUrl"": ""https://msxfaqdev-sample1.azurewebsites.net/api/Graph_Webhook"", ""resource"": ""communications/callRecords"", ""expirationDateTime"": ""$($expirationDateTime)"", ""clientState"": ""SecretClientState"" }"
Der "clientState" ist nicht zwingend. Ich kann hier aber eine "geheime" Information hinterlegen, die von Graph dann an meinen Webhook gesendet wird. So kann der Webhook schnell erkennen, ob der Request legitim ist. Der Webhook ist ja im Grund anonym erreichbar.
Bei der Anforderung "prüft" Graph, ob der angegeben Webservice erreichbar ist. Im Azure Portal kann ich den Zugriff sehen:
2021-11-13T16:23:36.742 [Information] Executing 'Functions.Graph_Webhook' (Reason='This function was programmatically called via the host APIs.', Id=<guid>) 2021-11-13T16:23:37.507 [Information] INFORMATION: Graph_Webhook: Start 2021-11-13T16:23:37.559 [Information] INFORMATION: Graph_Webhook:Headers: connection = Keep-Alive 2021-11-13T16:23:37.561 [Information] INFORMATION: Graph_Webhook:Headers: content-length = 0 2021-11-13T16:23:37.564 [Information] INFORMATION: Graph_Webhook:Headers: content-type = text/plain; charset=utf-8 2021-11-13T16:23:37.567 [Information] INFORMATION: Graph_Webhook:Headers: host = msxfaqdev-sample1.azurewebsites.net 2021-11-13T16:23:37.569 [Information] INFORMATION: Graph_Webhook:Headers: max-forwards = 9 2021-11-13T16:23:37.572 [Information] INFORMATION: Graph_Webhook:Headers: x-waws-unencoded-url = /api/Graph_Webhook?validationToken=Validation%3a+Testing+client+ application+reachability+for+subscription+Request-Id%3a+51cc90ea-67a7-455e-b160-63647fc4b4e5 2021-11-13T16:23:37.574 [Information] INFORMATION: Graph_Webhook:Headers: client-ip = 10.0.32.16:51415 2021-11-13T16:23:37.576 [Information] INFORMATION: Graph_Webhook:Headers: x-arr-log-id = 3d8ce1d0-5876-463a-ba91-18d7d01636ed 2021-11-13T16:23:37.579 [Information] INFORMATION: Graph_Webhook:Headers: x-site-deployment-id = msxfaqdev-sample1 2021-11-13T16:23:37.582 [Information] INFORMATION: Graph_Webhook:Headers: was-default-hostname = msxfaqdev-sample1.azurewebsites.net 2021-11-13T16:23:37.584 [Information] INFORMATION: Graph_Webhook:Headers: x-original-url = /api/Graph_Webhook?validationToken=Validation%3a+Testing+client+ application+reachability+for+subscription+Request-Id%3a+51cc90ea-67a7-455e-b160-63647fc4b4e5 2021-11-13T16:23:37.587 [Information] INFORMATION: Graph_Webhook:Headers: x-forwarded-for = 52.142.115.31:7552 2021-11-13T16:23:37.590 [Information] INFORMATION: Graph_Webhook:Headers: x-arr-ssl = 2048|256|C=US, O=Microsoft Corporation, CN=Microsoft RSA TLS CA 02|CN= *.azurewebsites.net 2021-11-13T16:23:37.593 [Information] INFORMATION: Graph_Webhook:Headers: x-forwarded-proto = https 2021-11-13T16:23:37.595 [Information] INFORMATION: Graph_Webhook:Headers: x-appservice-proto = https 2021-11-13T16:23:37.598 [Information] INFORMATION: Graph_Webhook:Headers: x-forwarded-tlsversion = 1.2 2021-11-13T16:23:37.628 [Information] INFORMATION: Graph_Webhook:Headers: disguised-host = msxfaqdev-sample1.azurewebsites.net 2021-11-13T16:23:37.635 [Information] INFORMATION: Graph_Webhook:Body = 2021-11-13T16:23:37.635 [Information] INFORMATION: Graph_Webhook: End 2021-11-13T16:23:37.644 [Information] Executed 'Functions.Graph_Webhook' (Succeeded, Id=<guid>, Duration=903ms)
Allerdings hat dies nicht auf den ersten Anhieb geklappt. Ich hatte einige Fehler, die ich im nächsten Abschnitt näher beschreibe. Wenn der Webhook dann aber korrekt registriert wurde, dann liefert Graph folgende Antwort zurück:
PS C:\> $webhookresponse @odata.context : https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity id : bf6f6746-ec39-4e95-9a94-97b78d3ee9dd resource : communications/callRecords applicationId : e68560af-3cd2-4d4a-a151-e7ff108fb08e changeType : created,updated clientState : notificationUrl : https://msxfaqdev-sample1.azurewebsites.net/api/Graph_Webhook notificationQueryOptions : lifecycleNotificationUrl : expirationDateTime : 14.11.2021 05:44:18 creatorId : 1c545711-5aba-4431-99d8-006fdd2468f1 includeResourceData : latestSupportedTlsVersion : v1_2 encryptionCertificate : encryptionCertificateId : notificationUrlAppId :
- Microsoft Graph: Subscription:
subscription resource typ
https://docs.microsoft.com/en-us/graph/api/resources/subscription?view=graph-rest-1.0#properties
Beschreibt die Bedeutung und möglichen Werte der Felder.
Fehlerbehandlung
Bei meinen Versuchen haben ich verschiedene Fehler gemacht. Es hilft natürlich vorab die Dokumente von Microsoft genau zu lesen.
- Set up notifications for changes in user
data - Notification endpoint validation
https://docs.microsoft.com/en-us/graph/webhooks#notification-endpoint-validation - changeNotificationCollection resource
type
https://docs.microsoft.com/en-us/graph/api/resources/changenotificationcollection
Meldung | Beschreibung |
---|---|
Authentication vergessenDas passiert schnell, wenn man die Beispiele kopiert und sich nur auf den Body beschränkt. Im Header muss schon das Bearer Token angegeben werden. |
{"error":{ "code":"InvalidAuthenticationToken", "message":"Access token is empty.", "innerError":{ "date":"2021-11-13T15:25:47", "request-id":"guid", "client-request-id":"guid"} } }} |
Expirationdate vergessenDie Angabe ist zwingend erforderlich. |
{"error":{ "code":"InvalidRequest", "message":"expirationDateTime is a required property for subscription creation.", "innerError":{"date":"2021-11-13T15:28:25", "request-id":"guid", "client-request-id":"guid"} } }} |
expirationDate zu spätDer Fehler erklärt sich wohl von selbst. |
{"error":{ "code":"ExtensionError", "message":"Subscription expiration can only be 4230 minutes in the future.", "innerError":{ "date":"2021-11-15T09:15:25", "request-id":"guid", "client-request-id":"guid"} } } |
Resource hatte einen führenden "/"Das Backend scheint sehr streng die Parameter zu prüfen. Die Fehlermeldung gibt auch einen Hinweis, dass das Datumformat korrekt sein muss. |
{"error":{ "code":"InvalidRequest", "message":"Could not process subscription creation payload. Are all property names spelled and camelCased properly? Also are the dateTimeOffest properties in a valid internet Date and Time format?", "innerError":{ "date":"2021-11-13T15:34:17", "request-id":"guid", "client-request-id":"guid"} } }} |
changetype "Deleted"Entgegen der Beschreibung versteht auch CallDetails kein "Deleted". |
{"error":{ "code":"InvalidRequest", "message":"Invalid 'changeType' attribute: 'deleted'.", "innerError":{ "date":"2021-11-13T20:12:43", "request-id":"guid", "client-request-id":"guid" } } }} |
validationTokenAn diesem Teil habe ich etwas länger gesucht, denn ich musste erst in meine Azure Function einen Code einbauen, um den validationToken zu finden. Der ist nicht im Body sondern im Header als Query-String versteckt. Den muss ich ermitteln und an die aufrufende Funktion zurücksenden. |
{"error":{ "code":"InvalidRequest", "message":"Subscription validation request failed. Response must exactly match validationToken query parameter.", "innerError":{ "date":"2021-11-13T15:40:29", "request-id":"guid", "client-request-id":"guid"} } }} Hier der Header mit dem Validationtoken (umgebrochen) x-original-url = /api/Graph_Webhook? validationToken=Validation%3a+Testing+ client+application+reachability+for+ subscription+Request-Id%3a+<guid> In der Azure Function kann ich den wie folgt ermitteln und dann in der Azure Function auch setzen. Write-Host ("Query validationToken = $($Request.Query[""validationToken""])")
Allerdings ist das nur für die Einrichtung des Webhooks erforderlich. Die späteren Webhook-Meldungen setzen kein validationToken mehr und es scheint ein 200 OK zu reichen.
|
Subscription auflisten/löschen
Per Graph kann ich auch eine Liste der eingetragenen Subscriptions erhalten:
$subscriptions = Invoke-RestMethod ` -Uri "https://graph.microsoft.com/v1.0/subscriptions" ` -Method GET ` -ContentType "application/json" ` -Header @{ 'Authorization' = "Bearer $($accesstoken)" }
Das Rückgabeobjekt ist eine Liste der aktuell aktiven Subscriptions.
PS C:\> $subscriptions @odata.context value -------------- ----- https://graph.microsoft.com/v1.0/$metadata#subscriptions {@{id=bf6f6746-ec39-4e95-9a94-97b78d3ee9dd; resource=communic… PS C:\> $subscriptions[0] | fl @odata.context : https://graph.microsoft.com/v1.0/$metadata#subscriptions value : {@ { id=<guid>; resource=communications/callRecords; applicationId=e68560af-3cd2-4d4a-a151-e7ff108fb08e; changeType=created,updated; clientState=; notificationUrl=https://msxfaqdev-sample1.azurewebsites.net/api/Graph_Webhook; notificationQueryOptions=; lifecycleNotificationUrl=; expirationDateTime=14.11.2021 05:44:18; creatorId=<guid>; includeResourceData=; latestSupportedTlsVersion=v1_2; encryptionCertificate=; encryptionCertificateId=; notificationUrlAppId= } }
Mit der ID kann die Subscription dann auch wieder vorzeitig gelöscht oder verlängert werden. Ein Update ist immer erforderlich, wenn die bestehende Subscription abläuft.
$delete = Invoke-RestMethod ` -Uri "https://graph.microsoft.com/v1.0/subscriptions/$($subscriptions.value.id)" ` -Method DELETE ` -Header @{ 'Authorization' = "Bearer $($accesstoken)"}
Die Variable "$Delete" enthält keine Informationen. Wenn der Delete fehlschlägt, dann haben Sie einen HTTP-Error mit der folgenden Payload:
{"error": { "code":"ResourceNotFound", "message":"The object was not found.", "innerError":{ "date":"2021-11-13T20:08:31", "request-id":"b73e9619-8993-406d-8459-f53b321629e9", "client-request-id":"b73e9619-8993-406d-8459-f53b321629e9" } } }
- Maximum length of subscription per
resource type
https://docs.microsoft.com/en-us/graph/api/resources/subscription?view=graph-rest-1.0#maximum-length-of-subscription-per-resource-type
Teams callRecord 4230 Minuten (unter 3 Tagen) - Update subscription
https://docs.microsoft.com/en-us/graph/api/subscription-update?view=graph-rest-1.0&tabs=http - Delete subscription
https://docs.microsoft.com/en-us/graph/api/subscription-delete?view=graph-rest-1.0&tabs=http - Reduce missing subscriptions and change
notifications - Responding to
subscriptionRemoved notifications
https://docs.microsoft.com/en-us/graph/webhooks-lifecycle#responding-to-subscriptionremoved-notifications
Webhook POST
Was wir dann noch brauchen, sind einfach "Anrufe" in Teams. Allerdings gehört auch etwas Geduld dazu, denn es gibt dokumentierte Latenzzeiten, die keine "Echtzeit"-Überwachung erlauben:
Quelle:
https://docs.microsoft.com/en-us/graph/webhooks#latency
Beim ersten Test habe ich nach ca. 10 Minuten folgendes in der Ausgabe gesehen.
2021-11-13T18:23:25.411 [Information] Executing 'Functions.Graph_Webhook' (Reason='This function was programmatically called via the host APIs.', Id=54278391-e836-4d34-b46f-b3912e4d0621) 2021-11-13T18:23:26.685 [Information] INFORMATION: Graph_Webhook: Start 2021-11-13T18:23:26.689 [Information] INFORMATION: Graph_Webhook: ---- Headers ----- 2021-11-13T18:23:26.746 [Information] INFORMATION: connection = Keep-Alive 2021-11-13T18:23:26.748 [Information] INFORMATION: content-length = 486 2021-11-13T18:23:26.751 [Information] INFORMATION: content-type = application/json; charset=utf-8 2021-11-13T18:23:26.753 [Information] INFORMATION: host = msxfaqdev-sample1.azurewebsites.net 2021-11-13T18:23:26.756 [Information] INFORMATION: max-forwards = 9 2021-11-13T18:23:26.758 [Information] INFORMATION: x-waws-unencoded-url = /api/Graph_Webhook 2021-11-13T18:23:26.761 [Information] INFORMATION: client-ip = 10.0.32.25:50739 2021-11-13T18:23:26.765 [Information] INFORMATION: x-arr-log-id = ce1be10b-8ffd-4ce1-93dd-804592693f01 2021-11-13T18:23:26.767 [Information] INFORMATION: x-site-deployment-id = msxfaqdev-sample1 2021-11-13T18:23:26.769 [Information] INFORMATION: was-default-hostname = msxfaqdev-sample1.azurewebsites.net 2021-11-13T18:23:26.772 [Information] INFORMATION: x-original-url = /api/Graph_Webhook 2021-11-13T18:23:26.775 [Information] INFORMATION: x-forwarded-for = 51.138.90.7:6784 2021-11-13T18:23:26.777 [Information] INFORMATION: x-arr-ssl = 2048|256|C=US, O=Microsoft Corporation, CN=Microsoft RSA TLS CA 02|CN=*.azurewebsites.net 2021-11-13T18:23:26.780 [Information] INFORMATION: x-forwarded-proto = https 2021-11-13T18:23:26.782 [Information] INFORMATION: x-appservice-proto = https 2021-11-13T18:23:26.795 [Information] INFORMATION: x-forwarded-tlsversion = 1.2 2021-11-13T18:23:26.795 [Information] INFORMATION: disguised-host = msxfaqdev-sample1.azurewebsites.net 2021-11-13T18:23:26.819 [Information] INFORMATION: Graph_Webhook: ---- Body ----- 2021-11-13T18:23:26.827 [Information] INFORMATION: System.Collections.DictionaryEntry 2021-11-13T18:23:26.827 [Information] INFORMATION: Graph_Webhook: ---- Query ----- 2021-11-13T18:23:26.827 [Information] INFORMATION: Query validationToken = 2021-11-13T18:23:26.827 [Information] INFORMATION: NO validationToken found. 2021-11-13T18:23:26.827 [Information] INFORMATION: Sending Azure Function Response 2021-11-13T18:23:26.834 [Information] INFORMATION: Graph_Webhook: End 2021-11-13T18:23:27.016 [Information] Executed 'Functions.Graph_Webhook' (Succeeded, Id=54278391-e836-4d34-b46f-b3912e4d0621, Duration=1652ms)
Leider war der Body ein "System.Collections.DictionaryEntry" und nicht der erwartete String. Und selbst als ich das Dictionary ausgeben wollte, kam wieder eine "Hashtable" raus. Bei der Suche bin ich auf ein Stück Sourcecode gestoßen, der die Konvertierung des Body beschreibt. Microsoft nimmt mit Arbeit ab, indem es auf den Mediatype "application/json" oder "application/octet-stream" prüft und den Body gleich konvertiert an meine Funktion übermittelt.
- azure-functions-host/src/WebJobs.Script/Rpc/MessageExtensions/RpcMessageConversionExtensions.cs
https://GitHub.com/Azure/azure-functions-host/blob/ec8984ef1529c95aa5638c5c942eb55077d95b4b/src/WebJobs.Script/Rpc/MessageExtensions/RpcMessageConversionExtensions.cs#L110-L135
Aber in der Funktion ist auch zu sehen, das es noch ein Property "RawBody" gibt. Ich habe also meinen Code entsprechend angepasst und nach einem Anruf habe ich folgenden RawBody erhalten (GUIDs wurden anonymisiert):
Aktion | Daten |
---|---|
Direct Routing Anruf: Start |
{"value":[ { "tenantId":"12345678-1234-1234-1234-1234567890ab", "subscriptionId":"12345678-1234-1234-1234-1234567890ab", "clientState":null, "changeType":"created", "resource":"communications/callRecords/12345678-1234-1234-1234-1234567890ab", "subscriptionExpirationDateTime":"2021-11-13T21:44:18.9037958-08:00", "resourceData":{ "oDataType":"#microsoft.graph.callrecord", "oDataId":"communications/callRecords/12345678-1234-1234-1234-1234567890ab", "id":"12345678-1234-1234-1234-1234567890ab" } } ]} |
Direct Routing Anruf: Ende |
{"value":[ { "tenantId":"12345678-1234-1234-1234-1234567890ab", "subscriptionId":"12345678-1234-1234-1234-1234567890ab", "clientState":null, "changeType":"updated", "resource":"communications/callRecords/12345678-1234-1234-1234-1234567890ab", "subscriptionExpirationDateTime":"2021-11-13T21:44:18.9037958-08:00", "resourceData":{ "oDataType":"#microsoft.graph.callrecord", "oDataId":"communications/callRecords/12345678-1234-1234-1234-1234567890ab", "id":"12345678-1234-1234-1234-1234567890ab" } } ]} |
Federation Call zu anderem Tenant Start |
{"value":[ { "tenantId":"de21c301-a4ae-4292-aa09-6db710a590a6", "subscriptionId":"62e4deea-8f3f-4b35-b732-c99fae0ae5ec", "clientState":"SecretClientState", "changeType":"created", "resource":"communications/callRecords/ca256223-386c-45d5-93f6-0ebf171560de", "subscriptionExpirationDateTime":"2021-11-13T21:44:18.9037958-08:00", "resourceData":{ "oDataType":"#microsoft.graph.callrecord", "oDataId":"communications/callRecords/ca256223-386c-45d5-93f6-0ebf171560de", "id":"ca256223-386c-45d5-93f6-0ebf171560de" } } ]} |
Meeting Start |
{"value":[ { "tenantId":"de21c301-a4ae-4292-aa09-6db710a590a6", "subscriptionId":"62e4deea-8f3f-4b35-b732-c99fae0ae5ec", "clientState":"SecretClientState", "changeType":"created", "resource":"communications/callRecords/14c61899-9415-4415-a21b-620dd037417e", "subscriptionExpirationDateTime":"2021-11-13T21:44:18.9037958-08:00", "resourceData":{ "oDataType":"#microsoft.graph.callrecord", "oDataId":"communications/callRecords/14c61899-9415-4415-a21b-620dd037417e", "id":"14c61899-9415-4415-a21b-620dd037417e" } } ]} |
Ich habe erst einmal nicht mehr Daten mitgeschnitten, denn die Latenzzeit zwischen dem Anruf und der Meldung betrug bei mir immer mehr als 10 Minuten.
Damit ist bestätigt, dass die Daten im JSON-Format geliefert werden und welches Feld die interessante "CallID" enthält. Eine Auswertung des Body kann dann über folgende Zeile erfolgen.
Write-Host "CallID1=$($Request.body.value.resourceData.id)"
Es gibt am Anfang nur den "Created" und am Ende einen "Updated"-Event aber kein "Deleted". Das macht auch Sinn, denn der CDR-Eintrag wird ja beim Ende des Calls nur aktualisiert aber nicht gelöscht. Ob er nach 30 Tagen noch ein "Update" sendet, habe ich nicht weiter verfolgt. Ein "Deleted" erwarte ich nicht, da ich dies gar nicht beim Subscribe angeben kann.
Verpasste Notifications
Es kann ja durchaus sein, dass der Webservice einmal "unpässlich" ist oder er zwar die Daten noch annimmt aber selbst nicht weiter verarbeiten oder abspeichern kann. Dazu gibt es leider nur zwei Dinge aktuell zu sagen:
- Kein Retry bei Fehler
Graph sendet wohl nur genau einmal den Request. Wenn er nicht ankommt, wir es nicht wiederholt. Wir verpassen also die CallID. - Kein Nachliefern
Ich kann auch nicht eine Subscription neu starten und ein Startdatum angeben, um z.B. die vergangenen Meldungen zu bekommen
Das ganze System der "CallRecord"-Webhooks ist aus meiner Sicht also nicht wasserdicht aber leider gibt es keine Alternative. Ich habe noch keinen Weg gefunden, z.B. die Liste aller Verbindungen per Graph zu erhalten. Nur die Abfrage nach DirectRoutung und PSTNCalls ist per Graph möglich (Stand Nov 2021)
- Reduce missing subscriptions and change
notifications - Responding to
subscriptionRemoved notifications
https://docs.microsoft.com/en-us/graph/webhooks-lifecycle#responding-to-subscriptionremoved-notifications - Reduce missing subscriptions and change
notifications - Responding to missed
notifications
https://docs.microsoft.com/en-us/graph/webhooks-lifecycle#responding-to-missed-notifications
10 Sek Timeout
Wenn Microsoft einen von ihnen hinterlegten Webhook aufruft, dann muss die Antwort zeitnah erfolgen. Die Grenze dabei sind 10 Sekunden. Wenn ein Aufruf nicht nach 10 Sekunden abgeschlossen ist, dann geht Microsoft davon aus, dass ihr Webserver ein Problem hat und stoppt die Benachrichtigung angeblich für 4 Stunden. Zumindest ist das bei einem Support Ticket herausgekommen, weil unsere Lösung viel zu spät die Information bekommen hat.
Entkoppeln Sie daher möglichst den Server für den Webhook mit der Verarbeitung der Daten Der Webhook sollte einfach die Daten annehmen und z.b. in Azure Event Grid, CosmoDB o.ä. ablegen.
- Set up notifications for changes in resource data
https://learn.microsoft.com/en-us/graph/webhooks?tabs=http - Webhooks, Automation-Runbooks, Logik-Apps als Ereignishandler für Azure
Event Grid-Ereignisse
https://learn.microsoft.com/de-de/azure/event-grid/handler-webhooks
Solche Limits habe andere Dienste auch:
- Bewährte Methoden für Integratoren
https://docs.github.com/de/rest/guides/best-practices-for-integrators - Github webhook timeout was lowered to 10 seconds, makes this tool
timeout-prone
https://github.com/dd32/Github-to-WordPress-Plugins-Sync/issues/6 - Webhook timeout intervals and how to avoid them
https://learn.fotoware.com/Integrations_and_APIs/Integration_using_webhooks/Managing_and_monitoring_webhook_requests/Webhook_timeout_intervals_and_how_to_avoid_them
Ablage in CosmosDB
Die Ausgabe der CallID in einer Debug-Session ist zwar nett, aber reicht natürlich nicht. Ich könnte nun in die Azure Function auch gleich den Code integrieren, um die Details zu laden und dann weiter zu verarbeiten. Aber die Azure function soll ja "minimalistisch" sein und die Weiterverarbeitung kann ja über all sonst erfolgen. Also wollte ich einfach die CallIDs in einer Liste speichern. Dazu eignen sich Azure Tables oder CosmosDB. Eine Suche führt schnell zu entsprechenden Seiten:
- Store unstructured data using Azure
Functions and Azure Cosmos DB
https://docs.microsoft.com/en-us/azure/azure-functions/functions-integrate-store-unstructured-data-cosmosdb - Azure Cosmos DB output binding for Azure
Functions 2.x and higher
https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-cosmosdb-v2-output?tabs=powershell - Azure Table storage output bindings for
Azure Functions
https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-table-output?tabs=powershell - Azure Cosmos DB free tier
https://docs.microsoft.com/en-us/azure/cosmos-db/free-tier - Types of Databases on Azure
https://azure.microsoft.com/en-us/product-categories/databases/ - What is Azure Table storage ?
https://docs.microsoft.com/en-us/azure/storage/tables/table-storage-overview - Get started with Storage Explorer
https://docs.microsoft.com/en-us/azure/vs-azure-tools-storage-manage-with-storage-explorer?tabs=windows
Die Anleitung auf den Seiten ist eigentlich sehr gut.
- CosmosDB anlegen
Über die "Subscription - Ressources - Create" lege ich eine CosmosDB - Core SQL an.
Die weiteren Dialoge erspare ich ihnen. Ich verzichte auf geografische Verteilung, erlaube die Verbindung von allen Endpunkten, lasse die Backups auf Default, Encryption auf "Service-managed key" und pflege meine Tags. Das Anlegen dauerte, wie angekündigt, ca. 2 Minuten. - Kosten anpassen
Eine CosmosDB ist bis zu einer gewissen Belastung kostenfrei. Per Default ist das Limit aber nicht aktiv. Solange Sie in der Azure Function noch keinen Schutz, z.B. über den "ClientState" integriert haben, könnte jemand über massenhaft API-Aufrufe auf eine "gefundene URL" natürlich ihr System füllen. Auch sonst ist ein "Cost Management" natürlich ratsam. In meiner Test-Umgebung habe ich den Default von "no Limit" angepasst.
- Output Binding addieren
Die neue CosmosDB muss natürlich in der Azure Function verknüpft werden. Auch das geht per Browser. Die CosmosDB Account Connection musste ich per "new" schnell anlegen.
- Code erweitern
Nun muss ich nur noch in der Azure Function den Code einbauen, dass die CallID und weitere Daten in die Datenbank geschrieben werden.
Push-OutputBinding ` -Name CosmosDBCallID ` -Value @{ timestamp = (get-date) Callid = $Request.body.value.resourceData.id) changeType = $Request.body.value.changeType }
Das war es auch schon. Jeder Meldung der Subscription landet nun in der CosmosDB. Über den Data Explorer kann ich direkt pro Eintrag eine Zeile mit einer eindeutigen ID sehen, die allerdings nicht die CallID ist. Die ist dann in dem Dokument enthalten.
Dennoch ist eine Suche per "Select"-Statement möglich und da das Dokument schon einen Timestamp vom System bekommen hat, kann ich danach sogar gezielt die älteren CallIDs abrufen. Einer eigenen Verarbeitung durch eine Lösung, die zyklisch, z.B. einmal pro Tag, die CallIDs holt um sich dann die Details wieder per Graph zur Auswertung zu sammeln, ist damit einfach möglich.
Ich habe absichtlich hier nur die CallIDs und keine weiteren Daten gespeichert. Die CallID ist einfach nur eine GUID und erlaubt nur dann einen Rückschluss auf den individuellen Call, wenn ich mit entsprechenden Rechten mir dann auch die Details dazu holen kann. Insofern wäre es vermutlich nicht mal schlimm, wenn die Datenbank "anonym" im Internet erreichbar wäre. Man könnte maximal über die Zeit eine Verteilung der Aktivitäten protokollieren.
- Azure PowerShell samples for Azure
Cosmos DB Core (SQL) API
https://docs.microsoft.com/en-us/azure/cosmos-db/sql/powershell-samples - Azure PowerShell samples for Azure
Cosmos DB Table API
https://docs.microsoft.com/en-us/azure/cosmos-db/table/powershell-samples - PlagueHO / CosmosDB
https://GitHub.com/PlagueHO/CosmosDB
Ablage in Azure Table
Bei der Arbeit mit CosmosDB und dem Storage Explorer haben ich mir überlegt, ob es auch eine andere Ablagemöglichkeit gibt, die eher wie eine Tabelle arbeitet. Ich könnte natürlich eine SQL-VM aufbauen oder eine SQL-Instanz oder eine SQL-Datenbank kaufen. Aber eigentlich brauche ich nur eine schlanke Tabelle, die ich später einfach auslesen und alte Werte löschen kann. Im Storage Explorer sind mir dann die "Tables" aufgefallen, die ich hier auch direkt anlegen kann. Die Tabelle hat erst einmal "nur" die Spalten "PartitionKey" und "RowKey". Die anderen Spalten kann ich aber einfach hinzufügen.
Spätestens bei der Eingabe des Timestamp erwartet Azure aber einen Wert. Ich kann die Tabelle wohl nur mit Spalten erweitern, wenn ich auch Werte übergebe. Geben Sie daher Werte ein und löschen Sie danach die Zeile einfach wieder. Allerdings gibt es in Azure Table keine fest vorgegebenen Spalten, da jede Teile eigene Properties haben kann
- Azure Table storage: Enter an ISO date
time formatted string
https://blog.ninethsense.com/technology/bookmark/azure-table-storage-enter-an-iso-date-time-formatted-string/ - Azure Table Storage and Microsoft Azure
SQL Database - Compared and Contrasted
https://docs.microsoft.com/en-us/previous-versions/azure/jj553018(v=azure.100)
07 Understanding Azure Table Storage
https://www.youtube.com/watch?v=c3t2i1KCASU
Die Nutzung aus Azure Functions ist auch sehr einfach.
- Azure Table storage output bindings for
Azure Functions
https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-table-output?tabs=powershell
Ich muss wieder ein Binding mit einem Variablennamen addieren.
Beim StorageAccountname schlägt der Assistent zwei Werte ("AzureWebJobsStorage" und "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING") vor, die aber beide bei mir nicht funktionieren. Über "New" habe den bestehenden StorageAccount daher addiert. Der Wert in "Table parameter name" ist der Variablenname, den ich dann bei "Push-OutputBinding" verwenden muss.
Push-OutputBinding -Name AzureTableCallID -Value@{ PartitionKey = 'Wert1' RowKey = "Wert2" Name = "Wert3" }
Es stellt sie aber die Frage, wie ich die vordefinierte Spalten "PartitionKey" und "RowKey" genutzt werden.
- Understanding the Table service data
model
https://docs.microsoft.com/en-us/rest/api/storageservices/understanding-the-table-service-data-model - What PartitionKey and RowKey are for in
Windows Azure Table Storage
https://blog.maartenballiauw.be/post/2012/10/08/what-partitionkey-and-rowkey-are-for-in-windows-azure-table-storage.html - PartitionKey and RowKey in Windows Azure
Table Storage
https://dzone.com/articles/partitionkey-and-rowkey - Quickstart: Build a Table API app with
.NET SDK and Azure Cosmos DB
https://docs.microsoft.com/en-us/azure/cosmos-db/table/create-table-dotnet
Eine Zeile wird durch die Kombination aus Partitionkey und Rowkey eindeutig identifiziert und der Partitionkey wird vom Backend genutzt, um alle Zeilen auf dem gleichen Storagenode zu betreiben. einen Timestamp addiert die Datenbank schon alleine. Meine Datenmenge ist eher "klein", so dass Verteilung weniger relevant sein dürfte und eher die Anfrage über mehrere Storagenodes später langsamer ist. Zudem kann ich per Suche direkt einen Storagenode nutzen und dort drin nach CallIDs suchen. Ich erspare dem Backend die Suche in mehreren Partitionen nach einer CallID. Am Ende habe ich mich aber dafür entschieden, den Storagekey am Format "yyyyMMddHH" festzumachen, so dass alle Calls einer Stunde in einer Partition liegen. Die CallID ist dann der eindeutige RowKey. Die Daten sind eigentlich egal.
Write-Host "Writing to Azure Table" Push-OutputBinding -Name AzureTableCallID -ErrorAction SilentlyContinue -Value @{ PartitionKey = (get-date -Format "yyyyMMddHH") RowKey = $Request.body.value.resourceData.id + "-" + $Request.body.value.changeType }
Bei eier Azure Table muss die Kombination aus PartitionKey und RowKey eindeutig sein. Da jeder Teams Call aber zumindest eine "Created" und eine "updated"-Meldung sendet, habe ich den changeType einfach mit addiert. Ganz "safe" ist das aber nicht.
- Push-OutputBinding for table storage
does not overwrite existing entities
https://GitHub.com/Azure/azure-functions-powershell-worker/issues/303
Azure Queues
Nachdem ich erst mit CosmosDB und dann mit Azure Tables zu tun hatte, bin ich auf die Azure Queues gekommen, die für meine Anwendung sogar noch besser geeignet wären. Ich könnte per Azure Function die CallID einfach vorne "reinwerfen" und ein anderes Programm kann sich dann später die Einträge abholen und asynchron weiter verarbeiten.
Diese Anbindung habe ich aber noch nicht umgesetzt.
- What is Azure Queue Storage?
https://docs.microsoft.com/en-us/azure/storage/queues/storage-queues-introduction - Azure Queue storage trigger and bindings
for Azure Functions overview
https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-queue
Event Grid
Mein nächster Versucht wäre die eingehenden CallIDs einfach über den Azure Eventhub an die entsprechenden nachverarbeitenden Stellen zu geben. Über diesen Weg kann ich Chatnachrichten und CallRecords (Meetings) erhalten und weiter verarbeiten
- Event Grid: Microsoft Graph API events
https://learn.microsoft.com/en-us/azure/event-grid/partner-events-graph-api - Microsoft Teams events in Azure Event Grid
https://learn.microsoft.com/en-us/azure/event-grid/teams-events - callRecord resource type
https://learn.microsoft.com/en-us/graph/api/resources/callrecords-callrecord?view=graph-rest-1.0
Einschätzung
Es ist mir gelungen, allein mit PowerShell und meinen Kenntnissen einen Webhook auf Azure einzurichten, der auf eine CallRecord-Subscription reagieren kann und die CallID einzusammeln. Ich bekomme so sogar die CallIDs für Teams-Anrufe, Federation Calls und Meetings, für die es keine nativen Graph-Schnittstellen gibt. Auf der Seite Graph Get-Callrecord zeige ich dann, wie ich weitere Details zu den Calls erhalten kann. Aber das löst viele andere Probleme noch nicht wie:
- Verpasste CallIDs
Wenn die Rückmeldung auf die Subscription nicht den Webservice erreicht, gibt es keinen "retry". Der CallID wird dann nicht protokolliert und es entsteht eine Lücke. Als Entwickler sollte man also immer eine Nacherfassung berücksichtigen, die bei einer Unterbrechung die Daten per Listenabruf ermittelt. Leider kommen über diesen API-Call nur die PSTN-Calls aber keine FederationCalls. Eine Lösung dazu kenne ich nicht. - Hohe Latenzzeit, nicht "Realtime"
Leider sendet Teams über diese API keine Meldungen in Echtzeit. Die API eignet sich nicht als "Remote Call Control"-Schnittstelle o.ä. Ich vermute, dass das Teams Backend seine Informationen in die CDR-Datenbank schreibt, was einfach etwas dauert. Die CDR-Datenbank wir dann den WebHook aufrufen..
Die Lösung per Graph Subscription ist aktuell anscheinend der einzige Weg eine komplette Liste alle CallID zu bekommen, in der nicht nur die Telefonanrufe sondern auch FederationCalls und Meetings enthalten sind. Die Graph Bordmittel erlauben ansonsten nur die Anfrage von PSTN-Calls und DirectRouting Calls.
Ich hoffe doch stark, dass Microsoft irgendwann auch per Graph die Liste aller CallIDs nach Zeit oder nach Benutzer bereitstellt.
Weitere Links
- Azure Functions
- Graph PowerShell
- Graph Get-Callrecord
- Teams Graph und CallIDs
- Teams Verbindungsdaten
- Graph CallReport Webhook
- Set up notifications for changes in user data - Notification endpoint
validation
https://docs.microsoft.com/en-us/graph/webhooks#notification-endpoint-validation - Set up notifications for changes in user data
https://docs.microsoft.com/en-us/graph/webhooks - Microsoft Graph permissions reference
https://docs.microsoft.com/en-us/graph/permissions-reference?WT.mc_id=Portal-Microsoft_AAD_RegisteredApps - Phishing mit Consent-Anforderung
- PowerShell Parameter
- GitHub: microsoftgraph / aspnetcore-webhooks-sample
https://GitHub.com/microsoftgraph/aspnetcore-webhooks-sample - Microsoft Teams meeting attendance
report
https://docs.microsoft.com/en-us/microsoftteams/teams-analytics-and-reports/meeting-attendance-report