Azure Communication Services

Pozwól użytkownikom dzwonić na numer telefonu odbierany przez twojego agenta ElevenLabs za pomocą ACS Call Automation.

Przegląd

To podejście daje agentowi numer telefonu. Dzwoniący wybiera ten numer, Azure Communication Services (ACS) odbiera połączenie przez dwukierunkowe przesyłanie strumieniowe mediów, a mały most przekazuje audio PCM między ACS a agentem ElevenLabs przez standardowy protokół WebSocket agenta. To model contact center / IVR — podobny do wdrożenia SIP trunking, gdzie ACS jest operatorem.

Łączy się też z Teams na dwa sposoby: użytkownik Teams z Calling Plan może zadzwonić bezpośrednio na numer ACS albo możesz udostępnić numer przez Teams Phone Extensibility, aby połączenia z kontem zasobu Teams trafiały do ACS.

ACS udostępnia numery PSTN tylko w ograniczonej liczbie krajów. Jeśli numer nie jest dostępny w twoim regionie, użyj dostawcy SIP z SIP trunking albo bota połączeń Graph.

Jak to działa

Dzwoniący wybiera numer ACS; ACS wysyła IncomingCall przez Event Grid do mostu, który odbiera połączenie przez dwukierunkowe przesyłanie mediów PCM 16k i przekazuje je do agenta ElevenLabs przez WebSocket
Połączenie przychodzące → ACS → most → ElevenLabs

Audio ma format PCM 16 kHz mono po obu stronach (format wejścia/wyjścia agenta to pcm_16000), więc jest przesyłane jako base64 bez resamplingu.

Most udostępnia te ścieżki:

ŚcieżkaCel
POST /api/incomingCallwebhook Event Grid: sprawdza subskrypcję, potem answer_call z przesyłaniem mediów
POST /api/callbackszdarzenia cyklu życia Call Automation (CallConnected, CallDisconnected, AddParticipant*)
GET|WS /wsgniazdo przesyłania mediów ACS ↔ ElevenLabs
POST /api/outboundCallOpcjonalnie: wykonuje połączenie wychodzące, które łączy odbierającego z agentem

Wymagania

  1. Płatna subskrypcja Azure (MCA / EA / Pay-As-You-Go) — bezpłatne, próbne i sponsorskie subskrypcje nie mogą kupować numerów.
  2. Zasób Azure Communication Services.
  3. Host HTTPS dla mostu z publicznym WebSocketem (Azure Container Apps, App Service lub VM).
  4. Agent ElevenLabs ustawiony na PCM 16000 Hz po obu stronach: format wyjścia TTS na karcie Voice, format audio wejścia użytkownika na karcie Advanced.

Uprawnienia i role

ZakresRola / uprawnieniePo co
Azure RBACContributor w grupie zasobówtworzenie zasobu ACS, Container App i subskrypcji Event Grid
Subskrypcja AzureOwner lub Contributor w subskrypcjikupowanie numerów telefonów (w przeciwnym razie opcja zakupu jest wyłączona)
RozliczeniaTyp subskrypcji MCA / EA / Pay-As-You-Gosubskrypcje bezpłatne, próbne, sponsorskie i Dev nie mogą kupować numerów

Przy roli Contributor (nie Owner) az containerapp up nie może utworzyć przypisania roli ACR pull dla tożsamości zarządzanej. Włącz użytkownika administratora rejestru i przypnij go zamiast tego — zobacz ostrzeżenie w kroku 2.

Krok 1 — Utwórz zasób ACS i numer

RG=my-rg
# Register providers (once)
az provider register -n Microsoft.Communication --wait
az provider register -n Microsoft.EventGrid --wait
# Create the ACS resource
az communication create --name my-acs --resource-group $RG \
--location global --data-location unitedstates

Kup numer w zasobie (Portal → twój zasób ACS → Phone numbers → Get albo SDK phone-numbers). Dla agenta, który odbiera połączenia, wystarczy numer z opcją inbound calling; dodaj funkcję outbound, jeśli chcesz też używać /api/outboundCall.

Widok Phone numbers zasobu ACS z listą aktywnych numerów i ich
możliwościami połączeń

Numery telefonów w zasobie ACS — kolumna Calling pokazuje kierunek każdego numeru

Aby sprawdzić to z CLI (wymaga az extension add --name communication) i pobrać connection string używany przez most jako ACS_CONNECTION_STRING:

CONN=$(az communication list-key -n my-acs -g $RG --query primaryConnectionString -o tsv)
az communication phonenumber list --connection-string "$CONN" --query "[].phoneNumber"

Krok 2 — Wdróż most

Most to mała aplikacja Flask + flask-sock używająca azure-communication-callautomation. Główna część przepływu przychodzącego:

