Vai alla navigazione

Bot di chiamata Graph

Chiama o chatta con il tuo agente ElevenLabs per nome direttamente in Microsoft Teams, come faresti con un collega.

Panoramica

Questo approccio rende l’agente un’identità Teams chiamabile. Un utente lo cerca per nome e lo chiama in 1:1, e l’agente risponde in tempo reale — senza numero di telefono, PSTN o Communications Credits. È l’unico approccio chiamabile per nome e il più complesso da gestire.

Usa un bot Microsoft Graph per contenuti multimediali in tempo reale (la piattaforma di chiamata Cloud Communications). L’SDK multimediale (Microsoft.Skype.Bots.Media) funziona solo con .NET su Windows Server — non esiste un percorso Linux o non .NET per l’audio raw nelle chiamate Teams.

Questo è l’unico approccio chiamabile per nome all’interno di Teams. Per una configurazione più semplice, usa la scheda widget, oppure ACS se vuoi specificamente un numero di telefono.

Come funziona

Un utente Teams chiama il bot per nome; Teams instrada la chiamata al bot multimediale su una VM Windows, che inoltra l'audio PCM raw a 16 kHz all'agente ElevenLabs tramite WebSocket
Chiamata per nome → bot multimediale → ElevenLabs

Il bot risponde con contenuti multimediali ospitati dall’applicazione, riceve 50 frame audio/sec (PCM 16 kHz da 20 ms), li inoltra all’agente ElevenLabs tramite WebSocket e trasmette l’audio dell’agente nella chiamata.

Requisiti

  1. Una registrazione Azure Bot + app (registrazione dell’app Entra).
  2. Autorizzazioni dell’applicazione Graph con consenso dell’amministratore: Calls.AccessMedia.All (contenuti multimediali raw) più Calls.Initiate.All.
  3. Una VM Windows Server (≥ 2 core fisici — ad esempio Standard_D4s_v3) con IP pubblico e porte multimediali aperte.
  4. Un certificato TLS firmato da una CA su un FQDN pubblico per l’endpoint di contenuti multimediali/segnalazione (la piattaforma multimediale rifiuta i certificati autofirmati).
  5. Un agente ElevenLabs impostato su PCM 16000 Hz su entrambi i lati: formato di output TTS nella scheda Voce, formato audio di input utente nella scheda Avanzate.

Un D2s_v3 (2 vCPU = 1 core fisico) non funziona e restituisce MediaPlatform needs a system with at least 2 cores. Usa una dimensione con ≥ 2 core fisici (ad esempio D4s_v3).

Autorizzazioni e ruoli

AmbitoRuolo / autorizzazioneMotivo
EntraAmministratore applicazionicreare la registrazione dell’app + Azure Bot
EntraAmministratore globale / Amministratore ruoli con privilegiconcedere il consenso dell’amministratore per le autorizzazioni di chiamata Graph — le autorizzazioni dell’app non possono ricevere l’autoconsenso
Microsoft Graph (applicazione)Calls.AccessMedia.All, Calls.Initiate.Allrispondere a chiamate 1:1 e accedere a contenuti multimediali raw
Azure RBACCollaboratore nel gruppo di risorsecreare la VM Windows + Azure Bot
Amministratore Teamsconsentire il caricamento di app personalizzate; abilitare il canale Chiamate del botcaricare localmente l’app e ricevere chiamate

Passaggio 1 — Registra il bot + autorizzazioni Graph

Crea una registrazione dell’app e un Azure Bot associato, quindi concedi e approva le autorizzazioni di chiamata (per il consenso ti servono i ruoli Amministratore globale / Amministratore ruoli con privilegi):

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

Concedi i due ruoli dell’applicazione Graph e il consenso dell’amministratore (richiede Amministratore globale / Amministratore ruoli con privilegi), quindi verifica che le assegnazioni siano state applicate:

# 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

Se admin-consent restituisce Consent validation failed, concedi invece i ruoli dell’app direttamente nel service principal:

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

Nel portale, verifica nell’interfaccia di amministrazione Entra in Registrazioni app → la tua app → Autorizzazioni API: entrambe le autorizzazioni dovrebbero mostrare Concesso con segni di spunta verdi.

Il pannello delle autorizzazioni API della registrazione dell'app che mostra Calls.AccessMedia.All e Calls.Initiate.All
concesse

Registrazione dell'app → Autorizzazioni API dopo il consenso dell'amministratore

Passaggio 2 — Configura la VM Windows, il certificato e le porte

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

