Graph 통화 봇

Microsoft Teams 내에서 동료처럼 이름으로 ElevenLabs 에이전트에 전화하거나 채팅하세요.

개요

이 방식은 에이전트를 전화할 수 있는 Teams ID로 만듭니다. 사용자는 이름으로 검색해 1:1 통화를 걸고, 에이전트는 실시간으로 응답합니다. 전화번호, PSTN 또는 Communications Credits가 필요하지 않습니다. 이름으로 전화를 걸 수 있는 유일한 방식이며, 운영 난이도도 가장 높습니다.

Microsoft Graph 실시간 미디어 봇(Cloud Communications 통화 플랫폼)을 사용합니다. 미디어 SDK(Microsoft.Skype.Bots.Media)는 Windows Server의 .NET 전용이며, Teams 통화에서 원시 오디오를 처리할 수 있는 Linux 또는 비-.NET 경로는 없습니다.

Teams 내에서 이름으로 전화를 걸 수 있는 유일한 방식입니다. 더 간단한 설정을 원한다면 위젯 탭을 사용하거나, 전화번호가 꼭 필요한 경우 ACS를 사용하세요.

작동 방식

Teams 사용자가 이름으로 봇에 전화를 걸면 Teams가 Windows VM의 미디어 봇으로 통화를 라우팅하고, 봇은 WebSocket을 통해 원시 PCM 16k 오디오를 ElevenLabs 에이전트에 연결합니다
이름으로 전화 → 미디어 봇 → ElevenLabs

봇은 애플리케이션 호스팅 미디어로 응답하고 초당 50개 오디오 프레임(20ms PCM 16kHz)을 수신하여 WebSocket을 통해 ElevenLabs 에이전트에 연결한 뒤, 에이전트 오디오를 통화로 다시 스트리밍합니다.

요구 사항

  1. Azure Bot 등록 + 앱(Entra 앱 등록).
  2. 관리자 동의가 적용된 Graph 애플리케이션 권한: Calls.AccessMedia.All(원시 미디어) 및 Calls.Initiate.All.
  3. 공개 IP와 열린 미디어 포트를 갖춘 Windows Server VM(≥ 물리 코어 2개 — 예: Standard_D4s_v3).
  4. 미디어/시그널링 엔드포인트용 공개 FQDN의 CA 서명 TLS 인증서(미디어 플랫폼은 자체 서명 인증서를 거부함).
  5. 양쪽 모두 PCM 16000Hz로 설정된 ElevenLabs 에이전트: Voice 탭의 TTS 출력 형식 및 Advanced 탭의 사용자 입력 오디오 형식.

D2s_v3(vCPU 2개 = 물리 코어 1개)는 MediaPlatform needs a system with at least 2 cores 오류로 실패합니다. 물리 코어가 2개 이상인 크기(예: D4s_v3)를 사용하세요.

권한 및 역할

범위역할 / 권한이유
Entra애플리케이션 관리자앱 등록 및 Azure Bot 생성
Entra전역 관리자 / 권한 있는 역할 관리자Graph 통화 권한에 대한 관리자 동의 부여 — 앱 권한은 자체 동의할 수 없음
Microsoft Graph (애플리케이션)Calls.AccessMedia.All, Calls.Initiate.All1:1 통화 응답 및 원시 미디어 액세스
Azure RBAC리소스 그룹의 기여자Windows VM 및 Azure Bot 생성
Teams 관리자사용자 지정 앱 업로드 허용, 봇 통화 채널 활성화앱을 사이드로드하고 통화 수신

1단계 — 봇 및 Graph 권한 등록

앱 등록을 만들고 연결된 Azure Bot을 생성한 다음, 통화 권한을 부여하고 동의하세요(동의하려면 전역 관리자 / 권한 있는 역할 관리자가 필요합니다).

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

두 Graph 애플리케이션 역할을 부여하고 관리자 동의를 제공한 다음(전역 관리자 / 권한 있는 역할 관리자 필요), 할당이 적용되었는지 확인하세요.

# 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

admin-consent가 Consent validation failed를 반환하는 경우, 대신 서비스 주체에서 앱 역할을 직접 부여하세요.

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

포털의 Entra 관리 센터에서 앱 등록 → 내 앱 → API 권한으로 이동해 확인하세요. 두 권한 모두 녹색 체크 표시와 함께 부여됨으로 표시되어야 합니다.

Calls.AccessMedia.All 및 Calls.Initiate.All 권한이 부여된 것으로 표시된 앱 등록 API 권한 블레이드

관리자 동의 후 앱 등록 → API 권한

2단계 — Windows VM, 인증서 및 포트 프로비저닝

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

VM에서 다음을 실행하세요(미디어 플랫폼의 네이티브 코드에 필요하며, Windows Server에는 기본적으로 설치되어 있지 않습니다).

# 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

