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.

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.

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:

  1. 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
  2. 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
  3. Graph triggert dann den hinterlegten Webhook
    Graph informiert meinen Webhook über einen HTTP-Request auf die hinterlegte Adresse
  4. 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,
  5. 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.

Einrichtung

Entsprechend führe ich nun die folgenden Schritte aus:

  1. Azure Function anlegen
    In einem beliebigen Azure Subscription lege ich eine Azure Function an
  2. Azure Function Code hinterlegen
    Ich habe mich für PowerShell entschieden, welche die Details des Requests einfach per Azure Logging ausgibt.
  3. "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.
  4. 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.

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-graph.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-graph.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-graph
2021-11-13T16:23:37.582 [Information] INFORMATION: Graph_Webhook:Headers: was-default-hostname = msxfaqdev-graph.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-graph.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-graph.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      :

Fehlerbehandlung

Bei meinen Versuchen haben ich verschiedene Fehler gemacht. Es hilft natürlich vorab die Dokumente von Microsoft genau zu lesen.

Meldung  Beschreibung

Authentication vergessen

Das 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 vergessen

Die 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ät

Der 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"
      }
   }
}}

validationToken

An 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-graph.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"
      }
   }
}

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-graph.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-graph
2021-11-13T18:23:26.769 [Information] INFORMATION: was-default-hostname = msxfaqdev-graph.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-graph.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.

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)

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:

Die Anleitung auf den Seiten ist eigentlich sehr gut.

  1. 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.
  2. 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.
  3. 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.
  4. 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.

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

07 Understanding Azure Table Storage
https://www.youtube.com/watch?v=c3t2i1KCASU

Die Nutzung aus Azure Functions ist auch sehr einfach.

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.

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 Kombinatoin 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.

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.

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