Sulla VM (il codice nativo della piattaforma multimediale li richiede — Windows Server non li include per impostazione predefinita):

# 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

Apri le stesse porte nel firewall di Windows e annota l’impronta digitale del certificato — il bot associa Kestrel (443 + una porta per le notifiche) e la piattaforma multimediale (8445) al certificato.

L’FQDN *.cloudapp.azure.com della VM funziona con un certificato Let’s Encrypt — non serve un dominio separato.

Passaggio 3 — Compila ed esegui il bot

Parti da PublicSamples/EchoBot di microsoft-graph-comms-samples di Microsoft — usa net6.0 e viene compilato con l’SDK .NET (non servono Visual Studio Build Tools):

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

Configura la sezione AppSettings di appsettings.json con AadAppId, AadAppSecret, ServiceDnsName/MediaDnsName (l’FQDN della VM), CertificateThumbprint e le porte (chiamate 443, notifiche 9441, contenuti multimediali 8445). Aggiungi due impostazioni per il bridge ElevenLabs indicato sotto: ElevenLabsAgentId e ElevenLabsOrigin (wss://api.elevenlabs.io, o il tuo host di residenza). Eseguilo come attività/servizio pianificato di Windows, così rimane attivo dopo i riavvii.

Il limite di tempo di esecuzione predefinito (72 ore) dell’Utilità di pianificazione termina silenziosamente le attività a lunga esecuzione — un bot avviato all’avvio si arresta tre giorni dopo e le chiamate non riescono con “we couldn’t connect you”. Disabilita il limite e aggiungi il riavvio in caso di errore:

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

EchoBot standard si arresta in modo anomalo per una chiamata effettuata sulla porta standard 443: HttpHelpers.SetAbsoluteUri chiama req.Host.Port.Value, che è null quando l’header Host non ha una porta esplicita. Correggilo in req.Host.Port ?? (req.IsHttps ? 443 : 80).

Sostituisci l’eco con ElevenLabs

Il collegamento audio di EchoBot è semplice: SpeechService.AppendAudioBuffer(in) e un evento OnSendMediaBufferEventArgs(out). Sostituisci il corpo Azure Speech con un bridge WebSocket dell’agente ElevenLabs che mantiene la stessa interfaccia:

SpeechService.cs — bridge ElevenLabs (core)
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 });
}
}

Entrambi i lati sono PCM mono a 16 kHz, quindi è un passthrough base64 — imposta l’agente su pcm_16000. In caso di interruption di ElevenLabs (barge-in), il bridge genera FlushMedia; collegalo al tuo stream multimediale affinché elimini eventuali AudioMediaBuffer in coda, altrimenti l’agente continua a parlare sopra il chiamante. Il riferimento completo dei messaggi è nella documentazione WebSocket. La chiusura al termine della chiamata e il trasferimento assistito sono descritti nelle sezioni seguenti.