bridge.py (fragment)
from azure.communication.callautomation import (
CallAutomationClient, MediaStreamingOptions, StreamingTransportType,
MediaStreamingContentType, MediaStreamingAudioChannelType, AudioFormat,
)
@app.route("/api/incomingCall", methods=["POST"])
def incoming_call():
for event in request.get_json():
# Event Grid subscription validation handshake
if event.get("eventType") == "Microsoft.EventGrid.SubscriptionValidationEvent":
return jsonify({"validationResponse": event["data"]["validationCode"]})
if event.get("eventType") == "Microsoft.Communication.IncomingCall":
client = CallAutomationClient.from_connection_string(ACS_CONNECTION_STRING)
client.answer_call(
incoming_call_context=event["data"]["incomingCallContext"],
callback_url=f"https://{HOST}/api/callbacks",
media_streaming=MediaStreamingOptions(
transport_url=f"wss://{HOST}/ws",
transport_type=StreamingTransportType.WEBSOCKET,
content_type=MediaStreamingContentType.AUDIO,
audio_channel_type=MediaStreamingAudioChannelType.MIXED,
start_media_streaming=True,
enable_bidirectional=True,
audio_format=AudioFormat.PCM16_K_MONO,
),
)
return jsonify({"status": "ok"})

W gnieździe /ws przekazuj PCM16 w obie strony: wysyłaj ramki ACS AudioData do ElevenLabs jako {"user_audio_chunk": "<base64>"}, a audio agenta odsyłaj jako {"Kind":"AudioData","AudioData":{"Data":"<base64>"},"StopAudio":null}. Pierwsza ramka wysyłana przez ACS to AudioMetadata (uzgodniony format) — zapisz ją w logach i zignoruj. Po stronie ElevenLabs używany jest standardowy protokół WebSocket agenta.

ACS używa innej wielkości liter w JSON w zależności od kierunku: ramki przychodzące, które wysyła, są w camelCase (kind, audioData.data), a ramki wychodzące, których oczekuje, w PascalCase (Kind, AudioData.Data, StopAudio). Nie mieszaj tych dwóch formatów — poniższy przekaźnik je odwzorowuje.

bridge.py — przekaźnik mediów
import asyncio, json, os, queue, threading, websockets
from flask_sock import Sock
sock = Sock(app)
AGENT_ID = os.environ["ELEVENLABS_AGENT_ID"]
# US default; data residency: wss://api.eu.residency.elevenlabs.io, .in., or .sg.
EL_ORIGIN = os.environ.get("ELEVENLABS_ORIGIN", "wss://api.elevenlabs.io")
EL_WS = f"{EL_ORIGIN}/v1/convai/conversation?agent_id={AGENT_ID}"
@sock.route("/ws")
def media_stream(ws):
loop = asyncio.new_event_loop()
el = {"ws": None}
to_acs = queue.Queue() # outbound frames; only this handler thread touches `ws`
async def el_session():
async with websockets.connect(EL_WS) as elws:
el["ws"] = elws
await elws.send(json.dumps({"type": "conversation_initiation_client_data"}))
async for msg in elws:
data = json.loads(msg)
kind = data.get("type")
if kind == "audio": # agent audio -> caller
b64 = data["audio_event"]["audio_base_64"]
to_acs.put({"Kind": "AudioData", "AudioData": {"Data": b64}, "StopAudio": None})
elif kind == "ping":
await elws.send(json.dumps({"type": "pong", "event_id": data["ping_event"]["event_id"]}))
elif kind == "interruption": # barge-in
to_acs.put({"Kind": "StopAudio", "AudioData": None, "StopAudio": {}})
threading.Thread(target=lambda: loop.run_until_complete(el_session()), daemon=True).start()
# Keep all ACS-socket I/O on this one thread: receive with a short timeout,
# then drain any audio the ElevenLabs thread queued. Sending from the other
# thread would race flask-sock and corrupt the stream.
try:
while True:
raw = ws.receive(timeout=0.02) # None when no frame arrived this tick
if raw:
evt = json.loads(raw)
if evt.get("kind") == "AudioData" and el["ws"]: # caller audio -> agent
asyncio.run_coroutine_threadsafe(
el["ws"].send(json.dumps({"user_audio_chunk": evt["audioData"]["data"]})), loop)
while not to_acs.empty():
ws.send(json.dumps(to_acs.get_nowait()))
except Exception:
pass # ACS socket closed

Ten przekaźnik jest celowo prosty. W środowisku produkcyjnym dodaj logowanie, ponowne łączenie i łagodne zamykanie. Pełna lista komunikatów jest w dokumentacji WebSocket .

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

Wdróż w Azure Container Apps i zapisz publiczny FQDN:

az containerapp up --name acs-el-bridge --resource-group $RG \
--source . --ingress external --target-port 8080 \
--env-vars ELEVENLABS_AGENT_ID=$AGENT_ID \
ELEVENLABS_ORIGIN=wss://api.elevenlabs.io
FQDN=$(az containerapp show -n acs-el-bridge -g $RG \
--query properties.configuration.ingress.fqdn -o tsv)

