Microsoft 365 Copilot の変更通知 API でチャットの会話を受け取る
はじめに
Microsoft 365 Copilot API には、変更通知 API があります。Microsoft Graph の変更通知 API は以前から提供されていましたが、これが Microsoft 365 Copilot にも対応したことになります。
Microsoft 365 Copilot 変更通知 API については、Microsoft のドキュメントを参照してください。変更通知にはリソース データを含む通知と含まない通知の 2 種類がありますが、今回はリソース データを含む通知を試してみます。
サンプル コード
実行手順
リソース データを含む通知では、通知に含まれるリソース データを暗号化する必要があります。これは意図しない情報漏洩を防ぐための仕組みです。サブスクリプションを作成するときに暗号化のための公開鍵を設定し、通知を受け取ったときに秘密鍵で復号します。これは一般的に公開鍵暗号方式と呼ばれる方法ですが、この仕組みを自分で実装する必要がある点には注意が必要です。
証明書は Azure Key Vault に保存することが推奨されますが、今回のサンプルでは割愛しています。証明書は 2,048 ビットから 4,096 ビットの長さの RSA 形式である必要があります。鍵は OpenSSL を使って作成するのが簡単です。
openssl req -x509 -newkey rsa:2048 -keyout private.pem -out public.pem -days 365 -nodes -subj "/CN=localhost"
リソース データを含む変更通知の使用方法は、Microsoft のドキュメントに記載されています。
サブスクリプションの作成
Functions/CreateSubscriptionFunction.cs
Microsoft Graph API を呼び出して Microsoft 365 Copilot の変更通知を受け取るサブスクリプションを作成する、関数のエントリ ポイントです。作成したサブスクリプションの情報は、ほかの関数から参照できるよう Azure Blob Storage に保存しておきます。
[Function("CreateSubscription")]
public async Task<IActionResult> RunAsync([HttpTrigger(AuthorizationLevel.Admin, "POST")] HttpRequest httpRequest)
{
try
{
this.logger.MethodExecuting();
// サブスクリプションを作成する
var baseUrl = new Uri(httpRequest.GetDisplayUrl()).GetLeftPart(UriPartial.Authority);
var notificationUrl = $"{baseUrl}/api/ReceiveSubscription?code={httpRequest.Query["code"]}";
var encryptionCertificateId = Guid
.NewGuid()
.ToString();
var clientState = Guid
.NewGuid()
.ToString();
var subscription = await this.graphService.CreateSubscriptionAsync(
notificationUrl,
encryptionCertificateId,
clientState
);
// サブスクリプション情報を設定ファイルとして保存する
var settings = new ChangeNotificationSettings()
{
SubscriptionId = subscription.Id!,
EncryptionCertificateId = encryptionCertificateId,
ClientState = clientState
};
await this.blobsService.SetSettingsAsync(settings);
return new OkObjectResult(settings);
}
catch (InvalidOperationException ex)
{
this.logger.MethodFailed(exception: ex);
return new StatusCodeResult(StatusCodes.Status400BadRequest);
}
catch (Exception ex)
{
this.logger.UnhandledErrorOccurred(exception: ex);
return new StatusCodeResult(StatusCodes.Status500InternalServerError);
}
finally
{
this.logger.MethodExecuted();
}
}
Services/GraphService.cs
公開鍵のバイナリ データを Base64 エンコードした文字列を、EncryptionCertificate に設定します。EncryptionCertificateId は証明書を管理するための識別子であり、Azure Key Vault に保存した証明書の名前など、わかりやすい値を設定します。
ExpirationDateTime は 15 分に設定しています。有効期限は最大で 3 日間まで設定できますが、1 時間以上の場合は、ライフサイクル管理のための LifecycleNotificationUrl が必要になります。いずれにせよ定期的に有効期限を更新する必要があるので、短い時間で更新したほうが実装としてはシンプルになります。
Resource には以下のいずれかを設定します。
- テナント全体の場合:
/copilot/interactionHistory/getAllEnterpriseInteractions - 特定のユーザーのみの場合:
/copilot/users/{user-id}/interactionHistory/getAllEnterpriseInteractions
public async Task<Subscription> CreateSubscriptionAsync(
string notificationUrl,
string encryptionCertificateId,
string clientState
)
{
var certificate = await File
.ReadAllBytesAsync(Constants.CrtPemPath)
.ConfigureAwait(false);
var subscription = await this
.graphServiceClient.Subscriptions.PostAsync(
new Subscription()
{
ChangeType = "created,deleted,updated",
NotificationUrl = notificationUrl,
Resource = "/copilot/interactionHistory/getAllEnterpriseInteractions",
IncludeResourceData = true,
EncryptionCertificate = Convert.ToBase64String(certificate),
EncryptionCertificateId = encryptionCertificateId,
ExpirationDateTime = DateTimeOffset.UtcNow.AddMinutes(15),
ClientState = clientState
}
)
.ConfigureAwait(false);
_ = subscription ?? throw new InvalidOperationException();
return subscription;
}
サブスクリプションの受信
Functions/ReceiveSubscriptionFunction.cs
サブスクリプションを作成するときに、NotificationUrl に対して検証リクエストが送信されます。ここに正しく応答しないと、サブスクリプションは作成されません。LifecycleNotificationUrl も同様に検証が行われます。
- ステータス コード: 200 OK
- コンテンツ タイプ: text/plain
- 本文: リクエスト URL に含まれる validationToken の値
サブスクリプションが作成されると、ユーザーが Microsoft 365 Copilot で会話したタイミングで通知が送られます。前述のとおり、リソース データは暗号化して送信されるため、復号する必要があります。手順としては、次のとおりです。
- 暗号化された共通鍵を秘密鍵を使って復号する
- 共通鍵を使ってデータの署名を計算し DataKey と一致するか検証する
- 共通鍵を使ってデータを復号する
このほか、サブスクリプションを作成したときに指定した ClientState が一致していることを確認する必要があります。
[Function("ReceiveSubscription")]
public async Task<IActionResult> RunAsync([HttpTrigger(AuthorizationLevel.Admin, "POST")] HttpRequest httpRequest)
{
try
{
this.logger.MethodExecuting();
// 検証トークンがクエリ文字列に含まれている場合はその値をそのまま返却する
var validationToken = httpRequest.Query["validationToken"];
if (validationToken.Count > 0)
{
return new ContentResult()
{
Content = validationToken,
ContentType = "text/plain",
StatusCode = StatusCodes.Status200OK
};
}
// 設定ファイルを取得する
var settings = await this.blobsService.GetSettingsAsync();
if (settings is null)
{
this.logger.SettingsFileNotFound();
}
else
{
var changeNotificationCollection = await httpRequest.ReadFromJsonAsync<ChangeNotificationCollection>();
_ = changeNotificationCollection ?? throw new InvalidOperationException();
foreach (var changeNotification in changeNotificationCollection.Value)
{
// サブスクリプションの登録時に設定したクライアント状態と一致するか検証する
if (changeNotification.ClientState != settings.ClientState)
{
this.logger.ClientStateDoesNotMatch();
continue;
}
// 暗号化された対称鍵を復号化する
var symmetricKey = await this.encryptionService.DecryptSymmetricKeyAsync(changeNotification.EncryptedContent.DataKey);
// 署名を検証する
var validationResult = await this.encryptionService.ValidateSignatureAsync(
symmetricKey,
changeNotification.EncryptedContent.Data,
changeNotification.EncryptedContent.DataSignature
);
if (validationResult is not true)
{
this.logger.SignatureValidationFailed();
continue;
}
// リソースデータを復号化する
var resourceData = await this.encryptionService.DecryptResourceDataAsync(symmetricKey, changeNotification.EncryptedContent.Data);
this.logger.ChangeNotificationReceived(resourceData: resourceData);
}
}
return new OkResult();
}
catch (InvalidOperationException ex)
{
this.logger.MethodFailed(exception: ex);
return new StatusCodeResult(StatusCodes.Status400BadRequest);
}
catch (Exception ex)
{
this.logger.UnhandledErrorOccurred(exception: ex);
return new StatusCodeResult(StatusCodes.Status500InternalServerError);
}
finally
{
this.logger.MethodExecuted();
}
}
実行結果
実行して取得された会話は以下のようになります。
{
"@odata.context": "https://graph.microsoft.com/$metadata#copilot/interactionHistory/interactions/$entity",
"id": "1768547260746",
"sessionId": "19:hTo5rn...@thread.v2",
"requestId": "4090bfe8-...",
"appClass": "IPM.SkypeTeams.Message.Copilot.WebChat",
"interactionType": "userPrompt",
"conversationType": "webchat",
"etag": "1768547260746",
"createdDateTime": "2026-01-16T07:07:40.746Z",
"locale": "en-us",
"contexts": [],
"from": {
"@odata.type": "#microsoft.graph.chatMessageFromIdentitySet",
"application": null,
"device": null,
"user": {
"userIdentityType": "aadUser",
"tenantId": "bf2a4ea3-...",
"id": "b93a9078-...",
"displayName": ""
}
},
"body": {
"contentType": "text",
"content": "六本木一丁目のおすすめのランチを3件教えて"
},
"attachments": [],
"links": [],
"mentions": []
}
おわりに
この方法を使うと、ユーザーがふさわしくない内容を Microsoft 365 Copilot へ入力したときに、即時に検知するといった仕組みを作ることができます。分析のために利用ログを取得するといった用途にも活用できますので、ぜひ活用してみてください。
Discussion