Bot wywołań Graph

Dzwoń lub rozmawiaj na czacie ze swoim agentem ElevenLabs po nazwie w Microsoft Teams, jak ze współpracownikiem.

Omówienie

To podejście sprawia, że agent staje się tożsamością Teams, do której można zadzwonić. Użytkownik wyszukuje go po nazwie i dzwoni do niego 1:1, a agent odpowiada w czasie rzeczywistym — bez numeru telefonu, PSTN ani Communications Credits. To jedyne podejście, które pozwala dzwonić po nazwie, i najbardziej złożone we wdrożeniu.

Korzysta z bota mediów w czasie rzeczywistym Microsoft Graph (platformy połączeń Cloud Communications). SDK mediów (Microsoft.Skype.Bots.Media) działa tylko z .NET na Windows Server — nie ma ścieżki dla Linuksa ani innych środowisk niż .NET do obsługi surowego audio w połączeniach Teams.

To jedyne podejście, do którego można dzwonić po nazwie w Teams. Jeśli chcesz prostszej konfiguracji, wybierz kartę z widgetem , a gdy potrzebujesz konkretnie numeru telefonu — ACS.

Jak to działa

Użytkownik Teams dzwoni do bota po nazwie; Teams przekazuje połączenie do bota mediów na maszynie wirtualnej z systemem Windows, który przez WebSocket przesyła surowe audio PCM 16k do agenta ElevenLabs
Połączenie po nazwie → bot mediów → ElevenLabs

Bot odbiera połączenia przez media hostowane przez aplikację, odbiera 50 ramek audio na sekundę (PCM 16 kHz co 20 ms), przekazuje je do agenta ElevenLabs przez WebSocket i przesyła audio agenta z powrotem do rozmowy.

Wymagania

  1. Rejestracja Azure Bot + aplikacja (rejestracja aplikacji Entra).
  2. Uprawnienia aplikacji Graph ze zgodą administratora: Calls.AccessMedia.All (surowe media) oraz Calls.Initiate.All.
  3. Maszyna wirtualna Windows Server (≥ 2 fizyczne rdzenie — np. Standard_D4s_v3) z publicznym IP i otwartymi portami mediów.
  4. Certyfikat TLS podpisany przez CA na publicznym FQDN dla punktu końcowego mediów/sygnalizacji (platforma mediów odrzuca certyfikaty z podpisem własnym).
  5. Agent ElevenLabs ustawiony na PCM 16000 Hz po obu stronach: format wyjściowy TTS na karcie Voice oraz format audio wejściowego użytkownika na karcie Advanced.

D2s_v3 (2 vCPU = 1 fizyczny rdzeń) kończy się błędem MediaPlatform needs a system with at least 2 cores. Użyj rozmiaru z ≥ 2 fizycznymi rdzeniami (np. D4s_v3).

Uprawnienia i role

ZakresRola / uprawnienieDlaczego
EntraApplication Administratortworzenie rejestracji aplikacji + Azure Bot
EntraGlobal Administrator / Privileged Role Administratorudzielenie zgody administratora na uprawnienia połączeń Graph — zgody na uprawnienia aplikacji nie można udzielić samodzielnie
Microsoft Graph (aplikacja)Calls.AccessMedia.All, Calls.Initiate.Allodbieranie połączeń 1:1 i dostęp do surowych mediów
Azure RBACContributor on the resource grouptworzenie maszyny wirtualnej Windows + Azure Bot
Administrator Teamszezwolenie na przesyłanie aplikacji niestandardowych; włączenie kanału bota Callingładowanie aplikacji lokalnie i odbieranie połączeń

Krok 1 — Zarejestruj bota i uprawnienia Graph

Utwórz rejestrację aplikacji oraz powiązanego z nią Azure Bota, a następnie nadaj uprawnienia do połączeń i udziel zgody (aby udzielić zgody, potrzebujesz roli Global Admin / Privileged Role Admin):

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

Nadaj dwie role aplikacji Graph i zgodę administratora (wymaga Global Admin / Privileged Role Admin), a potem potwierdź, że przypisania zostały dodane:

# 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

Jeśli admin-consent zwróci Consent validation failed, nadaj role aplikacji bezpośrednio w jednostce usługi:

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

W portalu sprawdź w centrum administracyjnym Entra, w sekcji App registrations → twoja aplikacja → API permissions: oba uprawnienia powinny mieć status Granted z zielonymi znacznikami.

Panel API permissions rejestracji aplikacji z przyznanymi uprawnieniami Calls.AccessMedia.All i Calls.Initiate.All

Rejestracja aplikacji → API permissions po wyrażeniu zgody administratora

Krok 2 — Przygotuj maszynę wirtualną Windows, certyfikat i porty

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