Windows 방화벽에서도 동일한 포트를 열고 인증서 지문을 기록해 두세요. 봇은 Kestrel(443 및 알림 포트)과 미디어 플랫폼(8445)을 이 인증서에 바인딩합니다.

VM 자체의 *.cloudapp.azure.com FQDN으로 Let’s Encrypt 인증서를 사용할 수 있으므로 별도의 도메인이 필요하지 않습니다.

3단계 — 봇 빌드 및 실행

Microsoft의 microsoft-graph-comms-samples PublicSamples/EchoBot에서 시작하세요. 이 샘플은 net6.0을 대상으로 하며 .NET SDK로 빌드됩니다(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

appsettings.json의 AppSettings 섹션을 AadAppId, AadAppSecret, ServiceDnsName/MediaDnsName(VM FQDN), CertificateThumbprint, 포트(통화 443, 알림 9441, 미디어 8445)로 구성하세요. 아래 ElevenLabs 브리지용 설정 두 개도 추가하세요. ElevenLabsAgentId 및 ElevenLabsOrigin(wss://api.elevenlabs.io 또는 데이터 레지던시 호스트)입니다. 재부팅 후에도 계속 실행되도록 Windows 예약 작업 / 서비스로 실행하세요.

작업 스케줄러의 기본 실행 시간 제한(72시간) 은 장기 실행 작업을 조용히 종료합니다. 부팅 시 시작한 봇은 3일 후 종료되고 통화 시 “연결할 수 없습니다” 오류가 발생합니다. 제한을 해제하고 실패 시 다시 시작을 추가하세요.

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

기본 EchoBot은 표준 포트 443으로 걸려온 통화에서 충돌합니다. HttpHelpers.SetAbsoluteUri가 req.Host.Port.Value를 호출하지만, Host 헤더에 명시적 포트가 없으면 null입니다. 이를 req.Host.Port ?? (req.IsHttps ? 443 : 80)로 패치하세요.

에코를 ElevenLabs로 교체

EchoBot의 오디오 연결 지점은 명확합니다. SpeechService.AppendAudioBuffer(in) 및 OnSendMediaBufferEventArgs(out) 이벤트입니다. Azure Speech 본문을 동일한 인터페이스를 유지하는 ElevenLabs 에이전트 WebSocket 브리지로 교체하세요.

SpeechService.cs — ElevenLabs 브리지(핵심)
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 });
}
}

양쪽 모두 PCM 16kHz 모노이므로 base64 패스스루입니다. 에이전트를 pcm_16000으로 설정하세요. ElevenLabs interruption(끼어들기) 발생 시 브리지는 FlushMedia를 발생시킵니다. 이를 미디어 스트림에 연결해 대기 중인 AudioMediaBuffer를 모두 삭제하세요. 그렇지 않으면 에이전트가 발신자 위로 계속 말하게 됩니다. 전체 메시지 레퍼런스는 WebSocket 문서에서 확인할 수 있습니다. 통화 종료 시 끊기와 웜 전송은 아래 섹션에서 다룹니다.

Connect()의 URL은 공개 에이전트에 연결합니다. 비공개 에이전트의 경우 서버 측에서 API 키로 GET /v1/convai/conversation/get-signed-url?agent_id=...를 요청하여 단기 서명 URL을 받은 후, 반환된 URL에 연결하세요. 데이터 레지던시를 사용하는 경우 ElevenLabsOrigin을 데이터 레지던시 호스트(wss://api.eu.residency.elevenlabs.io, .in., 또는 .sg.)로 설정하세요. 서명 URL 요청에는 일치하는 https:// 호스트를 사용합니다.

4단계 — Teams에서 통화 가능하도록 설정

  1. Azure Bot의 Teams 채널에서 통화를 활성화하고 통화 웹훅을 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"

    포털에서는 Azure Bot 리소스 → 채널 → Microsoft Teams → 통화 탭에 있습니다.

    정상 상태로 표시된 Microsoft Teams 채널이 나열된 Azure Bot 채널 블레이드

    Azure Bot → 채널 — 연결된 Microsoft Teams 채널

    통화 사용이 선택되어 있고 통화 웹훅이 설정된 Teams 채널 통화 탭

    Microsoft Teams 채널 → 통화 — 봇 웹훅으로 통화 활성화
  2. bots[0].supportsCalling: true와 봇의 앱 ID가 포함된 Teams 앱 매니페스트를 빌드한 후 사이드로드하세요(앱 → 앱 관리 → 사용자 지정 앱 업로드). 또는 UI 없이 New-TeamsApp -DistributionMethod organization -Path ./bot-app.zip(MicrosoftTeams PowerShell 모듈)으로 조직 전체에 게시할 수 있습니다.

Teams에서 이름으로 앱을 검색해 통화하세요. 봇이 응답하고 ElevenLabs 에이전트가 말합니다.

ElevenLabs 에이전트 봇과 진행 중인 Teams 통화

에이전트와의 실시간 1:1 통화 — 통화 도구 모음의 전송 및 상담에 주목

1:1 이름 기반 통화에는 전화번호나 리소스 계정이 필요하지 않습니다. 이는 PSTN 다이얼인에만 필요합니다. Calls.AccessMedia.All이 원시 오디오 브리지를 활성화합니다.

텍스트 채팅(동일한 봇)

동일한 Azure Bot이 Teams에서 텍스트에도 응답할 수 있으므로, 사용자는 에이전트에 통화하거나 채팅할 수 있습니다. 통화와 메시징은 봇에서 독립적인 채널입니다. 통화 웹훅은 음성을 처리하고 Bot Framework 메시징 엔드포인트(/api/messages)는 채팅을 처리합니다.

텍스트 메시지에 응답하는 ElevenLabs 에이전트 봇과의 Teams 채팅

Teams에서 동일한 봇과 채팅

봇의 메시징 엔드포인트를 이를 제공하는 호스트로 지정하세요(미디어 봇 또는 다른 서비스. Windows VM일 필요는 없습니다).

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

Bot Framework SDK로 엔드포인트를 구현하고, 각 메시지를 음성에 사용하는 것과 동일한 대화 WebSocket을 통해 텍스트 모드로 에이전트에 릴레이하세요. user_message 이벤트를 전송하고 agent_response 이벤트를 읽습니다. 먼저 에이전트 오버라이드 설정에서 첫 번째 메시지 필드를 활성화하세요. 아래 코드는 이를 비어 있는 값으로 오버라이드하므로, 응답이 에이전트의 인사가 아니라 사용자 메시지에 대한 답변이 됩니다.

ChatBot.cs — Teams 텍스트 채팅 -> ElevenLabs(텍스트 모드)
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);
}

