Bot d’appel Graph

Appelez ou échangez par chat avec votre agent ElevenLabs par son nom dans Microsoft Teams, comme avec un collègue.

Vue d’ensemble

Cette approche fait de l’agent une identité Teams appelable. Un utilisateur le recherche par son nom et l’appelle en tête-à-tête, l’agent répond en temps réel, sans numéro de téléphone, RTPC ni crédits de communication. C’est la seule approche appelable par son nom et la plus complexe à mettre en œuvre.

Elle utilise un bot de médias en temps réel Microsoft Graph (la plateforme d’appel Cloud Communications). Le SDK média (Microsoft.Skype.Bots.Media) fonctionne uniquement avec .NET sur Windows Server, il n’existe aucune option Linux ou autre que .NET pour l’audio brut lors des appels Teams.

C’est la seule approche appelable par son nom dans Teams. Préférez l’onglet widget pour une configuration plus légère, ou ACS si vous souhaitez spécifiquement un numéro de téléphone.

Fonctionnement

Un utilisateur Teams appelle le bot par son nom ; Teams achemine l’appel vers le bot média sur une VM Windows, qui transmet l’audio PCM brut à 16 kHz à l’agent ElevenLabs via un WebSocket
Appel par nom → bot média → ElevenLabs

Le bot répond avec des médias hébergés par l’application, reçoit 50 trames audio par seconde (PCM 16 kHz de 20 ms), les transmet à l’agent ElevenLabs via un WebSocket et diffuse l’audio de l’agent dans l’appel.

Prérequis

  1. Un enregistrement Azure Bot et une application (enregistrement d’application Entra).
  2. Des autorisations d’application Graph avec consentement administrateur : Calls.AccessMedia.All (médias bruts) et Calls.Initiate.All.
  3. Une VM Windows Server (≥ 2 cœurs physiques, par exemple Standard_D4s_v3) avec une IP publique et des ports média ouverts.
  4. Un certificat TLS signé par une AC sur un FQDN public pour le point de terminaison média/signalisation (la plateforme média refuse les certificats autosignés).
  5. Un agent ElevenLabs configuré sur PCM 16000 Hz dans les deux sens : le format de sortie TTS dans l’onglet Voice, le format audio d’entrée utilisateur dans l’onglet Advanced.

Une D2s_v3 (2 vCPU = 1 cœur physique) échoue avec MediaPlatform needs a system with at least 2 cores. Utilisez une taille avec ≥ 2 cœurs physiques (par exemple, D4s_v3).

Autorisations et rôles

PortéeRôle / autorisationPourquoi
EntraAdministrateur d’applicationcréer l’enregistrement de l’application et l’Azure Bot
EntraAdministrateur général / Administrateur de rôle privilégiéaccorder le consentement administrateur pour les autorisations d’appel Graph, les autorisations d’application ne peuvent pas faire l’objet d’un consentement autonome
Microsoft Graph (application)Calls.AccessMedia.All, Calls.Initiate.Allrépondre aux appels en tête-à-tête et accéder aux médias bruts
Azure RBACContributeur sur le groupe de ressourcescréer la VM Windows et l’Azure Bot
Administrateur Teamsautoriser le chargement d’application personnalisée ; activer le canal Calling du botcharger l’application de façon indépendante et recevoir des appels

Étape 1 : enregistrer le bot et les autorisations Graph

Créez un enregistrement d’application et un Azure Bot qui y est associé, puis accordez et approuvez les autorisations d’appel (vous devez être administrateur général ou administrateur de rôle privilégié pour donner votre consentement) :

APPID=$(az ad app create --display-name "ElevenLabs Teams Agent" \
--sign-in-audience AzureADMyOrg --query appId -o tsv)
az ad sp create --id "$APPID"
# create a client secret and record it
az ad app credential reset --id "$APPID" --display-name bot --query password -o tsv
# Azure Bot bound to the app
az bot create --resource-group $RG --name el-teams-agent-bot \
--app-type SingleTenant --appid "$APPID" --tenant-id $TENANT \
--endpoint "https://YOUR_FQDN/api/messages" --sku S1

Accordez les deux rôles d’application Graph et le consentement administrateur (nécessite le rôle d’administrateur général ou d’administrateur de rôle privilégié), puis confirmez que les attributions ont bien été effectuées :