Na maszynie wirtualnej (kod natywny platformy mediów ich wymaga — Windows Server domyślnie ich nie ma):

# 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

Otwórz te same porty w Zaporze systemu Windows i zanotuj odcisk palca certyfikatu — bot wiąże z nim Kestrel (443 + port powiadomień) oraz platformę mediów (8445).

Własny FQDN *.cloudapp.azure.com maszyny wirtualnej działa z certyfikatem Let’s Encrypt — nie potrzebujesz osobnej domeny.

Krok 3 — Zbuduj i uruchom bota

Zacznij od PublicSamples/EchoBot z microsoft-graph-comms-samples Microsoftu — jest przeznaczony dla net6.0 i buduje się za pomocą SDK .NET (bez 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

Skonfiguruj sekcję AppSettings w pliku appsettings.json, podając AadAppId, AadAppSecret, ServiceDnsName/MediaDnsName (FQDN maszyny wirtualnej), CertificateThumbprint oraz porty (połączenia 443, powiadomienia 9441, media 8445). Dodaj dwie poniższe opcje mostu ElevenLabs: ElevenLabsAgentId i ElevenLabsOrigin (wss://api.elevenlabs.io lub host rezydencji danych). Uruchom go jako zaplanowane zadanie/usługę Windows, aby przetrwał restarty.

Domyślny limit czasu wykonania (72 godziny) w Harmonogramie zadań po cichu zatrzymuje długo działające zadania — bot uruchomiony przy starcie przestaje działać po trzech dniach, a połączenia kończą się komunikatem „nie mogliśmy cię połączyć”. Wyłącz limit i dodaj ponowne uruchamianie po błędzie:

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

Standardowy EchoBot ulega awarii przy połączeniu na standardowy port 443: HttpHelpers.SetAbsoluteUri wywołuje req.Host.Port.Value, które ma wartość null, gdy nagłówek Host nie zawiera jawnego portu. Zmień to na req.Host.Port ?? (req.IsHttps ? 443 : 80).

Zamień echo na ElevenLabs

Warstwa audio EchoBot jest prosta: SpeechService.AppendAudioBuffer(in) i zdarzenie OnSendMediaBufferEventArgs(out). Zastąp jego część Azure Speech mostem WebSocket do agenta ElevenLabs, zachowując ten sam interfejs:

SpeechService.cs — most ElevenLabs (rdzeń)
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 });
}
}

Obie strony używają monofonicznego PCM 16 kHz, więc wystarczy przekazywanie base64 — ustaw dla agenta pcm_16000. Gdy ElevenLabs wyśle interruption (przerwanie przez rozmówcę), most wywołuje FlushMedia; podłącz je do strumienia mediów, aby odrzucał wszystkie zakolejkowane AudioMediaBuffer, inaczej agent będzie mówił dalej równocześnie z rozmówcą. Pełny opis komunikatów znajdziesz w dokumentacji WebSocket. Rozłączanie po zakończeniu rozmowy i ciepłe przekierowanie opisano w sekcjach poniżej.

URL w Connect() łączy się z publicznym agentem. W przypadku prywatnego agenta poproś po stronie serwera o krótkotrwały podpisany URL — GET /v1/convai/conversation/get-signed-url?agent_id=... z kluczem API — i połącz się z otrzymanym URL. W przypadku rezydencji danych ustaw ElevenLabsOrigin na host rezydencji (wss://api.eu.residency.elevenlabs.io, .in. lub .sg.) — żądania podpisanego URL używają odpowiadającego hosta https://.

Krok 4 — Umożliwiaj połączenia w Teams

  1. Włącz Calling w kanale Teams Azure Bota i ustaw webhook połączeń na 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"

    W portalu znajdziesz to w zasobie Azure Bot → Channels → Microsoft Teams → karta Calling:

    Panel Azure Bot Channels z kanałem Microsoft Teams oznaczonym jako
działający

    Azure Bot → Channels — połączony kanał Microsoft Teams

    Karta Calling kanału Teams z zaznaczoną opcją Enable calling i ustawionym webhookiem
połączeń

    Kanał Microsoft Teams → Calling — połączenia włączone z webhookiem bota
  2. Utwórz manifest aplikacji Teams z bots[0].supportsCalling: true i identyfikatorem aplikacji bota, a potem załaduj go lokalnie (Apps → Manage your apps → Upload a custom app) lub opublikuj dla całej organizacji bez interfejsu: New-TeamsApp -DistributionMethod organization -Path ./bot-app.zip (moduł PowerShell MicrosoftTeams).

Wyszukaj aplikację po nazwie w Teams i zadzwoń do niej — bot odbierze, a agent ElevenLabs zacznie mówić.

Aktywne połączenie Teams z botem agenta
ElevenLabs

Aktywne połączenie 1:1 z agentem — zwróć uwagę na Transfer i Consult na pasku połączenia

Do połączenia 1:1 po nazwie nie potrzebujesz numeru telefonu ani konta zasobu — są one wymagane tylko do połączeń PSTN. Calls.AccessMedia.All umożliwia most surowego audio.

Czat tekstowy (ten sam bot)

Ten sam Azure Bot może też odpowiadać na wiadomości tekstowe w Teams — użytkownicy mogą więc dzwonić do agenta lub z nim pisać. Połączenia i wiadomości to niezależne kanały bota: webhook połączeń obsługuje głos, a punkt końcowy wiadomości Bot Framework (/api/messages) obsługuje czat.

Czat w Teams z botem agenta ElevenLabs odpowiadającym na wiadomości
tekstowe

Czat z tym samym botem w Teams

Skieruj punkt końcowy wiadomości bota na hosta, który go obsługuje (bot mediów lub dowolna inna usługa — nie musi to być maszyna wirtualna Windows):

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

Zaimplementuj punkt końcowy za pomocą SDK Bot Framework i przekaż każdą wiadomość do agenta w trybie tekstowym przez ten sam WebSocket rozmowy, który służy do głosu — wyślij zdarzenie user_message, a odczytaj zdarzenie agent_response. Najpierw włącz pole pierwszej wiadomości w ustawieniach nadpisań agenta — poniższy kod nadpisuje je pustą wartością, aby odpowiedź była odpowiedzią na wiadomość użytkownika, a nie powitaniem agenta:

ChatBot.cs — Teams text chat -> ElevenLabs (text mode)
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);
}