표준 방식으로 등록하세요(CloudAdapter, AddTransient<IBot, ChatBot>()을 통한 봇 등록, /api/messages 컨트롤러). 그리고 매니페스트의 봇 항목에 채팅 범위를 추가하세요.

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

이 스니펫은 메시지마다 새 대화를 열기 때문에 각 턴은 독립적입니다. 채팅 메모리를 유지하려면 Teams conversation.id마다 WebSocket을 하나씩 열어 턴 간에 재사용하고, 유휴 세션을 정리하세요. 그러면 에이전트가 해당 채팅의 이전 메시지를 기억합니다. first_message 오버라이드는 에이전트의 오버라이드 설정에서 활성화해야 합니다. 허용되지 않은 오버라이드가 전송되면 서버가 대화를 종료합니다. 활성화할 수 없다면 오버라이드를 생략하고, 각 세션의 첫 번째 agent_response(인사)를 버린 후 다음 응답을 반환하세요.

채팅 응답이 도착하지 않으면 에이전트의 고급 설정에서 agent_response 클라이언트 이벤트를 활성화하세요. 텍스트 응답은 이 이벤트를 통해 전달됩니다.

통화 종료

ElevenLabs가 대화를 종료하면(통화 종료 도구가 WebSocket을 닫음) Teams 연결도 끊으세요.

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

상담원에게 웜 전송

에이전트가 사용자 지정 transfer_to_human 클라이언트 도구를 실행하면 봇이 진행 중인 통화에 Teams 사용자를 초대한 후(상담식 추가) 물러납니다.

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

상담식 전송(replacesCallId)은 양쪽이 동일한 테넌트의 Teams 사용자여야 합니다. PSTN 전송 대상에는 애플리케이션 인스턴스가 필요합니다. 먼저 상담원에게 상황을 설명하려면 에이전트에서 reason 매개변수를 전달하고 연결하기 전에 이를 상담원에게 재생하세요.

문제 해결

VM에 물리적 코어가 하나만 있습니다. 물리적 코어를 2개 이상으로 조정하고(예: D4s_v3) 다시 시작하세요.

VC++ 재배포 가능 패키지(vcredist140)와 Server-Media-Foundation Windows 기능을 설치한 후 봇을 다시 시작하세요.

443에서 발생하는 EchoBot 포트 null 버그입니다. HttpHelpers.SetAbsoluteUri를 패치하세요(3단계 참조). 인증서가 CA 서명되었고 443에서 연결 가능한지도 확인하세요.

Teams 채널에서 올바른 /api/calling 웹훅으로 통화가 활성화되어 있는지, Graph Calls.AccessMedia.All 권한이 동의되었는지, NSG와 Windows 방화벽 모두에서 포트 443/8445/9441이 열려 있는지 확인하세요. 통화가 이전에는 작동했지만 중단된 경우, VM에서 봇 프로세스가 여전히 실행 중인지 확인하세요. 작업 스케줄러의 기본 72시간 실행 제한은 부팅 후 며칠 뒤 봇을 종료합니다(3단계의 경고 참조).

유용한 링크