Następnie ustaw w aplikacji BRIDGE_PUBLIC_HOST=$FQDN oraz connection string ACS (jako sekret).

Przy roli Contributor (nie Owner) az containerapp up nie może utworzyć roli ACR pull dla tożsamości zarządzanej. Włącz użytkownika administratora rejestru (az acr update --admin-enabled true) i przypnij go przez az containerapp registry set, a potem uruchom az containerapp update --image ....

Krok 3 — Przekieruj IncomingCall do mostu

Utwórz subskrypcję Event Grid w zasobie ACS, która wysyła IncomingCall do mostu. Walidacja mostu (powyżej) automatycznie kończy tworzenie subskrypcji.

ACS_ID=$(az communication show -n my-acs -g $RG --query id -o tsv)
az eventgrid event-subscription create \
--name acs-incomingcall \
--source-resource-id "$ACS_ID" \
--endpoint "https://$FQDN/api/incomingCall" \
--endpoint-type webhook \
--included-event-types Microsoft.Communication.IncomingCall
# Verify — should print "Succeeded"
az eventgrid event-subscription show --name acs-incomingcall \
--source-resource-id "$ACS_ID" --query provisioningState -o tsv

Subskrypcja pojawi się w widoku Events zasobu ACS:

Widok Events zasobu ACS z subskrypcją webhooka acs-incomingcall filtrowaną
według
Microsoft.Communication.IncomingCall

Zasób ACS → Events → Event Subscriptions

Zadzwoń na numer — agent odbierze.

Łączenie z Teams

  • Wybieranie bezpośrednie: użytkownik Teams z Teams Phone + Calling Plan może zadzwonić na numer ACS jak na każdy numer zewnętrzny.
  • Konto zasobu Teams (TPE): połącz konto zasobu Teams z zasobem ACS przez Teams Phone Extensibility, aby połączenia z kontem zasobu uruchamiały ten sam przepływ IncomingCall → most.

Koniec połączenia

Gdy agent kończy rozmowę (np. narzędziem End Call), ElevenLabs zamyka WebSocket. Rozłącz połączenie ACS, aby dzwoniący nie został na martwej linii:

CallAutomationClient.from_connection_string(ACS_CONNECTION_STRING) \
.get_call_connection(call_connection_id).hang_up(is_for_everyone=True)

Ciepłe przekazanie do człowieka

Natywne narzędzia przekazywania ElevenLabs działają tylko wtedy, gdy ElevenLabs obsługuje telefonię. Tutaj agent uruchamia więc niestandardowe narzędzie klienta (np. transfer_to_human), które most obsługuje przez dodanie człowieka do trwającego połączenia za pomocą add_participant (ciepłe przekazanie), zamiast ślepego transferu:

conn = client.get_call_connection(call_connection_id)
conn.add_participant(
PhoneNumberIdentifier(human_number),
source_caller_id_number=PhoneNumberIdentifier(your_outbound_number),
invitation_timeout=30,
)
# then mute the bot and skip the end-of-call hangup so the human's leg survives

ACS wysyła wywołania zwrotne AddParticipantSucceeded / AddParticipantFailed do /api/callbacks. Zwróć do agenta client_tool_result, aby mógł wypowiedzieć komunikat o przekazaniu. Konfigurację po stronie agenta znajdziesz w narzędziach systemowych.

Ustaw blokadę transferu natychmiast po uruchomieniu narzędzia (przed wywołaniem add_participant), bo szybkie zamknięcie EL WebSocket może kolidować z rozłączeniem i zakończyć połączenie, zanim człowiek dołączy.

Rozwiązywanie problemów

Sprawdź, czy subskrypcja Event Grid została utworzona (provisioningState: Succeeded) oraz czy /api/incomingCall mostu zwróciło odpowiedź walidacyjną. Sprawdź, czy numer ma połączenia przychodzące i należy do tego samego zasobu ACS, co subskrypcja. Na karcie Filters subskrypcji typy zdarzeń muszą obejmować Incoming Call:

Karta Filters subskrypcji zdarzeń z typem zdarzenia filtrowanym do
Incoming
Call

Subskrypcja zdarzeń → Filters → Incoming Call

Połączenia wychodzące ACS do niektórych krajów (np. Indii) są ograniczone lub niestabilne. Użyj obsługiwanego miejsca docelowego albo obsłuż połączenie z człowiekiem przez numer SIP/Operator. Logika mostu pozostaje bez zmian — to błąd po stronie operatora dla połączenia wychodzącego.

Obie strony muszą używać PCM 16 kHz mono. Ustaw format wejścia/wyjścia agenta na pcm_16000; most zapisuje uzgodniony format z conversation_initiation_metadata.

Zakup numeru wymaga płatnego typu subskrypcji (MCA/EA/PAYG). Jeśli ACS nie oferuje numerów w twoim kraju, użyj dostawcy SIP.

Przydatne linki