Zarejestruj go standardowo (CloudAdapter, bota przez AddTransient<IBot, ChatBot>() oraz kontroler /api/messages) i dodaj zakresy czatu do wpisu bota w manifeście:

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

Fragment kodu otwiera nową rozmowę dla każdej wiadomości, więc każda tura jest niezależna. Aby zachować pamięć czatu, utrzymuj jeden WebSocket otwarty dla każdego conversation.id Teams (używaj go ponownie między turami) i zamykaj nieaktywne sesje — agent będzie wtedy pamiętać wcześniejsze wiadomości na tym czacie. Nadpisanie first_message musi być włączone w ustawieniach nadpisań agenta — serwer zamyka rozmowę, jeśli zostanie wysłane niedozwolone nadpisanie. Jeśli nie możesz go włączyć, pomiń nadpisanie i zamiast tego odrzuć pierwsze agent_response każdej sesji (powitanie), a zwróć kolejne.

Jeśli odpowiedzi na czacie nigdy nie przychodzą, włącz zdarzenie klienta agent_response client event w ustawieniach Zaawansowanych agenta — odpowiedzi tekstowe są dostarczane przez to zdarzenie.

Koniec połączenia

Gdy ElevenLabs kończy rozmowę (narzędzie End Call zamyka WebSocket), rozłącz połączenie Teams:

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

Ciepłe przekazanie do człowieka

Agent uruchamia niestandardowe narzędzie klienta transfer_to_human; bot zaprasza użytkownika Teams do trwającego połączenia (dodanie konsultacyjne), a potem się wycofuje:

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

Przekazanie konsultacyjne (replacesCallId) wymaga, aby obie strony były użytkownikami Teams w **tej samej dzierżawie **; cele przekazania PSTN wymagają instancji aplikacji. Aby najpierw przekazać informacje człowiekowi, przekaż parametr reason od agenta i odtwórz go człowiekowi przed połączeniem rozmów.

Rozwiązywanie problemów

Maszyna wirtualna ma tylko jeden fizyczny rdzeń. Zmień rozmiar na ≥ 2 fizyczne rdzenie (np. D4s_v3) i uruchom ją ponownie.

Zainstaluj VC++ Redistributable (vcredist140) oraz funkcję Windows Server-Media-Foundation, a potem uruchom bota ponownie.

Błąd EchoBot z pustym portem na 443 — popraw HttpHelpers.SetAbsoluteUri (zobacz krok 3). Sprawdź też, czy certyfikat jest podpisany przez CA i dostępny na porcie 443.

Sprawdź, czy Calling jest włączone w kanale Teams z poprawnym webhookiem /api/calling, czy zgoda na uprawnienie Graph Calls.AccessMedia.All została udzielona oraz czy porty 443/8445/9441 są otwarte zarówno w NSG, jak i w zaporze Windows. Jeśli połączenia wcześniej działały, a przestały, sprawdź, czy proces bota nadal działa na maszynie wirtualnej — domyślny 72-godzinny limit wykonania w Harmonogramie zadań kończy go kilka dni po uruchomieniu systemu (zobacz ostrzeżenie w kroku 3).

Przydatne linki