L’URL in Connect() raggiunge un agente pubblico. Per un agente privato, richiedi lato server un URL firmato a breve scadenza — GET /v1/convai/conversation/get-signed-url?agent_id=... con la tua chiave API — e connettiti invece all’URL restituito. Per la residenza dei dati, imposta ElevenLabsOrigin sul tuo host di residenza (wss://api.eu.residency.elevenlabs.io, .in. o .sg.) — le richieste di URL firmati usano l’host https:// corrispondente.

Passaggio 4 — Rendilo chiamabile in Teams

  1. Abilita le chiamate nel canale Teams dell’Azure Bot e imposta il webhook di chiamata su 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"

    Nel portale si trova nella risorsa Azure Bot → Canali → Microsoft Teams → scheda Chiamate:

    Il pannello Canali di Azure Bot con il canale Microsoft Teams elencato come
integro

    Azure Bot → Canali — il canale Microsoft Teams connesso

    La scheda Chiamate del canale Teams con Abilita chiamate selezionato e il webhook di chiamata
impostato

    Canale Microsoft Teams → Chiamate — chiamate abilitate con il webhook del bot
  2. Crea un manifest dell’app Teams con bots[0].supportsCalling: true e l’ID app del bot, quindi caricalo localmente (App → Gestisci le tue app → Carica un’app personalizzata), oppure pubblicalo a livello di organizzazione senza l’interfaccia: New-TeamsApp -DistributionMethod organization -Path ./bot-app.zip (modulo PowerShell MicrosoftTeams).

Cerca l’app per nome in Teams e chiamala — il bot risponde e l’agente ElevenLabs parla.

Una chiamata Teams attiva con il bot dell'agente
ElevenLabs

Una chiamata 1:1 in corso con l'agente — nota Trasferisci e Consulta nella barra degli strumenti della chiamata

Per le chiamate 1:1 per nome non servono né un numero di telefono né un account risorsa — sono necessari solo per le chiamate PSTN in ingresso. Calls.AccessMedia.All abilita il bridge audio raw.

Chat di testo (stesso bot)

Lo stesso Azure Bot può anche rispondere ai messaggi di testo in Teams — gli utenti possono quindi chiamare l’agente oppure chattare con lui. Le chiamate e la messaggistica sono canali indipendenti del bot: il webhook di chiamata gestisce la voce, mentre un endpoint di messaggistica Bot Framework (/api/messages) gestisce la chat.

Una chat Teams con il bot dell'agente ElevenLabs che risponde ai messaggi
di testo

Chat con lo stesso bot in Teams

Indirizza l’endpoint di messaggistica del bot all’host che lo serve (il bot multimediale o qualsiasi altro servizio — non deve essere necessariamente la VM Windows):

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

Implementa l’endpoint con l’SDK Bot Framework e inoltra ogni messaggio all’agente in modalità testo tramite lo stesso WebSocket di conversazione usato per la voce — invia un evento user_message, leggi l’evento agent_response. Prima abilita il campo primo messaggio nelle impostazioni delle sostituzioni dell’agente — il codice seguente lo sostituisce con un valore vuoto, affinché la risposta risponda al messaggio dell’utente anziché al saluto dell’agente:

ChatBot.cs — chat di testo Teams -> ElevenLabs (modalità testo)
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);
}

Registralo nel modo standard (un CloudAdapter, il bot tramite AddTransient<IBot, ChatBot>() e un controller /api/messages) e aggiungi gli ambiti di chat alla voce del bot nel manifest:

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

Lo snippet apre una nuova conversazione per ogni messaggio, quindi ogni turno è indipendente. Per la memoria della chat, mantieni un WebSocket aperto per ogni conversation.id di Teams (riutilizzalo tra i turni) ed elimina le sessioni inattive — l’agente ricorderà quindi i messaggi precedenti in quella chat. La sostituzione first_message deve essere abilitata nelle impostazioni delle sostituzioni dell’agente — il server chiude la conversazione se viene inviata una sostituzione non consentita. Se non puoi abilitarla, ometti la sostituzione e ignora invece il primo agent_response di ogni sessione (il saluto), quindi restituisci quello successivo.

Se le risposte della chat non arrivano mai, abilita l’evento client agent_response nelle impostazioni Avanzate dell’agente — le risposte di testo vengono recapitate tramite quell’evento.

Fine della chiamata

Quando ElevenLabs termina la conversazione (il relativo strumento Termina chiamata chiude il WebSocket), riaggancia la parte Teams:

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

Trasferimento assistito a una persona

L’agente attiva uno strumento client personalizzato transfer_to_human; il bot invita un utente Teams nella chiamata in corso (aggiunta consultiva), quindi si ritira:

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

Il trasferimento consultivo (replacesCallId) richiede che entrambe le parti siano utenti Teams nello **stesso tenant **; le destinazioni di trasferimento PSTN richiedono un’istanza dell’applicazione. Per informare prima la persona, passa un parametro reason dall’agente e riproducilo alla persona prima di collegarla.

Risoluzione dei problemi

La VM ha un solo core fisico. Ridimensionala a ≥ 2 core fisici (ad esempio D4s_v3) e riavviala.

Installa VC++ Redistributable (vcredist140) e la funzionalità Windows Server-Media-Foundation, quindi riavvia il bot.

È il bug della porta null di EchoBot su 443 — correggi HttpHelpers.SetAbsoluteUri (vedi il passaggio 3). Verifica anche che il certificato sia firmato da una CA e raggiungibile sulla porta 443.

Verifica che le chiamate siano abilitate nel canale Teams con il webhook /api/calling corretto, che l’autorizzazione Graph Calls.AccessMedia.All abbia ricevuto il consenso e che le porte 443/8445/9441 siano aperte sia nel NSG sia nel firewall di Windows. Se le chiamate funzionavano e hanno smesso di funzionare, verifica che il processo del bot sia ancora in esecuzione sulla VM — il limite di esecuzione predefinito di 72 ore dell’Utilità di pianificazione lo termina alcuni giorni dopo l’avvio (vedi l’avviso nel passaggio 3).