Hoppa till navigering

Graph-samtalsbot

Ring eller chatta med din ElevenLabs-agent med namn i Microsoft Teams, som med en kollega.

Översikt

Med den här metoden blir agenten en uppringningsbar Teams-identitet. En användare söker efter den med namn och ringer den 1:1, och agenten svarar i realtid — utan telefonnummer, PSTN eller Communications Credits. Det är den enda metoden som kan ringas upp med namn och den mest omfattande att köra.

Den använder en Microsoft Graph-bot för realtidsmedia (Cloud Communications-samtalsplattformen). Media-SDK:t (Microsoft.Skype.Bots.Media) fungerar endast med .NET på Windows Server — det finns ingen Linux- eller annan .NET-lösning för råljud i Teams-samtal.

Detta är den enda metoden som kan ringas upp med namn i Teams. Välj helst widget- fliken för en enklare konfiguration, eller ACS när du specifikt vill ha ett telefonnummer.

Så fungerar det

En Teams-användare ringer boten med namn; Teams dirigerar samtalet till mediaboten på en Windows-VM, som bryggar rått PCM 16k-ljud till ElevenLabs-agenten via en WebSocket
Ring med namn → mediabot → ElevenLabs

Boten svarar med applikationsvärdad media, tar emot 50 ljudramar/sekund (20 ms PCM 16 kHz), bryggar dem till ElevenLabs-agenten via en WebSocket och strömmar tillbaka agentens ljud till samtalet.

Krav

  1. En Azure Bot-registrering + app (Entra-appregistrering).
  2. Graph-programbehörigheter med administratörsmedgivande: Calls.AccessMedia.All (råmedia) samt Calls.Initiate.All.
  3. En Windows Server-VM (≥ 2 fysiska kärnor — till exempel Standard_D4s_v3) med en offentlig IP-adress och öppna medieportar.
  4. Ett TLS-certifikat signerat av en CA på ett offentligt FQDN för media-/signaleringsslutpunkten (medieplattformen avvisar självsignerade certifikat).
  5. En ElevenLabs-agent inställd på PCM 16000 Hz i båda riktningarna: TTS-utdataformat på fliken Voice, och ljudformat för användarindata på fliken Advanced.

En D2s_v3 (2 vCPU = 1 fysisk kärna) misslyckas med MediaPlatform needs a system with at least 2 cores. Använd en storlek med ≥ 2 fysiska kärnor (till exempel D4s_v3).

Behörigheter och roller

OmfattningRoll / behörighetVarför
EntraApplication Administratorskapa appregistreringen + Azure Bot
EntraGlobal Administrator / Privileged Role Administratorge administratörssamtycke för Graphs samtalsbehörigheter — appbehörigheter kan inte godkännas av appen själv
Microsoft Graph (applikation)Calls.AccessMedia.All, Calls.Initiate.Allbesvara 1:1-samtal och få åtkomst till råmedia
Azure RBACContributor för resursgruppenskapa Windows-VM:en + Azure Bot
Teams-administratörtillåt uppladdning av anpassade appar; aktivera botens Calling-kanalsideloada appen och ta emot samtal

Steg 1 — Registrera boten + Graph-behörigheter

Skapa en appregistrering och en Azure Bot som är kopplad till den, och bevilja + godkänn sedan samtalsbehörigheterna (du behöver Global Admin / Privileged Role Admin för att godkänna):

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

Bevilja de två Graph-applikationsrollerna och administratörssamtycke (kräver Global Admin / Privileged Role Admin), och bekräfta sedan att tilldelningarna har lagts till:

# 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

Om admin-consent returnerar Consent validation failed beviljar du i stället approllerna direkt för tjänstens huvudnamn:

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

I portalen kontrollerar du i Entra-administrationscentret under App registrations → din app → API permissions: båda behörigheterna ska visas som Granted med gröna bockar.

Bladet för appregistreringens API-behörigheter som visar Calls.AccessMedia.All och Calls.Initiate.All
beviljade

Appregistrering → API-behörigheter efter administratörssamtycke

Steg 2 — Etablera Windows-VM, certifikat och portar

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

På VM:en (mediaplattformens inbyggda kod behöver dessa — Windows Server saknar dem som standard):

