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上のメディアボットにルーティングし、メディアボットは生のPCM 16kオーディオをWebSocket経由でElevenLabsエージェントにブリッジします
名前で通話→メディアボット→ElevenLabs

ボットはアプリケーションホスト型メディアで応答し、毎秒50オーディオフレーム(20 ms PCM 16 kHz)を受信します。これを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 16000 Hzに設定したElevenLabsエージェント:VoiceタブでTTS出力形式、Advancedタブでユーザー入力オーディオ形式を設定します。

D2s_v3(2 vCPU=物理コア1つ)では、MediaPlatform needs a system with at least 2 coresというエラーで失敗します。物理コアが2つ以上のサイズ(例:D4s_v3)を使用してください。

権限とロール

スコープロール/権限理由
EntraApplication Administratorアプリ登録とAzure Botを作成する
EntraGlobal Administrator / Privileged Role AdministratorGraph通話権限に管理者の同意を付与する(アプリ権限は自己同意できません)
Microsoft Graph(アプリケーション)Calls.AccessMedia.All, Calls.Initiate.All1対1の通話に応答し、生のメディアにアクセスする
Azure RBACリソースグループのContributorWindows VMとAzure Botを作成する
Teams管理者カスタムアプリのアップロードを許可し、ボットのCallingチャネルを有効化するアプリをサイドロードして通話を受ける

ステップ1 — ボットとGraph権限を登録する

アプリ登録とそれに紐付けたAzure Botを作成し、通話権限を付与して同意します(同意には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

2つのGraphアプリケーションロールを付与し、管理者の同意を取得します(Global AdminまたはPrivileged Role Adminが必要)。その後、割り当てが反映されたことを確認してください:

# 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

標準ポート443への通話では、標準のEchoBotがクラッシュします。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 bridge (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 });
}
}

両側ともPCM 16kHzモノラルなので、base64をそのまま渡せます。エージェントはpcm_16000に設定してください。ElevenLabsのinterruption(バージイン)時、ブリッジはFlushMediaを発行します。これをメディアストリームに接続し、キュー済みのAudioMediaBufferをすべて破棄するようにしてください。そうしないと、エージェントが発信者に重なって話し続けます。メッセージの完全なリファレンスはWebSocketドキュメントにあります。通話終了時の切断とウォーム転送については、以下のセクションを参照してください。

Connect()内のURLは公開エージェントに接続します。プライベートエージェントの場合は、サーバー側で短期間有効な 署名付きURLをリクエストしてください。APIキーを使用してGET /v1/convai/conversation/get-signed-url?agent_id=...を呼び出し、 代わりに返されたURLに接続します。データ レジデンシーでは、ElevenLabsOriginをレジデンシーの ホスト(wss://api.eu.residency.elevenlabs.io、.in.、または.sg.)に設定します。署名付きURLリクエストには、 対応するhttps://ホストを使用します。

ステップ4 — Teamsから呼び出せるようにする

  1. Azure BotのTeamsチャネルでCallingを有効にし、calling webhookを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リソース → Channels → Microsoft Teams → Callingタブにあります。

    Microsoft Teamsチャネルが正常と表示されているAzure BotのChannelsブレード

    Azure Bot → Channels — 接続済みのMicrosoft Teamsチャネル

    Enable callingがオンになり、calling webhookが設定されたTeamsチャネルのCallingタブ

    Microsoft Teamsチャネル → Calling — ボットのwebhookでCallingを有効化
  2. bots[0].supportsCalling: trueとボットのアプリIDを含むTeamsアプリマニフェストを作成し、サイドロードします(Apps → Manage your apps → Upload a custom app)。または、UIを使わずに組織全体へ公開できます。New-TeamsApp -DistributionMethod organization -Path ./bot-app.zip(MicrosoftTeams PowerShellモジュール)。

Teamsでアプリ名を検索して通話すると、ボットが応答し、ElevenLabsエージェントが話します。

ElevenLabsエージェントボットとのアクティブなTeams通話

エージェントとのライブ1対1通話 — 通話ツールバーのTransferとConsultに注目

1対1の名前指定通話には、電話番号やリソースアカウントは不要です。これらが必要なのはPSTN ダイヤルインのみです。生オーディオブリッジを有効にするのはCalls.AccessMedia.Allです。

テキストチャット(同じボット)

同じAzure BotはTeamsでテキストにも応答できるため、ユーザーはエージェントに通話することもチャットすることもできます。Callingとメッセージングはボット上で独立したチャネルです。calling webhookは音声を処理し、Bot Frameworkのmessaging endpoint(/api/messages)はチャットを処理します。

テキストメッセージに応答するElevenLabsエージェントボットとのTeamsチャット

Teamsで同じボットとチャット

ボットのmessaging endpointを、提供元のホストに向けます(メディアボットでも別のサービスでも構いません。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イベントを読み取ります。まず、エージェントのオーバーライド設定でfirst messageフィールドを有効にしてください。以下のコードではこれを空文字でオーバーライドするため、エージェントの挨拶ではなくユーザーのメッセージへの回答が返されます。

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);
}

標準的な方法で登録します(CloudAdapter、AddTransient<IBot, ChatBot>()によるボットの登録、/api/messagesコントローラー)。また、マニフェストのボットエントリにチャットスコープを追加します。

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

このスニペットではメッセージごとに新しい会話を開始するため、各ターンは独立しています。チャットの メモリーを維持するには、Teamsのconversation.idごとに1つのWebSocketを開いたままにし(ターン間で再利用)、 アイドル状態のセッションを終了します。これにより、エージェントはそのチャット内の過去のメッセージを記憶します。first_message オーバーライドは、エージェントの オーバーライド設定で有効にする必要があります。許可されていないオーバーライドを送信すると、サーバーは 会話を終了します。有効にできない場合は、オーバーライドを省略し、各セッションの最初のagent_response(挨拶)を破棄して、 次の応答を返してください。

チャットの返信が届かない場合は、エージェントのAdvanced設定で**agent_response**クライアント イベントを有効にしてください。テキスト応答はこのイベントを通じて配信されます。

通話終了

ElevenLabsが会話を終了したら(End Callツールによって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の物理コアが1つしかありません。2つ以上の物理コア(例:D4s_v3)にリサイズして再起動してください。

VC++ Redistributable(vcredist140)とServer-Media-Foundation Windows機能をインストールしてから、ボットを再起動してください。

443で発生するEchoBotのポートnullバグです。HttpHelpers.SetAbsoluteUriを修正してください(ステップ3を参照)。また、証明書がCA署名済みで、443で到達可能であることも確認してください。

Teamsチャネルで正しい/api/calling webhookを使用してCallingが有効になっていること、GraphのCalls.AccessMedia.All権限に同意していること、NSGとWindowsファイアウォールの両方でポート443/8445/9441が開いていることを確認してください。通話が以前は機能していて停止した場合は、VM上でボットプロセスがまだ実行中か確認してください。タスク スケジューラのデフォルトの72時間実行制限により、起動から数日後に停止します(ステップ3の警告を参照)。

便利なリンク