# Graph app roles: Calls.AccessMedia.All, Calls.Initiate.All
az ad app permission add --id "$APPID" --api 00000003-0000-0000-c000-000000000000 \
--api-permissions a7a681dc-756e-4909-b988-f160edc6655f=Role \
284383ee-7f6e-4e40-a2a8-e85dcb029101=Role
az ad app permission admin-consent --id "$APPID"
# Verify — should print both role ids
az rest --method GET \
--url "https://graph.microsoft.com/v1.0/servicePrincipals(appId='$APPID')/appRoleAssignments" \
--query "value[].appRoleId" -o tsv

Si admin-consent renvoie Consent validation failed, accordez plutôt directement les rôles d’application sur le principal de service :

GRAPH_SP=$(az ad sp show --id 00000003-0000-0000-c000-000000000000 --query id -o tsv)
BOT_SP=$(az ad sp show --id "$APPID" --query id -o tsv)
for ROLE in a7a681dc-756e-4909-b988-f160edc6655f 284383ee-7f6e-4e40-a2a8-e85dcb029101; do
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/$GRAPH_SP/appRoleAssignedTo" \
--body "{\"principalId\": \"$BOT_SP\", \"resourceId\": \"$GRAPH_SP\", \"appRoleId\": \"$ROLE\"}"
done

Dans le portail, vérifiez dans le centre d’administration Entra, sous App registrations → your app → API permissions : les deux autorisations doivent afficher Granted avec des coches vertes.

Le volet API permissions de l’enregistrement d’application indiquant que Calls.AccessMedia.All et Calls.Initiate.All sont
accordées

Enregistrement d’application → API permissions après le consentement administrateur

Étape 2 : provisionner la VM Windows, le certificat et les ports

az vm create -g $RG -n teams-media-bot --image Win2022Datacenter \
--size Standard_D4s_v3 --admin-username azureuser --admin-password '<strong-pw>' \
--public-ip-sku Standard --public-ip-address-dns-name elevenmediabot
az vm open-port -g $RG -n teams-media-bot --port 80,443,8445,9441 --priority 300

Sur la VM (le code natif de la plateforme média les nécessite, Windows Server ne les inclut pas par défaut) :

# VC++ runtime + Media Foundation feature (required by NativeMedia.dll)
choco install -y vcredist140
Install-WindowsFeature Server-Media-Foundation
# CA cert for the VM's FQDN via win-acme (HTTP-01), then import to LocalMachine\My
& wacs.exe --target manual --host <vm-fqdn>.cloudapp.azure.com `
--validation selfhosting --store pfxfile --pfxfilepath C:\bot\certs --accepttos

Ouvrez les mêmes ports dans le pare-feu Windows et notez l’empreinte du certificat, le bot y associe Kestrel (443 et un port de notifications) ainsi que la plateforme média (8445).

Le FQDN *.cloudapp.azure.com de la VM fonctionne avec un certificat Let’s Encrypt, aucun domaine distinct n’est nécessaire.

Étape 3 : créer et exécuter le bot

Partez de l’PublicSamples/EchoBot de microsoft-graph-comms-samples, il cible net6.0 et se compile avec le SDK .NET (aucun outil de génération Visual Studio requis) :

git clone --depth 1 https://github.com/microsoftgraph/microsoft-graph-comms-samples.git C:\bot\samples
cd C:\bot\samples\Samples\PublicSamples\EchoBot\src
dotnet build EchoBot.sln -c Release

Configurez la section AppSettings de appsettings.json avec vos valeurs AadAppId, AadAppSecret, ServiceDnsName/MediaDnsName (le FQDN de la VM), CertificateThumbprint et les ports (443 pour les appels, 9441 pour les notifications, 8445 pour les médias). Ajoutez deux paramètres pour le pont ElevenLabs ci-dessous : ElevenLabsAgentId et ElevenLabsOrigin (wss://api.elevenlabs.io, ou votre hôte de résidence des données). Exécutez-le en tant que tâche planifiée ou service Windows afin qu’il continue de fonctionner après les redémarrages.

La limite de durée d’exécution par défaut (72 heures) du Planificateur de tâches arrête silencieusement les tâches de longue durée, un bot lancé au démarrage s’arrête trois jours plus tard et les appels échouent avec « we couldn’t connect you ». Désactivez la limite et ajoutez un redémarrage en cas d’échec :

$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -StartWhenAvailable
Set-ScheduledTask -TaskName EchoBot -Settings $s

L’EchoBot fourni s’arrête lorsqu’un appel est passé au port standard 443 : HttpHelpers.SetAbsoluteUri appelle req.Host.Port.Value, qui est nul lorsque l’en-tête Host n’a pas de port explicite. Corrigez-le avec req.Host.Port ?? (req.IsHttps ? 443 : 80).

Remplacer l’écho par ElevenLabs

L’interface audio d’EchoBot est claire : SpeechService.AppendAudioBuffer(in) et un événement OnSendMediaBufferEventArgs(out). Remplacez son corps Azure Speech par un pont WebSocket d’agent ElevenLabs qui conserve la même interface :

SpeechService.cs : pont ElevenLabs (noyau)
public class SpeechService
{
private readonly AppSettings _settings;
private readonly ILogger _logger;
private ClientWebSocket _ws;
private bool _started;
private bool _connecting;
public event EventHandler<MediaStreamEventArgs> SendMediaBuffer; // agent audio -> call
public event EventHandler FlushMedia; // barge-in: drop queued audio
public SpeechService(AppSettings settings, ILogger logger) { _settings = settings; _logger = logger; }
// Caller audio -> ElevenLabs
public async Task AppendAudioBuffer(AudioMediaBuffer buffer)
{
if (!_started)
{
if (_connecting) return; // a connect attempt is already in flight
_connecting = true;
try { await Connect(); _started = true; }
catch (Exception ex) { _logger.Error(ex, "ElevenLabs connect failed; retry on next frame"); return; }
finally { _connecting = false; }
}
if (_ws?.State != WebSocketState.Open || buffer.Length <= 0) return;
var pcm = new byte[buffer.Length];
Marshal.Copy(buffer.Data, pcm, 0, (int)buffer.Length);
var msg = JsonSerializer.Serialize(new { user_audio_chunk = Convert.ToBase64String(pcm) });
await _ws.SendAsync(Encoding.UTF8.GetBytes(msg), WebSocketMessageType.Text, true, default);
}
private async Task Connect()
{
_ws = new ClientWebSocket();
// ElevenLabsOrigin: wss://api.elevenlabs.io, or a residency host (.eu./.in./.sg.)
var url = $"{_settings.ElevenLabsOrigin}/v1/convai/conversation?agent_id={_settings.ElevenLabsAgentId}";
await _ws.ConnectAsync(new Uri(url), default);
await _ws.SendAsync(Encoding.UTF8.GetBytes(
JsonSerializer.Serialize(new { type = "conversation_initiation_client_data" })),
WebSocketMessageType.Text, true, default);
_ = Task.Run(ReceiveLoop);
}
private async Task ReceiveLoop()
{
var buf = new byte[32768]; var sb = new StringBuilder();
while (_ws.State == WebSocketState.Open)
{
sb.Clear(); WebSocketReceiveResult r;
do { r = await _ws.ReceiveAsync(buf, default); sb.Append(Encoding.UTF8.GetString(buf, 0, r.Count)); }
while (!r.EndOfMessage);
using var doc = JsonDocument.Parse(sb.ToString());
var type = doc.RootElement.GetProperty("type").GetString();
if (type == "audio") // ElevenLabs audio -> call
Emit(Convert.FromBase64String(doc.RootElement
.GetProperty("audio_event").GetProperty("audio_base_64").GetString()));
else if (type == "ping")
await _ws.SendAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new {
type = "pong", event_id = doc.RootElement.GetProperty("ping_event").GetProperty("event_id").GetInt32() })),
WebSocketMessageType.Text, true, default);
else if (type == "interruption") // barge-in: drop any agent audio still queued
FlushMedia?.Invoke(this, EventArgs.Empty);
}
}
// slice PCM into 20 ms / 640-byte frames the media platform expects
private void Emit(byte[] pcm)
{
var all = new List<AudioMediaBuffer>(); long tick = DateTime.Now.Ticks;
for (int off = 0; off < pcm.Length; off += 640)
{
var frame = new byte[640];
Array.Copy(pcm, off, frame, 0, Math.Min(640, pcm.Length - off));
all.AddRange(Utilities.CreateAudioMediaBuffers(frame, tick, _logger));
tick += 20 * 10000;
}
if (all.Count > 0) SendMediaBuffer?.Invoke(this, new MediaStreamEventArgs { AudioMediaBuffers = all });
}
}

Les deux côtés utilisent du PCM mono 16 kHz, il s’agit donc d’un relais base64. Configurez l’agent sur pcm_16000. Lors d’une interruption ElevenLabs (interruption de parole), le pont déclenche FlushMedia. Reliez-le à votre flux média pour supprimer tous les AudioMediaBuffer en file d’attente, sans quoi l’agent continue à parler par-dessus l’appelant. La référence complète des messages se trouve dans la documentation WebSocket. La fin d’appel et le transfert assisté sont abordés dans les sections ci-dessous.

L’URL dans Connect() atteint un agent public. Pour un agent privé, demandez côté serveur une URL signée de courte durée, GET /v1/convai/conversation/get-signed-url?agent_id=... avec votre clé API, puis connectez-vous à l’URL renvoyée. Pour la résidence des données, définissez ElevenLabsOrigin sur votre hôte de résidence des données (wss://api.eu.residency.elevenlabs.io, .in. ou .sg.), les requêtes d’URL signée utilisent l’hôte https:// correspondant.

Étape 4 : le rendre appelable dans Teams

  1. Activez Calling sur le canal Teams de l’Azure Bot et définissez le webhook d’appel sur https://YOUR_FQDN/api/calling :

    az bot msteams create -g $RG -n el-teams-agent-bot \
    --enable-calling --calling-web-hook "https://YOUR_FQDN/api/calling"

    Dans le portail, vous le trouverez dans votre ressource Azure Bot → Channels → Microsoft Teams → onglet Calling :

    Le volet Azure Bot Channels indiquant que le canal Microsoft Teams est
opérationnel

    Azure Bot → Channels, le canal Microsoft Teams connecté

    L’onglet Calling du canal Teams, avec Enable calling activé et le webhook d’appel
défini

    Canal Microsoft Teams → Calling, appels activés avec le webhook du bot
  2. Créez un manifeste d’application Teams avec bots[0].supportsCalling: true et l’ID d’application du bot, puis chargez-le de façon indépendante (Apps → Manage your apps → Upload a custom app) ou publiez-le dans toute l’organisation sans l’interface : New-TeamsApp -DistributionMethod organization -Path ./bot-app.zip (module PowerShell MicrosoftTeams).

Recherchez l’application par son nom dans Teams et appelez-la, le bot répond et l’agent ElevenLabs parle.

Un appel Teams actif avec le bot de l’agent ElevenLabs

Un appel en direct en tête-à-tête avec l’agent, remarquez Transfer et Consult dans la barre d’outils d’appel

Aucun numéro de téléphone ni compte de ressource n’est nécessaire pour les appels en tête-à-tête par nom, ils sont uniquement requis pour les appels entrants RTPC. Calls.AccessMedia.All permet le pont audio brut.

Chat textuel (même bot)

Le même Azure Bot peut également répondre aux messages textuels dans Teams, les utilisateurs peuvent donc appeler l’agent ou échanger avec lui par chat. Les appels et la messagerie sont des canaux indépendants sur le bot : le webhook d’appel gère la voix et un point de terminaison de messagerie Bot Framework (/api/messages) gère le chat.

Une conversation Teams avec le bot de l’agent ElevenLabs répondant à des messages
textuels

Discussion avec le même bot dans Teams

Dirigez le point de terminaison de messagerie du bot vers l’hôte qui le sert (le bot média ou tout autre service, il ne doit pas nécessairement s’agir de la VM Windows) :

az bot update -g $RG -n el-teams-agent-bot --endpoint "https://YOUR_FQDN/api/messages"

Implémentez le point de terminaison avec le SDK Bot Framework et transmettez chaque message à l’agent en mode texte via le même WebSocket de conversation utilisé pour la voix, envoyez un événement user_message, puis lisez l’événement agent_response. Activez d’abord le champ first message dans les paramètres d’overrides de l’agent, le code ci-dessous le remplace par une valeur vide afin que la réponse traite le message de l’utilisateur plutôt que le message d’accueil de l’agent :

ChatBot.cs : chat textuel Teams -> ElevenLabs (mode texte)
public class ChatBot : ActivityHandler
{
private readonly AppSettings _settings;
public ChatBot(AppSettings settings) => _settings = settings;
protected override async Task OnMessageActivityAsync(
ITurnContext<IMessageActivity> turn, CancellationToken ct)
{
var reply = await AskAgent(turn.Activity.Text, ct);
await turn.SendActivityAsync(MessageFactory.Text(reply), ct);
}
private async Task<string> AskAgent(string text, CancellationToken ct)
{
using var ws = new ClientWebSocket();
var url = $"{_settings.ElevenLabsOrigin}/v1/convai/conversation?agent_id={_settings.ElevenLabsAgentId}";
await ws.ConnectAsync(new Uri(url), ct);
// Suppress the agent's greeting: with no override, the first agent_response is the
// configured first message, not the answer to this user_message.
await Send(ws, new
{
type = "conversation_initiation_client_data",
conversation_config_override = new { agent = new { first_message = "" } },
}, ct);
await Send(ws, new { type = "user_message", text }, ct);
var buf = new byte[16384]; var sb = new StringBuilder();
while (ws.State == WebSocketState.Open)
{
sb.Clear(); WebSocketReceiveResult r;
do { r = await ws.ReceiveAsync(buf, ct); sb.Append(Encoding.UTF8.GetString(buf, 0, r.Count)); }
while (!r.EndOfMessage);
using var doc = JsonDocument.Parse(sb.ToString());
switch (doc.RootElement.GetProperty("type").GetString())
{
case "agent_response":
return doc.RootElement.GetProperty("agent_response_event")
.GetProperty("agent_response").GetString();
case "ping":
await Send(ws, new { type = "pong", event_id = doc.RootElement
.GetProperty("ping_event").GetProperty("event_id").GetInt32() }, ct);
break;
}
}
return "Sorry, I couldn't reach the agent.";
}
private static Task Send(ClientWebSocket ws, object msg, CancellationToken ct) =>
ws.SendAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(msg)),
WebSocketMessageType.Text, true, ct);
}

Enregistrez-le de la manière habituelle (un CloudAdapter, le bot via AddTransient<IBot, ChatBot>() et un contrôleur /api/messages), puis ajoutez des étendues de chat à l’entrée du bot dans le manifeste :

"bots": [
{ "botId": "YOUR_APP_ID", "supportsCalling": true, "scopes": ["personal", "team", "groupChat"] }
]

L’extrait ouvre une nouvelle conversation par message, chaque tour est donc indépendant. Pour conserver la mémoire du chat, maintenez un WebSocket ouvert par conversation.id Teams (réutilisez-le entre les tours) et fermez les sessions inactives, l’agent se souviendra alors des messages précédents de cette conversation. Le remplacement first_message doit être activé dans les paramètres d’overrides de l’agent, le serveur ferme la conversation si un remplacement non autorisé est envoyé. Si vous ne pouvez pas l’activer, omettez le remplacement et ignorez plutôt le premier agent_response de chaque session (le message d’accueil), puis renvoyez le suivant.

Si les réponses du chat n’arrivent jamais, activez l’événement client agent_response client event dans les paramètres Advanced de l’agent, les réponses textuelles sont transmises par cet événement.

Fin d’appel

Quand ElevenLabs termine la conversation (son outil End Call ferme le WebSocket), raccrochez la partie Teams :

await this.Call.DeleteAsync(); // after a short delay so the goodbye audio finishes

Transfert assisté vers un humain

L’agent déclenche un outil client personnalisé transfer_to_human. Le bot invite un utilisateur Teams dans l’appel en cours (ajout consultatif), puis se retire :

var target = new IdentitySet { User = new Identity { Id = humanObjectId } };
await this.Call.Participants.InviteAsync(target, replacesCallId: null);
// suppress the end-call hangup while transferring, and mute the bot

Le transfert consultatif (replacesCallId) exige que les deux parties soient des utilisateurs Teams du **même tenant **. Les cibles de transfert RTPC nécessitent une instance d’application. Pour informer d’abord l’humain, transmettez un paramètre reason depuis l’agent et diffusez-le à l’humain avant la mise en relation.

Dépannage

La VM ne possède qu’un seul cœur physique. Redimensionnez-la avec ≥ 2 cœurs physiques (par exemple, D4s_v3) et redémarrez-la.

Installez le VC++ Redistributable (vcredist140) et la fonctionnalité Windows Server-Media-Foundation, puis redémarrez le bot.

Il s’agit du bug de port nul d’EchoBot sur 443, corrigez HttpHelpers.SetAbsoluteUri (voir l’étape 3). Vérifiez également que le certificat est signé par une AC et accessible sur le port 443.

Vérifiez que Calling est activé sur le canal Teams avec le webhook /api/calling correct, que l’autorisation Graph Calls.AccessMedia.All a reçu le consentement et que les ports 443/8445/9441 sont ouverts à la fois dans le NSG et le pare-feu Windows. Si les appels fonctionnaient auparavant puis se sont arrêtés, vérifiez que le processus du bot fonctionne toujours sur la VM, la limite d’exécution par défaut de 72 heures du Planificateur de tâches l’arrête quelques jours après le démarrage (voir l’avertissement de l’étape 3).

Liens utiles