# 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

Öppna samma portar i Windows-brandväggen, och notera certifikatets tumavtryck — boten binder Kestrel (443 + en aviseringsport) och mediaplattformen (8445) till det.

VM:ens egna *.cloudapp.azure.com-FQDN fungerar för ett Let’s Encrypt-certifikat — ingen separat domän behövs.

Steg 3 — Bygg och kör boten

Börja med Microsofts microsoft-graph-comms-samples PublicSamples/EchoBot — den använder net6.0 och byggs med .NET SDK (inga Visual Studio Build Tools behövs):

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

Konfigurera avsnittet AppSettings i appsettings.json med dina AadAppId, AadAppSecret, ServiceDnsName/MediaDnsName (VM:ens FQDN), CertificateThumbprint och portar (samtal 443, aviseringar 9441, media 8445). Lägg till två inställningar för ElevenLabs-bryggan nedan: ElevenLabsAgentId och ElevenLabsOrigin (wss://api.elevenlabs.io eller din dataplaceringsvärd). Kör den som en schemalagd Windows-uppgift / tjänst så att den överlever omstarter.

Aktivitetsschemaläggarens standardgräns för körningstid (72 timmar) avslutar tyst långvariga uppgifter — en bot som startas vid uppstart slutar fungera tre dagar senare och samtal misslyckas med “we couldn’t connect you”. Inaktivera gränsen och lägg till omstart vid fel:

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

Standardversionen av EchoBot kraschar vid samtal till standardporten 443: HttpHelpers.SetAbsoluteUri anropar req.Host.Port.Value, som är null när Host-rubriken saknar en uttrycklig port. Ändra den till req.Host.Port ?? (req.IsHttps ? 443 : 80).

Byt ut ekot mot ElevenLabs

EchoBots ljudgränssnitt är tydligt: SpeechService.AppendAudioBuffer(in) och en OnSendMediaBufferEventArgs(out)-händelse. Ersätt Azure Speech-innehållet med en ElevenLabs-agent-WebSocket-brygga som behåller samma gränssnitt:

SpeechService.cs — ElevenLabs-brygga (kärna)
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 });
}
}

Båda sidor använder mono-PCM i 16 kHz, så det är en base64-genomströmning — ställ in agenten på pcm_16000. Vid en ElevenLabs-interruption (avbrott) utlöser bryggan FlushMedia; koppla den till din mediaström så att den släpper alla köade AudioMediaBuffers, annars fortsätter agenten att prata över den som ringer. Den fullständiga meddelandereferensen finns i WebSocket-dokumentationen. Avslut av samtal och varm överföring behandlas i avsnitten nedan.

URL:en i Connect() når en offentlig agent. För en privat agent begär du en kortlivad signerad URL på serversidan — GET /v1/convai/conversation/get-signed-url?agent_id=... med din API- nyckel — och ansluter i stället till den returnerade URL:en. För dataplacering ställer du in ElevenLabsOrigin på din dataplaceringsvärd (wss://api.eu.residency.elevenlabs.io, .in. eller .sg.) — förfrågningar om signerade URL:er använder motsvarande https://-värd.

Steg 4 — Gör den uppringbar i Teams

  1. Aktivera Calling på Azure Botens Teams-kanal och ställ in webhooken för samtal på 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"

    I portalen finns detta under din Azure Bot-resurs → Channels → Microsoft Teams → fliken Calling:

    Azure Bots Channels-blad som listar Microsoft Teams-kanalen som
felfri

    Azure Bot → Channels — den anslutna Microsoft Teams-kanalen

    Teams-kanalens Calling-flik med Enable calling markerat och webhooken för samtal
inställd

    Microsoft Teams-kanal → Calling — samtal aktiverat med botens webhook
  2. Skapa ett Teams-appmanifest med bots[0].supportsCalling: true och botens app-ID, och sideloada det (Apps → Manage your apps → Upload a custom app), eller publicera det för hela organisationen utan gränssnittet: New-TeamsApp -DistributionMethod organization -Path ./bot-app.zip (MicrosoftTeams PowerShell-modul).

Sök efter appen på namn i Teams och ring den — boten svarar och ElevenLabs-agenten pratar.

Ett aktivt Teams-samtal med ElevenLabs-agent-
boten

Ett aktivt 1:1-samtal med agenten — observera Transfer och Consult i samtalsverktygsfältet

Inget telefonnummer eller resurskonto behövs för 1:1-samtal via namn — de behövs endast för PSTN- uppringning. Calls.AccessMedia.All är det som aktiverar råljudsbryggan.

Textchatt (samma bot)

Samma Azure Bot kan även svara med text i Teams — användare kan alltså antingen ringa agenten eller chatta med den. Samtal och meddelanden är oberoende kanaler på boten: webhooken för samtal hanterar röst, och en Bot Framework-meddelandeslutpunkt (/api/messages) hanterar chatt.

En Teams-chatt där ElevenLabs-agentboten svarar på text-
meddelanden

Chatta med samma bot i Teams

Peka botens meddelandeslutpunkt till den värd som tillhandahåller den (mediaboten eller en annan tjänst — den behöver inte vara Windows-VM:en):

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

Implementera slutpunkten med Bot Framework SDK och vidarebefordra varje meddelande till agenten i textläge via samma WebSocket för konversationer som används för röst — skicka en user_message-händelse, läs agent_response-händelsen. Aktivera först fältet first message i agentens inställningar för åsidosättningar — koden nedan åsidosätter det till tomt så att svaret besvarar användarens meddelande i stället för agentens hälsning:

ChatBot.cs — Teams-textchatt -> ElevenLabs (textläge)
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);
}

Registrera den på vanligt sätt (en CloudAdapter, boten via AddTransient<IBot, ChatBot>() och en /api/messages-styrenhet) och lägg till chattomfattningar i manifestets botpost:

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

Kodavsnittet öppnar en ny konversation per meddelande, så varje tur är oberoende. För chatt- minne behåller du en WebSocket öppen per Teams conversation.id (återanvänd den mellan turer) och rensar inaktiva sessioner — agenten kommer då ihåg tidigare meddelanden i chatten. Åsidosättningen first_message måste vara aktiverad i agentens inställningar för åsidosättningar — servern stänger konversationen om en otillåten åsidosättning skickas. Om du inte kan aktivera den utelämnar du åsidosättningen och ignorerar i stället den första agent_response i varje session (hälsningen) och returnerar nästa.

Om chattsvar aldrig kommer fram aktiverar du klient- händelsen agent_response client event i agentens **Advanced **- inställningar — textsvar levereras via den händelsen.

Avsluta samtal

När ElevenLabs avslutar konversationen (verktyget End Call stänger WebSocket-anslutningen) avslutar du Teams-delen:

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

Varm överföring till en människa

Agenten aktiverar ett anpassat klientverktyg, transfer_to_human; boten bjuder in en Teams-användare till det aktiva samtalet (konsultativ anslutning) och kliver sedan åt sidan:

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

Konsultativ överföring (replacesCallId) kräver att båda parter är Teams-användare i **samma klientorganisation **; PSTN-överföringsmål kräver en applikationsinstans. Om du först vill informera personen, skicka en reason-parameter från agenten och spela upp den för personen innan du kopplar ihop dem.

Felsökning

VM:en har endast en fysisk kärna. Ändra storlek till ≥ 2 fysiska kärnor (t.ex. D4s_v3) och starta om.

Installera VC++ Redistributable (vcredist140) och Windows-funktionen Server-Media-Foundation, och starta sedan om boten.

EchoBots port-null-bugg på 443 — korrigera HttpHelpers.SetAbsoluteUri (se steg 3). Kontrollera även att certifikatet är CA-signerat och nåbart på 443.

Kontrollera att Calling är aktiverat på Teams-kanalen med rätt /api/calling-webhook, att Graph- behörigheten Calls.AccessMedia.All är godkänd och att portarna 443/8445/9441 är öppna i både NSG och Windows-brandväggen. Om samtal tidigare fungerade men slutade, kontrollera att botprocessen fortfarande körs på VM:en — Aktivitetsschemaläggarens standardgräns på 72 timmar avslutar den några dagar efter uppstart (se varningen i steg 3).

Användbara länkar